roadmodel 0.2.75__tar.gz → 0.2.77__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 (57) hide show
  1. {roadmodel-0.2.75 → roadmodel-0.2.77}/PKG-INFO +1 -1
  2. {roadmodel-0.2.75 → roadmodel-0.2.77}/pyproject.toml +1 -1
  3. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/__init__.py +1 -1
  4. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/cost.py +5 -2
  5. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/recommend.py +134 -12
  6. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/scoring.py +1 -1
  7. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/usage.py +25 -0
  8. {roadmodel-0.2.75 → roadmodel-0.2.77}/.gitignore +0 -0
  9. {roadmodel-0.2.75 → roadmodel-0.2.77}/LICENSE +0 -0
  10. {roadmodel-0.2.75 → roadmodel-0.2.77}/NOTICE +0 -0
  11. {roadmodel-0.2.75 → roadmodel-0.2.77}/README.md +0 -0
  12. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/benchmarks.json +0 -0
  13. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/catalog.json +0 -0
  14. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/context-windows.json +0 -0
  15. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/model-selector.txt +0 -0
  16. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/model-tier-cost-scale.md +0 -0
  17. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/settings-display.md +0 -0
  18. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/templates/phase-roadmap-template.md +0 -0
  19. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/templates/planning-kit-how-to-use.md +0 -0
  20. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/templates/project-roadmap-template.md +0 -0
  21. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/templates/prompt-phase-roadmap.md +0 -0
  22. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/templates/prompt-project-roadmap.md +0 -0
  23. {roadmodel-0.2.75 → roadmodel-0.2.77}/docs/user-context.example.md +0 -0
  24. {roadmodel-0.2.75 → roadmodel-0.2.77}/hatch_build.py +0 -0
  25. {roadmodel-0.2.75 → roadmodel-0.2.77}/infra/README.md +0 -0
  26. {roadmodel-0.2.75 → roadmodel-0.2.77}/infra/gcp-killswitch/README.md +0 -0
  27. {roadmodel-0.2.75 → roadmodel-0.2.77}/infra/supabase/README.md +0 -0
  28. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/benchmarks.json +0 -0
  29. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/catalog.json +0 -0
  30. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/context-windows.json +0 -0
  31. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/model-selector.txt +0 -0
  32. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/model-tier-cost-scale.md +0 -0
  33. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/phase-roadmap-template.md +0 -0
  34. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  35. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/project-roadmap-template.md +0 -0
  36. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  37. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/prompt-project-roadmap.md +0 -0
  38. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/settings-display.md +0 -0
  39. {roadmodel-0.2.75 → roadmodel-0.2.77}/roadmodel/data/user-context.example.md +0 -0
  40. {roadmodel-0.2.75 → roadmodel-0.2.77}/scripts/agent_context.py +0 -0
  41. {roadmodel-0.2.75 → roadmodel-0.2.77}/scripts/agent_rules.py +0 -0
  42. {roadmodel-0.2.75 → roadmodel-0.2.77}/scripts/update_projects.py +0 -0
  43. {roadmodel-0.2.75 → roadmodel-0.2.77}/scripts/updater_lock.py +0 -0
  44. {roadmodel-0.2.75 → roadmodel-0.2.77}/scripts/upgrade_fleet.py +0 -0
  45. {roadmodel-0.2.75 → roadmodel-0.2.77}/scripts/worktree_memory.py +0 -0
  46. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/__main__.py +0 -0
  47. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/cli.py +0 -0
  48. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/config.py +0 -0
  49. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/errors.py +0 -0
  50. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/mcp_server.py +0 -0
  51. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/providers/__init__.py +0 -0
  52. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/providers/anthropic.py +0 -0
  53. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/providers/google.py +0 -0
  54. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/providers/openai.py +0 -0
  55. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/providers/openai_compatible.py +0 -0
  56. {roadmodel-0.2.75 → roadmodel-0.2.77}/src/roadmodel/providers/registry.py +0 -0
  57. {roadmodel-0.2.75 → roadmodel-0.2.77}/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.75
