Compare commits

...
Author SHA1 Message Date
Alex Schapiro 26785e54b8 Fix LiteLLM cost model resolution 2026-08-11 18:03:27 +00:00
5 changed files with 211 additions and 32 deletions
+54
View File
@@ -0,0 +1,54 @@
"""LiteLLM model-name resolution for local cost estimates."""
from __future__ import annotations
from functools import lru_cache
from typing import Any, cast
@lru_cache(maxsize=512)
def resolve_litellm_model(model: str) -> str | None:
"""Return a provider-qualified model name that LiteLLM can price."""
try:
import litellm
normalized = model.strip()
for prefix in ("litellm/", "any-llm/", "openai/"):
if normalized.startswith(prefix):
normalized = normalized.removeprefix(prefix)
break
if not normalized:
return None
model_cost = cast(
"dict[str, dict[str, Any]]",
getattr(litellm, "model_cost"), # noqa: B009
)
bare_entry = model_cost.get(normalized)
if "/" not in normalized and isinstance(bare_entry, dict):
provider = bare_entry.get("litellm_provider")
if isinstance(provider, str) and provider:
return f"{provider}/{normalized}"
if "/" in normalized and isinstance(bare_entry, dict):
return normalized
names = [normalized]
if "/" in normalized:
names.append(normalized.rsplit("/", 1)[-1])
for name in names:
matches = sorted(key for key in model_cost if key.endswith(f"/{name}"))
if not matches:
continue
prices = {
(
model_cost[key].get("input_cost_per_token"),
model_cost[key].get("output_cost_per_token"),
)
for key in matches
if isinstance(model_cost.get(key), dict)
}
if len(matches) == 1 or len(prices) == 1:
return matches[0]
return None # noqa: TRY300
except Exception: # noqa: BLE001
return None
+6 -2
View File
@@ -14,6 +14,7 @@ from agents.usage import Usage
from strix.config import codex from strix.config import codex
from strix.config.loader import load_settings from strix.config.loader import load_settings
from strix.core.paths import run_dir_for from strix.core.paths import run_dir_for
from strix.report.pricing import resolve_litellm_model
from strix.report.sarif import write_sarif from strix.report.sarif import write_sarif
from strix.report.usage import LLMUsageLedger from strix.report.usage import LLMUsageLedger
from strix.report.writer import ( from strix.report.writer import (
@@ -696,10 +697,13 @@ def _estimate_response_cost(kwargs: Any, completion_response: Any) -> float | No
candidates.append(model.rsplit("/", 1)[-1]) candidates.append(model.rsplit("/", 1)[-1])
for candidate in candidates: for candidate in candidates:
resolved = resolve_litellm_model(candidate)
if not resolved:
continue
try: try:
value = completion_cost( value = completion_cost(
completion_response={"model": candidate, "usage": usage_payload}, completion_response={"model": resolved, "usage": usage_payload},
model=candidate, model=resolved,
) )
except Exception: # nosec B112 # noqa: BLE001, S112 except Exception: # nosec B112 # noqa: BLE001, S112
continue continue
+30 -29
View File
@@ -7,6 +7,8 @@ from typing import Any
from agents.usage import Usage, deserialize_usage, serialize_usage from agents.usage import Usage, deserialize_usage, serialize_usage
from strix.report.pricing import resolve_litellm_model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,7 +20,9 @@ class LLMUsageLedger:
self._total_usage = Usage() self._total_usage = Usage()
self._agent_usage: dict[str, Usage] = {} self._agent_usage: dict[str, Usage] = {}
self._agent_metadata: dict[str, dict[str, str]] = {} self._agent_metadata: dict[str, dict[str, str]] = {}
self._total_cost = 0.0 self._observed_cost = 0.0
self._estimated_cost = 0.0
self._has_observed_cost = False
# When True, tokens are still tracked but cost stays $0 — the run is on a # When True, tokens are still tracked but cost stays $0 — the run is on a
# model subscription, so there is no metered per-token charge to report. # model subscription, so there is no metered per-token charge to report.
self.zero_cost = False self.zero_cost = False
@@ -44,10 +48,10 @@ class LLMUsageLedger:
if model: if model:
metadata["model"] = model metadata["model"] = model
if not self.zero_cost and not _is_litellm_routed(model): if not self.zero_cost:
estimated = _estimate_litellm_cost(usage, model) estimated = _estimate_litellm_cost(usage, model)
if estimated: if estimated:
self._total_cost += estimated self._estimated_cost += estimated
return True return True
@@ -55,15 +59,18 @@ class LLMUsageLedger:
if self.zero_cost: if self.zero_cost:
return return
if isinstance(cost, int | float) and cost > 0: if isinstance(cost, int | float) and cost > 0:
self._total_cost += float(cost) self._observed_cost += float(cost)
self._has_observed_cost = True
@property @property
def total_cost(self) -> float: def total_cost(self) -> float:
return _round_cost(self._total_cost) if self.zero_cost:
return 0.0
return _round_cost(self._observed_cost if self._has_observed_cost else self._estimated_cost)
def to_record(self) -> dict[str, Any]: def to_record(self) -> dict[str, Any]:
record = serialize_usage(self._total_usage) record = serialize_usage(self._total_usage)
record["cost"] = _round_cost(self._total_cost) record["cost"] = self.total_cost
record["agents"] = [] record["agents"] = []
agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()} agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()}
@@ -72,7 +79,7 @@ class LLMUsageLedger:
usage = self._agent_usage[agent_id] usage = self._agent_usage[agent_id]
metadata = self._agent_metadata.get(agent_id, {}) metadata = self._agent_metadata.get(agent_id, {})
agent_cost = ( agent_cost = (
self._total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0 self.total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
) )
agent_record = serialize_usage(usage) agent_record = serialize_usage(usage)
@@ -92,7 +99,9 @@ class LLMUsageLedger:
self._total_usage = Usage() self._total_usage = Usage()
self._agent_usage.clear() self._agent_usage.clear()
self._agent_metadata.clear() self._agent_metadata.clear()
self._total_cost = 0.0 self._observed_cost = 0.0
self._estimated_cost = 0.0
self._has_observed_cost = False
if not isinstance(raw_usage, dict): if not isinstance(raw_usage, dict):
return return
@@ -103,7 +112,9 @@ class LLMUsageLedger:
logger.exception("Failed to hydrate aggregate llm_usage from run.json") logger.exception("Failed to hydrate aggregate llm_usage from run.json")
self._total_usage = Usage() self._total_usage = Usage()
self._total_cost = _float_or_zero(raw_usage.get("cost")) persisted_cost = _float_or_zero(raw_usage.get("cost"))
self._observed_cost = persisted_cost
self._estimated_cost = persisted_cost
for raw_agent in raw_usage.get("agents") or []: for raw_agent in raw_usage.get("agents") or []:
if not isinstance(raw_agent, dict): if not isinstance(raw_agent, dict):
@@ -136,15 +147,6 @@ def _resolve_total_tokens(usage: Usage) -> int:
return prompt + completion return prompt + completion
def _is_litellm_routed(model: str | None) -> bool:
if not model:
return False
name = model.strip().lower()
if "/" not in name:
return False
return not name.startswith("openai/")
def _usage_has_activity(usage: Usage) -> bool: def _usage_has_activity(usage: Usage) -> bool:
return bool( return bool(
usage.requests usage.requests
@@ -201,24 +203,23 @@ def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
candidates = [model] candidates = [model]
if "/" in model: if "/" in model:
candidates.append(model.split("/", 1)[-1]) candidates.append(model.rsplit("/", 1)[-1])
cost: Any = None
for candidate in candidates: for candidate in candidates:
resolved = resolve_litellm_model(candidate)
if not resolved:
continue
try: try:
cost = completion_cost( cost = completion_cost(
completion_response={"model": candidate, "usage": usage_payload}, completion_response={"model": resolved, "usage": usage_payload},
model=model, model=resolved,
) )
break
except Exception: # nosec B112 # noqa: BLE001, S112 except Exception: # nosec B112 # noqa: BLE001, S112
continue continue
if cost > 0:
if cost is None: return float(cost)
logger.debug("LiteLLM cost estimate unavailable for model %s", model) logger.debug("LiteLLM cost estimate unavailable for model %s", model)
return None return None
return cost if isinstance(cost, int | float) and cost >= 0 else None
def _litellm_model_name(model: str | None) -> str | None: def _litellm_model_name(model: str | None) -> str | None:
+1 -1
View File
@@ -143,7 +143,7 @@ def test_cost_callback_estimates_cost_with_bare_model_fallback() -> None:
} }
def fake_completion_cost(**kwargs: object) -> float: def fake_completion_cost(**kwargs: object) -> float:
if kwargs["model"] == "gpt-4o-mini": if kwargs["model"] == "openai/gpt-4o-mini":
return 0.025 return 0.025
raise ValueError(kwargs["model"]) raise ValueError(kwargs["model"])
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from unittest.mock import patch
import litellm
from agents.usage import Usage
from strix.report.pricing import resolve_litellm_model
from strix.report.usage import LLMUsageLedger
def test_resolves_common_bare_model_names() -> None:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
assert resolve_litellm_model("openai/deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
assert resolve_litellm_model("grok-4.5") == "xai/grok-4.5"
assert resolve_litellm_model("MiniMax-M3") == "minimax/MiniMax-M3"
def test_resolver_returns_none_for_unresolvable_model() -> None:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("provider/not-a-real-model") is None
def test_ledger_uses_estimate_when_routed_provider_reports_no_cost() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
with patch("litellm.completion_cost", return_value=0.42):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
assert ledger.total_cost == 0.42
def test_ledger_prefers_observed_cost_over_estimate() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
with patch("litellm.completion_cost", return_value=0.42):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
ledger.record_observed_cost(0.17)
assert ledger.total_cost == 0.17
def test_hydrated_estimate_continues_accumulating_new_estimates() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
ledger.hydrate({"cost": 0.42})
with patch("litellm.completion_cost", return_value=0.17):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
assert ledger.total_cost == 0.59
def test_zero_cost_disables_both_observed_and_estimated_costs() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
ledger.zero_cost = True
with patch("litellm.completion_cost", return_value=0.42) as estimate:
ledger.record(agent_id="a", usage=usage, model="deepseek-v4-flash")
ledger.record_observed_cost(1.0)
estimate.assert_not_called()
assert ledger.total_cost == 0.0
def test_resolver_uses_provider_when_bare_entry_has_one() -> None:
original = litellm.model_cost
litellm.model_cost = {
"example": {
"litellm_provider": "example-provider",
"input_cost_per_token": 1.0,
"output_cost_per_token": 2.0,
}
}
try:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("example") == "example-provider/example"
finally:
litellm.model_cost = original
resolve_litellm_model.cache_clear()
def test_resolver_does_not_guess_between_differently_priced_providers() -> None:
original = litellm.model_cost
litellm.model_cost = {
"provider-a/example": {
"input_cost_per_token": 1.0,
"output_cost_per_token": 2.0,
},
"provider-b/example": {
"input_cost_per_token": 3.0,
"output_cost_per_token": 4.0,
},
}
try:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("example") is None
finally:
litellm.model_cost = original
resolve_litellm_model.cache_clear()