BigPizzaV3/CodexPlusPlus · error

chat response choice missing message

Error message

chat response choice missing message

What it means

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.

Source

Thrown at crates/codex-plus-core/src/protocol_proxy.rs:246

    let context = build_codex_tool_context(original_request.get("tools"));
    chat_completion_to_response_with_context(body, &context, Some(original_request))
}

fn chat_completion_to_response_with_context(
    body: Value,
    tool_context: &CodexToolContext,
    original_request: Option<&Value>,
) -> anyhow::Result<Value> {
    let choices = body
        .get("choices")
        .and_then(Value::as_array)
        .ok_or_else(|| anyhow::anyhow!("chat response missing choices"))?;
    let choice = choices
        .first()
        .ok_or_else(|| anyhow::anyhow!("chat response choices is empty"))?;
    let message = choice
        .get("message")
        .ok_or_else(|| anyhow::anyhow!("chat response choice missing message"))?;

    let response_id = response_id_from_chat_id(body.get("id").and_then(Value::as_str));
    let mut output = Vec::new();
    if let Some(reasoning) = chat_reasoning_to_response_output_item(message, &response_id) {
        output.push(reasoning);
    }
    if let Some(message) = chat_message_to_response_output_item(message, &response_id) {
        output.push(message);
    }
    output.extend(chat_tool_calls_to_response_output_items(
        message,
        tool_context,
    ));

    let mut response = json!({
        "id": response_id,
        "object": "response",
        "created_at": body.get("created").and_then(Value::as_u64).unwrap_or(0),

View on GitHub (pinned to 1f431ae49b)

Solutions

  1. 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
  2. Point base_url at the chat completions endpoint, not the legacy completions endpoint (choices with text instead of message means wrong endpoint)
  3. Dump choices[0] and check whether it has delta (stream chunk) or text (legacy) to identify which mismatch you have
  4. If the upstream emits finish_reason content_filter with no message, adjust the prompt or switch provider

Example fix

// before
let message = choice.get("message").ok_or_else(|| anyhow::anyhow!("chat response choice missing message"))?;

// after: accept streaming chunk shape by falling back to delta
let message = choice
    .get("message")
    .or_else(|| choice.get("delta"))
    .ok_or_else(|| anyhow::anyhow!("chat response choice missing message"))?;
Defensive patterns

Strategy: type-guard

Validate before calling

fn choice_has_message(body: &Value) -> bool {
    body.get("choices")
        .and_then(Value::as_array)
        .and_then(|c| c.first())
        .map(|c| c.get("message").is_some())
        .unwrap_or(false)
}

Type guard

fn is_non_streaming_choice(body: &Value) -> bool {
    matches!(
        body.get("choices").and_then(Value::as_array).and_then(|c| c.first()),
        Some(choice) if choice.get("message").is_some()
    )
}

Try / catch

if !is_non_streaming_choice(&body) {
    anyhow::bail!("upstream sent a streaming/legacy choice shape: {}", body);
}
let response = chat_completion_to_response_with_request(body, &request)?;

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of BigPizzaV3/CodexPlusPlus@1f431ae49b (2026-08-16). Data as JSON: /api/errors/954b3449fc8eb526. Report an issue: GitHub.