roadmodel 0.2.63__tar.gz → 0.2.64__tar.gz

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 (50) hide show
  1. {roadmodel-0.2.63 → roadmodel-0.2.64}/PKG-INFO +1 -1
  2. {roadmodel-0.2.63 → roadmodel-0.2.64}/pyproject.toml +1 -1
  3. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/__init__.py +1 -1
  4. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/recommend.py +31 -4
  5. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/scoring.py +97 -22
  6. {roadmodel-0.2.63 → roadmodel-0.2.64}/.gitignore +0 -0
  7. {roadmodel-0.2.63 → roadmodel-0.2.64}/LICENSE +0 -0
  8. {roadmodel-0.2.63 → roadmodel-0.2.64}/NOTICE +0 -0
  9. {roadmodel-0.2.63 → roadmodel-0.2.64}/README.md +0 -0
  10. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/benchmarks.json +0 -0
  11. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/catalog.json +0 -0
  12. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/model-selector.txt +0 -0
  13. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/model-tier-cost-scale.md +0 -0
  14. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/settings-display.md +0 -0
  15. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/templates/phase-roadmap-template.md +0 -0
  16. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/templates/planning-kit-how-to-use.md +0 -0
  17. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/templates/project-roadmap-template.md +0 -0
  18. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/templates/prompt-phase-roadmap.md +0 -0
  19. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/templates/prompt-project-roadmap.md +0 -0
  20. {roadmodel-0.2.63 → roadmodel-0.2.64}/docs/user-context.example.md +0 -0
  21. {roadmodel-0.2.63 → roadmodel-0.2.64}/hatch_build.py +0 -0
  22. {roadmodel-0.2.63 → roadmodel-0.2.64}/infra/README.md +0 -0
  23. {roadmodel-0.2.63 → roadmodel-0.2.64}/infra/gcp-killswitch/README.md +0 -0
  24. {roadmodel-0.2.63 → roadmodel-0.2.64}/infra/supabase/README.md +0 -0
  25. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/benchmarks.json +0 -0
  26. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/catalog.json +0 -0
  27. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/model-selector.txt +0 -0
  28. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/model-tier-cost-scale.md +0 -0
  29. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/phase-roadmap-template.md +0 -0
  30. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  31. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/project-roadmap-template.md +0 -0
  32. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  33. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/prompt-project-roadmap.md +0 -0
  34. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/settings-display.md +0 -0
  35. {roadmodel-0.2.63 → roadmodel-0.2.64}/roadmodel/data/user-context.example.md +0 -0
  36. {roadmodel-0.2.63 → roadmodel-0.2.64}/scripts/update_projects.py +0 -0
  37. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/__main__.py +0 -0
  38. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/cli.py +0 -0
  39. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/config.py +0 -0
  40. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/cost.py +0 -0
  41. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/errors.py +0 -0
  42. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/mcp_server.py +0 -0
  43. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/providers/__init__.py +0 -0
  44. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/providers/anthropic.py +0 -0
  45. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/providers/google.py +0 -0
  46. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/providers/openai.py +0 -0
  47. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/providers/openai_compatible.py +0 -0
  48. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/providers/registry.py +0 -0
  49. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/usage.py +0 -0
  50. {roadmodel-0.2.63 → roadmodel-0.2.64}/src/roadmodel/user_context.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: roadmodel
3
- Version: 0.2.63
3
+ Version: 0.2.64
4
4
  Summary: BYO-key CLI that recommends the right AI model, platform, and settings for a prompt.
5
5
  Project-URL: Homepage, https://roadmodel.ai
6
6
  Project-URL: Repository, https://github.com/nathanramoscfa/roadmodel
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "roadmodel"
7
- version = "0.2.63"
7
+ version = "0.2.64"
8
8
  description = "BYO-key CLI that recommends the right AI model, platform, and settings for a prompt."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  # src/roadmodel/__init__.py
