roadmodel 0.2.62__tar.gz → 0.2.63__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.62 → roadmodel-0.2.63}/PKG-INFO +1 -1
  2. {roadmodel-0.2.62 → roadmodel-0.2.63}/pyproject.toml +1 -1
  3. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/__init__.py +1 -1
  4. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/recommend.py +56 -6
  5. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/scoring.py +99 -10
  6. {roadmodel-0.2.62 → roadmodel-0.2.63}/.gitignore +0 -0
  7. {roadmodel-0.2.62 → roadmodel-0.2.63}/LICENSE +0 -0
  8. {roadmodel-0.2.62 → roadmodel-0.2.63}/NOTICE +0 -0
  9. {roadmodel-0.2.62 → roadmodel-0.2.63}/README.md +0 -0
  10. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/benchmarks.json +0 -0
  11. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/catalog.json +0 -0
  12. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/model-selector.txt +0 -0
  13. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/model-tier-cost-scale.md +0 -0
  14. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/settings-display.md +0 -0
  15. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/templates/phase-roadmap-template.md +0 -0
  16. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/templates/planning-kit-how-to-use.md +0 -0
  17. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/templates/project-roadmap-template.md +0 -0
  18. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/templates/prompt-phase-roadmap.md +0 -0
  19. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/templates/prompt-project-roadmap.md +0 -0
  20. {roadmodel-0.2.62 → roadmodel-0.2.63}/docs/user-context.example.md +0 -0
  21. {roadmodel-0.2.62 → roadmodel-0.2.63}/hatch_build.py +0 -0
  22. {roadmodel-0.2.62 → roadmodel-0.2.63}/infra/README.md +0 -0
  23. {roadmodel-0.2.62 → roadmodel-0.2.63}/infra/gcp-killswitch/README.md +0 -0
  24. {roadmodel-0.2.62 → roadmodel-0.2.63}/infra/supabase/README.md +0 -0
  25. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/benchmarks.json +0 -0
  26. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/catalog.json +0 -0
  27. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/model-selector.txt +0 -0
  28. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/model-tier-cost-scale.md +0 -0
  29. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/phase-roadmap-template.md +0 -0
  30. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  31. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/project-roadmap-template.md +0 -0
  32. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  33. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/prompt-project-roadmap.md +0 -0
  34. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/settings-display.md +0 -0
  35. {roadmodel-0.2.62 → roadmodel-0.2.63}/roadmodel/data/user-context.example.md +0 -0
  36. {roadmodel-0.2.62 → roadmodel-0.2.63}/scripts/update_projects.py +0 -0
  37. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/__main__.py +0 -0
  38. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/cli.py +0 -0
  39. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/config.py +0 -0
  40. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/cost.py +0 -0
  41. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/errors.py +0 -0
  42. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/mcp_server.py +0 -0
  43. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/providers/__init__.py +0 -0
  44. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/providers/anthropic.py +0 -0
  45. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/providers/google.py +0 -0
  46. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/providers/openai.py +0 -0
  47. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/providers/openai_compatible.py +0 -0
  48. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/providers/registry.py +0 -0
  49. {roadmodel-0.2.62 → roadmodel-0.2.63}/src/roadmodel/usage.py +0 -0
  50. {roadmodel-0.2.62 → roadmodel-0.2.63}/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.62
