fix(passthrough): bill an OpenAI websocket session at the price its own turns came to

The handler already worked out what each turn cost from the frames the provider
sent back, but the logging layer prices whatever result it is handed and a
single result can only name one model, so it threw that away and recomputed the
whole connection at the session model's rate. A connection that ran a mini model
and then a reasoning model was billed at the mini rate over both turns' tokens,
roughly a third of what it really cost.

The result now carries the settled price on the header the cost calculator reads
before it recomputes, so what the handler works out is what lands in the spend
row. The price is set after the response is built because the constructor's
hidden_params argument never survives: it stores the private attribute before
pydantic initializes it, and pydantic then resets it to the default
This commit is contained in:
mateo-berri 2026-09-06 00:53:31 -07:00
parent 8a86bd4675
commit c01a3bc8ca
2 changed files with 47 additions and 7 deletions

View file

@ -23,6 +23,7 @@ _SESSION_CREATED: Final = "session.created"
_REALTIME_RESPONSE_DONE: Final = "response.done"
_RESPONSES_COMPLETED: Final = "response.completed"
_MODEL_NAMING_EVENTS: Final = frozenset({_SESSION_CREATED, _REALTIME_RESPONSE_DONE, _RESPONSES_COMPLETED})
_RESPONSE_COST_HEADER: Final = "llm_provider-x-litellm-response-cost"
class _NamedModel(BaseModel):
@ -148,6 +149,21 @@ def _responses_cost(usage: Usage, model: str) -> float:
return 0.0
def _priced_result(model: str, billing: _Billing, start_time: datetime) -> ModelResponse:
result: Final = ModelResponse(
id=f"openai-websocket-{start_time.timestamp()}",
object="chat.completion",
created=int(start_time.timestamp()),
model=model,
usage=billing.usage,
)
hidden: Final = { # mutable-ok: response_cost_calculator writes optional_params into this dict
"additional_headers": MappingProxyType({_RESPONSE_COST_HEADER: billing.cost})
}
result._hidden_params = hidden # pyright: ignore[reportPrivateUsage] # the one settled-cost channel
return result
class OpenAIWebsocketPassthroughLoggingHandler:
def openai_websocket_passthrough_handler(
self,
@ -181,13 +197,7 @@ class OpenAIWebsocketPassthroughLoggingHandler:
logging_obj.model_call_details.update(MappingProxyType({"response_cost": billing.cost}))
priced: Final[PassThroughEndpointLoggingTypedDict] = {
"result": ModelResponse(
id=f"openai-websocket-{start_time.timestamp()}",
object="chat.completion",
created=int(start_time.timestamp()),
model=model,
usage=billing.usage,
),
"result": _priced_result(model, billing, start_time),
"kwargs": {
**kwargs,
"model": model,

View file

@ -237,3 +237,33 @@ def test_a_responses_turn_without_readable_usage_is_not_billed():
assert handled["result"] is None
assert handled["kwargs"] == {"model": RESPONSES_MODEL, "custom_llm_provider": "openai"}
def test_the_price_of_a_multi_model_connection_reaches_the_spend_row():
"""
The logging layer prices whatever result it is handed, and one result can
only name one model, so a connection that ran two models has to carry the
price it already worked out or the cheaper model pays for both turns.
"""
handled = _handle(
[
_responses_completed(RESPONSES_MODEL, input_tokens=13, output_tokens=5),
_responses_completed(REASONING_MODEL, input_tokens=20, output_tokens=7),
],
url_route="/openai_passthrough/v1/responses",
)
logged_cost = litellm.response_cost_calculator(
response_object=handled["result"],
model=handled["kwargs"]["model"],
custom_llm_provider="openai",
call_type="completion",
optional_params={},
)
session_prices = litellm.model_cost[RESPONSES_MODEL]
priced_on_the_session_model = (
33 * session_prices["input_cost_per_token"] + 12 * session_prices["output_cost_per_token"]
)
assert logged_cost == pytest.approx(handled["kwargs"]["response_cost"])
assert logged_cost > priced_on_the_session_model