3
+ Version: 0.2.77
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.75"
7
+ version = "0.2.77"
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.75"
2
+ __version__ = "0.2.77"
@@ -404,7 +404,7 @@ _HOST_METHODS: frozenset[str] = frozenset({"antigravity"})
404
404
  _LOCAL_PROVIDERS: frozenset[str] = frozenset({"ollama"})
405
405
 
406
406
 
407
- def model_provider(model_ref: str) -> str | None:
407
+ def model_provider(model_ref: str, catalog: dict[str, Any] | None = None) -> str | None:
408
408
  """Resolve a model id-or-name to its MAKER (the company that produces it):
409
409
  ``anthropic`` / ``openai`` / ``google`` / ``xai`` / ``deepseek`` / ``mistral``
410
410
  / ``zai`` / ``groq`` / ``cursor`` …
@@ -419,7 +419,10 @@ def model_provider(model_ref: str) -> str | None:
419
419
  rather than dropping a valid backup.
420
420
  """
421
421
  try:
422
- catalog = _load_catalog()
422
+ # A caller that already holds the catalog passes it: the scorer asks
423
+ # once per candidate, and re-parsing the JSON each time was most of a
424
+ # ladder table's cost (Phase 4.5, 2026-10-08).
425
+ catalog = catalog if catalog is not None else _load_catalog()
423
426
  model_id = str(_resolve_model(model_ref, catalog)["id"])
424
427
  except (ValueError, BundledDocNotFoundError, KeyError, TypeError):
425
428
  return None
@@ -1,14 +1,18 @@
1
1
  # src/roadmodel/recommend.py
2
2
  from __future__ import annotations
3
3
 
4
+ import contextvars
4
5
  import json
6
+ import os
5
7
  import re
8
+ from collections import Counter
9
+ from concurrent.futures import ThreadPoolExecutor
6
10
  from dataclasses import asdict
7
11
  from importlib import resources
8
12
  from importlib.resources.abc import Traversable
9
13
  from typing import Any, Final
10
14
 
11
- from roadmodel import cost, scoring, user_context
15
+ from roadmodel import cost, scoring, usage, user_context
12
16
  from roadmodel.config import Config
