Skip to content

fix(convert_dict_to_response): handle empty choices list without raising 500 APIError - #40294

Merged
mateo-berri merged 8 commits into
BerriAI:litellm_internal_stagingfrom
shotsan:fix/empty-choices-handling
Sep 9, 2026
Merged

mateo-berri merged 8 commits into
BerriAI:litellm_internal_stagingfrom
shotsan:fix/empty-choices-handling

Conversation

@shotsan

@shotsan shotsan commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Upstream models returning choices: [] (such as Gemini/Vertex safety blocks) trigger a 500 APIError: Response was missing 'choices' key.
  • Cache hits and the /v1/messages bridge turn that empty list into a 500 the client cannot retry around

How it solves it:

  • Checks that "choices" is a real list rather than relying on boolean truthiness, so {}, "", and None still raise
  • Allows empty list choices: [] to map cleanly to ModelResponse(choices=[]).
  • /v1/messages answers an empty completion with an empty assistant message and stop_reason: end_turn
  • A stream served from the cache for a completion stored with no choices ends as one finish_reason: stop chunk instead of failing on choices[0] in the streaming wrapper
  • When choices is present but not a list (null, an object, a string, a number), the error names that type instead of claiming the key is missing; the old wording stays for a response that really has no choices key

Why end_turn for a completion with no choice: the Anthropic Messages API allows an empty content list with stop_reason: end_turn and the Anthropic SDK's Message model accepts that shape, so an Anthropic client already handles it. refusal would claim a safety refusal the provider did not report (Gemini's NO_IMAGE means the requested modality was not produced), max_tokens would be a guess: the reproduced case has zero output tokens, while a thinking model that spends its whole budget on thoughts is dropped the same way (#36881, with fix PRs #36870, #38301 and #40252 open) and arrives with nonzero output_tokens because the usage mapper folds reasoning tokens into completion_tokens, so the adapter cannot tell the two apart from usage and reports the one reason the SDK treats as a normal end. Raising would put the 500 back on the cache-hit path this PR fixes. The provider's own reason never reaches this layer: the Gemini transformation drops a candidate that has no content before building choices (_process_candidates, unchanged here), so surfacing it is LIT-7413 (tracked publicly as #40477), a change in that transformation rather than in the converter

The same empty list is what Gemini's MALFORMED_FUNCTION_CALL and OTHER candidates become, because _process_candidates drops any candidate without a content key whatever its finishReason. So on /v1/messages a botched tool call is now an empty end_turn answer where before it was a 500 after the gateway's own provider retries (two by default), the one path a nondeterministic failed call could have recovered through, and that trade holds until #40477 turns the dropped candidate into a choice with its finish reason. The truncated thinking model of #36881 is the same trade with a different cost: it ends as an empty end_turn rather than max_tokens, so a client that would have continued the turn on max_tokens will not. A Gemini safety block is not one of these cases: the transformation turns a blocked prompt or a flagged finishReason into a content_filter choice, so the producer reproduced below is a candidate with no content key. The tests/test_litellm/test_main.py change is required by the narrowed check: the patched create left a MagicMock where choices goes, which the old Iterable test accepted and iterated to nothing

Why the converter rather than a Gemini-only change: the converter is the one place every non-stream answer, every cache hit and the /v1/messages bridge pass through, so fixing it there makes the first send and the cached repeat agree. The alternatives were a per-provider allowance (the converter has no provider at hand, so scoping it means threading one through every caller, and the cache-hit path would still 500 for any other provider that returns an empty list) and keeping the error (which keeps the customer's 500 and spends the gateway's retries on an answer that is empty every time). The cost is that every OpenAI-shaped upstream (OpenAI, Azure, Mistral, the rust bridge, any OpenAI-compatible server) now returns choices: [] as a 200 that router retries and fallbacks do not fire on, listed under Caveats. This also reverses the choices: [] case of #29492 (its test_convert_to_model_response_object_empty_choices_raises_api_error pinned the empty list as an error next to the missing key); that PR's missing-key tests stay as they were

User Flow

Before: a Claude-compatible client pointed at a Gemini model through the gateway gets a 500 whenever Gemini answers with no candidate content

  1. They send POST https://litellm-domain/v1/messages with "model": "gemini-2.5-flash-image", "max_tokens": 256, and a text-only question Gemini declines to answer with content
  2. The response is HTTP 500 on the first send and on the repeat: {"type":"error","error":{"type":"api_error","message":"litellm.APIError: LiteLLM: provider returned a response with no 'choices'. Raw keys: ['id', 'created', 'model', 'object', 'system_fingerprint', 'choices', ...]"}}, an error that lists choices among the keys it says are missing. The bridge fails on the empty answer, the gateway retries, and the retry is served from the response cache the first attempt just filled
  3. They send the same question to POST https://litellm-domain/v1/chat/completions and get HTTP 200 with "choices": []
  4. They send that chat request again and now get HTTP 500 with the same "no 'choices'" message, because the gateway serves the second one from its response cache
  5. Their client treats the 500 as a gateway failure and fails the turn or retries into the same error

After: the same requests come back HTTP 200 as an empty assistant turn every time

  1. They send POST https://litellm-domain/v1/messages with "model": "gemini-2.5-flash-image", "max_tokens": 256, and a text-only question Gemini declines to answer with content
  2. The response is HTTP 200: {"type":"message","role":"assistant","content":[],"stop_reason":"end_turn","usage":{"input_tokens":19,"output_tokens":0}}
  3. They send the same question to POST https://litellm-domain/v1/chat/completions and get HTTP 200 with "choices": []
  4. They send that chat request again and still get HTTP 200 with "choices": [], served from the response cache, and a repeated /v1/messages send comes back from the cache the same way
  5. Their client sees an empty turn with real usage and moves on

Relevant issues

Fixes #40276 (the 500 and the message that named choices as missing). The finish reason the issue also asks to see is #40477, a change in the Gemini transformation

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally
  • My PR passes all required CI/CD checks
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Both legs ran a 2-worker proxy with no database, booted from this branch's worktree at the named commit, with a local Redis response cache flushed and the proxy restarted between legs. Every request went to the real Gemini API. Requests 1 to 4 were each sent twice so the second send is a cache hit, requests 5 and 6 (/v1/responses) once, and request 9 repeats those two the same way. Requests 7 and 7b copy one cached entry over another in Redis, which is the shape a semantic cache serves to a stream request, and restart the proxy so the answer comes from Redis rather than a worker's in-memory copy. Requests 8 to 8c do the same for /v1/messages: the Messages endpoint keeps its own copy of a streamed answer's events, so after the swap that copy is dropped, which makes the next stream fall through to the completion-level entry, the path in the reporter's traceback. Bearer values below are the proxy's own master key from the environment, and long response ids are shortened

config.yaml:

model_list:
  - model_name: gemini-2.5-flash-image
    litellm_params:
      model: gemini/gemini-2.5-flash-image
      api_key: os.environ/GEMINI_API_KEY
      modalities: ["image"]
litellm_settings:
  cache: true
  cache_params:
    type: redis
    host: 127.0.0.1
    port: 21556
    ttl: 900
general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY

Boot and shared shell setup (an image-only Gemini model asked a text-only question is a real upstream reply with a candidate but no content, which is how LiteLLM ends up with choices: []):

python litellm/proxy/proxy_cli.py --config config.yaml --port $PORT --num_workers 2
H=(-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json")
Q='What is 2+2? Reply in text only. Do not generate any image.'

Before (096984b)

1. /v1/chat/completions, first send (cache miss)

  1. curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/chat/completions ${H[@]} -d '{"model":"gemini-2.5-flash-image","messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
{"id":"IMqhao-gLrCqsOIPobrH8QQ","created":1788987936,"model":"gemini-2.5-flash-image","object":"chat.completion","choices":[],"usage":{"completion_tokens":0,"prompt_tokens":19,"total_tokens":19,"prompt_tokens_details":{"text_tokens":19}},"vertex_ai_grounding_metadata":[],"vertex_ai_url_context_metadata":[],"vertex_ai_safety_results":[],"vertex_ai_citation_metadata":[],"service_tier":"default"}
HTTP 200

1b. /v1/chat/completions, same request 3 seconds later (cache hit)

  1. Same command as the request above
  2. Output:
{"error":{"message":"litellm.APIError: LiteLLM: provider returned a response with no 'choices'. Raw keys: ['id', 'created', 'model', 'object', 'system_fingerprint', 'choices', 'usage', 'vertex_ai_grounding_metadata', 'vertex_ai_url_context_metadata', 'vertex_ai_safety_results', 'vertex_ai_citation_metadata', 'service_tier']. Received Model Group=gemini-2.5-flash-image\nAvailable Model Group Fallbacks=None","type":"internal_server_error","param":null,"code":"500"}}
HTTP 500

2. /v1/chat/completions, stream=true, first send

  1. curl -sN -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/chat/completions ${H[@]} -d '{"model":"gemini-2.5-flash-image","stream":true,"messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
data: {"id":"K8qhaq36MZHJ-sAP877ByAc","object":"chat.completion.chunk","created":1788987948,"model":"gemini-2.5-flash-image","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]


HTTP 200

2b. /v1/chat/completions, stream=true, same request 3 seconds later (cache hit on the stream entry)

  1. Same command as the request above
  2. Output:
data: {"id":"K8qhaq36MZHJ-sAP877ByAc","object":"chat.completion.chunk","created":1788987951,"model":"gemini-2.5-flash-image","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}

data: {"id":"K8qhaq36MZHJ-sAP877ByAc","object":"chat.completion.chunk","created":1788987951,"model":"gemini-2.5-flash-image","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]


HTTP 200

3. /v1/messages, first send (cache miss)

  1. curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/messages ${H[@]} -d '{"model":"gemini-2.5-flash-image","max_tokens":256,"messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
{"type":"error","error":{"type":"api_error","message":"litellm.APIError: LiteLLM: provider returned a response with no 'choices'. Raw keys: ['id', 'created', 'model', 'object', 'system_fingerprint', 'choices', 'usage', 'vertex_ai_grounding_metadata', 'vertex_ai_url_context_metadata', 'vertex_ai_safety_results', 'vertex_ai_citation_metadata', 'service_tier']. Received Model Group=gemini-2.5-flash-image\nAvailable Model Group Fallbacks=None"}}
HTTP 500

3b. /v1/messages, same request 3 seconds later (cache hit)

  1. Same command as the request above
  2. Output:
{"type":"error","error":{"type":"api_error","message":"litellm.APIError: LiteLLM: provider returned a response with no 'choices'. Raw keys: ['id', 'created', 'model', 'object', 'system_fingerprint', 'choices', 'usage', 'vertex_ai_grounding_metadata', 'vertex_ai_url_context_metadata', 'vertex_ai_safety_results', 'vertex_ai_citation_metadata', 'service_tier']. Received Model Group=gemini-2.5-flash-image\nAvailable Model Group Fallbacks=None"}}
HTTP 500

4. /v1/messages, stream=true, first send

  1. curl -sN -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/messages ${H[@]} -d '{"model":"gemini-2.5-flash-image","max_tokens":256,"stream":true,"messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_29d3ae64-5c8f-49a2-941e-db946e3386dd", "type": "message", "role": "assistant", "content": [], "model": "gemini-2.5-flash-image", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 19, "output_tokens": 0}}

event: message_stop
data: {"type": "message_stop"}


HTTP 200

4b. /v1/messages, stream=true, same request 3 seconds later

  1. Same command as the request above
  2. Output:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_29d3ae64-5c8f-49a2-941e-db946e3386dd", "type": "message", "role": "assistant", "content": [], "model": "gemini-2.5-flash-image", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 19, "output_tokens": 0}}

event: message_stop
data: {"type": "message_stop"}


HTTP 200

5. /v1/responses

  1. curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/responses ${H[@]} -d '{"model":"gemini-2.5-flash-image","input":"'"$Q"'"}'
  2. Output:
{"id":"resp_OtyKj6T6WOoA...","created_at":1788987976,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":false,"temperature":0.0,"tool_choice":"auto","tools":[],"top_p":null,"max_output_tokens":null,"previous_response_id":null,"reasoning":null,"status":"completed","text":{},"truncation":null,"usage":{"input_tokens":19,"input_tokens_details":{"audio_tokens":null,"cached_tokens":0,"text_tok
HTTP 200

6. /v1/responses, stream=true

  1. curl -sN -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/responses ${H[@]} -d '{"model":"gemini-2.5-flash-image","stream":true,"input":"'"$Q"'"}'
  2. Output:
data: {"type":"response.created","response":{"id":"resp_GQjJ5wgk7omt...","created_at":1788987980,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choice":"auto","tools":[],"top_p":1.0,"reasoning":{"effort":null,"summary":null},"status":"in_progress","store":true},"model":"gemini-2.5-flash-image"}

data: {"type":"response.in_progress","response":{"id":"resp_GQjJ5wgk7omt...","created_at":1788987980,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choice":"auto","tools":[],"top_p":1.0,"reasoning":{"effort":null,"summary":null},"status":"in_progress","store":true},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0f0f8888-8b5d-454a-af3b-28733b92d3a9","type":"message","role":"assistant","status":"in_progress","content":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.added","item_id":"msg_0f0f8888-8b5d-454a-af3b-28733b92d3a9","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_text.done","item_id":"msg_0f0f8888-8b5d-454a-af3b-28733b92d3a9","output_index":0,"content_index":0,"text":"","model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.done","item_id":"msg_0f0f8888-8b5d-454a-af3b-28733b92d3a9","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"id":"msg_0f0f8888-8b5d-454a-af3b-28733b92d3a9","status":"completed","type":"message","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.completed","response":{"id":"resp_GQjJ5wgk7omt...","created_at":1788987980,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[{"type":"message","id":"msg_0f0f8888-8b5d-454a-af3b-28733b92d3a9","status":"completed","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]}],"parallel_tool_calls":false,"temperature":0.0,"tool_choice":"auto","tools":[],"status":"completed","text":{},"usage":{"input_tokens":19,"input_tokens_det

data: [DONE]


HTTP 200

7. Stream cache hit on an entry stored with choices: [] (the shape a semantic cache serves)

  1. redis-cli -p 21556 --raw GET $EMPTY_KEY | cut -c1-220 (a cached non-stream answer with choices: [], in this leg the one request 5 stored, since /v1/responses caches its underlying chat completion too):
{"timestamp": 1788987977.0197172, "response": "{\"id\":\"SMqhat_1LKSn1MkPmJ7QKA\",\"created\":1788987976,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[],\"us
  1. redis-cli -p 21556 --raw GET $STREAM_KEY | cut -c1-220 (request 2's cached answer):
{"timestamp": 1788987948.305753, "response": "{\"id\":\"K8qhaq36MZHJ-sAP877ByAc\",\"created\":1788987948,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[{\"fin
  1. redis-cli -p 21556 SET $STREAM_KEY "$(redis-cli -p 21556 --raw GET $EMPTY_KEY)":
OK
  1. Restart the proxy, so the next answer comes from Redis rather than the worker's in-memory copy

7b. /v1/chat/completions, stream=true, sent again after the restart (served from the copied entry)

  1. Same command as request 2
  2. Output:
{"error":{"message":"litellm.APIError: LiteLLM: provider returned a response with no 'choices'. Raw keys: ['id', 'created', 'model', 'object', 'system_fingerprint', 'choices', 'usage', 'vertex_ai_grounding_metadata', 'vertex_ai_url_context_metadata', 'vertex_ai_safety_results', 'vertex_ai_citation_metadata', 'service_tier']","type":null,"param":null,"code":"500"}}
HTTP 500

8. Stream cache hit on /v1/messages on an entry stored with choices: [] (the path in the reporter's traceback)

The cache is flushed first, then requests 3 and 4 are sent once each, the completion-level stream entry is overwritten with the empty non-stream entry, and the Messages endpoint's own copy of the streamed events is dropped so the next stream falls through to the completion-level cache

8a. /v1/messages, first send after the flush

  1. Same command as request 3
  2. Output:
{"type":"error","error":{"type":"api_error","message":"litellm.APIError: LiteLLM: provider returned a response with no 'choices'. Raw keys: ['id', 'created', 'model', 'object', 'system_fingerprint', 'choices', 'usage', 'vertex_ai_grounding_metadata', 'vertex_ai_url_context_metadata', 'vertex_ai_safety_results', 'vertex_ai_citation_metadata', 'service_tier']. Received Model Group=gemini-2.5-flash-image\nAvailable Model Group Fallbacks=None"}}
HTTP 500

8b. /v1/messages, stream=true, first send after the flush

  1. Same command as request 4
  2. Output:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_a0e69b68-14db-4224-b70e-d6a808a67fb9", "type": "message", "role": "assistant", "content": [], "model": "gemini-2.5-flash-image", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 19, "output_tokens": 0}}

event: message_stop
data: {"type": "message_stop"}


HTTP 200
  1. redis-cli -p 21556 --raw GET $EMPTY_KEY | cut -c1-220 (request 8a's cached answer):
{"timestamp": 1788992230.532109, "response": "{\"id\":\"5tqhavfmC6it1MkPxpWhGA\",\"created\":1788992229,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[],\"usa
  1. redis-cli -p 21556 --raw GET $STREAM_KEY | cut -c1-220 (request 8b's cached answer):
{"timestamp": 1788992239.716115, "response": "{\"id\":\"79qhasL3E8Pf-sAPoI20mQk\",\"created\":1788992239,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[{\"fin
  1. redis-cli -p 21556 SET $STREAM_KEY "$(redis-cli -p 21556 --raw GET $EMPTY_KEY)":
OK
  1. redis-cli -p 21556 --raw GET $SSE_KEY | cut -c1-160 (the Messages endpoint's own copy of request 8b's events):
{"timestamp": 1788992239.71369, "response": {"litellm_cached_anthropic_sse_events": ["event: message_start\ndata: {\"type\": \"message_start\", \"message\": {\"
  1. redis-cli -p 21556 DEL $SSE_KEY:
1
  1. Restart the proxy, so the next answer comes from Redis rather than the worker's in-memory copy

8c. /v1/messages, stream=true, sent again after the restart (served from the copied entry)

  1. Same command as request 4
  2. Output:
{"error":{"message":"litellm.APIError: LiteLLM: provider returned a response with no 'choices'. Raw keys: ['id', 'created', 'model', 'object', 'system_fingerprint', 'choices', 'usage', 'vertex_ai_grounding_metadata', 'vertex_ai_url_context_metadata', 'vertex_ai_safety_results', 'vertex_ai_citation_metadata', 'service_tier']","type":"internal_server_error","param":null,"code":"500"}}
HTTP 500

9. /v1/responses sent twice, non-stream and stream, after emptying the cache

  1. redis-cli -p 21556 FLUSHALL, then each /v1/responses request is sent twice, 3 seconds apart

9a. /v1/responses, first send after the flush (cache miss)

  1. Same command as request 5
  2. Output:
{"id":"resp_cCoiWUAFb7eJ...","created_at":1788996461,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[],"paralle
HTTP 200

9b. /v1/responses, same request 3 seconds later (cache hit)

  1. Same command as request 5
  2. Output:
{"id":"resp_TcSgWJj1yLBa...","created_at":1788996461,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[],"paralle
HTTP 200

9c. /v1/responses, stream=true, first send after the flush

  1. Same command as request 6
  2. Output:
data: {"type":"response.created","response":{"id":"resp_ewzpml_3mRZq...","created_at":1788996468,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choi

data: {"type":"response.in_progress","response":{"id":"resp_ewzpml_3mRZq...","created_at":1788996468,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_

data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_522a2600-094a-41d2-ad12-3662173be65e","type":"message","role":"assistant","status":"in_progress","content":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.added","item_id":"msg_522a2600-094a-41d2-ad12-3662173be65e","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_text.done","item_id":"msg_522a2600-094a-41d2-ad12-3662173be65e","output_index":0,"content_index":0,"text":"","model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.done","item_id":"msg_522a2600-094a-41d2-ad12-3662173be65e","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"id":"msg_522a2600-094a-41d2-ad12-3662173be65e","status":"completed","type":"message","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.completed","response":{"id":"resp_ewzpml_3mRZq...","created_at":1788996468,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[{"type":"message","id":

data: [DONE]


HTTP 200

9d. /v1/responses, stream=true, same request 3 seconds later (cache hit on the stream entry)

  1. Same command as request 6
  2. Output:
data: {"type":"response.created","response":{"id":"resp_Ubrn6EI7aYrV...","created_at":1788996472,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choi

data: {"type":"response.in_progress","response":{"id":"resp_Ubrn6EI7aYrV...","created_at":1788996472,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_

data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_7fd251a7-d7fe-4538-b88f-eb911cfb8b52","type":"message","role":"assistant","status":"in_progress","content":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.added","item_id":"msg_7fd251a7-d7fe-4538-b88f-eb911cfb8b52","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_text.done","item_id":"msg_7fd251a7-d7fe-4538-b88f-eb911cfb8b52","output_index":0,"content_index":0,"text":"","model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.done","item_id":"msg_7fd251a7-d7fe-4538-b88f-eb911cfb8b52","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"id":"msg_7fd251a7-d7fe-4538-b88f-eb911cfb8b52","status":"completed","type":"message","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.completed","response":{"id":"resp_Ubrn6EI7aYrV...","created_at":1788996472,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[{"type":"message","id":

data: [DONE]


HTTP 200

After (21e6c6e)

1. /v1/chat/completions, first send (cache miss)

  1. curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/chat/completions ${H[@]} -d '{"model":"gemini-2.5-flash-image","messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
{"id":"duShavnJMKyV39IP8phy","created":1788994678,"model":"gemini-2.5-flash-image","object":"chat.completion","choices":[],"usage":{"completion_tokens":0,"prompt_tokens":19,"total_tokens":19,"prompt_tokens_details":{"text_tokens":19}},"vertex_ai_grounding_metadata":[],"vertex_ai_url_context_metadata":[],"vertex_ai_safety_results":[],"vertex_ai_citation_metadata":[],"service_tier":"default"}
HTTP 200

1b. /v1/chat/completions, same request 3 seconds later (cache hit)

  1. Same command as the request above
  2. Output:
{"id":"duShavnJMKyV39IP8phy","created":1788994678,"model":"gemini-2.5-flash-image","object":"chat.completion","choices":[],"usage":{"completion_tokens":0,"prompt_tokens":19,"total_tokens":19,"prompt_tokens_details":{"text_tokens":19}},"vertex_ai_grounding_metadata":[],"vertex_ai_url_context_metadata":[],"vertex_ai_safety_results":[],"vertex_ai_citation_metadata":[],"service_tier":"default"}
HTTP 200

2. /v1/chat/completions, stream=true, first send

  1. curl -sN -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/chat/completions ${H[@]} -d '{"model":"gemini-2.5-flash-image","stream":true,"messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
data: {"id":"fuShasGbFumw_PUP4vfYiAI","object":"chat.completion.chunk","created":1788994686,"model":"gemini-2.5-flash-image","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]


HTTP 200

2b. /v1/chat/completions, stream=true, same request 3 seconds later (cache hit on the stream entry)

  1. Same command as the request above
  2. Output:
data: {"id":"fuShasGbFumw_PUP4vfYiAI","object":"chat.completion.chunk","created":1788994689,"model":"gemini-2.5-flash-image","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}

data: {"id":"fuShasGbFumw_PUP4vfYiAI","object":"chat.completion.chunk","created":1788994689,"model":"gemini-2.5-flash-image","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]


HTTP 200

3. /v1/messages, first send (cache miss)

  1. curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/messages ${H[@]} -d '{"model":"gemini-2.5-flash-image","max_tokens":256,"messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
{"id":"heShas3DHs3f-sAPyd3hUQ","type":"message","role":"assistant","model":"gemini-2.5-flash-image","stop_sequence":null,"usage":{"input_tokens":19,"output_tokens":0},"content":[],"stop_reason":"end_turn","stop_details":null}
HTTP 200

3b. /v1/messages, same request 3 seconds later (cache hit)

  1. Same command as the request above
  2. Output:
{"id":"heShas3DHs3f-sAPyd3hUQ","type":"message","role":"assistant","model":"gemini-2.5-flash-image","stop_sequence":null,"usage":{"input_tokens":19,"output_tokens":0},"content":[],"stop_reason":"end_turn","stop_details":null}
HTTP 200

4. /v1/messages, stream=true, first send

  1. curl -sN -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/messages ${H[@]} -d '{"model":"gemini-2.5-flash-image","max_tokens":256,"stream":true,"messages":[{"role":"user","content":"'"$Q"'"}]}'
  2. Output:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_f20520b4-2555-4f1e-bb3b-b934b5cfa3af", "type": "message", "role": "assistant", "content": [], "model": "gemini-2.5-flash-image", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 19, "output_tokens": 0}}

event: message_stop
data: {"type": "message_stop"}


HTTP 200

4b. /v1/messages, stream=true, same request 3 seconds later

  1. Same command as the request above
  2. Output:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_f20520b4-2555-4f1e-bb3b-b934b5cfa3af", "type": "message", "role": "assistant", "content": [], "model": "gemini-2.5-flash-image", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 19, "output_tokens": 0}}

event: message_stop
data: {"type": "message_stop"}


HTTP 200

5. /v1/responses

  1. curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/responses ${H[@]} -d '{"model":"gemini-2.5-flash-image","input":"'"$Q"'"}'
  2. Output:
{"id":"resp_RhNaeak5b3T6...","created_at":1788994707,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":false,"temperature":0.0,"tool_choice":"auto","tools":[],"top_p":null,"max_output_tokens":null,"previous_response_id":null,"reasoning":null,"status":"completed","text":{},"truncation":null,"usage":{"input_tokens":19,"input_tokens_details":{"audio_tokens":null,"cached_tokens":0,"text_tok
HTTP 200

6. /v1/responses, stream=true

  1. curl -sN -w '\nHTTP %{http_code}\n' http://127.0.0.1:$PORT/v1/responses ${H[@]} -d '{"model":"gemini-2.5-flash-image","stream":true,"input":"'"$Q"'"}'
  2. Output:
data: {"type":"response.created","response":{"id":"resp_BCICIaF2cxg_...","created_at":1788994712,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choice":"auto","tools":[],"top_p":1.0,"reasoning":{"effort":null,"summary":null},"status":"in_progress","store":true},"model":"gemini-2.5-flash-image"}

data: {"type":"response.in_progress","response":{"id":"resp_BCICIaF2cxg_...","created_at":1788994712,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choice":"auto","tools":[],"top_p":1.0,"reasoning":{"effort":null,"summary":null},"status":"in_progress","store":true},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0b9d823e-cf25-4d18-a100-0fa4c40695ee","type":"message","role":"assistant","status":"in_progress","content":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.added","item_id":"msg_0b9d823e-cf25-4d18-a100-0fa4c40695ee","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_text.done","item_id":"msg_0b9d823e-cf25-4d18-a100-0fa4c40695ee","output_index":0,"content_index":0,"text":"","model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.done","item_id":"msg_0b9d823e-cf25-4d18-a100-0fa4c40695ee","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"id":"msg_0b9d823e-cf25-4d18-a100-0fa4c40695ee","status":"completed","type":"message","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.completed","response":{"id":"resp_BCICIaF2cxg_...","created_at":1788994712,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[{"type":"message","id":"msg_0b9d823e-cf25-4d18-a100-0fa4c40695ee","status":"completed","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]}],"parallel_tool_calls":false,"temperature":0.0,"tool_choice":"auto","tools":[],"status":"completed","text":{},"usage":{"input_tokens":19,"input_tokens_det

data: [DONE]


HTTP 200

7. Stream cache hit on an entry stored with choices: [] (the shape a semantic cache serves)

  1. redis-cli -p 21556 --raw GET $EMPTY_KEY | cut -c1-220 (request 1's cached answer):
{"timestamp": 1788994679.12864, "response": "{\"id\":\"duShavnJMKyV39IP8phy\",\"created\":1788994678,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[],\"usage\
  1. redis-cli -p 21556 --raw GET $STREAM_KEY | cut -c1-220 (request 2's cached answer):
{"timestamp": 1788994686.738133, "response": "{\"id\":\"fuShasGbFumw_PUP4vfYiAI\",\"created\":1788994686,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[{\"fin
  1. redis-cli -p 21556 SET $STREAM_KEY "$(redis-cli -p 21556 --raw GET $EMPTY_KEY)":
OK
  1. Restart the proxy, so the next answer comes from Redis rather than the worker's in-memory copy

7b. /v1/chat/completions, stream=true, sent again after the restart (served from the copied entry)

  1. Same command as request 2
  2. Output:
data: {"id":"duShavnJMKyV39IP8phy","object":"chat.completion.chunk","created":1788994751,"model":"gemini-2.5-flash-image","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]


HTTP 200

8. Stream cache hit on /v1/messages on an entry stored with choices: [] (the path in the reporter's traceback)

The cache is flushed first, then requests 3 and 4 are sent once each, the completion-level stream entry is overwritten with the empty non-stream entry, and the Messages endpoint's own copy of the streamed events is dropped so the next stream falls through to the completion-level cache

8a. /v1/messages, first send after the flush

  1. Same command as request 3
  2. Output:
{"id":"w-Shaqf7INOl1MkP45jjSQ","type":"message","role":"assistant","model":"gemini-2.5-flash-image","stop_sequence":null,"usage":{"input_tokens":19,"output_tokens":0},"content":[],"stop_reason":"end_turn","stop_details":null}
HTTP 200

8b. /v1/messages, stream=true, first send after the flush

  1. Same command as request 4
  2. Output:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_cdfa7df9-827c-4146-b033-d97ae597fa47", "type": "message", "role": "assistant", "content": [], "model": "gemini-2.5-flash-image", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 19, "output_tokens": 0}}

event: message_stop
data: {"type": "message_stop"}


HTTP 200
  1. redis-cli -p 21556 --raw GET $EMPTY_KEY | cut -c1-220 (request 8a's cached answer):
{"timestamp": 1788994755.943253, "response": "{\"id\":\"w-Shaqf7INOl1MkP45jjSQ\",\"created\":1788994755,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[],\"usa
  1. redis-cli -p 21556 --raw GET $STREAM_KEY | cut -c1-220 (request 8b's cached answer):
{"timestamp": 1788994759.8874319, "response": "{\"id\":\"x-Shav-aIf6a9MoPxLKrwAI\",\"created\":1788994759,\"model\":\"gemini-2.5-flash-image\",\"object\":\"chat.completion\",\"system_fingerprint\":null,\"choices\":[{\"fi
  1. redis-cli -p 21556 SET $STREAM_KEY "$(redis-cli -p 21556 --raw GET $EMPTY_KEY)":
OK
  1. redis-cli -p 21556 --raw GET $SSE_KEY | cut -c1-160 (the Messages endpoint's own copy of request 8b's events):
{"timestamp": 1788994759.879955, "response": {"litellm_cached_anthropic_sse_events": ["event: message_start\ndata: {\"type\": \"message_start\", \"message\": {\
  1. redis-cli -p 21556 DEL $SSE_KEY:
1
  1. Restart the proxy, so the next answer comes from Redis rather than the worker's in-memory copy

8c. /v1/messages, stream=true, sent again after the restart (served from the copied entry)

  1. Same command as request 4
  2. Output:
event: message_start
data: {"type": "message_start", "message": {"id": "msg_d5465ba4-f095-4dfd-9e21-bb7899f573b3", "type": "message", "role": "assistant", "content": [], "model": "gemini-2.5-flash-image", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 25, "output_tokens": 0}}

event: message_stop
data: {"type": "message_stop"}


HTTP 200

9. /v1/responses sent twice, non-stream and stream, after emptying the cache

  1. redis-cli -p 21556 FLUSHALL, then each /v1/responses request is sent twice, 3 seconds apart

9a. /v1/responses, first send after the flush (cache miss)

  1. Same command as request 5
  2. Output:
{"id":"resp_dsB2MyiKbIls...","created_at":1788996489,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[],"paralle
HTTP 200

9b. /v1/responses, same request 3 seconds later (cache hit)

  1. Same command as request 5
  2. Output:
{"id":"resp_IosozTMCyDB2...","created_at":1788996489,"error":null,"incomplete_details":null,"instructions":null,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[],"paralle
HTTP 200

9c. /v1/responses, stream=true, first send after the flush

  1. Same command as request 6
  2. Output:
data: {"type":"response.created","response":{"id":"resp_QYArsfDKjelN...","created_at":1788996497,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choi

data: {"type":"response.in_progress","response":{"id":"resp_QYArsfDKjelN...","created_at":1788996497,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_

data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_269ba46c-0185-4351-a7a7-3714e075be74","type":"message","role":"assistant","status":"in_progress","content":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.added","item_id":"msg_269ba46c-0185-4351-a7a7-3714e075be74","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_text.done","item_id":"msg_269ba46c-0185-4351-a7a7-3714e075be74","output_index":0,"content_index":0,"text":"","model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.done","item_id":"msg_269ba46c-0185-4351-a7a7-3714e075be74","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"id":"msg_269ba46c-0185-4351-a7a7-3714e075be74","status":"completed","type":"message","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.completed","response":{"id":"resp_QYArsfDKjelN...","created_at":1788996497,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[{"type":"message","id":

data: [DONE]


HTTP 200

9d. /v1/responses, stream=true, same request 3 seconds later (cache hit on the stream entry)

  1. Same command as request 6
  2. Output:
data: {"type":"response.created","response":{"id":"resp_0szKGi_4_6Qp...","created_at":1788996500,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_choi

data: {"type":"response.in_progress","response":{"id":"resp_0szKGi_4_6Qp...","created_at":1788996500,"model":"gemini-2.5-flash-image","object":"response","output":[],"parallel_tool_calls":true,"tool_

data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_4df39ec4-4a4f-4b44-960f-77a0af50e353","type":"message","role":"assistant","status":"in_progress","content":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.added","item_id":"msg_4df39ec4-4a4f-4b44-960f-77a0af50e353","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_text.done","item_id":"msg_4df39ec4-4a4f-4b44-960f-77a0af50e353","output_index":0,"content_index":0,"text":"","model":"gemini-2.5-flash-image"}

data: {"type":"response.content_part.done","item_id":"msg_4df39ec4-4a4f-4b44-960f-77a0af50e353","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.output_item.done","output_index":0,"sequence_number":1,"item":{"id":"msg_4df39ec4-4a4f-4b44-960f-77a0af50e353","status":"completed","type":"message","role":"assistant","content":[{"type":"output_text","text":"","annotations":[]}]},"model":"gemini-2.5-flash-image"}

data: {"type":"response.completed","response":{"id":"resp_0szKGi_4_6Qp...","created_at":1788996500,"metadata":{},"model":"gemini-2.5-flash-image","object":"response","output":[{"type":"message","id":

data: [DONE]


HTTP 200

Observations from the run:

  • Streaming requests (2, 4, 6) already returned an empty stream before, unchanged
  • /v1/responses already returned output: [] before, unchanged
  • The repeated /v1/messages send (3b) is a cache hit. Before the fix the first send (3) fails with the same message because the router retries the bridge's error into the entry the first attempt filled
  • A stream request does not hit the non-stream entry on its own (2b is served from the stream entry, which carries a choice), so 7 copies the empty entry over it; the copy needs a proxy restart because each worker serves its in-memory copy before Redis
  • A repeated /v1/messages stream (4b) is served from the Messages endpoint's own copy of the events and never reaches the completion-level entry, so 8 drops that copy after the swap; 8c is then the reporter's trace, HTTP 500 before and an empty end_turn stream after
  • A repeated /v1/responses send (9b) is HTTP 200 at both commits because the endpoint caches its own Responses object next to the underlying chat completion and replays that copy, so the repeat never reaches the converter; the stream repeat (9d) is served from the stream entry, which carries a choice
  • Gemini's NO_IMAGE finish reason is not surfaced, unchanged ([Bug]: Gemini candidate with a finishReason but no content is dropped, so the reason never reaches the client #40477, LIT-7413)

Type

🐛 Bug Fix

Caveats (if any)

Medium

Low

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
  • 21e6c6e passes /live-pr-risk: the cached-stream branch is driven live by requests 2b, 7b and 8c, the /v1/messages bridge by 3, 3b, 4, 4b and 8c, the Responses API cache replay by 9b and 9d (both carry a choice); the non-list choices message is covered by unit tests only, since no real provider returns that shape on demand; the guardrail block reply in proxy_server.py, the other producer of a cached_response stream, always carries a choice and was not driven live

Note

Medium Risk
Changes global completion conversion semantics so empty choices returns HTTP 200 instead of 500, which may surprise clients that assume at least one choice; cache and bridge behavior shifts accordingly.

Overview
Fixes 500 errors when providers return choices: [] (e.g. Gemini/Vertex candidates with no content), including response-cache replays that previously failed after a successful first request.

Response conversion now requires "choices" to be a list (isinstance(..., list)) instead of treating an empty list as missing. choices: [] maps to an empty ModelResponse / streaming chunk; missing or non-list values still raise APIError via shared _invalid_choices_message (clearer type-specific messages).

Downstream paths are aligned: the cached_response stream handler no longer indexes choices[0] on empty lists; the Anthropic /v1/messages adapter defaults finish reason when there are no choices (empty content + stop_reason: end_turn). Tests cover empty lists, invalid types, cache streaming, and the adapter.

Reviewed by Cursor Bugbot for commit 21e6c6e. Bugbot is set up for automated code reviews on this repo. Configure here.

@CLAassistant

CLAassistant commented Sep 8, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment on lines +25 to +35
- name: Fetch the pull request head and its merge base
id: revisions
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT"
- name: Set up uv
@codspeed

codspeed Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing shotsan:fix/empty-choices-handling (21e6c6e) with litellm_internal_staging (fc161fa)

Open in CodSpeed

@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@shotsan
shotsan force-pushed the fix/empty-choices-handling branch from 04696d5 to fe955b7 Compare September 8, 2026 21:05
@shotsan
shotsan changed the base branch from main to litellm_internal_staging September 8, 2026 21:05
@shotsan
shotsan requested a review from a team September 8, 2026 21:05
@shotsan
shotsan force-pushed the fix/empty-choices-handling branch from fe955b7 to c1132d0 Compare September 8, 2026 21:09

@ParBproject ParBproject left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One malformed-response regression seems to come with the new Iterable check. choices is an OpenAI-style list, but values like {}, "", or () are also Iterable; because they are empty, the loop simply produces choices=[] and the response is now accepted as if it were the intended safety-block case. Before this change those falsy malformed shapes were rejected by not response_object.get("choices"). That can hide an upstream/provider contract failure as a legitimate empty completion. Could this narrow the accepted empty case to an actual list (or at least a sequence of choice mappings, explicitly excluding mappings/strings), and add a regression test such as choices={} still raising APIError?

… keep /v1/messages alive on an empty one

Narrows the no-choices guard so a dict, string, or None still raises the APIError while an empty list passes through,
guards the non-stream Anthropic bridge against indexing an empty choices list, and repairs test_completion_missing_role,
whose raw-response mock was patched in as the create() callable itself so the handler only ever saw a MagicMock
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR treats choices: [] as a valid empty completion while continuing to reject missing or non-list choices.

  • Preserves empty responses through model-response conversion and cache replay.
  • Prevents cached streaming responses from indexing an absent first choice.
  • Maps empty completions to an Anthropic empty assistant turn with stop_reason: end_turn.
  • Adds regression coverage for conversion, streaming, cache replay, and Anthropic adaptation.
  • Changes since the previous review only clarify the expected error message and its assertion for choices=None.

Confidence Score: 5/5

The PR appears safe to merge with no outstanding actionable findings.

The empty-choice paths are guarded without weakening validation for malformed values, and the cache-stream and Anthropic bridge cases have focused regression coverage. All previous review threads are resolved, including the line-length finding that Greptile explicitly conceded as incorrect.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py Accepts empty choice lists while consistently rejecting missing and non-list values.
litellm/litellm_core_utils/streaming_handler.py Safely handles cached response chunks with no choices and permits normal stream termination.
litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py Converts an empty OpenAI completion into an empty Anthropic end-turn response.
tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py Verifies empty-list acceptance and precise rejection of null choices.
tests/test_litellm/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py Covers synchronous and asynchronous empty, missing, and invalid choices conversion.
tests/test_litellm/litellm_core_utils/test_streaming_handler.py Covers cached empty chunks and their synthesized terminal stop chunk.
tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py Verifies Anthropic response shape and usage preservation for empty choices.
tests/test_litellm/test_main.py Corrects the mocked SDK call so the fixture returns the intended raw response.

Reviews (5): Last reviewed commit: "test(convert_dict_to_response): expect t..." | Re-trigger Greptile

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

… an empty stream

A stream cache hit on an entry stored with choices == [] indexed choices[0]
in the cached_response branch and failed with IndexError, so the streaming
converters' empty chunk had no working consumer. The branch now treats a
chunk without choices as empty and lets the wrapper close the stream with
its usual finish_reason stop chunk
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread litellm/litellm_core_utils/streaming_handler.py
…converter error

When a provider returns choices as null, an object, a string or a number, the
converter said the response had no 'choices' even though the key was present in
the raw keys it listed. A shared message now keeps the old wording for a missing
key and names the offending type otherwise.

The cached-stream regression test also pins the chunk count so a leaked extra
chunk fails it.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 21e6c6e. Configure here.

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Empty choices list misreported as 500 no-choices error (Gemini safety-filtered responses)

5 participants