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.
- free_fleet/__init__.py +116 -0
- free_fleet/catalog.py +520 -0
- free_fleet/cli.py +1019 -0
- free_fleet/data/routes.seed.json +342 -0
- free_fleet/engine.py +405 -0
- free_fleet/eval.py +286 -0
- free_fleet/export.py +269 -0
- free_fleet/grounding.py +319 -0
- free_fleet/input_data.py +311 -0
- free_fleet/mcp_server.py +305 -0
- free_fleet/migrations/001_control_plane.sql +149 -0
- free_fleet/migrations/002_intelligence_and_policy.sql +53 -0
- free_fleet/models.py +519 -0
- free_fleet/packer.py +44 -0
- free_fleet/providers/base.py +70 -0
- free_fleet/providers/demo.py +144 -0
- free_fleet/providers/openai_compatible.py +248 -0
- free_fleet/providers/opencode.py +143 -0
- free_fleet/providers/openrouter.py +219 -0
- free_fleet/providers/registry.py +64 -0
- free_fleet/resources/skill/SKILL.md +48 -0
- free_fleet/resources/skill/agents/openai.yaml +6 -0
- free_fleet/resources/skill/references/operations.md +143 -0
- free_fleet/resources/skill/references/task-contracts.md +64 -0
- free_fleet/scoring.py +137 -0
- free_fleet/sessions.py +82 -0
- free_fleet/setup.py +165 -0
- free_fleet/slicer.py +33 -0
- free_fleet/store.py +932 -0
- free_fleet/task.py +82 -0
- free_fleet/ui.py +118 -0
- free_fleet-0.2.4.data/data/share/free-fleet/schemas/candidate-output-v1.schema.json +101 -0
- free_fleet-0.2.4.data/data/share/free-fleet/schemas/input-item-v1.schema.json +77 -0
- free_fleet-0.2.4.data/data/share/free-fleet/schemas/output-v2.schema.json +114 -0
- free_fleet-0.2.4.data/data/share/free-fleet/schemas/packet-v2.schema.json +626 -0
- free_fleet-0.2.4.data/data/share/free-fleet/schemas/task-v1.schema.json +71 -0
- free_fleet-0.2.4.dist-info/METADATA +351 -0
- free_fleet-0.2.4.dist-info/RECORD +42 -0
- free_fleet-0.2.4.dist-info/WHEEL +5 -0
- free_fleet-0.2.4.dist-info/entry_points.txt +3 -0
- free_fleet-0.2.4.dist-info/licenses/LICENSE +21 -0
- 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
|