Skip to content

Commit 1c9ab99

Browse files
authored
Use observed LiteLLM cost for LiteLLM-routed calls (#529)
Register a litellm.success_callback that captures kwargs['response_cost'] into a new observed-cost bucket on LLMUsageLedger. record() skips the tokens-times-registry estimate for LiteLLM-routed models so we do not double-count with the callback; OpenAI direct routes keep estimating since LiteLLM is not invoked for them. Per-agent attribution for LiteLLM-routed calls is apportioned by token share at to_record() time.
1 parent 04eb03f commit 1c9ab99

4 files changed

Lines changed: 145 additions & 29 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ ignore = [
219219
# ReportState carries scan artifact/report fields and
220220
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
221221
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
222+
"strix/report/usage.py" = ["PLC0415"]
223+
"strix/report/cost_capture.py" = ["PLC0415"]
224+
"strix/config/models.py" = ["PLC0415"]
222225
# Interface utility branches per scope-mode / target-type combination;
223226
# splitting would obscure the decision tree without simplifying it.
224227
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]

strix/config/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ def _configure_litellm_compatibility() -> None:
104104
litellm.disable_streaming_logging = True
105105
litellm.suppress_debug_info = True
106106

107+
_register_litellm_cost_callback()
108+
109+
110+
def _register_litellm_cost_callback() -> None:
111+
import litellm
112+
113+
from strix.report.cost_capture import litellm_cost_callback
114+
115+
for bucket_name in ("success_callback", "_async_success_callback"):
116+
bucket = getattr(litellm, bucket_name, None)
117+
if not isinstance(bucket, list):
118+
continue
119+
if litellm_cost_callback in bucket:
120+
continue
121+
bucket.append(litellm_cost_callback)
122+
107123

108124
def _configure_litellm_default(name: str, value: str) -> None:
109125
"""Set LiteLLM's module-level defaults without adding a provider wrapper."""

strix/report/cost_capture.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""LiteLLM success-callback that feeds observed cost into the report ledger."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def litellm_cost_callback(
13+
kwargs: dict[str, Any],
14+
completion_response: Any,
15+
_start_time: Any = None,
16+
_end_time: Any = None,
17+
) -> None:
18+
cost = _extract_cost(kwargs, completion_response)
19+
if cost is None or cost <= 0:
20+
return
21+
22+
from strix.report.state import get_global_report_state
23+
24+
report_state = get_global_report_state()
25+
if report_state is None:
26+
return
27+
28+
try:
29+
report_state._llm_usage.record_observed_cost(cost)
30+
except Exception:
31+
logger.exception("Failed to record observed LiteLLM cost")
32+
33+
34+
def _extract_cost(kwargs: dict[str, Any], completion_response: Any) -> float | None:
35+
cost = kwargs.get("response_cost") if isinstance(kwargs, dict) else None
36+
if isinstance(cost, int | float) and cost > 0:
37+
return float(cost)
38+
39+
hidden = getattr(completion_response, "_hidden_params", None)
40+
if isinstance(hidden, dict):
41+
candidate = hidden.get("response_cost")
42+
if isinstance(candidate, int | float) and candidate > 0:
43+
return float(candidate)
44+
headers = hidden.get("additional_headers") or {}
45+
if isinstance(headers, dict):
46+
from_header = headers.get("llm_provider-x-litellm-response-cost")
47+
try:
48+
value = float(from_header) if from_header is not None else None
49+
except (TypeError, ValueError):
50+
value = None
51+
if value is not None and value > 0:
52+
return value
53+
return None

strix/report/usage.py

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ def __init__(self) -> None:
1818
self._total_usage = Usage()
1919
self._agent_usage: dict[str, Usage] = {}
2020
self._agent_metadata: dict[str, dict[str, str]] = {}
21-
self._total_cost = 0.0
22-
self._agent_cost: dict[str, float] = {}
21+
self._estimated_cost = 0.0
22+
self._agent_estimated_cost: dict[str, float] = {}
23+
self._observed_cost = 0.0
2324

2425
def record(
2526
self,
@@ -33,8 +34,6 @@ def record(
3334
return False
3435

3536
normalized_agent_id = str(agent_id or "unknown")
36-
estimated_cost = _estimate_litellm_cost(usage, model)
37-
3837
self._total_usage.add(usage)
3938
self._agent_usage.setdefault(normalized_agent_id, Usage()).add(usage)
4039

@@ -44,31 +43,49 @@ def record(
4443
if model:
4544
metadata["model"] = model
4645

47-
if estimated_cost is not None:
48-
self._total_cost += estimated_cost
49-
self._agent_cost[normalized_agent_id] = (
50-
self._agent_cost.get(normalized_agent_id, 0.0) + estimated_cost
51-
)
46+
if not _is_litellm_routed(model):
47+
estimated_cost = _estimate_litellm_cost(usage, model)
48+
if estimated_cost is not None:
49+
self._estimated_cost += estimated_cost
50+
self._agent_estimated_cost[normalized_agent_id] = (
51+
self._agent_estimated_cost.get(normalized_agent_id, 0.0) + estimated_cost
52+
)
5253

5354
return True
5455

56+
def record_observed_cost(self, cost: float) -> None:
57+
if isinstance(cost, int | float) and cost > 0:
58+
self._observed_cost += float(cost)
59+
5560
def to_record(self) -> dict[str, Any]:
5661
record = serialize_usage(self._total_usage)
57-
record["cost"] = _round_cost(self._total_cost)
58-
record["cost_source"] = "litellm_estimate"
62+
grand_total = self._estimated_cost + self._observed_cost
63+
record["cost"] = _round_cost(grand_total)
64+
record["cost_source"] = _cost_source_label(self._estimated_cost, self._observed_cost)
5965
record["agents"] = []
6066

67+
total_tokens = max(0, int(self._total_usage.total_tokens or 0))
68+
6169
for agent_id in sorted(self._agent_usage):
6270
usage = self._agent_usage[agent_id]
6371
metadata = self._agent_metadata.get(agent_id, {})
72+
agent_tokens = max(0, int(usage.total_tokens or 0))
73+
observed_share = (
74+
self._observed_cost * (agent_tokens / total_tokens) if total_tokens else 0.0
75+
)
76+
agent_total = self._agent_estimated_cost.get(agent_id, 0.0) + observed_share
77+
6478
agent_record = serialize_usage(usage)
6579
agent_record.update(
6680
{
6781
"agent_id": agent_id,
6882
"agent_name": metadata.get("agent_name") or agent_id,
6983
"model": metadata.get("model"),
70-
"cost": _round_cost(self._agent_cost.get(agent_id, 0.0)),
71-
"cost_source": "litellm_estimate",
84+
"cost": _round_cost(agent_total),
85+
"cost_source": _cost_source_label(
86+
self._agent_estimated_cost.get(agent_id, 0.0),
87+
observed_share,
88+
),
7289
}
7390
)
7491
record["agents"].append(agent_record)
@@ -79,8 +96,9 @@ def hydrate(self, raw_usage: Any) -> None:
7996
self._total_usage = Usage()
8097
self._agent_usage.clear()
8198
self._agent_metadata.clear()
82-
self._total_cost = 0.0
83-
self._agent_cost.clear()
99+
self._estimated_cost = 0.0
100+
self._agent_estimated_cost.clear()
101+
self._observed_cost = 0.0
84102

85103
if not isinstance(raw_usage, dict):
86104
return
@@ -91,7 +109,9 @@ def hydrate(self, raw_usage: Any) -> None:
91109
logger.exception("Failed to hydrate aggregate llm_usage from run.json")
92110
self._total_usage = Usage()
93111

94-
self._total_cost = _float_or_zero(raw_usage.get("cost"))
112+
# Resumed runs have already-aggregated cost; treat as estimated. New calls
113+
# in this resume add observed cost on top.
114+
self._estimated_cost = _float_or_zero(raw_usage.get("cost"))
95115
agents = raw_usage.get("agents") or []
96116
if not isinstance(agents, list):
97117
return
@@ -116,7 +136,24 @@ def hydrate(self, raw_usage: Any) -> None:
116136
if isinstance(model, str) and model:
117137
metadata["model"] = model
118138
self._agent_metadata[agent_id] = metadata
119-
self._agent_cost[agent_id] = _float_or_zero(raw_agent.get("cost"))
139+
self._agent_estimated_cost[agent_id] = _float_or_zero(raw_agent.get("cost"))
140+
141+
142+
def _is_litellm_routed(model: str | None) -> bool:
143+
if not model:
144+
return False
145+
name = model.strip().lower()
146+
if "/" not in name:
147+
return False
148+
return not name.startswith("openai/")
149+
150+
151+
def _cost_source_label(estimated: float, observed: float) -> str:
152+
if observed > 0 and estimated > 0:
153+
return "mixed"
154+
if observed > 0:
155+
return "litellm_observed"
156+
return "litellm_estimate"
120157

121158

122159
def _usage_has_activity(usage: Usage) -> bool:
@@ -171,18 +208,25 @@ def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
171208
if completion_details:
172209
usage_payload["completion_tokens_details"] = completion_details
173210

174-
try:
175-
from litellm import completion_cost
176-
177-
cost = completion_cost(
178-
completion_response={
179-
"model": model.split("/", 1)[-1],
180-
"usage": usage_payload,
181-
},
182-
model=model,
183-
)
184-
except Exception: # noqa: BLE001 - LiteLLM raises plain Exception for unknown model prices.
185-
logger.debug("LiteLLM cost estimate unavailable for model %s", model, exc_info=True)
211+
from litellm import completion_cost
212+
213+
candidates = [model]
214+
if "/" in model:
215+
candidates.append(model.split("/", 1)[-1])
216+
217+
cost: Any = None
218+
for candidate in candidates:
219+
try:
220+
cost = completion_cost(
221+
completion_response={"model": candidate, "usage": usage_payload},
222+
model=model,
223+
)
224+
break
225+
except Exception: # nosec B112 # noqa: BLE001, S112
226+
continue
227+
228+
if cost is None:
229+
logger.debug("LiteLLM cost estimate unavailable for model %s", model)
186230
return None
187231

188232
return cost if isinstance(cost, int | float) and cost >= 0 else None

0 commit comments

Comments
 (0)