fix: preserve logprobs on audio transcription responses

`convert_to_model_response_object` copied only
`language/task/duration/words/segments` from a transcription response, so
`logprobs` was silently dropped — even when the caller explicitly asked for
it via `include[]=logprobs`.

Effect: for `gpt-4o-transcribe` there was no way to read per-token
confidence through the proxy. The only visible workaround was `stream=True`,
which returns the raw SSE body as `text` with `usage: null` — and therefore
silently disables cost tracking and budget enforcement for every
transcription request.

Adding `logprobs` to the copied keys makes the non-streaming path complete,
so callers can have both confidence scores and correct spend accounting.

Tests: two regression tests (with and without `logprobs`) in
`tests/test_litellm/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py`.
This commit is contained in:
Maximilian Kohl 2026-08-05 23:11:22 +01:00
parent f54cd287a2
commit 7dc382ecbb
2 changed files with 48 additions and 1 deletions

View file

@ -809,7 +809,7 @@ def convert_to_model_response_object(
if "text" in response_object:
model_response_object.text = response_object["text"]
optional_keys: Final = ["language", "task", "duration", "words", "segments"]
optional_keys: Final = ["language", "task", "duration", "words", "segments", "logprobs"]
for key in optional_keys: # not guaranteed to be in response
if key in response_object:
setattr(model_response_object, key, response_object[key])

View file

@ -14,6 +14,7 @@ from litellm.types.utils import (
ChatCompletionMessageToolCall,
Function,
ModelResponse,
TranscriptionResponse,
)
OPENAI_CUSTOM_TOOL_CALL_RESPONSE = {
@ -102,3 +103,49 @@ def test_handle_invalid_parallel_tool_calls_skips_custom_tool_calls():
)
result = _handle_invalid_parallel_tool_calls([custom_tool_call, function_tool_call])
assert result == [custom_tool_call, function_tool_call]
def test_audio_transcription_preserves_logprobs():
"""`logprobs` must survive conversion (requested via `include[]=logprobs`).
Regression: only language/task/duration/words/segments were copied, so
`logprobs` was silently dropped for `gpt-4o-transcribe` and callers had no
way to read the per-token confidence without falling back to `stream=True`.
"""
response_object = {
"text": "Guten Tag.",
"logprobs": [
{"token": "Guten", "logprob": -0.01, "bytes": [71]},
{"token": " Tag", "logprob": -0.02, "bytes": [32]},
],
"usage": {
"type": "tokens",
"input_tokens": 30,
"output_tokens": 4,
"total_tokens": 34,
"input_token_details": {"text_tokens": 0, "audio_tokens": 30},
},
}
result = convert_to_model_response_object(
response_object=response_object,
model_response_object=TranscriptionResponse(),
response_type="audio_transcription",
)
assert result.text == "Guten Tag."
assert result.logprobs == response_object["logprobs"]
# usage must keep working alongside it (cost tracking depends on it)
assert result.usage.input_tokens == 30
def test_audio_transcription_without_logprobs_is_unchanged():
"""No `logprobs` in the response → attribute simply absent, no crash."""
result = convert_to_model_response_object(
response_object={"text": "Guten Tag."},
model_response_object=TranscriptionResponse(),
response_type="audio_transcription",
)
assert result.text == "Guten Tag."
assert getattr(result, "logprobs", None) is None