mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
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:
parent
4ee39dac33
commit
08fd75f73c
2 changed files with 108 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue