free-fleet 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. free_fleet/__init__.py +116 -0
  2. free_fleet/catalog.py +520 -0
  3. free_fleet/cli.py +1019 -0
  4. free_fleet/data/routes.seed.json +342 -0
  5. free_fleet/engine.py +405 -0
  6. free_fleet/eval.py +286 -0
  7. free_fleet/export.py +269 -0
  8. free_fleet/grounding.py +319 -0
  9. free_fleet/input_data.py +311 -0
  10. free_fleet/mcp_server.py +305 -0
  11. free_fleet/migrations/001_control_plane.sql +149 -0
  12. free_fleet/migrations/002_intelligence_and_policy.sql +53 -0
  13. free_fleet/models.py +519 -0
  14. free_fleet/packer.py +44 -0
  15. free_fleet/providers/base.py +70 -0
  16. free_fleet/providers/demo.py +144 -0
  17. free_fleet/providers/openai_compatible.py +248 -0
  18. free_fleet/providers/opencode.py +143 -0
  19. free_fleet/providers/openrouter.py +219 -0
  20. free_fleet/providers/registry.py +64 -0
  21. free_fleet/resources/skill/SKILL.md +48 -0
  22. free_fleet/resources/skill/agents/openai.yaml +6 -0
  23. free_fleet/resources/skill/references/operations.md +143 -0
  24. free_fleet/resources/skill/references/task-contracts.md +64 -0
  25. free_fleet/scoring.py +137 -0
  26. free_fleet/sessions.py +82 -0
  27. free_fleet/setup.py +165 -0
  28. free_fleet/slicer.py +33 -0
  29. free_fleet/store.py +932 -0
  30. free_fleet/task.py +82 -0
  31. free_fleet/ui.py +118 -0
  32. free_fleet-0.2.4.data/data/share/free-fleet/schemas/candidate-output-v1.schema.json +101 -0
  33. free_fleet-0.2.4.data/data/share/free-fleet/schemas/input-item-v1.schema.json +77 -0
  34. free_fleet-0.2.4.data/data/share/free-fleet/schemas/output-v2.schema.json +114 -0
  35. free_fleet-0.2.4.data/data/share/free-fleet/schemas/packet-v2.schema.json +626 -0
  36. free_fleet-0.2.4.data/data/share/free-fleet/schemas/task-v1.schema.json +71 -0
  37. free_fleet-0.2.4.dist-info/METADATA +351 -0
  38. free_fleet-0.2.4.dist-info/RECORD +42 -0
  39. free_fleet-0.2.4.dist-info/WHEEL +5 -0
  40. free_fleet-0.2.4.dist-info/entry_points.txt +3 -0
  41. free_fleet-0.2.4.dist-info/licenses/LICENSE +21 -0
  42. free_fleet-0.2.4.dist-info/top_level.txt +1 -0
free_fleet/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """Free Fleet: Coordinated free & local LLM worker fleet with closed fields and exact source evidence."""
2
+ __version__ = "0.2.4"
3
+ import json
4
+ from pathlib import Path
5
+ from .task import load_task_spec
6
+ from .engine import Engine
7
+ from .catalog import RouteCatalog
8
+ from .grounding import verify_grounding
9
+ from .models import CleanPacket, ExtractedItem, InputItem, ModelOutput, QuoteRef, TaskSpec
10
+ from .input_data import load_input_items
11
+ from .store import BulkLanesStore, FreeFleetStore
12
+ from .slicer import slice_document
13
+ from .packer import pack_items
14
+ from .export import export_clean_packet
15
+
16
+ def read_packet(packet_path: str) -> dict:
17
+ """Convenience helper for downstream trusted applications to safely load a clean packet."""
18
+ p = Path(packet_path).resolve()
19
+ if not p.exists():
20
+ raise FileNotFoundError(f"Packet file not found: {packet_path}")
21
+ return CleanPacket.model_validate_json(p.read_text()).model_dump(mode="json", by_alias=True)
22
+
23
+ def process(
24
+ task: str | TaskSpec | Path,
25
+ input: str | Path | list[InputItem] | list[dict],
26
+ run_id: str | None = None,
27
+ concurrency: int = 4,
28
+ max_attempts: int = 300,
29
+ output: str | Path | None = None,
30
+ policy=None,
31
+ db: str | Path | None = None,
32
+ **load_kwargs,
33
+ ) -> dict:
34
+ """High-level Python SDK: `free_fleet.process(task, input, ...) -> packet`.
35
+
36
+ Example:
37
+ import free_fleet
38
+ packet = free_fleet.process("my-task", "data.csv", concurrency=8)
39
+ df = packet["records"] # or use export helpers
40
+
41
+ Supports local paths (.csv/.jsonl/.json/.txt/.html/.pdf), lists of InputItem,
42
+ or pandas DataFrames (if installed) via `input=df`.
43
+ """
44
+ import time as _time
45
+
46
+ # Resolve task
47
+ from pathlib import Path as _P
48
+
49
+ store = FreeFleetStore(_P(db)) if db is not None else FreeFleetStore()
50
+ if isinstance(task, TaskSpec):
51
+ spec = task
52
+ store.register_task(spec)
53
+ elif isinstance(task, (str, _P)) and _P(str(task)).is_file():
54
+ spec = load_task_spec(str(task))
55
+ store.register_task(spec)
56
+ else:
57
+ spec = store.get_task(str(task))
58
+
59
+ # Resolve input items (DataFrame support optional)
60
+ items: list[InputItem]
61
+ input_path_str = str(input) if isinstance(input, (str, _P)) else "python-sdk"
62
+ if isinstance(input, list):
63
+ # Already InputItem/dict list
64
+ items = [i if isinstance(i, InputItem) else InputItem.model_validate(i) for i in input] # type: ignore
65
+ elif isinstance(input, (str, _P)) and _P(str(input)).is_file():
66
+ items = load_input_items(str(input), **load_kwargs) # type: ignore
67
+ else:
68
+ # Try DataFrame-like (has to_dict)
69
+ try:
70
+ if hasattr(input, "to_dict") and hasattr(input, "columns"):
71
+ # pandas DataFrame
72
+ recs = input.to_dict(orient="records") # type: ignore
73
+ # Heuristic columns: try to detect id/text columns
74
+ items = []
75
+ for idx, rec in enumerate(recs):
76
+ # Prefer explicit id column
77
+ cand_id = rec.get("item_id") or rec.get("id") or f"row_{idx}"
78
+ txt = rec.get("text") or rec.get("body") or rec.get("content") or rec.get("description") or ""
79
+ if not txt:
80
+ # fallback: first stringifiable column value
81
+ for v in rec.values():
82
+ if isinstance(v, str) and len(v.strip()) > 20:
83
+ txt = v
84
+ break
85
+ items.append(InputItem(item_id=str(cand_id), text=str(txt), metadata={k: v for k, v in rec.items() if k not in ("item_id", "id", "text", "body", "content")}))
86
+ else:
87
+ raise TypeError("unsupported input type")
88
+ except Exception as e:
89
+ raise ValueError(f"Unable to resolve input '{input}': {e}") from e
90
+
91
+ rid = run_id or f"sdk-{int(_time.time())}"
92
+ out_path = _P(output) if output is not None else _P(f"runs/{rid}/clean_packet.json")
93
+ eng = Engine(task=spec, store=store, policy=policy)
94
+ return eng.run_campaign(raw_items=items, run_id=rid, input_path=input_path_str, concurrency=concurrency, max_attempts=max_attempts, output_packet_path=out_path)
95
+
96
+ __all__ = [
97
+ "load_task_spec",
98
+ "Engine",
99
+ "RouteCatalog",
100
+ "verify_grounding",
101
+ "CleanPacket",
102
+ "ExtractedItem",
103
+ "InputItem",
104
+ "ModelOutput",
105
+ "QuoteRef",
106
+ "TaskSpec",
107
+ "FreeFleetStore",
108
+ "BulkLanesStore",
109
+ "load_input_items",
110
+ "slice_document",
111
+ "pack_items",
112
+ "export_clean_packet",
113
+ "read_packet",
114
+ "process",
115
+ "__version__",
116
+ ]
free_fleet/catalog.py ADDED
@@ -0,0 +1,520 @@
1
+ """Route catalog and dynamic circuit-breaker management with automated free-schema discovery."""
2
+ import hashlib
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Any
12
+ import httpx
13
+
14
+ from .models import RouteInfo
15
+ from .store import BulkLanesStore
16
+
17
+ DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "data" / "routes.seed.json"
18
+
19
+ class RouteCircuitBreaker(Exception):
20
+ pass
21
+
22
+ class PriceState(str, Enum):
23
+ CANDIDATE = "candidate"
24
+ PRICE_OBSERVED_ZERO = "price_observed_zero"
25
+ UNKNOWN = "unknown"
26
+ DISABLED = "disabled"
27
+
28
+
29
+ def _observed_prices(model_data: dict) -> list[float] | None:
30
+ pricing = model_data.get("pricing") or model_data.get("cost")
31
+ if isinstance(pricing, dict):
32
+ input_value = pricing.get("prompt", pricing.get("input"))
33
+ output_value = pricing.get("completion", pricing.get("output"))
34
+ if input_value is None or output_value is None:
35
+ return None
36
+ raw_values: list[Any] = []
37
+
38
+ def collect(value: Any) -> None:
39
+ if isinstance(value, dict):
40
+ for nested in value.values():
41
+ collect(nested)
42
+ elif value is not None:
43
+ raw_values.append(value)
44
+
45
+ collect(pricing)
46
+ try:
47
+ return [float(value) for value in raw_values]
48
+ except (TypeError, ValueError):
49
+ return None
50
+ return None
51
+
52
+
53
+ def classify_price_state(model_data: dict) -> PriceState:
54
+ """Classify evidence without treating marketing text as observed pricing."""
55
+ prices = _observed_prices(model_data)
56
+ if prices is not None:
57
+ return PriceState.PRICE_OBSERVED_ZERO if all(value == 0 for value in prices) else PriceState.UNKNOWN
58
+ schema_dump = json.dumps(model_data).lower()
59
+ if re.search(r"(\bfree\b|:free|-free|_free)", schema_dump):
60
+ return PriceState.CANDIDATE
61
+ return PriceState.UNKNOWN
62
+
63
+
64
+ def is_free_in_schema(model_data: dict) -> bool:
65
+ """Compatibility predicate: true only for explicit observed zero pricing."""
66
+ return classify_price_state(model_data) is PriceState.PRICE_OBSERVED_ZERO
67
+
68
+ class RouteCatalog:
69
+ def __init__(self, config_path: Optional[Path] = None, db_path: Optional[Path] = None):
70
+ self.config_path = config_path or DEFAULT_CONFIG_PATH
71
+ self._lock = threading.RLock()
72
+ resolved_db = db_path or (self.config_path.with_suffix(".db") if config_path else None)
73
+ self.store = BulkLanesStore(resolved_db)
74
+ if self.store.route_count() == 0:
75
+ self._seed_from_json()
76
+ self.data = self._load()
77
+
78
+ def _seed_from_json(self) -> None:
79
+ """Import packaged route hints without treating bundled history as local evidence."""
80
+ if not self.config_path.exists():
81
+ return
82
+ data = json.loads(self.config_path.read_text())
83
+ for raw_route in data.get("routes", []):
84
+ route = dict(raw_route)
85
+ hinted_zero = (
86
+ route.get("price_state") == PriceState.PRICE_OBSERVED_ZERO.value
87
+ or (route.get("cost_per_1k_input") == 0 and route.get("cost_per_1k_output") == 0)
88
+ )
89
+ route["enabled"] = False
90
+ route["price_state"] = PriceState.CANDIDATE.value if hinted_zero else PriceState.UNKNOWN.value
91
+ route["last_verified"] = None
92
+ route["verification_source"] = "packaged route hint; refresh required"
93
+ route.pop("zero_price_verified", None)
94
+ self.store.upsert_route(RouteInfo.model_validate(route))
95
+
96
+ def _load(self) -> dict:
97
+ routes = self.store.list_routes(observed_zero_only=False, include_disabled=True)
98
+ return {"revision": 2, "routes": [route.model_dump(mode="json") for route in routes]}
99
+
100
+ def save(self):
101
+ with self._lock:
102
+ for raw_route in self.data.get("routes", []):
103
+ self.store.upsert_route(RouteInfo.model_validate(raw_route))
104
+
105
+ def get_routes(
106
+ self,
107
+ provider: Optional[str] = None,
108
+ free_only: bool = True,
109
+ include_disabled: bool = False,
110
+ ) -> List[dict]:
111
+ routes = self.data.get("routes", [])
112
+ matched = []
113
+ for r in routes:
114
+ if not include_disabled and not r.get("enabled", False):
115
+ continue
116
+ if provider and r.get("provider") != provider:
117
+ continue
118
+ if free_only and r.get("price_state") != PriceState.PRICE_OBSERVED_ZERO.value:
119
+ continue
120
+ matched.append(r)
121
+ return matched
122
+
123
+ def add_route(
124
+ self,
125
+ route_id: str,
126
+ provider: str,
127
+ cost_per_1k_input: Optional[float] = None,
128
+ cost_per_1k_output: Optional[float] = None,
129
+ enabled: bool = True,
130
+ price_state: Optional[str] = None,
131
+ verification_source: str = "manual_registration",
132
+ ) -> RouteInfo:
133
+ """Register or update a route in the catalog."""
134
+ if price_state is None:
135
+ if (
136
+ cost_per_1k_input == 0
137
+ and cost_per_1k_output == 0
138
+ and cost_per_1k_input is not None
139
+ and cost_per_1k_output is not None
140
+ ):
141
+ price_state = PriceState.PRICE_OBSERVED_ZERO.value
142
+ else:
143
+ price_state = PriceState.UNKNOWN.value
144
+ route = RouteInfo(
145
+ id=route_id,
146
+ provider=provider,
147
+ enabled=enabled,
148
+ price_state=price_state,
149
+ cost_per_1k_input=cost_per_1k_input,
150
+ cost_per_1k_output=cost_per_1k_output,
151
+ last_verified=time.strftime("%Y-%m-%d"),
152
+ verification_source=verification_source,
153
+ )
154
+ self.store.upsert_route(route)
155
+ self.data = self._load()
156
+ return route
157
+
158
+ def set_cooldown(self, route_id: str, duration_sec: Optional[float] = None, reason: str = "") -> float:
159
+ """Temporarily cool down a route after rate limits or transient errors.
160
+
161
+ If duration_sec is None, applies adaptive exponential backoff based on consecutive rate limits.
162
+ Returns the cooldown expiry timestamp.
163
+ """
164
+ if duration_sec is None:
165
+ return self.store.record_rate_limit_with_adaptive_backoff(route_id, reason)
166
+ expiry = time.time() + duration_sec
167
+ self.store.set_cooldown(route_id, expiry, reason)
168
+ return expiry
169
+
170
+ def is_cooled_down(self, route_id: str) -> bool:
171
+ """Check whether a route is currently in cooldown."""
172
+ return self.store.is_route_cooled_down(route_id)
173
+
174
+ def get_earliest_cooldown_retry(self, route_ids: Optional[List[str]] = None) -> float:
175
+ """Return the number of seconds until the earliest cooled-down route is available again."""
176
+ exp = self.store.get_earliest_cooldown_expiry(route_ids)
177
+ if exp:
178
+ return max(0.0, exp - time.time())
179
+ return 0.0
180
+
181
+ def get_route_summary(self, include_disabled: bool = True) -> list[dict[str, Any]]:
182
+ """Return comprehensive route catalog summary with Bayesian scores and attempt stats."""
183
+ routes = self.get_routes(free_only=False, include_disabled=include_disabled)
184
+ active_cooldowns = self.store.get_active_cooldowns()
185
+ now = time.time()
186
+
187
+ from .scoring import RouteScorer
188
+ scorer = RouteScorer(self.store)
189
+ history_stats = self.store.get_route_history_stats()
190
+ route_dicts = [r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in routes]
191
+ scores = scorer.score_routes(route_dicts)
192
+
193
+ summary = []
194
+ for r in routes:
195
+ rid = r["id"] if isinstance(r, dict) else r.id
196
+ provider = r["provider"] if isinstance(r, dict) else r.provider
197
+ enabled = r["enabled"] if isinstance(r, dict) else r.enabled
198
+ last_verified = r.get("last_verified") if isinstance(r, dict) else getattr(r, "last_verified", None)
199
+ stat = history_stats.get(rid, {})
200
+ cd_until = active_cooldowns.get(rid)
201
+ is_cooling = cd_until is not None and cd_until > now
202
+
203
+ status_str = "COOLING" if is_cooling else ("ACTIVE" if enabled else "DISABLED")
204
+ total = stat.get("total", 0)
205
+ completed = stat.get("completed", 0)
206
+ success_rate = (completed / total * 100.0) if total > 0 else None
207
+
208
+ summary.append({
209
+ "id": rid,
210
+ "provider": provider,
211
+ "enabled": enabled,
212
+ "status": status_str,
213
+ "is_cooling": is_cooling,
214
+ "cooling_seconds": round(max(0.0, cd_until - now), 1) if is_cooling else 0.0,
215
+ "bayesian_score": scores.get(rid, 0.5) if enabled and not is_cooling else (0.0 if is_cooling else 0.5),
216
+ "total_attempts": total,
217
+ "completed": completed,
218
+ "success_rate": success_rate,
219
+ "rate_limits": stat.get("rate_limits", 0),
220
+ "avg_duration": stat.get("avg_duration", 0.0),
221
+ "cooldown_until": cd_until,
222
+ "cooldown_remaining_sec": round(max(0.0, cd_until - now), 1) if is_cooling else 0.0,
223
+ "last_verified": last_verified,
224
+ })
225
+
226
+ def sort_key(x):
227
+ status_order = {"ACTIVE": 0, "COOLING": 1, "DISABLED": 2}.get(x["status"], 3)
228
+ return (status_order, -x["bayesian_score"])
229
+
230
+ return sorted(summary, key=sort_key)
231
+
232
+ def refresh_from_openai_compatible(
233
+ self,
234
+ base_url: Optional[str] = None,
235
+ api_key: Optional[str] = None,
236
+ provider_name: str = "openai_compatible",
237
+ ) -> int:
238
+ """Discover routes from an OpenAI-compatible /models endpoint (e.g. Ollama, LM Studio, vLLM)."""
239
+ from .providers.openai_compatible import OpenAICompatibleProvider
240
+ provider = OpenAICompatibleProvider(base_url=base_url, api_key=api_key, provider_name=provider_name)
241
+ headers = {}
242
+ if provider.api_key:
243
+ headers["Authorization"] = f"Bearer {provider.api_key}"
244
+ try:
245
+ with httpx.Client(timeout=10.0) as client:
246
+ resp = client.get(f"{provider.base_url}/models", headers=headers)
247
+ if resp.status_code != 200:
248
+ return 0
249
+ data = resp.json()
250
+ models = data.get("data", [])
251
+ count = 0
252
+ is_local = provider.is_local
253
+ price_state = PriceState.PRICE_OBSERVED_ZERO.value if is_local else PriceState.UNKNOWN.value
254
+ cost = 0.0 if is_local else None
255
+ for m in models:
256
+ mid = m.get("id")
257
+ if not mid:
258
+ continue
259
+ rid = f"{provider_name}/{mid}"
260
+ self.add_route(
261
+ route_id=rid,
262
+ provider=provider_name,
263
+ cost_per_1k_input=cost,
264
+ cost_per_1k_output=cost,
265
+ enabled=True,
266
+ price_state=price_state,
267
+ verification_source=f"{provider_name} /models discovery (local={is_local})",
268
+ )
269
+ count += 1
270
+ return count
271
+ except Exception:
272
+ return 0
273
+
274
+ def get_ladder(
275
+ self,
276
+ task_seed: str = "",
277
+ provider: Optional[str] = None,
278
+ free_only: bool = True,
279
+ task_name: Optional[str] = None,
280
+ policy: Optional[Any] = None,
281
+ ) -> List[str]:
282
+ """Returns an intelligently prioritized list of route IDs based on historical performance and eval scores."""
283
+ effective_free_only = free_only
284
+ if policy is not None:
285
+ # Explicit --free-only overrides; otherwise paid-aware policy disables free-only filter
286
+ if getattr(policy, "free_only", False):
287
+ effective_free_only = True
288
+ elif (
289
+ getattr(policy, "max_cost_per_1k_input", 0.0) > 0
290
+ or getattr(policy, "max_cost_per_1k_output", 0.0) > 0
291
+ or getattr(policy, "allowed_routes", None)
292
+ ):
293
+ effective_free_only = False
294
+ routes = self.get_routes(provider=provider, free_only=effective_free_only)
295
+ # Demo output is synthetic and must never enter a real campaign implicitly.
296
+ routes = [r for r in routes if r.get("provider") != "demo" or (
297
+ policy is not None and (
298
+ r["id"] in (getattr(policy, "allowed_routes", None) or [])
299
+ or "demo" in (getattr(policy, "allowed_transports", None) or [])
300
+ or "demo" in (getattr(policy, "allowed_providers", None) or [])
301
+ )
302
+ )]
303
+ if not routes:
304
+ return []
305
+ from .scoring import filter_and_rank_routes
306
+ return filter_and_rank_routes(
307
+ routes=routes,
308
+ store=self.store,
309
+ task_name=task_name,
310
+ policy=policy,
311
+ seed=task_seed,
312
+ )
313
+
314
+ def record_cost(
315
+ self,
316
+ route_id: str,
317
+ reported_cost: Optional[float],
318
+ policy: Optional[Any] = None,
319
+ ) -> None:
320
+ """Update state from provider-reported cost and trip circuit breaker only when appropriate."""
321
+ with self._lock:
322
+ routes = self.data.get("routes", [])
323
+ target_route = next((r for r in routes if r["id"] == route_id), None)
324
+
325
+ is_zero_price_route = False
326
+ if target_route:
327
+ if target_route.get("price_state") == PriceState.PRICE_OBSERVED_ZERO.value:
328
+ is_zero_price_route = True
329
+ elif (
330
+ target_route.get("cost_per_1k_input") == 0.0
331
+ and target_route.get("cost_per_1k_output") == 0.0
332
+ and target_route.get("cost_per_1k_input") is not None
333
+ and target_route.get("cost_per_1k_output") is not None
334
+ ):
335
+ is_zero_price_route = True
336
+
337
+ run_is_free_only = True
338
+ if policy and (
339
+ getattr(policy, "max_cost_per_1k_input", 0.0) > 0
340
+ or getattr(policy, "max_cost_per_1k_output", 0.0) > 0
341
+ or getattr(policy, "allowed_routes", None)
342
+ ):
343
+ run_is_free_only = False
344
+
345
+ if reported_cost == 0:
346
+ if target_route:
347
+ if target_route.get("price_state") != PriceState.DISABLED.value:
348
+ target_route["price_state"] = PriceState.PRICE_OBSERVED_ZERO.value
349
+ target_route["last_price_observation"] = time.time()
350
+ self.save()
351
+ return
352
+
353
+ if reported_cost is not None and reported_cost > 0:
354
+ # If this was admitted as a free route OR the run is strictly free-only:
355
+ if is_zero_price_route or run_is_free_only:
356
+ if target_route:
357
+ target_route["enabled"] = False
358
+ target_route["price_state"] = PriceState.DISABLED.value
359
+ target_route["disabled_reason"] = (
360
+ f"Circuit breaker tripped: reported cost {reported_cost} > 0 on free route."
361
+ )
362
+ target_route["disabled_at"] = time.time()
363
+ self.save()
364
+ raise RouteCircuitBreaker(
365
+ f"Non-zero cost {reported_cost} reported on free route '{route_id}'! Route disabled."
366
+ )
367
+
368
+ # For paid routes: check per-request spend ceiling if configured in policy
369
+ max_request_cost = getattr(policy, "max_request_cost", None) if policy else None
370
+ if max_request_cost is not None and reported_cost > max_request_cost:
371
+ if target_route:
372
+ target_route["enabled"] = False
373
+ target_route["price_state"] = PriceState.DISABLED.value
374
+ target_route["disabled_reason"] = (
375
+ f"Circuit breaker tripped: reported cost {reported_cost} exceeded policy max_request_cost {max_request_cost}."
376
+ )
377
+ target_route["disabled_at"] = time.time()
378
+ self.save()
379
+ raise RouteCircuitBreaker(
380
+ f"Request cost {reported_cost} exceeded policy max_request_cost ceiling of {max_request_cost} on '{route_id}'!"
381
+ )
382
+
383
+ # Record last price observation for the paid route without disabling it
384
+ if target_route:
385
+ target_route["last_price_observation"] = time.time()
386
+ self.save()
387
+
388
+ def refresh_from_opencode(self) -> int:
389
+ """Query OpenCode and record candidates separately from observed zero prices."""
390
+ if not shutil.which("opencode"):
391
+ raise RuntimeError("opencode CLI not found in PATH")
392
+
393
+ res = subprocess.run(
394
+ ["opencode", "models", "opencode", "--verbose"],
395
+ capture_output=True,
396
+ text=True,
397
+ timeout=30
398
+ )
399
+ if res.returncode != 0:
400
+ raise RuntimeError(f"Failed to query opencode models: {res.stderr}")
401
+
402
+ text = res.stdout
403
+ models = {}
404
+ decoder = json.JSONDecoder()
405
+ while text.strip():
406
+ text = text.lstrip()
407
+ header, sep, rest = text.partition("\n")
408
+ if not sep:
409
+ break
410
+ if header.startswith("opencode/"):
411
+ try:
412
+ obj, end = decoder.raw_decode(rest.lstrip())
413
+ models[header] = obj
414
+ text = rest.lstrip()[end:]
415
+ except Exception:
416
+ text = rest
417
+ else:
418
+ text = rest
419
+
420
+ known = {r["id"]: r for r in self.data.get("routes", [])}
421
+ discovered_count = 0
422
+
423
+ for model_id, model_data in models.items():
424
+ price_state = classify_price_state(model_data)
425
+ is_active = model_data.get("status") == "active"
426
+
427
+ if model_id in known:
428
+ r = known[model_id]
429
+ r["price_state"] = price_state.value
430
+ r["enabled"] = price_state is PriceState.PRICE_OBSERVED_ZERO and is_active
431
+ r["last_verified"] = time.strftime("%Y-%m-%d")
432
+ r["verification_source"] = "opencode models opencode --verbose"
433
+ elif price_state in {PriceState.CANDIDATE, PriceState.PRICE_OBSERVED_ZERO} and is_active:
434
+ self.data["routes"].append({
435
+ "id": model_id,
436
+ "provider": "opencode",
437
+ "enabled": price_state is PriceState.PRICE_OBSERVED_ZERO,
438
+ "price_state": price_state.value,
439
+ "auth": "hosted-free",
440
+ "last_verified": time.strftime("%Y-%m-%d"),
441
+ "verification_source": "opencode models opencode --verbose"
442
+ })
443
+ if price_state in {PriceState.CANDIDATE, PriceState.PRICE_OBSERVED_ZERO}:
444
+ discovered_count += 1
445
+
446
+ self.save()
447
+ return discovered_count
448
+
449
+ def refresh_from_openrouter(self) -> int:
450
+ """Query OpenRouter and distinguish explicit zero pricing from name candidates."""
451
+ try:
452
+ with httpx.Client(timeout=15) as client:
453
+ resp = client.get("https://openrouter.ai/api/v1/models")
454
+ if resp.status_code != 200:
455
+ raise RuntimeError(f"OpenRouter models API returned HTTP {resp.status_code}")
456
+ data = resp.json().get("data", [])
457
+ except Exception as e:
458
+ raise RuntimeError(f"Failed to fetch OpenRouter models: {e}")
459
+
460
+ known = {r["id"]: r for r in self.data.get("routes", [])}
461
+ discovered_count = 0
462
+
463
+ for m in data:
464
+ price_state = classify_price_state(m)
465
+ if price_state in {PriceState.CANDIDATE, PriceState.PRICE_OBSERVED_ZERO}:
466
+ raw_id = m.get("id", "")
467
+ route_id = f"openrouter/{raw_id}" if not raw_id.startswith("openrouter/") else raw_id
468
+
469
+ if route_id in known:
470
+ r = known[route_id]
471
+ r["price_state"] = price_state.value
472
+ r["enabled"] = price_state is PriceState.PRICE_OBSERVED_ZERO
473
+ pricing = m.get("pricing", {})
474
+ if price_state is PriceState.PRICE_OBSERVED_ZERO:
475
+ r["cost_per_1k_input"] = float(pricing.get("prompt", pricing.get("input", 0))) * 1000
476
+ r["cost_per_1k_output"] = float(pricing.get("completion", pricing.get("output", 0))) * 1000
477
+ else:
478
+ r.pop("cost_per_1k_input", None)
479
+ r.pop("cost_per_1k_output", None)
480
+ r["last_verified"] = time.strftime("%Y-%m-%d")
481
+ r["verification_source"] = "openrouter /api/v1/models pricing"
482
+ else:
483
+ route = {
484
+ "id": route_id,
485
+ "provider": "openrouter",
486
+ "enabled": price_state is PriceState.PRICE_OBSERVED_ZERO,
487
+ "price_state": price_state.value,
488
+ "auth": "api-key",
489
+ "last_verified": time.strftime("%Y-%m-%d"),
490
+ "verification_source": "openrouter /api/v1/models pricing"
491
+ }
492
+ if price_state is PriceState.PRICE_OBSERVED_ZERO:
493
+ pricing = m["pricing"]
494
+ route["cost_per_1k_input"] = float(pricing.get("prompt", pricing.get("input", 0))) * 1000
495
+ route["cost_per_1k_output"] = float(pricing.get("completion", pricing.get("output", 0))) * 1000
496
+ self.data["routes"].append(route)
497
+ discovered_count += 1
498
+
499
+ self.save()
500
+ return discovered_count
501
+
502
+ def refresh_all(self) -> Dict[str, int]:
503
+ """Refresh observed-zero routes and unverified candidates."""
504
+ results = {}
505
+ try:
506
+ results["opencode"] = self.refresh_from_opencode()
507
+ except Exception as e:
508
+ results["opencode_error"] = str(e)
509
+
510
+ try:
511
+ results["openrouter"] = self.refresh_from_openrouter()
512
+ except Exception as e:
513
+ results["openrouter_error"] = str(e)
514
+
515
+ try:
516
+ results["openai_compatible"] = self.refresh_from_openai_compatible()
517
+ except Exception as e:
518
+ results["openai_compatible_error"] = str(e)
519
+
520
+ return results