2
- __version__ = "0.2.63"
2
+ __version__ = "0.2.64"
@@ -1295,7 +1295,14 @@ def _table_row_key(
1295
1295
  def _rung_pick_sentence(row: scoring.Ladder, tier: str) -> str:
1296
1296
  r = row.rungs[tier]
1297
1297
  c = r.candidate
1298
- facts = f"AA Index {r.point.aa_index:g}, ${c.blended_price_usd:.2f} per 1M tokens at list price"
1298
+ aa = "not measured" if r.point.aa_index is None else f"{r.point.aa_index:g}"
1299
+ facts = f"AA Index {aa}, ${c.blended_price_usd:.2f} per 1M tokens at list price"
1300
+ if r.specialist:
1301
+ return (
1302
+ f"{c.model_name} is the strongest model you can run for {row.task.category} work: "
1303
+ f"its {row.task.category} rating ({c.letter}) is above every model on your "
1304
+ f"cost/quality frontier ({facts})."
1305
+ )
1299
1306
  if tier == "quality":
1300
1307
  return f"{c.model_name} is the strongest model on your cost/quality frontier for {row.task.category} work ({facts})."
1301
1308
  if tier == "cost":
@@ -1375,6 +1382,18 @@ def _attach_backup_plans(picks: dict[str, dict[str, Any]], row: scoring.Ladder)
1375
1382
  }
1376
1383
 
1377
1384
 
1385
+ def _mark_specialist(picks: dict[str, dict[str, Any]], row: scoring.Ladder) -> None:
1386
+ """Flag the QUALITY pick ``specialist`` with its category when the row
1387
+ puts a category specialist there and the pick still names it."""
1388
+ rung = row.rungs["quality"]
1389
+ if not rung.specialist:
1390
+ return
1391
+ if picks["quality"].get("model") != cost.canonical_model_name(rung.candidate.model_name):
1392
+ return
1393
+ picks["quality"]["specialist"] = True
1394
+ picks["quality"]["specialist_category"] = row.task.category
1395
+
1396
+
1378
1397
  # Claude Code's Ultracode is a session setting above its Max level: it stands
1379
1398
  # where the row says max.
1380
1399
  _ULTRACODE_PLATFORM: Final = "claude-code"
@@ -1457,9 +1476,11 @@ def recommend_structured_ladder(
1457
1476
  whose dial the catalog documents (:func:`_enforce_table_effort`), and
1458
1477
  BACKUP included where the row names one (:func:`_enforce_table_backup`;
1459
1478
  such a pick also carries a ``backup_plan`` with the backup's platform and
1460
- effort). ``guard`` then reports ``mode: "frontier"``, the row, how it was
1461
- found, the tiers rewritten, the tiers whose effort was set and the tiers
1462
- whose backup was set, and is healthy: two rungs on one model are the
1479
+ effort). A QUALITY pick the row gives a category specialist carries
1480
+ ``specialist: True`` and ``specialist_category``. ``guard`` then reports
1481
+ ``mode: "frontier"``, the row, how it was found, the tiers rewritten, the
1482
+ tiers whose effort was set, the tiers whose backup was set and the
1483
+ specialist (or None), and is healthy: two rungs on one model are the
1463
1484
  table's answer, not a collapse.
1464
1485
  Without a table, or a response the table cannot place, the engine's own
1465
1486
  picks stand under the tier-distinctness guard as before.
@@ -1509,6 +1530,11 @@ def recommend_structured_ladder(
1509
1530
  "effort_set": _enforce_table_effort(ladder, row),
1510
1531
  "backup_set": _enforce_table_backup(ladder, row),
1511
1532
  "frontier": [p.candidate.model_name for p in row.frontier],
1533
+ "specialist": (
1534
+ {"tier": "quality", "category": row.task.category}
1535
+ if row.rungs["quality"].specialist
1536
+ else None
1537
+ ),
1512
1538
  }
1513
1539
  else:
1514
1540
  frontier_guard = {"mode": "frontier", "classification": None, "found": found}
@@ -1524,6 +1550,7 @@ def recommend_structured_ladder(
1524
1550
  }
1525
1551
  if frontier_guard is not None and frontier_guard["classification"] is not None:
1526
1552
  _attach_backup_plans(picks, table[frontier_guard["classification"]])
1553
+ _mark_specialist(picks, table[frontier_guard["classification"]])
1527
1554
  guard = _ladder_tier_guard(picks)
1528
1555
  if frontier_guard is not None:
1529
1556
  guard.update(frontier_guard)
@@ -1016,7 +1016,9 @@ FRONTIER_INDEX: Final[str] = "artificial_analysis_intelligence_index"
1016
1016
  @dataclass(frozen=True)
1017
1017
  class FrontierPoint:
1018
1018
  candidate: Candidate
1019
- aa_index: float
1019
+ # The AA Intelligence Index; every frontier point has one, and only a
1020
+ # category specialist (off the frontier) may be unmeasured.
1021
+ aa_index: float | None
1020
1022
  # Blended list $/1M, the price /models plots. List price, not what the
1021
1023
  # operator pays: on a subscription every covered model costs them $0, which
1022
1024
  # would collapse the frontier to its top model; list price is what a pick
@@ -1054,6 +1056,8 @@ class Rung:
1054
1056
  # The best cross-maker substitute at no higher list price; None when the
1055
1057
  # pool holds no other maker's model.
1056
1058
  backup: Backup | None = None
1059
+ # True for a QUALITY rung held by a category specialist off the frontier.
1060
+ specialist: bool = False
1057
1061
 
1058
1062
  @property
1059
1063
  def candidate(self) -> Candidate:
@@ -1099,6 +1103,7 @@ class Ladder:
1099
1103
  **point(r.point),
1100
1104
  "effort": r.effort,
1101
1105
  "native_effort": r.native,