3
+ Version: 0.2.63
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.62"
7
+ version = "0.2.63"
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.62"
2
+ __version__ = "0.2.63"
@@ -501,8 +501,10 @@ _SAAS_LADDER_TABLE_HEADER: Final = (
501
501
  "gives both the same settings because a lower effort saves this user nothing or "
502
502
  "the dial has no lower level; say which in that block's RATIONALE, and never "
503
503
  "invent another difference.\n"
504
- "- BACKUP obeys the Step 7 HARD cross-provider rule and names a model this user "
505
- "can reach.\n"
504
+ "- Each block's BACKUP is the model the row names after `backup` in that tier's "
505
+ "cell, copied verbatim; code checks it against the row and replaces one that "
506
+ "differs. Where a cell names no backup, BACKUP obeys the Step 7 HARD "
507
+ "cross-provider rule and names a model this user can reach.\n"
506
508
  "- Each RATIONALE is three labelled segments (TASK: / PICK: / EFFORT:), one crisp "
507
509
  "sentence each. PICK says why the row's model fits its tier in the table's terms "
508
510
  "(where it sits on the user's frontier, its AA Index); EFFORT says why the setting "
@@ -1331,6 +1333,48 @@ def _enforce_table_row(ladder: dict[str, dict[str, str]], row: scoring.Ladder) -
1331
1333
  return rewritten
1332
1334
 
1333
1335
 
1336
+ def _enforce_table_backup(ladder: dict[str, dict[str, str]], row: scoring.Ladder) -> list[str]:
1337
+ """Make every rung's BACKUP the row's: the best model from another maker
1338
+ the user can run at no higher list price (scoring._backup_for). A rung
1339
+ whose row names no backup (no other maker funded) keeps the engine's,
1340
+ under the Step 7 guard. Returns the tiers whose backup it set."""
1341
+ adjusted: list[str] = []
1342
+ for tier in _LADDER_TIERS:
1343
+ backup = row.rungs[tier].backup
1344
+ if backup is None:
1345
+ continue
1346
+ b = backup.candidate
1347
+ written = ladder[tier].get("backup", "")
1348
+ if written and (
1349
+ cost.model_id_of(written) == b.model_id
1350
+ or _model_key(written) in {_model_key(b.model_id), _model_key(b.model_name)}
1351
+ ):
1352
+ continue
1353
+ ladder[tier]["backup"] = b.model_name
1354
+ adjusted.append(tier)
1355
+ return adjusted
1356
+
1357
+
1358
+ def _attach_backup_plans(picks: dict[str, dict[str, Any]], row: scoring.Ladder) -> None:
1359
+ """Give each pick whose BACKUP is the row's a ``backup_plan``: the
1360
+ platform and effort the row runs that backup at, so a caller can show the
1361
+ backup on the surface the scorer chose (the user's subscription surface
1362
+ where one reaches it) instead of guessing one. A backup the guard replaced
1363
+ gets none."""
1364
+ for tier in _LADDER_TIERS:
1365
+ backup = row.rungs[tier].backup
1366
+ if backup is None:
1367
+ continue
1368
+ b = backup.candidate
1369
+ if picks[tier].get("backup") != cost.canonical_model_name(b.model_name):
1370
+ continue
1371
+ picks[tier]["backup_plan"] = {
1372
+ "model": picks[tier]["backup"],
1373
+ "platform": b.platform_name,
1374
+ "effort": _effort_word(backup.level),
1375
+ }
1376
+
1377
+
1334
1378
  # Claude Code's Ultracode is a session setting above its Max level: it stands
1335
1379
  # where the row says max.
1336
1380
  _ULTRACODE_PLATFORM: Final = "claude-code"
@@ -1410,10 +1454,13 @@ def recommend_structured_ladder(
1410
1454
  ladder table (scoring.ladder_table) goes into the prompt, the engine
1411
1455
  classifies the task and copies its row, and every rung is then made the
1412
1456
  row's (:func:`_enforce_table_row`), effort included on every surface
1413
- whose dial the catalog documents (:func:`_enforce_table_effort`). ``guard``
1414
- then reports ``mode: "frontier"``, the row, how it was found, the tiers
1415
- rewritten and the tiers whose effort was set, and is healthy: two rungs on
1416
- one model are the table's answer, not a collapse.
1457
+ whose dial the catalog documents (:func:`_enforce_table_effort`), and
1458
+ BACKUP included where the row names one (:func:`_enforce_table_backup`;
1459
+ 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
1463
+ table's answer, not a collapse.
1417
1464
  Without a table, or a response the table cannot place, the engine's own
1418
1465
  picks stand under the tier-distinctness guard as before.
1419
1466
 
@@ -1460,6 +1507,7 @@ def recommend_structured_ladder(
1460
1507
  "found": found,
1461
1508
  "rewritten": _enforce_table_row(ladder, row),
1462
1509
  "effort_set": _enforce_table_effort(ladder, row),
1510
+ "backup_set": _enforce_table_backup(ladder, row),
1463
1511
  "frontier": [p.candidate.model_name for p in row.frontier],
1464
1512
  }
1465
1513
  else:
@@ -1474,6 +1522,8 @@ def recommend_structured_ladder(
1474
1522
  )
1475
1523
  for tier in _LADDER_TIERS
1476
1524
  }
1525
+ if frontier_guard is not None and frontier_guard["classification"] is not None:
1526
+ _attach_backup_plans(picks, table[frontier_guard["classification"]])
1477
1527
  guard = _ladder_tier_guard(picks)
1478
1528
  if frontier_guard is not None:
1479
1529
  guard.update(frontier_guard)
@@ -56,7 +56,7 @@ from __future__ import annotations
56
56
  import json
57
57
  import math
58
58
  import re
59
- from dataclasses import asdict, dataclass, field
59
+ from dataclasses import asdict, dataclass, field, replace
60
60
  from datetime import datetime, timedelta, timezone
61
61
  from importlib import resources
62
62
  from typing import Any, Final
@@ -1024,6 +1024,24 @@ class FrontierPoint:
1024
1024
  price_usd: float
1025
1025
 
1026
1026
 
1027
+ @dataclass(frozen=True)
1028
+ class Backup:
1029
+ """A rung's cross-maker substitute, read off the frontier of the other
1030
+ makers' models (see :func:`ladder`)."""
1031
+
1032
+ point: FrontierPoint
1033
+ effort: str
1034
+ native: str | None = None
1035
+
1036
+ @property
1037
+ def candidate(self) -> Candidate:
1038
+ return self.point.candidate
1039
+
1040
+ @property
1041
+ def level(self) -> str:
1042
+ return self.native or self.effort
1043
+
1044
+
1027
1045
  @dataclass(frozen=True)
1028
1046
  class Rung:
1029
1047
  tier: str
@@ -1033,6 +1051,9 @@ class Rung:
1033
1051
  # The same level as the platform's own dial names it (native_level), or
1034
1052
  # None when the catalog documents no dial for the platform and model.
1035
1053
  native: str | None = None
1054
+ # The best cross-maker substitute at no higher list price; None when the
1055
+ # pool holds no other maker's model.
1056
+ backup: Backup | None = None
1036
1057
 
1037
1058
  @property
1038
1059
  def candidate(self) -> Candidate:
@@ -1052,6 +1073,8 @@ class Ladder:
1052
1073
  # complexity's requirement, cheapest first.
1053
1074
  adequate: list[FrontierPoint]
1054
1075
  rungs: dict[str, Rung]
1076
+ # Why a rung has no backup: the pool holds no other maker's model.
1077
+ backup_warning: str | None = None
1055
1078
 
1056
1079
  def to_dict(self) -> dict[str, Any]:
1057
1080
  def point(p: FrontierPoint) -> dict[str, Any]:
@@ -1072,9 +1095,23 @@ class Ladder:
1072
1095
  "frontier": [point(p) for p in self.frontier],
1073
1096
  "adequate": [p.candidate.model_id for p in self.adequate],
1074
1097
  "rungs": {
1075
- t: {**point(r.point), "effort": r.effort, "native_effort": r.native}
1098
+ t: {
1099
+ **point(r.point),
1100
+ "effort": r.effort,
1101
+ "native_effort": r.native,
1102
+ "backup": (
1103
+ {
1104
+ **point(r.backup.point),
1105
+ "effort": r.backup.effort,
1106
+ "native_effort": r.backup.native,
1107
+ }
1108
+ if r.backup is not None
1109
+ else None
1110
+ ),
1111
+ }
1076
1112
  for t, r in self.rungs.items()
1077
1113
  },
1114
+ "backup_warning": self.backup_warning,
1078
1115
  }
1079
1116
 
1080
1117
 
@@ -1129,6 +1166,9 @@ def ladder(
1129
1166
  level they may converge. On an `uncapped` pool every free rung runs at
1130
1167
  the top effort and two rungs on one model converge: a lower effort saves
1131
1168
  that operator nothing.
1169
+ - Each rung carries a BACKUP from another maker (:func:`_backup_for`):
1170
+ the best adequate point on the other makers' frontier at no higher list
1171
+ price. ``backup_warning`` says why a rung has none.
1132
1172
 
1133
1173
  None when no pool model carries an AA Index (no frontier to read)."""
1134
1174
  cat = catalog if catalog is not None else _cost._load_catalog()
@@ -1183,8 +1223,52 @@ def ladder(
1183
1223
  )
1184
1224
  balanced_rung = _below(balanced_rung, quality_rung, cat)
1185
1225
  cost_rung = _below(cost_rung, balanced_rung, cat)
1186
- rungs = {"cost": cost_rung, "balanced": balanced_rung, "quality": quality_rung}
1187
- return Ladder(task=base, frontier=front, adequate=adequate, rungs=rungs)
1226
+ pool = funded or ranking.candidates
1227
+ rungs = {
1228
+ r.tier: replace(r, backup=_backup_for(r, pool, task, headroom, bench, cat))
1229
+ for r in (cost_rung, balanced_rung, quality_rung)
1230
+ }
1231
+ warning: str | None = None
1232
+ if any(r.backup is None for r in rungs.values()):
1233
+ makers = sorted({c.provider for c in pool})
1234
+ warning = (
1235
+ f"No cross-provider backup: the models this user can run come from "
1236
+ f"{', '.join(makers) or 'no maker'} only. Add a second maker's subscription or "
1237
+ "API key so an outage or an exhausted pool has somewhere to go."
1238
+ )
1239
+ return Ladder(task=base, frontier=front, adequate=adequate, rungs=rungs, backup_warning=warning)
1240
+
1241
+
1242
+ def _backup_for(
1243
+ rung: Rung,
1244
+ pool: list[Candidate],
1245
+ task: Task,
1246
+ headroom: str,
1247
+ bench: dict[str, Any],
1248
+ catalog: dict[str, Any],
1249
+ ) -> Backup | None:
1250
+ """The rung's backup: the best substitute from another maker the operator
1251
+ can run, at no higher list price.
1252
+
1253
+ Over the pool's models from a maker other than the rung model's, draw the
1254
+ frontier and keep its adequate points. The backup is the adequate point
1255
+ with the highest list price at or below the rung's; else the cheapest
1256
+ adequate point above it; else, with no adequate point, the frontier's
1257
+ strongest for the category. It runs at the rung's posture's effort on its
1258
+ own platform. None when the pool holds no other maker's model."""
1259
+ front = frontier([c for c in pool if c.provider != rung.candidate.provider], bench)
1260
+ if not front:
1261
+ return None
1262
+ adequate = [p for p in front if p.candidate.requirement_penalty == 0]
1263
+ if adequate:
1264
+ within = [p for p in adequate if p.price_usd <= rung.point.price_usd]
1265
+ chosen = within[-1] if within else adequate[0]
1266
+ else:
1267
+ chosen = max(front, key=lambda p: (p.candidate.quality, p.aa_index, -p.price_usd))
1268
+ t = Task(task.category, task.complexity, task.novel, TIER_BUDGET[rung.tier])
1269
+ c = chosen.candidate
1270
+ effort = effort_for(t, headroom, c.scarcity)
1271
+ return Backup(chosen, effort, native_level(c.platform_id, c.model_id, effort, catalog=catalog))
1188
1272
 
1189
1273
 
1190
1274
  def _below(lower: Rung, upper: Rung, catalog: dict[str, Any]) -> Rung:
@@ -1271,14 +1355,19 @@ def render_ladder_table(table: dict[str, Ladder]) -> str:
1271
1355
  for p in any_ladder.frontier
1272
1356
  ),
1273
1357
  "Rows: <category>/<complexity>[/novel]: COST | BALANCED | QUALITY, each "
1274
- "<model> @ <platform> · <effort>, the effort in the platform's own level words.",
1358
+ "<model> @ <platform> · <effort>, the effort in the platform's own level words, "
1359
+ "then `backup <model> @ <platform>`: that pick's BACKUP.",
1275
1360
  ]
1361
+
1362
+ def cell(t: str, r: Rung) -> str:
1363
+ text = f"{t.upper()} = {r.candidate.model_name} @ {r.candidate.platform_name} · {r.level}"
1364
+ if r.backup is not None:
1365
+ b = r.backup.candidate
1366
+ text += f", backup {b.model_name} @ {b.platform_name}"
1367
+ return text
1368
+
1276
1369
  for key, lad in table.items():
1277
- cells = " | ".join(
1278
- f"{t.upper()} = {r.candidate.model_name} @ {r.candidate.platform_name} · {r.level}"
1279
- for t in LADDER_TIERS
1280
- for r in [lad.rungs[t]]
1281
- )
1370
+ cells = " | ".join(cell(t, lad.rungs[t]) for t in LADDER_TIERS)
1282
1371
  lines.append(f"{key}: {cells}")
1283
1372
  lines.append("</ladder-table>")
1284
1373
  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