roadmodel 0.2.75__tar.gz → 0.2.76__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.76}/PKG-INFO +1 -1
  2. {roadmodel-0.2.75 → roadmodel-0.2.76}/pyproject.toml +1 -1
  3. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/__init__.py +1 -1
  4. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/recommend.py +110 -11
  5. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/usage.py +25 -0
  6. {roadmodel-0.2.75 → roadmodel-0.2.76}/.gitignore +0 -0
  7. {roadmodel-0.2.75 → roadmodel-0.2.76}/LICENSE +0 -0
  8. {roadmodel-0.2.75 → roadmodel-0.2.76}/NOTICE +0 -0
  9. {roadmodel-0.2.75 → roadmodel-0.2.76}/README.md +0 -0
  10. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/benchmarks.json +0 -0
  11. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/catalog.json +0 -0
  12. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/context-windows.json +0 -0
  13. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/model-selector.txt +0 -0
  14. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/model-tier-cost-scale.md +0 -0
  15. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/settings-display.md +0 -0
  16. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/phase-roadmap-template.md +0 -0
  17. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/planning-kit-how-to-use.md +0 -0
  18. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/project-roadmap-template.md +0 -0
  19. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/prompt-phase-roadmap.md +0 -0
  20. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/prompt-project-roadmap.md +0 -0
  21. {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/user-context.example.md +0 -0
  22. {roadmodel-0.2.75 → roadmodel-0.2.76}/hatch_build.py +0 -0
  23. {roadmodel-0.2.75 → roadmodel-0.2.76}/infra/README.md +0 -0
  24. {roadmodel-0.2.75 → roadmodel-0.2.76}/infra/gcp-killswitch/README.md +0 -0
  25. {roadmodel-0.2.75 → roadmodel-0.2.76}/infra/supabase/README.md +0 -0
  26. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/benchmarks.json +0 -0
  27. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/catalog.json +0 -0
  28. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/context-windows.json +0 -0
  29. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/model-selector.txt +0 -0
  30. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/model-tier-cost-scale.md +0 -0
  31. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/phase-roadmap-template.md +0 -0
  32. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  33. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/project-roadmap-template.md +0 -0
  34. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/prompt-phase-roadmap.md +0 -0
  35. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/prompt-project-roadmap.md +0 -0
  36. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/settings-display.md +0 -0
  37. {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/user-context.example.md +0 -0
  38. {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/agent_context.py +0 -0
  39. {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/agent_rules.py +0 -0
  40. {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/update_projects.py +0 -0
  41. {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/updater_lock.py +0 -0
  42. {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/upgrade_fleet.py +0 -0
  43. {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/worktree_memory.py +0 -0
  44. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/__main__.py +0 -0
  45. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/cli.py +0 -0
  46. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/config.py +0 -0
  47. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/cost.py +0 -0
  48. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/errors.py +0 -0
  49. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/mcp_server.py +0 -0
  50. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/__init__.py +0 -0
  51. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/anthropic.py +0 -0
  52. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/google.py +0 -0
  53. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/openai.py +0 -0
  54. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/openai_compatible.py +0 -0
  55. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/registry.py +0 -0
  56. {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/scoring.py +0 -0
  57. {roadmodel-0.2.75 → roadmodel-0.2.76}/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.76
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.76"
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.76"
@@ -1,14 +1,17 @@
1
1
  # src/roadmodel/recommend.py
2
2
  from __future__ import annotations
3
3
 
4
+ import contextvars
4
5
  import json
5
6
  import re
7
+ from collections import Counter
8
+ from concurrent.futures import ThreadPoolExecutor
6
9
  from dataclasses import asdict
7
10
  from importlib import resources
8
11
  from importlib.resources.abc import Traversable
9
12
  from typing import Any, Final
10
13
 
11
- from roadmodel import cost, scoring, user_context
14
+ from roadmodel import cost, scoring, usage, user_context
12
15
  from roadmodel.config import Config
13
16
  from roadmodel.errors import (
14
17
  BundledDocNotFoundError,
@@ -788,6 +791,36 @@ def recommend(
788
791
  return parse_response(raw_response)
789
792
 
790
793
 
794
+ # A classification vote: the same prompt (so the provider serves its cached
795
+ # prefix) asking for the CLASSIFICATION line alone. Its output cap leaves room
796
+ # for a reasoning model's thinking, which counts against it.
797
+ _VOTE_INSTRUCTION: Final = (
798
+ "\n\nFor this reply return ONLY the `CLASSIFICATION:` line (and the `INPUT:` "
799
+ "line if it applies): no tiers, no rationale."
800
+ )
801
+ _VOTE_MAX_OUTPUT_TOKENS: Final = 1024
802
+
803
+
804
+ def _pinned_instruction(key: str) -> str:
805
+ category, complexity, *rest = key.split("/")
806
+ novel = "novel" if rest else "routine"
807
+ return (
808
+ f"\n\nThis task's classification is already fixed: write the line "
809
+ f"`CLASSIFICATION: {category} / {complexity} / {novel}` and copy that row."
810
+ )
811
+
812
+
813
+ def _vote(key: str | None, votes: list[str | None]) -> tuple[str | None, str]:
814
+ """The row the request answers to: the majority of the main call's key and
815
+ the votes, else the main call's own. Returns (key, source)."""
816
+ found = [k for k in (key, *votes) if k]
817
+ if found:
818
+ top, count = Counter(found).most_common(1)[0]
819
+ if count >= 2 and top != key:
820
+ return top, "vote"
821
+ return key, "declared"
822
+
823
+
791
824
  def recommend_ladder(
792
825
  prompt: str,
793
826
  config: Config,
@@ -799,6 +832,8 @@ def recommend_ladder(
799
832
  thinking_budget: int | None = None,
800
833
  temperature: float | None = None,
801
834
  ladder_table: str | None = None,
835
+ pinned_classification: str | None = None,
836
+ classification_votes: int = 0,
802
837
  ) -> dict[str, dict[str, str]]:
803
838
  """One LLM call that returns the whole Cost/Balanced/Quality ladder (tasks
804
839
  #1/#3): ``{"quality", "balanced", "cost"}`` base dicts, Quality anchored
@@ -810,7 +845,19 @@ def recommend_ladder(
810
845
  picks from the table instead of choosing them, and each base dict carries
811
846
  the ``classification`` key the response declared, when it declared one,
812
847
  and ``input_tokens``, the size of the task's material its INPUT line
813
- declared."""
848
+ declared.
849
+
850
+ The classification is the one judgement left to the engine, and at its
851
+ reasoning floor it varies between identical calls (Phase 4.5 soak,
852
+ 2026-10-08: 6 of 33 probes land on a different row across three calls).
853
+ ``pinned_classification`` (a table key, from an earlier answer to the
854
+ same task) fixes it: the engine is told the row and code holds it.
855
+ Otherwise ``classification_votes`` extra calls, run in parallel with the
856
+ ladder call on the same cached prompt, each return the CLASSIFICATION
857
+ line alone, and the majority row wins (else the ladder call's own). Their
858
+ token usage is folded into the request's (:func:`usage.add`). Each base
859
+ dict then carries ``classification_source`` (pinned / vote / declared)
860
+ and ``classification_votes`` (the votes' keys, comma-separated)."""
814
861
  resolved_user_context = (
815
862
  user_context_text
816
863
  if user_context_text is not None
@@ -825,23 +872,58 @@ def recommend_ladder(
825
872
  ladder_table=ladder_table,
826
873
  )
827
874
  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
- )
875
+ pinned = pinned_classification if ladder_table else None
876
+ n_votes = classification_votes if ladder_table and not pinned else 0
877
+
878
+ def call(user: str, cap: int | None) -> str:
879
+ return adapter.recommend(
880
+ user,
881
+ system_prompt,
882
+ model=config.model,
883
+ api_key=config.api_key,
884
+ max_output_tokens=cap,
885
+ thinking_budget=thinking_budget,
886
+ temperature=temperature,
887
+ )
888
+
889
+ def vote() -> tuple[str | None, usage.CallUsage | None]:
890
+ usage.reset()
891
+ try:
892
+ text = call(user_prompt + _VOTE_INSTRUCTION, _VOTE_MAX_OUTPUT_TOKENS)
893
+ except Exception: # noqa: BLE001 - a failed vote is no vote
894
+ return None, usage.last()
895
+ return parse_classification(text), usage.last()
896
+
897
+ pool = ThreadPoolExecutor(max_workers=n_votes) if n_votes else None
898
+ try:
899
+ pending = (
900
+ [pool.submit(contextvars.copy_context().run, vote) for _ in range(n_votes)]
901
+ if pool
902
+ else []
903
+ )
904
+ raw_response = call(
905
+ user_prompt + (_pinned_instruction(pinned) if pinned else ""), max_output_tokens
906
+ )
907
+ votes = [f.result() for f in pending]
908
+ finally:
909
+ if pool is not None:
910
+ pool.shutdown(wait=False)
911
+ usage.add([u for _, u in votes])
837
912
  parsed = parse_ladder_response(raw_response)
838
913
  classification = parse_classification(raw_response) if ladder_table else None
914
+ if pinned:
915
+ classification, source = pinned, "pinned"
916
+ else:
917
+ classification, source = _vote(classification, [k for k, _ in votes])
839
918
  input_tokens = parse_input_tokens(raw_response) if ladder_table else None
840
919
  for base in parsed.values():
841
920
  if classification:
842
921
  base["classification"] = classification
843
922
  if input_tokens:
844
923
  base["input_tokens"] = str(input_tokens)
924
+ if ladder_table:
925
+ base["classification_source"] = source
926
+ base["classification_votes"] = ",".join(k or "-" for k, _ in votes)
845
927
  return parsed
846
928
 
847
929
 
@@ -1526,6 +1608,8 @@ def recommend_structured_ladder(
1526
1608
  thinking_budget: int | None = None,
1527
1609
  temperature: float | None = None,
1528
1610
  scoring_context_text: str | None = None,
1611
+ classification: str | None = None,
1612
+ classification_votes: int = 0,
1529
1613
  ) -> dict[str, Any]:
1530
1614
  """One-call Cost/Balanced/Quality ladder (tasks #1/#3).
1531
1615
 
@@ -1559,6 +1643,12 @@ def recommend_structured_ladder(
1559
1643
  ``user_context_text`` with no scoring twin gets no table, since the scorer
1560
1644
  could not read its funding.
1561
1645
 
1646
+ ``classification`` pins the row (a ``scoring.table_key`` this table holds;
1647
+ any other value is ignored), and ``classification_votes`` asks for that
1648
+ many classification votes when nothing is pinned (:func:`recommend_ladder`).
1649
+ ``guard`` then reports ``classification_source`` (pinned / vote /
1650
+ declared) and ``classification_votes``.
1651
+
1562
1652
  Raises :class:`MalformedResponseError` (via :func:`recommend_ladder`) on a
1563
1653
  malformed ladder, so the caller falls back to the per-priority fan-out.
1564
1654
  """
@@ -1583,7 +1673,12 @@ def recommend_structured_ladder(
1583
1673
  thinking_budget=thinking_budget,
1584
1674
  temperature=temperature,
1585
1675
  ladder_table=scoring.render_ladder_table(table) or None,
1676
+ pinned_classification=classification if classification in table else None,
1677
+ classification_votes=max(0, classification_votes),
1586
1678
  )
1679
+ first = next(iter(ladder.values()), {})
1680
+ source = first.get("classification_source")
1681
+ votes = [v for v in (first.get("classification_votes") or "").split(",") if v]
1587
1682
  frontier_guard: dict[str, Any] | None = None
1588
1683
  fitted: scoring.Ladder | None = None
1589
1684
  if table:
@@ -1617,6 +1712,8 @@ def recommend_structured_ladder(
1617
1712
  for base in ladder.values():
1618
1713
  base.pop("classification", None)
1619
1714
  base.pop("input_tokens", None)
1715
+ base.pop("classification_source", None)
1716
+ base.pop("classification_votes", None)
1620
1717
  picks = {
1621
1718
  tier: _base_to_payload(
1622
1719
  ladder[tier],
@@ -1631,6 +1728,8 @@ def recommend_structured_ladder(
1631
1728
  guard = _ladder_tier_guard(picks)
1632
1729
  if frontier_guard is not None:
1633
1730
  guard.update(frontier_guard)
1731
+ guard["classification_source"] = source
1732
+ guard["classification_votes"] = [None if v == "-" else v for v in votes]
1634
1733
  if frontier_guard["classification"] is not None:
1635
1734
  guard["healthy"] = True
1636
1735
  return {"picks": picks, "guard": guard}
@@ -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