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.
- {roadmodel-0.2.75 → roadmodel-0.2.76}/PKG-INFO +1 -1
- {roadmodel-0.2.75 → roadmodel-0.2.76}/pyproject.toml +1 -1
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/__init__.py +1 -1
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/recommend.py +110 -11
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/usage.py +25 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/.gitignore +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/LICENSE +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/NOTICE +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/README.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/benchmarks.json +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/catalog.json +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/context-windows.json +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/model-selector.txt +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/settings-display.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/phase-roadmap-template.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/project-roadmap-template.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/templates/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/docs/user-context.example.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/hatch_build.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/infra/README.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/infra/gcp-killswitch/README.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/infra/supabase/README.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/benchmarks.json +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/catalog.json +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/context-windows.json +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/model-selector.txt +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/model-tier-cost-scale.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/phase-roadmap-template.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/planning-kit-how-to-use.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/project-roadmap-template.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/prompt-phase-roadmap.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/prompt-project-roadmap.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/settings-display.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/roadmodel/data/user-context.example.md +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/agent_context.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/agent_rules.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/update_projects.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/updater_lock.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/upgrade_fleet.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/scripts/worktree_memory.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/__main__.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/cli.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/config.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/cost.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/errors.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/mcp_server.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/__init__.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/anthropic.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/google.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/openai.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/openai_compatible.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/providers/registry.py +0 -0
- {roadmodel-0.2.75 → roadmodel-0.2.76}/src/roadmodel/scoring.py +0 -0
- {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.
|
|
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
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# src/roadmodel/__init__.py
|
|
2
|
-
__version__ = "0.2.
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|