differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
tools/model_ranker.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/model_ranker.py
|
|
3
|
+
---------------------
|
|
4
|
+
Dynamically updates model tiers (high / medium / low) from the live OpenRouter
|
|
5
|
+
catalog, for AGENT use (text in -> text out, tool calling). No LLM is asked to
|
|
6
|
+
judge model quality; price is the main capability proxy, refined by recency,
|
|
7
|
+
context size and reasoning support.
|
|
8
|
+
|
|
9
|
+
On launch the script asks the user for a MAXIMUM PRICE (USD per 1M tokens).
|
|
10
|
+
No model in any tier will have an input OR output price above that number.
|
|
11
|
+
Press Enter to set no limit.
|
|
12
|
+
|
|
13
|
+
Pipeline
|
|
14
|
+
1. Candidate filtering: text-only output (no image/audio/embedding models),
|
|
15
|
+
tool calling required, valid non-negative pricing, not expired, not a
|
|
16
|
+
router / ':' variant / specialty model, and within the user's PRICE CAP.
|
|
17
|
+
2. Version de-duplication (gpt-x-2024-08-06 vs gpt-x-2024-11-20 -> newest).
|
|
18
|
+
3. Tier bands are PERCENTILES of the cost distribution of what remains, so
|
|
19
|
+
they adapt to the price cap and to catalog drift.
|
|
20
|
+
4. Inside each band, models are ranked by a composite score.
|
|
21
|
+
5. Provider diversity cap per tier (relaxed only when needed).
|
|
22
|
+
6. Every tier gets up to `per_tier` models (default 5) and at least
|
|
23
|
+
`min_per_tier` (default 4). If the percentile bands are too thin the
|
|
24
|
+
catalog is split into three equal cost-rank slices instead. If the cap
|
|
25
|
+
is so low that even that is impossible, the user is told the lowest cap
|
|
26
|
+
that works and asked for a new number.
|
|
27
|
+
7. Atomic write; a failed run never overwrites a good tiers file.
|
|
28
|
+
|
|
29
|
+
Non-interactive use (cron, CI): pass --max-price 15 or set MODEL_MAX_PRICE=15;
|
|
30
|
+
with no value and no terminal the script runs without a cap.
|
|
31
|
+
|
|
32
|
+
Output format of model_tiers.json is unchanged:
|
|
33
|
+
{"high": [...ids], "medium": [...ids], "low": [...ids]}
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import bisect
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import math
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import sys
|
|
43
|
+
import tempfile
|
|
44
|
+
import time
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from datetime import date, timedelta
|
|
47
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
48
|
+
|
|
49
|
+
from tools.llm_selector import fetch_openrouter_models
|
|
50
|
+
|
|
51
|
+
log = logging.getLogger("model_ranker")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _env_float(name: str) -> Optional[float]:
|
|
55
|
+
try:
|
|
56
|
+
v = os.environ.get(name)
|
|
57
|
+
return float(v) if v not in (None, "") else None
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
# Configuration
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
TIERS_FILE = os.environ.get("MODEL_TIERS_FILE", "model_tiers.json")
|
|
66
|
+
|
|
67
|
+
# Optional non-interactive default (USD per 1M tokens; None = ask / no cap).
|
|
68
|
+
MAX_PRICE: Optional[float] = _env_float("MODEL_MAX_PRICE")
|
|
69
|
+
|
|
70
|
+
MODELS_PER_TIER = 5 # target size of every tier
|
|
71
|
+
MIN_MODELS_PER_TIER = 4 # hard minimum
|
|
72
|
+
|
|
73
|
+
MIN_CONTEXT = 32_000 # agents need room for tool schemas + history
|
|
74
|
+
REFERENCE_CONTEXT = 1_000_000
|
|
75
|
+
MAX_AGE_DAYS = 540
|
|
76
|
+
MIN_POOL_AFTER_AGE_FILTER = 20 # else the age filter is skipped
|
|
77
|
+
REQUIRE_TOOLS = True
|
|
78
|
+
MAX_PER_PROVIDER = 2 # per tier; relaxed if a tier cannot be filled
|
|
79
|
+
|
|
80
|
+
# Ranking cost: typical agent workloads send more input than they receive.
|
|
81
|
+
INPUT_WEIGHT = 3.0
|
|
82
|
+
OUTPUT_WEIGHT = 1.0
|
|
83
|
+
|
|
84
|
+
# Tier bands: percentile of blended cost among eligible paid models.
|
|
85
|
+
# Bottom 10% = tiny/obscure models, top 3% = outlier pricing.
|
|
86
|
+
BANDS: Dict[str, Tuple[float, float]] = {
|
|
87
|
+
"low": (0.10, 0.50),
|
|
88
|
+
"medium": (0.50, 0.80),
|
|
89
|
+
"high": (0.80, 0.97),
|
|
90
|
+
}
|
|
91
|
+
TIER_ORDER = ("high", "medium", "low")
|
|
92
|
+
|
|
93
|
+
# Composite score weights (inside a band).
|
|
94
|
+
W_PRICE = 0.50
|
|
95
|
+
W_RECENCY = 0.30
|
|
96
|
+
W_CONTEXT = 0.12
|
|
97
|
+
W_REASONING = 0.08
|
|
98
|
+
|
|
99
|
+
# Not general-purpose agent models.
|
|
100
|
+
EXCLUDE_ID_PATTERN = re.compile(
|
|
101
|
+
r"embed|moderat|guard|rerank|tts|whisper|dall-e|stable-diffusion|"
|
|
102
|
+
r"deep-research|-audio|audio-|image|-vision-only|roleplay|-rp\b",
|
|
103
|
+
re.IGNORECASE,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
_FAMILY_SUFFIXES = [
|
|
107
|
+
re.compile(r"-\d{4}-\d{2}-\d{2}$"), # -2024-08-06
|
|
108
|
+
re.compile(r"-\d{8}$"), # -20240806
|
|
109
|
+
re.compile(r"-(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$"), # -0613 (MMDD)
|
|
110
|
+
re.compile(r"-latest$"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Candidate:
|
|
116
|
+
id: str
|
|
117
|
+
cost: float # blended USD per 1M tokens (used for ranking)
|
|
118
|
+
peak_price: float # max(input, output) USD per 1M (used for the cap)
|
|
119
|
+
context: int
|
|
120
|
+
created: Optional[int] # unix seconds, may be unknown
|
|
121
|
+
reasoning: bool
|
|
122
|
+
cost_pct: float = 0.0 # percentile rank among eligible models (0..1)
|
|
123
|
+
score: float = 0.0
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def provider(self) -> str:
|
|
127
|
+
return self.id.split("/", 1)[0]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
# Parsing / filtering
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
def _price_per_million(value: Any) -> Optional[float]:
|
|
134
|
+
"""USD/token -> USD/1M tokens. None for missing, malformed, NaN or negative
|
|
135
|
+
values (OpenRouter uses -1 as a 'dynamic pricing' sentinel)."""
|
|
136
|
+
try:
|
|
137
|
+
v = float(value)
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
return None
|
|
140
|
+
if math.isnan(v) or v < 0:
|
|
141
|
+
return None
|
|
142
|
+
return v * 1_000_000
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_text_agent_model(m: Dict[str, Any]) -> bool:
|
|
146
|
+
"""Text must be an input, and TEXT must be the ONLY output modality
|
|
147
|
+
(rejects image-generation, audio-output and embedding models)."""
|
|
148
|
+
arch = m.get("architecture") or {}
|
|
149
|
+
outs = arch.get("output_modalities")
|
|
150
|
+
ins = arch.get("input_modalities")
|
|
151
|
+
if outs is None and isinstance(arch.get("modality"), str) and "->" in arch["modality"]:
|
|
152
|
+
left, right = arch["modality"].split("->", 1)
|
|
153
|
+
outs = [x.strip() for x in right.split("+")]
|
|
154
|
+
ins = ins or [x.strip() for x in left.split("+")]
|
|
155
|
+
if outs is not None and set(outs) != {"text"}:
|
|
156
|
+
return False
|
|
157
|
+
if ins is not None and "text" not in ins:
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_expired(m: Dict[str, Any], today: date) -> bool:
|
|
163
|
+
exp = m.get("expiration_date")
|
|
164
|
+
if not exp:
|
|
165
|
+
return False
|
|
166
|
+
try:
|
|
167
|
+
exp_date = date.fromisoformat(str(exp)[:10])
|
|
168
|
+
except ValueError:
|
|
169
|
+
return False
|
|
170
|
+
return exp_date <= today + timedelta(days=14)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_candidate(
|
|
174
|
+
m: Dict[str, Any],
|
|
175
|
+
today: date,
|
|
176
|
+
max_price: Optional[float],
|
|
177
|
+
) -> Optional[Candidate]:
|
|
178
|
+
if not isinstance(m, dict):
|
|
179
|
+
return None
|
|
180
|
+
mid = m.get("id")
|
|
181
|
+
if not isinstance(mid, str) or not mid:
|
|
182
|
+
return None
|
|
183
|
+
# Routers have dynamic pricing; ':' variants (:free, :nitro, :thinking ...)
|
|
184
|
+
# are routing aliases of a base model, and :free ones are rate-limit traps.
|
|
185
|
+
if mid.lower().startswith("openrouter/") or ":" in mid:
|
|
186
|
+
return None
|
|
187
|
+
if EXCLUDE_ID_PATTERN.search(mid):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
ctx = int(m.get("context_length") or 0)
|
|
192
|
+
except (TypeError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
if ctx < MIN_CONTEXT:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
if not _is_text_agent_model(m) or _is_expired(m, today):
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
pricing = m.get("pricing") or {}
|
|
201
|
+
p_in = _price_per_million(pricing.get("prompt"))
|
|
202
|
+
p_out = _price_per_million(pricing.get("completion"))
|
|
203
|
+
if p_in is None or p_out is None: # unknown price != free
|
|
204
|
+
return None
|
|
205
|
+
cost = (INPUT_WEIGHT * p_in + OUTPUT_WEIGHT * p_out) / (INPUT_WEIGHT + OUTPUT_WEIGHT)
|
|
206
|
+
if cost <= 0: # free models skipped on purpose
|
|
207
|
+
return None
|
|
208
|
+
peak = max(p_in, p_out)
|
|
209
|
+
|
|
210
|
+
# ---- user's price cap: neither input nor output may exceed it ----
|
|
211
|
+
if max_price is not None and peak > max_price:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
params = m.get("supported_parameters")
|
|
215
|
+
if REQUIRE_TOOLS and isinstance(params, list) and "tools" not in params:
|
|
216
|
+
return None
|
|
217
|
+
reasoning = isinstance(params, list) and (
|
|
218
|
+
"reasoning" in params or "include_reasoning" in params
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
created = m.get("created")
|
|
222
|
+
created = int(created) if isinstance(created, (int, float)) and created > 0 else None
|
|
223
|
+
|
|
224
|
+
return Candidate(id=mid, cost=cost, peak_price=peak, context=ctx,
|
|
225
|
+
created=created, reasoning=reasoning)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _family_key(model_id: str) -> str:
|
|
229
|
+
key = model_id.lower()
|
|
230
|
+
changed = True
|
|
231
|
+
while changed:
|
|
232
|
+
changed = False
|
|
233
|
+
for pat in _FAMILY_SUFFIXES:
|
|
234
|
+
new = pat.sub("", key)
|
|
235
|
+
if new != key:
|
|
236
|
+
key, changed = new, True
|
|
237
|
+
return key
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _dedupe_versions(cands: List[Candidate]) -> List[Candidate]:
|
|
241
|
+
"""Keep only the newest snapshot per family (ties -> cheaper)."""
|
|
242
|
+
best: Dict[str, Candidate] = {}
|
|
243
|
+
for c in cands:
|
|
244
|
+
k = _family_key(c.id)
|
|
245
|
+
cur = best.get(k)
|
|
246
|
+
if cur is None or (c.created or 0, -c.cost) > (cur.created or 0, -cur.cost):
|
|
247
|
+
best[k] = c
|
|
248
|
+
return list(best.values())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _drop_stale(cands: List[Candidate], now_ts: float) -> List[Candidate]:
|
|
252
|
+
cutoff = now_ts - MAX_AGE_DAYS * 86400
|
|
253
|
+
fresh = [c for c in cands if c.created is None or c.created >= cutoff]
|
|
254
|
+
return fresh if len(fresh) >= MIN_POOL_AFTER_AGE_FILTER else cands
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _eligible(raw_models: List[Dict[str, Any]], max_price: Optional[float],
|
|
258
|
+
now_ts: float) -> List[Candidate]:
|
|
259
|
+
today = date.fromtimestamp(now_ts)
|
|
260
|
+
parsed = [c for c in (_parse_candidate(m, today, max_price) for m in raw_models) if c]
|
|
261
|
+
return _drop_stale(_dedupe_versions(parsed), now_ts)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def min_cap_needed(raw_models: List[Dict[str, Any]], min_per_tier: int = MIN_MODELS_PER_TIER,
|
|
265
|
+
now_ts: Optional[float] = None) -> Optional[float]:
|
|
266
|
+
"""Lowest price cap (USD per 1M) that still leaves enough models for
|
|
267
|
+
`min_per_tier` in every tier, or None if the catalog itself is too small."""
|
|
268
|
+
now_ts = now_ts or time.time()
|
|
269
|
+
need = min_per_tier * len(TIER_ORDER)
|
|
270
|
+
peaks = sorted(c.peak_price for c in _eligible(raw_models, None, now_ts))
|
|
271
|
+
return peaks[need - 1] if len(peaks) >= need else None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --------------------------------------------------------------------------- #
|
|
275
|
+
# Scoring / selection
|
|
276
|
+
# --------------------------------------------------------------------------- #
|
|
277
|
+
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
|
278
|
+
return max(lo, min(hi, x))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _assign_percentiles(cands: List[Candidate]) -> None:
|
|
282
|
+
costs = sorted(c.cost for c in cands)
|
|
283
|
+
n = len(costs)
|
|
284
|
+
for c in cands:
|
|
285
|
+
c.cost_pct = bisect.bisect_left(costs, c.cost) / (n - 1) if n > 1 else 0.5
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _score(c: Candidate, band: Tuple[float, float], now_ts: float) -> float:
|
|
289
|
+
lo, hi = band
|
|
290
|
+
price_pos = _clamp((c.cost_pct - lo) / (hi - lo)) if hi > lo else 0.5
|
|
291
|
+
if c.created is None:
|
|
292
|
+
recency = 0.4
|
|
293
|
+
else:
|
|
294
|
+
age_days = max(0.0, (now_ts - c.created) / 86400)
|
|
295
|
+
recency = _clamp(1 - age_days / MAX_AGE_DAYS)
|
|
296
|
+
ctx = _clamp(
|
|
297
|
+
math.log2(max(c.context, MIN_CONTEXT) / MIN_CONTEXT)
|
|
298
|
+
/ math.log2(REFERENCE_CONTEXT / MIN_CONTEXT)
|
|
299
|
+
)
|
|
300
|
+
return (
|
|
301
|
+
W_PRICE * price_pos
|
|
302
|
+
+ W_RECENCY * recency
|
|
303
|
+
+ W_CONTEXT * ctx
|
|
304
|
+
+ W_REASONING * (1.0 if c.reasoning else 0.0)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _pick(ranked: List[Candidate], n: int) -> List[Candidate]:
|
|
309
|
+
"""Top-n with a per-provider cap; the cap is relaxed only to fill the tier."""
|
|
310
|
+
chosen: List[Candidate] = []
|
|
311
|
+
per_provider: Dict[str, int] = {}
|
|
312
|
+
for c in ranked:
|
|
313
|
+
if len(chosen) >= n:
|
|
314
|
+
break
|
|
315
|
+
if per_provider.get(c.provider, 0) < MAX_PER_PROVIDER:
|
|
316
|
+
chosen.append(c)
|
|
317
|
+
per_provider[c.provider] = per_provider.get(c.provider, 0) + 1
|
|
318
|
+
for c in ranked:
|
|
319
|
+
if len(chosen) >= n:
|
|
320
|
+
break
|
|
321
|
+
if c not in chosen:
|
|
322
|
+
chosen.append(c)
|
|
323
|
+
return chosen
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def build_tiers(
|
|
327
|
+
raw_models: List[Dict[str, Any]],
|
|
328
|
+
max_price: Optional[float] = None,
|
|
329
|
+
per_tier: int = MODELS_PER_TIER,
|
|
330
|
+
min_per_tier: int = MIN_MODELS_PER_TIER,
|
|
331
|
+
now_ts: Optional[float] = None,
|
|
332
|
+
) -> Optional[Dict[str, List[str]]]:
|
|
333
|
+
"""Pure function: catalog -> tiers dict, or None if the constraints
|
|
334
|
+
(price cap + minimum models per tier) cannot be satisfied."""
|
|
335
|
+
now_ts = now_ts or time.time()
|
|
336
|
+
per_tier = max(per_tier, min_per_tier)
|
|
337
|
+
|
|
338
|
+
cands = _eligible(raw_models, max_price, now_ts)
|
|
339
|
+
log.info("%d raw -> %d eligible models (max price: %s)",
|
|
340
|
+
len(raw_models), len(cands),
|
|
341
|
+
f"${max_price:g}/1M" if max_price is not None else "none")
|
|
342
|
+
|
|
343
|
+
need = min_per_tier * len(TIER_ORDER)
|
|
344
|
+
if len(cands) < need:
|
|
345
|
+
log.error("Only %d eligible models but %d are needed (%d tiers x %d).",
|
|
346
|
+
len(cands), need, len(TIER_ORDER), min_per_tier)
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
_assign_percentiles(cands)
|
|
350
|
+
|
|
351
|
+
# Mode A: percentile bands (trims tiny models and pricing outliers).
|
|
352
|
+
groups = {
|
|
353
|
+
name: [c for c in cands if BANDS[name][0] <= c.cost_pct < BANDS[name][1]]
|
|
354
|
+
for name in TIER_ORDER
|
|
355
|
+
}
|
|
356
|
+
bands = dict(BANDS)
|
|
357
|
+
|
|
358
|
+
# Mode B: when any band is too thin for the minimum (small pool or tight
|
|
359
|
+
# price cap), split ALL eligible models into three contiguous cost slices.
|
|
360
|
+
# Tiers stay strictly ordered by price and no model is shared between tiers.
|
|
361
|
+
if any(len(g) < min_per_tier for g in groups.values()):
|
|
362
|
+
log.info("Percentile bands too thin; using equal cost-rank slices instead.")
|
|
363
|
+
ordered = sorted(cands, key=lambda c: c.cost)
|
|
364
|
+
n = len(ordered)
|
|
365
|
+
cut1, cut2 = round(n / 3), round(2 * n / 3)
|
|
366
|
+
groups = {"low": ordered[:cut1], "medium": ordered[cut1:cut2], "high": ordered[cut2:]}
|
|
367
|
+
bands = {t: (g[0].cost_pct, max(g[-1].cost_pct, g[0].cost_pct + 1e-9))
|
|
368
|
+
for t, g in groups.items() if g}
|
|
369
|
+
|
|
370
|
+
chosen: Dict[str, List[Candidate]] = {}
|
|
371
|
+
for name in TIER_ORDER:
|
|
372
|
+
pool = groups[name]
|
|
373
|
+
for c in pool:
|
|
374
|
+
c.score = _score(c, bands[name], now_ts)
|
|
375
|
+
chosen[name] = _pick(sorted(pool, key=lambda c: c.score, reverse=True), per_tier)
|
|
376
|
+
|
|
377
|
+
short = {t: len(chosen[t]) for t in TIER_ORDER if len(chosen[t]) < min_per_tier}
|
|
378
|
+
if short:
|
|
379
|
+
log.error("Tiers below the minimum of %d models: %s", min_per_tier, short)
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
for t in TIER_ORDER:
|
|
383
|
+
log.info("%-6s %s", t, ", ".join(
|
|
384
|
+
f"{c.id} (${c.cost:.2f} blended, max ${c.peak_price:.2f})" for c in chosen[t]))
|
|
385
|
+
return {t: [c.id for c in chosen[t]] for t in TIER_ORDER}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --------------------------------------------------------------------------- #
|
|
389
|
+
# User interaction
|
|
390
|
+
# --------------------------------------------------------------------------- #
|
|
391
|
+
QUIT = object() # sentinel: the user chose to quit
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def prompt_max_price(ask: Callable[[str], str] = input):
|
|
395
|
+
"""Ask for the maximum price until a valid answer is given.
|
|
396
|
+
Returns a positive float, None (= no limit), or QUIT."""
|
|
397
|
+
while True:
|
|
398
|
+
try:
|
|
399
|
+
raw = ask(
|
|
400
|
+
"\nMaximum price for ANY model, in USD per 1M tokens "
|
|
401
|
+
"(applies to input and output).\n"
|
|
402
|
+
"Enter a number (e.g. 15), press Enter for no limit, or 'q' to quit: "
|
|
403
|
+
).strip()
|
|
404
|
+
except (EOFError, KeyboardInterrupt):
|
|
405
|
+
print()
|
|
406
|
+
return QUIT
|
|
407
|
+
if not raw:
|
|
408
|
+
return None
|
|
409
|
+
if raw.lower() in ("q", "quit", "exit"):
|
|
410
|
+
return QUIT
|
|
411
|
+
try:
|
|
412
|
+
value = float(raw.replace("$", "").replace(",", ".").strip())
|
|
413
|
+
except ValueError:
|
|
414
|
+
print(f" '{raw}' is not a number. Try again.")
|
|
415
|
+
continue
|
|
416
|
+
if not math.isfinite(value) or value <= 0:
|
|
417
|
+
print(" The price must be a positive number. Try again.")
|
|
418
|
+
continue
|
|
419
|
+
return value
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# --------------------------------------------------------------------------- #
|
|
423
|
+
# I/O
|
|
424
|
+
# --------------------------------------------------------------------------- #
|
|
425
|
+
def _fetch_catalog() -> List[Dict[str, Any]]:
|
|
426
|
+
try:
|
|
427
|
+
try:
|
|
428
|
+
raw = fetch_openrouter_models(force_refresh=True) # bypass any cache
|
|
429
|
+
except TypeError:
|
|
430
|
+
raw = fetch_openrouter_models() # older signature
|
|
431
|
+
except Exception as exc:
|
|
432
|
+
log.error("Fetch failed: %s", exc)
|
|
433
|
+
return []
|
|
434
|
+
if isinstance(raw, dict):
|
|
435
|
+
raw = raw.get("data", [])
|
|
436
|
+
return raw if isinstance(raw, list) else []
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _atomic_write_json(path: str, data: Any) -> None:
|
|
440
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
441
|
+
fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
|
442
|
+
try:
|
|
443
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
444
|
+
json.dump(data, f, indent=2)
|
|
445
|
+
f.write("\n")
|
|
446
|
+
os.replace(tmp, path)
|
|
447
|
+
except Exception:
|
|
448
|
+
if os.path.exists(tmp):
|
|
449
|
+
os.remove(tmp)
|
|
450
|
+
raise
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def auto_update_tiers(
|
|
454
|
+
max_price: Optional[float] = None,
|
|
455
|
+
per_tier: int = MODELS_PER_TIER,
|
|
456
|
+
min_per_tier: int = MIN_MODELS_PER_TIER,
|
|
457
|
+
output_file: Optional[str] = None,
|
|
458
|
+
interactive: bool = False,
|
|
459
|
+
ask: Callable[[str], str] = input,
|
|
460
|
+
) -> bool:
|
|
461
|
+
"""Fetch the catalog and rewrite the tiers file.
|
|
462
|
+
|
|
463
|
+
max_price USD per 1M tokens; no model's input OR output price may exceed
|
|
464
|
+
it. Defaults to the MODEL_MAX_PRICE environment variable.
|
|
465
|
+
interactive if True and no max_price is known, ask the user for it, and
|
|
466
|
+
ask again (with a suggestion) if the cap leaves too few models.
|
|
467
|
+
"""
|
|
468
|
+
path = output_file or TIERS_FILE
|
|
469
|
+
if max_price is None:
|
|
470
|
+
max_price = MAX_PRICE
|
|
471
|
+
if max_price is None and interactive:
|
|
472
|
+
max_price = prompt_max_price(ask)
|
|
473
|
+
if max_price is QUIT:
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
log.info("Fetching latest models from OpenRouter...")
|
|
477
|
+
raw_models = _fetch_catalog()
|
|
478
|
+
if not raw_models:
|
|
479
|
+
log.error("Failed to fetch models from OpenRouter; keeping existing tiers.")
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
while True:
|
|
483
|
+
tiers = build_tiers(raw_models, max_price, per_tier, min_per_tier)
|
|
484
|
+
if tiers:
|
|
485
|
+
break
|
|
486
|
+
if not interactive or max_price is None:
|
|
487
|
+
log.error("Could not build valid tiers; keeping existing tiers.")
|
|
488
|
+
return False
|
|
489
|
+
hint = min_cap_needed(raw_models, min_per_tier)
|
|
490
|
+
if hint is not None:
|
|
491
|
+
print(f"\nA limit of ${max_price:g} leaves too few models "
|
|
492
|
+
f"(need {min_per_tier} per tier). A limit of about "
|
|
493
|
+
f"${math.ceil(hint * 100) / 100:g} or higher is enough.")
|
|
494
|
+
else:
|
|
495
|
+
print("\nThe catalog has too few usable models for this setup.")
|
|
496
|
+
max_price = prompt_max_price(ask)
|
|
497
|
+
if max_price is QUIT:
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
502
|
+
if json.load(f) == tiers:
|
|
503
|
+
log.info("Tiers unchanged (%s).", path)
|
|
504
|
+
print(json.dumps(tiers, indent=2))
|
|
505
|
+
return True
|
|
506
|
+
except (OSError, ValueError):
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
_atomic_write_json(path, tiers)
|
|
510
|
+
log.info("Successfully updated %s", path)
|
|
511
|
+
print(json.dumps(tiers, indent=2))
|
|
512
|
+
return True
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__":
|
|
516
|
+
ap = argparse.ArgumentParser(description="Update model tiers from OpenRouter.")
|
|
517
|
+
ap.add_argument("--max-price", type=float, default=None,
|
|
518
|
+
help="maximum price in USD per 1M tokens (skips the prompt)")
|
|
519
|
+
ap.add_argument("--per-tier", type=int, default=MODELS_PER_TIER,
|
|
520
|
+
help="target models per tier (default 5)")
|
|
521
|
+
ap.add_argument("--min-per-tier", type=int, default=MIN_MODELS_PER_TIER,
|
|
522
|
+
help="minimum models per tier, else abort (default 4)")
|
|
523
|
+
ap.add_argument("--output", default=None, help="tiers file path")
|
|
524
|
+
args = ap.parse_args()
|
|
525
|
+
|
|
526
|
+
logging.basicConfig(level=logging.INFO, format="[Model Ranker] %(message)s")
|
|
527
|
+
# Ask the user unless a cap was given by flag/env or there is no terminal.
|
|
528
|
+
ok = auto_update_tiers(
|
|
529
|
+
max_price=args.max_price,
|
|
530
|
+
per_tier=args.per_tier,
|
|
531
|
+
min_per_tier=args.min_per_tier,
|
|
532
|
+
output_file=args.output,
|
|
533
|
+
interactive=sys.stdin.isatty(),
|
|
534
|
+
)
|
|
535
|
+
raise SystemExit(0 if ok else 1)
|