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