mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26785e54b8 |
@@ -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
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user