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.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. 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)