{"record":{"id":"954b3449fc8eb526","repo":"BigPizzaV3/CodexPlusPlus","slug":"chat-response-choice-missing-message","errorCode":null,"errorMessage":"chat response choice missing message","messagePattern":"chat response choice missing message","errorType":"http","errorClass":null,"httpStatus":502,"severity":"error","filePath":"crates/codex-plus-core/src/protocol_proxy.rs","lineNumber":246,"sourceCode":"    let context = build_codex_tool_context(original_request.get(\"tools\"));\n    chat_completion_to_response_with_context(body, &context, Some(original_request))\n}\n\nfn chat_completion_to_response_with_context(\n    body: Value,\n    tool_context: &CodexToolContext,\n    original_request: Option<&Value>,\n) -> anyhow::Result<Value> {\n    let choices = body\n        .get(\"choices\")\n        .and_then(Value::as_array)\n        .ok_or_else(|| anyhow::anyhow!(\"chat response missing choices\"))?;\n    let choice = choices\n        .first()\n        .ok_or_else(|| anyhow::anyhow!(\"chat response choices is empty\"))?;\n    let message = choice\n        .get(\"message\")\n        .ok_or_else(|| anyhow::anyhow!(\"chat response choice missing message\"))?;\n\n    let response_id = response_id_from_chat_id(body.get(\"id\").and_then(Value::as_str));\n    let mut output = Vec::new();\n    if let Some(reasoning) = chat_reasoning_to_response_output_item(message, &response_id) {\n        output.push(reasoning);\n    }\n    if let Some(message) = chat_message_to_response_output_item(message, &response_id) {\n        output.push(message);\n    }\n    output.extend(chat_tool_calls_to_response_output_items(\n        message,\n        tool_context,\n    ));\n\n    let mut response = json!({\n        \"id\": response_id,\n        \"object\": \"response\",\n        \"created_at\": body.get(\"created\").and_then(Value::as_u64).unwrap_or(0),","sourceCodeStart":228,"sourceCodeEnd":264,"githubUrl":"https://github.com/BigPizzaV3/CodexPlusPlus/blob/1f431ae49b57b3055e0e6845ba6156c6b4232b4d/crates/codex-plus-core/src/protocol_proxy.rs#L228-L264","documentation":"Thrown by chat_completion_to_response_with_context in crates/codex-plus-core/src/protocol_proxy.rs when choices[0] exists but has no message key. The converter reads choice.message to extract reasoning, content, and tool calls, so a choice without message (only delta, text, or vendor extensions) cannot be converted. Classic causes: a streaming chunk shape ({delta: ...}) fed to the non-streaming converter, or a legacy /v1/completions response whose choice carries text instead of message.","triggerScenarios":"Upstream returns chat.completion.chunk objects (choices[0].delta, no message) for a non-stream request; base_url actually points at /v1/completions so choices contain text fields; vendor returns a choice with only finish_reason and no message when the model output was filtered.","commonSituations":"Misconfigured relay base_url mixing completions and chat/completions endpoints; upstream that forces stream:true regardless of the request; aggregating proxies forwarding SSE frames as JSON; upstream finishing with finish_reason=content_filter and omitting message.","solutions":["Confirm the request had stream:false / stream omitted, and that the upstream honors it — force-disable streaming on the relay if the provider always streams","Point base_url at the chat completions endpoint, not the legacy completions endpoint (choices with text instead of message means wrong endpoint)","Dump choices[0] and check whether it has delta (stream chunk) or text (legacy) to identify which mismatch you have","If the upstream emits finish_reason content_filter with no message, adjust the prompt or switch provider"],"exampleFix":"// before\nlet message = choice.get(\"message\").ok_or_else(|| anyhow::anyhow!(\"chat response choice missing message\"))?;\n\n// after: accept streaming chunk shape by falling back to delta\nlet message = choice\n    .get(\"message\")\n    .or_else(|| choice.get(\"delta\"))\n    .ok_or_else(|| anyhow::anyhow!(\"chat response choice missing message\"))?;","handlingStrategy":"type-guard","validationCode":"fn choice_has_message(body: &Value) -> bool {\n    body.get(\"choices\")\n        .and_then(Value::as_array)\n        .and_then(|c| c.first())\n        .map(|c| c.get(\"message\").is_some())\n        .unwrap_or(false)\n}","typeGuard":"fn is_non_streaming_choice(body: &Value) -> bool {\n    matches!(\n        body.get(\"choices\").and_then(Value::as_array).and_then(|c| c.first()),\n        Some(choice) if choice.get(\"message\").is_some()\n    )\n}","tryCatchPattern":"if !is_non_streaming_choice(&body) {\n    anyhow::bail!(\"upstream sent a streaming/legacy choice shape: {}\", body);\n}\nlet response = chat_completion_to_response_with_request(body, &request)?;","preventionTips":["Ensure stream:false is sent and honored by the upstream for non-streaming calls","Verify base_url targets /v1/chat/completions, not /v1/completions","Inspect choices[0] keys (delta vs text vs message) when onboarding a new vendor"],"tags":["chat-completions","streaming","upstream-schema","protocol-conversion"],"backgroundTag":"upstream-response-schema-invalid","analyzedSha":"1f431ae49b57b3055e0e6845ba6156c6b4232b4d","analyzedAt":"2026-08-16T20:54:18.598Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}