13
17
  from roadmodel.errors import (
14
18
  BundledDocNotFoundError,
@@ -788,6 +792,36 @@ def recommend(
788
792
  return parse_response(raw_response)
789
793
 
790
794
 
795
+ # A classification vote: the same prompt (so the provider serves its cached
796
+ # prefix) asking for the CLASSIFICATION line alone. Its output cap leaves room
797
+ # for a reasoning model's thinking, which counts against it.
798
+ _VOTE_INSTRUCTION: Final = (
799
+ "\n\nFor this reply return ONLY the `CLASSIFICATION:` line (and the `INPUT:` "
800
+ "line if it applies): no tiers, no rationale."
801
+ )
802
+ _VOTE_MAX_OUTPUT_TOKENS: Final = 1024
803
+
804
+
805
+ def _pinned_instruction(key: str) -> str:
806
+ category, complexity, *rest = key.split("/")
807
+ novel = "novel" if rest else "routine"
808
+ return (
809
+ f"\n\nThis task's classification is already fixed: write the line "
810
+ f"`CLASSIFICATION: {category} / {complexity} / {novel}` and copy that row."
811
+ )
812
+
813
+
814
+ def _vote(key: str | None, votes: list[str | None]) -> tuple[str | None, str]:
815
+ """The row the request answers to: the majority of the main call's key and
816
+ the votes, else the main call's own. Returns (key, source)."""
817
+ found = [k for k in (key, *votes) if k]
818
+ if found:
819
+ top, count = Counter(found).most_common(1)[0]
820
+ if count >= 2 and top != key:
821
+ return top, "vote"
822
+ return key, "declared"
823
+
824
+
791
825
  def recommend_ladder(
792
826
  prompt: str,
793
827
  config: Config,
@@ -799,6 +833,8 @@ def recommend_ladder(
799
833
  thinking_budget: int | None = None,
800
834
  temperature: float | None = None,
801
835
  ladder_table: str | None = None,
836
+ pinned_classification: str | None = None,
837
+ classification_votes: int = 0,
802
838
  ) -> dict[str, dict[str, str]]:
803
839
  """One LLM call that returns the whole Cost/Balanced/Quality ladder (tasks
804
840
  #1/#3): ``{"quality", "balanced", "cost"}`` base dicts, Quality anchored
@@ -810,7 +846,19 @@ def recommend_ladder(
810
846
  picks from the table instead of choosing them, and each base dict carries
811
847
  the ``classification`` key the response declared, when it declared one,
812
848
  and ``input_tokens``, the size of the task's material its INPUT line
813
- declared."""
849
+ declared.
850
+
851
+ The classification is the one judgement left to the engine, and at its
852
+ reasoning floor it varies between identical calls (Phase 4.5 soak,
853
+ 2026-10-08: 6 of 33 probes land on a different row across three calls).
854
+ ``pinned_classification`` (a table key, from an earlier answer to the
855
+ same task) fixes it: the engine is told the row and code holds it.
856
+ Otherwise ``classification_votes`` extra calls, run in parallel with the
857
+ ladder call on the same cached prompt, each return the CLASSIFICATION
858
+ line alone, and the majority row wins (else the ladder call's own). Their
859
+ token usage is folded into the request's (:func:`usage.add`). Each base
860
+ dict then carries ``classification_source`` (pinned / vote / declared)
861
+ and ``classification_votes`` (the votes' keys, comma-separated)."""
814
862
  resolved_user_context = (
815
863
  user_context_text
816
864
  if user_context_text is not None
@@ -825,23 +873,58 @@ def recommend_ladder(
825
873
  ladder_table=ladder_table,
826
874
  )
827
875
  adapter = PROVIDER_ADAPTERS[config.provider]
828
- raw_response = adapter.recommend(
829
- user_prompt,
830
- system_prompt,
831
- model=config.model,
832
- api_key=config.api_key,
833
- max_output_tokens=max_output_tokens,
834
- thinking_budget=thinking_budget,
835
- temperature=temperature,
836
- )
876
+ pinned = pinned_classification if ladder_table else None
877
+ n_votes = classification_votes if ladder_table and not pinned else 0
878
+
879
+ def call(user: str, cap: int | None) -> str:
880
+ return adapter.recommend(
881
+ user,
882
+ system_prompt,
883
+ model=config.model,
884
+ api_key=config.api_key,
885
+ max_output_tokens=cap,
886
+ thinking_budget=thinking_budget,
887
+ temperature=temperature,
888
+ )
889
+
890
+ def vote() -> tuple[str | None, usage.CallUsage | None]:
891
+ usage.reset()
892
+ try:
893
+ text = call(user_prompt + _VOTE_INSTRUCTION, _VOTE_MAX_OUTPUT_TOKENS)
894
+ except Exception: # noqa: BLE001 - a failed vote is no vote
895
+ return None, usage.last()
896
+ return parse_classification(text), usage.last()
897
+
898
+ pool = ThreadPoolExecutor(max_workers=n_votes) if n_votes else None
899
+ try:
900
+ pending = (
901
+ [pool.submit(contextvars.copy_context().run, vote) for _ in range(n_votes)]
902
+ if pool
903
+ else []
904
+ )
905
+ raw_response = call(
906
+ user_prompt + (_pinned_instruction(pinned) if pinned else ""), max_output_tokens
907
+ )
908
+ votes = [f.result() for f in pending]
909
+ finally:
910
+ if pool is not None:
911
+ pool.shutdown(wait=False)
912
+ usage.add([u for _, u in votes])
837
913
  parsed = parse_ladder_response(raw_response)
838
914
  classification = parse_classification(raw_response) if ladder_table else None
915
+ if pinned:
916
+ classification, source = pinned, "pinned"
917
+ else:
918
+ classification, source = _vote(classification, [k for k, _ in votes])
839
919
  input_tokens = parse_input_tokens(raw_response) if ladder_table else None
840
920
  for base in parsed.values():
841
921
  if classification:
842
922
  base["classification"] = classification
843
923
  if input_tokens:
844
924
  base["input_tokens"] = str(input_tokens)
925
+ if ladder_table:
926
+ base["classification_source"] = source
927
+ base["classification_votes"] = ",".join(k or "-" for k, _ in votes)
845
928
  return parsed
846
929
 
847
930
 
@@ -1471,15 +1554,37 @@ def _enforce_table_effort(ladder: dict[str, dict[str, str]], row: scoring.Ladder
1471
1554
  return adjusted
1472
1555
 
1473
1556
 
1557
+ # Ladder tables already computed in this process, by user-context text, the
1558
+ # unavailable models and any catalog override. The catalog and benchmarks are
1559
+ # bundled, so one context always yields the same table; rebuilding it (28 rows,
1560
+ # each ranking the whole pool) cost the hosted service about a second a request
1561
+ # for a well-funded user (Phase 4.5 soak, 2026-10-08: B9 latency). Rows are
1562
+ # read, never mutated. Oldest entry out first past _TABLE_CACHE_MAX.
1563
+ _TABLE_CACHE: dict[tuple[str, tuple[str, ...], str], dict[str, scoring.Ladder]] = {}
1564
+ _TABLE_CACHE_MAX: Final = 64
1565
+
1566
+
1474
1567
  def _ladder_table_for(
1475
1568
  user_context_text: str, unavailable_models: list[str] | None
1476
1569
  ) -> dict[str, scoring.Ladder]:
1477
1570
  """The scorer's ladder table for this user-context, or an empty one if it
1478
1571
  cannot be computed: a scoring failure costs the table, never the call."""
1572
+ key = (
1573
+ user_context_text,
1574
+ tuple(sorted(unavailable_models or [])),
1575
+ os.environ.get("ROADMODEL_CATALOG_PATH", ""),
1576
+ )
1577
+ cached = _TABLE_CACHE.get(key)
1578
+ if cached is not None:
1579
+ return cached
1479
1580
  try:
1480
- return scoring.ladder_table(user_context_text, unavailable_models=unavailable_models)
1581
+ table = scoring.ladder_table(user_context_text, unavailable_models=unavailable_models)
1481
1582
  except Exception: # noqa: BLE001 - fail open to the free-choice ladder
1482
1583
  return {}
1584
+ if len(_TABLE_CACHE) >= _TABLE_CACHE_MAX:
1585
+ _TABLE_CACHE.pop(next(iter(_TABLE_CACHE)), None)
1586
+ _TABLE_CACHE[key] = table
1587
+ return table
1483
1588
 
1484
1589
 
1485
1590
  def _fit_row_to_input(
@@ -1526,6 +1631,8 @@ def recommend_structured_ladder(
1526
1631
  thinking_budget: int | None = None,
1527
1632
  temperature: float | None = None,
1528
1633
  scoring_context_text: str | None = None,
1634
+ classification: str | None = None,
1635
+ classification_votes: int = 0,
1529
1636
  ) -> dict[str, Any]:
1530
1637
  """One-call Cost/Balanced/Quality ladder (tasks #1/#3).
1531
1638
 
@@ -1559,6 +1666,12 @@ def recommend_structured_ladder(
1559
1666
  ``user_context_text`` with no scoring twin gets no table, since the scorer
1560
1667
  could not read its funding.
1561
1668
 
1669
+ ``classification`` pins the row (a ``scoring.table_key`` this table holds;
1670
+ any other value is ignored), and ``classification_votes`` asks for that
1671
+ many classification votes when nothing is pinned (:func:`recommend_ladder`).
1672
+ ``guard`` then reports ``classification_source`` (pinned / vote /
1673
+ declared) and ``classification_votes``.
1674
+
1562
1675
  Raises :class:`MalformedResponseError` (via :func:`recommend_ladder`) on a
1563
1676
  malformed ladder, so the caller falls back to the per-priority fan-out.
1564
1677
  """
@@ -1583,7 +1696,12 @@ def recommend_structured_ladder(
1583
1696
  thinking_budget=thinking_budget,
1584
1697
  temperature=temperature,
1585
1698
  ladder_table=scoring.render_ladder_table(table) or None,
1699
+ pinned_classification=classification if classification in table else None,
1700
+ classification_votes=max(0, classification_votes),
1586
1701
  )
1702
+ first = next(iter(ladder.values()), {})
1703
+ source = first.get("classification_source")
1704
+ votes = [v for v in (first.get("classification_votes") or "").split(",") if v]
1587
1705
  frontier_guard: dict[str, Any] | None = None
1588
1706
  fitted: scoring.Ladder | None = None
1589
1707
  if table:
@@ -1617,6 +1735,8 @@ def recommend_structured_ladder(
1617
1735
  for base in ladder.values():
1618
1736
  base.pop("classification", None)
1619
1737
  base.pop("input_tokens", None)
1738
+ base.pop("classification_source", None)
1739
+ base.pop("classification_votes", None)
1620
1740
  picks = {
1621
1741
  tier: _base_to_payload(
1622
1742
  ladder[tier],
@@ -1631,6 +1751,8 @@ def recommend_structured_ladder(
1631
1751
  guard = _ladder_tier_guard(picks)
1632
1752
  if frontier_guard is not None:
1633
1753
  guard.update(frontier_guard)
1754
+ guard["classification_source"] = source
1755
+ guard["classification_votes"] = [None if v == "-" else v for v in votes]
1634
1756
  if frontier_guard["classification"] is not None:
1635
1757
  guard["healthy"] = True
1636
1758
  return {"picks": picks, "guard": guard}
@@ -1034,7 +1034,7 @@ def rank(
1034
1034
  if str(model.get("jurisdiction", "")).lower() not in juris:
1035
1035
  excluded.append({"model": model_id, "reason": "jurisdiction"})
1036
1036
  continue
1037
- provider = _cost.model_provider(model_id) or str(model.get("provider", "unknown"))
1037
+ provider = _cost.model_provider(model_id, cat) or str(model.get("provider", "unknown"))
1038
1038
  quality, source, letter = _quality(model, task, bench, scale)
1039
1039
  points = _evidence_points(model_id, task, bench, scale)
1040
1040
  shortfall = max(0.0, requirement - quality)
@@ -88,3 +88,28 @@ def record(
88
88
  reasoning_tokens=_int(reasoning_tokens),
89
89
  )
90
90
  )
91
+
92
+
93
+ def add(extra: list[CallUsage | None]) -> None:
94
+ """Fold other calls made for the same request (the classification votes,
95
+ run in their own contexts) into this context's record, summing every
96
+ count, so the caller meters the request whole. ``None`` entries (a call
97
+ that reported nothing) are skipped."""
98
+ calls = [u for u in extra if u is not None]
99
+ if not calls:
100
+ return
101
+ base = _LAST.get()
102
+ if base is not None:
103
+ calls = [base, *calls]
104
+ reasoning = [u.reasoning_tokens for u in calls if u.reasoning_tokens is not None]
105
+ _LAST.set(
106
+ CallUsage(
107
+ provider=calls[0].provider,
108
+ model=calls[0].model,
109
+ input_tokens=sum(u.input_tokens for u in calls),
110
+ cached_input_tokens=sum(u.cached_input_tokens for u in calls),
111
+ cache_write_tokens=sum(u.cache_write_tokens for u in calls),
112
+ output_tokens=sum(u.output_tokens for u in calls),
113
+ reasoning_tokens=sum(reasoning) if reasoning else None,
114
+ )
115
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes