fix(proxy): bill realtime transcription sessions on the OpenAI websocket passthrough

A transcription session names its model only in session.updated under audio.input.transcription, and reports usage on each completed transcription instead of response.done, so its row logged unknown at $0 and a budgeted key never spent.
This commit is contained in:
mateo-berri 2026-10-30 20:01:32 -07:00
parent 4ee39dac33
commit 08fd75f73c
2 changed files with 108 additions and 11 deletions

View file

@ -22,12 +22,15 @@ WebsocketMessages: TypeAlias = Sequence[Mapping[str, object]]
OPENAI_WEBSOCKET_PROVIDER: Final = LlmProviders.OPENAI.value
_SESSION_CREATED: Final = "session.created"
_SESSION_UPDATED: Final = "session.updated"
_RESPONSE_CREATED: Final = "response.created"
_ERROR: Final = "error"
_REALTIME_RESPONSE_DONE: Final = "response.done"
_TRANSCRIPTION_COMPLETED: Final = "conversation.item.input_audio_transcription.completed"
_RESPONSES_TURN_ENDS: Final = frozenset({"response.completed", "response.incomplete", "response.failed"})
_TURN_ENDS: Final = _RESPONSES_TURN_ENDS | {_REALTIME_RESPONSE_DONE}
_MODEL_NAMING_EVENTS: Final = _TURN_ENDS | {_SESSION_CREATED, _RESPONSE_CREATED}
_MODEL_NAMING_EVENTS: Final = _TURN_ENDS | {_SESSION_CREATED, _SESSION_UPDATED, _RESPONSE_CREATED}
_REALTIME_BILLED_EVENTS: Final = frozenset({_REALTIME_RESPONSE_DONE, _TRANSCRIPTION_COMPLETED})
_RESPONSE_COST_HEADER: Final = "llm_provider-x-litellm-response-cost"
@ -37,10 +40,29 @@ class _NamedModel(BaseModel):
model: str | None = None
class _AudioInput(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
transcription: _NamedModel | None = None
class _SessionAudio(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
input: _AudioInput | None = None
class _NamedSession(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
model: str | None = None
audio: _SessionAudio | None = None
class _ModelNamingEvent(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
session: _NamedModel | None = None
session: _NamedSession | None = None
response: _NamedModel | None = None
@ -75,14 +97,11 @@ def _event_model(event: Mapping[str, object]) -> str | None:
naming: Final = _MODEL_NAMING_EVENT.validate_python(event)
except ValidationError:
return None
return next(
(
container.model
for container in (naming.session, naming.response)
if container is not None and container.model
),
None,
)
session: Final = naming.session or _NamedSession()
response: Final = naming.response or _NamedModel()
audio_input: Final = (session.audio or _SessionAudio()).input or _AudioInput()
transcription: Final = audio_input.transcription or _NamedModel()
return session.model or response.model or transcription.model or None
def observed_websocket_model(messages: WebsocketMessages) -> str | None:
@ -103,7 +122,7 @@ def openai_websocket_frame_kind(frame: Mapping[str, object]) -> WebsocketUpstrea
def _realtime_billing(messages: WebsocketMessages, model: str, logging_obj: LiteLLMLoggingObj) -> _Billing | None:
if not any(event.get("type") == _REALTIME_RESPONSE_DONE for event in messages):
if not any(event.get("type") in _REALTIME_BILLED_EVENTS for event in messages):
return None
results: Final = cast( # cast-ok: the realtime cost helpers read raw provider frames through their TypedDict view
OpenAIRealtimeStreamList, tuple(event for event in messages if "type" in event)

View file

@ -19,6 +19,7 @@ from litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_websocket
REALTIME_MODEL: Final = "gpt-realtime-2.1"
RESPONSES_MODEL: Final = "gpt-5.4-mini"
REASONING_MODEL: Final = "gpt-5.4"
TRANSCRIPTION_MODEL: Final = "gpt-4o-mini-transcribe"
SESSION_START: Final = datetime(2026, 1, 1, 12, 0, 0)
@ -308,6 +309,83 @@ def test_a_turn_the_caller_walked_out_of_still_names_the_model_it_ran():
assert logging_obj.model_call_details["model"] == REASONING_MODEL
def _transcription_session(model: str) -> tuple[dict[str, object], ...]:
return (
{"type": "session.created", "session": {"type": "transcription", "audio": {"input": {"transcription": None}}}},
{
"type": "session.updated",
"session": {"type": "transcription", "audio": {"input": {"transcription": {"model": model}}}},
},
)
def _transcription_completed(usage: Mapping[str, object]) -> dict[str, object]:
return {
"type": "conversation.item.input_audio_transcription.completed",
"item_id": "item_fake",
"transcript": "hello",
"usage": usage,
}
@pytest.mark.parametrize(
("usage", "billed_units"),
[
({"type": "duration", "seconds": 3}, ((3, "input_cost_per_second"),)),
(
{
"type": "tokens",
"input_tokens": 30,
"output_tokens": 11,
"input_token_details": {"text_tokens": 0, "audio_tokens": 30},
},
((30, "input_cost_per_audio_token"), (11, "output_cost_per_token")),
),
],
)
def test_a_transcription_session_is_billed_for_every_clip_it_transcribed(usage, billed_units):
handled: Final = _handle(
[
*_transcription_session(TRANSCRIPTION_MODEL),
_transcription_completed(usage),
_transcription_completed(usage),
],
url_route="/openai_passthrough/v1/realtime",
)
logged_cost: Final = litellm.response_cost_calculator(
response_object=handled["result"],
model=handled["kwargs"]["model"],
custom_llm_provider="openai",
call_type="completion",
optional_params={},
)
prices: Final = litellm.model_cost[TRANSCRIPTION_MODEL]
expected_cost: Final = 2 * sum(units * prices[rate] for units, rate in billed_units)
assert expected_cost > 0
assert handled["kwargs"]["model"] == TRANSCRIPTION_MODEL
assert handled["kwargs"]["response_cost"] == pytest.approx(expected_cost)
assert logged_cost == pytest.approx(expected_cost)
def test_a_conversation_that_also_transcribes_the_caller_is_logged_on_its_own_model():
handled: Final = _handle(
[
{
"type": "session.created",
"session": {
"model": REALTIME_MODEL,
"audio": {"input": {"transcription": {"model": TRANSCRIPTION_MODEL}}},
},
},
_realtime_response_done(text_in=10, audio_in=0, text_out=5, audio_out=0),
]
)
assert handled["kwargs"]["model"] == REALTIME_MODEL
@pytest.mark.parametrize("event", ["response.incomplete", "response.failed"])
def test_a_responses_turn_that_ended_early_is_billed_for_the_tokens_it_used(event):
"""