1106
+ "specialist": r.specialist,
1102
1107
  "backup": (
1103
1108
  {
1104
1109
  **point(r.backup.point),
@@ -1121,20 +1126,70 @@ def frontier(candidates: list[Candidate], bench: dict[str, Any]) -> list[Frontie
1121
1126
  at its price or less. The rule web/lib/benchmark-grid.ts paretoFrontier
1122
1127
  draws on /models: a tie on price goes to the higher index, a tie on index to
1123
1128
  the cheaper price. Unmeasured models have no place on it."""
1124
- points = [
1125
- FrontierPoint(c, aa, c.blended_price_usd)
1129
+ measured = [
1130
+ (c, aa)
1126
1131
  for c in candidates
1127
1132
  for aa in [_evidence(bench, c.model_id, FRONTIER_INDEX)]
1128
1133
  if aa is not None
1129
1134
  ]
1130
- points.sort(key=lambda p: (p.price_usd, -p.aa_index, p.candidate.model_id))
1135
+ measured.sort(key=lambda m: (m[0].blended_price_usd, -m[1], m[0].model_id))
1131
1136
  out: list[FrontierPoint] = []
1132
- for p in points:
1133
- if not out or p.aa_index > out[-1].aa_index:
1134
- out.append(p)
1137
+ best = -math.inf
1138
+ for c, aa in measured:
1139
+ if aa > best:
1140
+ out.append(FrontierPoint(c, aa, c.blended_price_usd))
1141
+ best = aa
1135
1142
  return out
1136
1143
 
1137
1144
 
1145
+ def _strength(p: FrontierPoint) -> tuple[float, float, float]:
1146
+ """A point's standing for the task's category: its category quality, then
1147
+ the AA Index, then the cheaper price."""
1148
+ return (p.candidate.quality, -1.0 if p.aa_index is None else p.aa_index, -p.price_usd)
1149
+
1150
+
1151
+ # Letters from weakest to strongest.
1152
+ LETTER_ORDER: Final[str] = "DCBAS"
1153
+ # Categories whose evidence is something other than the AA Intelligence Index,
1154
+ # the frontier's own axis: there a model off the frontier can be clearly
1155
+ # stronger for the category (every category but planning).
1156
+ SPECIALIST_CATEGORIES: Final[frozenset[str]] = frozenset(
1157
+ cat for cat, key in CATEGORY_EVIDENCE.items() if key != FRONTIER_INDEX
1158
+ )
1159
+
1160
+
1161
+ def _specialist(
1162
+ task: Task,
1163
+ pool: list[Candidate],
1164
+ front: list[FrontierPoint],
1165
+ top: FrontierPoint,
1166
+ bench: dict[str, Any],
1167
+ ) -> FrontierPoint | None:
1168
+ """A category specialist for QUALITY: the pool model off the frontier
1169
+ whose letter in the task's category is strictly above that of ``top``
1170
+ (the strongest adequate frontier point) and whose category quality is
1171
+ strictly above it too; the strongest such model, else None. Only for
1172
+ SPECIALIST_CATEGORIES."""
1173
+ if task.category not in SPECIALIST_CATEGORIES:
1174
+ return None
1175
+ on_front = {p.candidate.model_id for p in front}
1176
+ rank = LETTER_ORDER.find
1177
+ t = top.candidate
1178
+ stronger = [
1179
+ c
1180
+ for c in pool
1181
+ if c.model_id not in on_front and rank(c.letter) > rank(t.letter) and c.quality > t.quality
1182
+ ]
1183
+ if not stronger:
1184
+ return None
1185
+ best = max(
1186
+ stronger, key=lambda c: (c.quality, rank(c.letter), -c.blended_price_usd, c.model_id)
1187
+ )
1188
+ return FrontierPoint(
1189
+ best, _evidence(bench, best.model_id, FRONTIER_INDEX), best.blended_price_usd
1190
+ )
1191
+
1192
+
1138
1193
  def ladder(
1139
1194
  task: Task,
1140
1195
  user_context_text: str,
@@ -1166,6 +1221,12 @@ def ladder(
1166
1221
  level they may converge. On an `uncapped` pool every free rung runs at
1167
1222
  the top effort and two rungs on one model converge: a lower effort saves
1168
1223
  that operator nothing.
1224
+ - A category specialist (:func:`_specialist`) may take QUALITY in a
1225
+ category whose evidence is not the AA Index: a pool model off the
1226
+ frontier whose letter and category quality are both strictly above
1227
+ the strongest adequate frontier point's. It runs at the best posture's
1228
+ effort; COST and BALANCED stay on the frontier, BALANCED then spanning
1229
+ every adequate point above COST.
1169
1230
  - Each rung carries a BACKUP from another maker (:func:`_backup_for`):
1170
1231
  the best adequate point on the other makers' frontier at no higher list
1171
1232
  price. ``backup_warning`` says why a rung has none.
@@ -1186,15 +1247,14 @@ def ladder(
1186
1247
  if not front:
1187
1248
  return None
1188
1249
 
1189
- def strength(p: FrontierPoint) -> tuple[float, float, float]:
1190
- return (p.candidate.quality, p.aa_index, -p.price_usd)
1191
-
1192
1250
  adequate = [p for p in front if p.candidate.requirement_penalty == 0]
1193
1251
  if not adequate:
1194
- adequate = [max(front, key=strength)]
1195
- top = max(adequate, key=strength)
1252
+ adequate = [max(front, key=_strength)]
1253
+ top = max(adequate, key=_strength)
1196
1254
  span = adequate[: adequate.index(top) + 1]
1197
1255
  headroom = consumption_headroom(user_context_text or "")
1256
+ pool = funded or ranking.candidates
1257
+ specialist = _specialist(task, pool, front, top, bench)
1198
1258
 
1199
1259
  def rung(tier: str, p: FrontierPoint) -> Rung:
1200
1260
  t = Task(task.category, task.complexity, task.novel, TIER_BUDGET[tier])
@@ -1206,8 +1266,14 @@ def ladder(
1206
1266
  return a.candidate.model_id == b.candidate.model_id and a.level == b.level
1207
1267
 
1208
1268
  cost_rung = rung("cost", span[0])
1209
- quality_rung = rung("quality", top)
1210
- between = span[1:-1]
1269
+ quality_rung = (
1270
+ replace(rung("quality", specialist), specialist=True)
1271
+ if specialist is not None
1272
+ else rung("quality", top)
1273
+ )
1274
+ # With a specialist on QUALITY, BALANCED spans every adequate frontier point
1275
+ # above COST, the frontier's strongest included.
1276
+ between = span[1:] if specialist is not None else span[1:-1]
1211
1277
  if between:
1212
1278
  balanced_rung = rung(
1213
1279
  "balanced", max(between, key=lambda p: (p.candidate.score, -p.price_usd))
@@ -1215,15 +1281,16 @@ def ladder(
1215
1281
  else:
1216
1282
  # Two adequate points or one: BALANCED runs one of them at the balanced
1217
1283
  # posture, whichever differs from both other rungs; it converges only
1218
- # when neither does.
1219
- options = [rung("balanced", span[0]), rung("balanced", top)]
1284
+ # when neither does. Beside a specialist it stays on the frontier.
1285
+ options = [rung("balanced", span[0])]
1286
+ if specialist is None:
1287
+ options.append(rung("balanced", top))
1220
1288
  balanced_rung = next(
1221
1289
  (r for r in options if not same(r, cost_rung) and not same(r, quality_rung)),
1222
1290
  options[0],
1223
1291
  )
1224
1292
  balanced_rung = _below(balanced_rung, quality_rung, cat)
1225
1293
  cost_rung = _below(cost_rung, balanced_rung, cat)
1226
- pool = funded or ranking.candidates
1227
1294
  rungs = {
1228
1295
  r.tier: replace(r, backup=_backup_for(r, pool, task, headroom, bench, cat))
1229
1296
  for r in (cost_rung, balanced_rung, quality_rung)
@@ -1264,7 +1331,7 @@ def _backup_for(
1264
1331
  within = [p for p in adequate if p.price_usd <= rung.point.price_usd]
1265
1332
  chosen = within[-1] if within else adequate[0]
1266
1333
  else:
1267
- chosen = max(front, key=lambda p: (p.candidate.quality, p.aa_index, -p.price_usd))
1334
+ chosen = max(front, key=_strength)
1268
1335
  t = Task(task.category, task.complexity, task.novel, TIER_BUDGET[rung.tier])
1269
1336
  c = chosen.candidate
1270
1337
  effort = effort_for(t, headroom, c.scarcity)
@@ -1339,6 +1406,10 @@ def ladder_table(
1339
1406
  return out
1340
1407
 
1341
1408
 
1409
+ def _aa_text(p: FrontierPoint) -> str:
1410
+ return "not measured" if p.aa_index is None else f"{p.aa_index:g}"
1411
+
1412
+
1342
1413
  def render_ladder_table(table: dict[str, Ladder]) -> str:
1343
1414
  """The table as the engine reads it: one row per classification, each rung
1344
1415
  as model, platform and effort, under the frontier it was read from."""
@@ -1351,23 +1422,27 @@ def render_ladder_table(table: dict[str, Ladder]) -> str:
1351
1422
  "the AA Intelligence Index than any they can run for less), cheapest first:",
1352
1423
  " "
1353
1424
  + " < ".join(
1354
- f"{p.candidate.model_name} (AA {p.aa_index:g}, ${p.candidate.blended_price_usd:.2f}/1M list)"
1425
+ f"{p.candidate.model_name} (AA {_aa_text(p)}, ${p.candidate.blended_price_usd:.2f}/1M list)"
1355
1426
  for p in any_ladder.frontier
1356
1427
  ),
1357
1428
  "Rows: <category>/<complexity>[/novel]: COST | BALANCED | QUALITY, each "
1358
1429
  "<model> @ <platform> · <effort>, the effort in the platform's own level words, "
1359
- "then `backup <model> @ <platform>`: that pick's BACKUP.",
1430
+ "then `backup <model> @ <platform>`: that pick's BACKUP. A QUALITY cell marked "
1431
+ "`(top for <category> work)` names a model off the frontier whose rating for that "
1432
+ "category is above every frontier model's.",
1360
1433
  ]
1361
1434
 
1362
- def cell(t: str, r: Rung) -> str:
1435
+ def cell(t: str, r: Rung, category: str) -> str:
1363
1436
  text = f"{t.upper()} = {r.candidate.model_name} @ {r.candidate.platform_name} · {r.level}"
1437
+ if r.specialist:
1438
+ text += f" (top for {category} work)"
1364
1439
  if r.backup is not None:
1365
1440
  b = r.backup.candidate
1366
1441
  text += f", backup {b.model_name} @ {b.platform_name}"
1367
1442
  return text
1368
1443
 
1369
1444
  for key, lad in table.items():
1370
- cells = " | ".join(cell(t, lad.rungs[t]) for t in LADDER_TIERS)
1445
+ cells = " | ".join(cell(t, lad.rungs[t], lad.task.category) for t in LADDER_TIERS)
1371
1446
  lines.append(f"{key}: {cells}")
1372
1447
  lines.append("</ladder-table>")
1373
1448
  return "\n".join(lines)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes