Skip to content

Assistant messages with empty content are sent as "content": null, causing a non-retryable 400 #10

Description

@xue77777777777

Problem

ConvertMessages forwards assistant messages that have no text and no tool call as "content": null, which the upstream gateway rejects with a non-retryable 400. The whole request fails.

Real-world trigger: a client sends role: "assistant" with empty content in the message history. This is common in practice — for example, a turn cancelled mid-stream leaves a partial assistant message behind (reasoning only, no text), and many OpenAI-compatible clients emit content: null on tool-call-only assistant turns.

Reproduction

Minimal request against the proxy (no special setup needed):

curl -X POST http://127.0.0.1:55990/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $COMMANDCODE_API_KEY" \
  -d '{
    "model": "deepseek/deepseek-v4-flash",
    "max_tokens": 16,
    "stream": false,
    "messages": [
      {"role": "user", "content": "hi"},
      {"role": "assistant", "content": ""},
      {"role": "user", "content": "say ok"}
    ]
  }'

Response:

{"error":{"message":"Upstream error: {\"success\":false,\"error\":{\"code\":\"BAD_REQUEST\",\"status\":400,\"message\":\"Invalid request error. ... Validation error: Invalid input: expected string, received null at \\\"params.messages[1].content\\\" or Invalid input: expected array, received null at \\\"params.messages[1].content\\\" or Invalid input: expected \\\"tool\\\" at \\\"params.messages[1].role\\\"; Invalid input: expected array, received null at \\\"params.messages[1].content\\\"\",\"docs\":\"https://commandcode.ai/docs/reference/errors/bad_request\"}}","type":"api_error"}}

Note the position in the error (params.messages[1]) points straight at the empty assistant message.

Trigger matrix

Every form below produces the same 400 on the current main:

message content result
"content": "" 400
"content": null 400
content omitted entirely 400
"content": [] 400
"content": [{"type":"text","text":""}] 400
"content": "hello" (control) 200

Root cause

Two lines combine to produce invalid JSON:

  1. parseContent returns a nil slice for empty content — internal/proxy/convert.go:209-212 (and the same shape for nil input at :207):
case string:
    if v == "" {
        return nil
    }
  1. CCMessage.Content has no omitempty, so a nil slice marshals as nullinternal/api/commandcode.go:23-26:
type CCMessage struct {
    Role    string          `json:"role"`
    Content []CCContentPart `json:"content"`
}

Then ConvertMessages appends that message unconditionally (internal/proxy/convert.go:75):

ccMsgs = append(ccMsgs, api.CCMessage{Role: m.Role, Content: parseContent(m.Content, toolNames)})

So an assistant message with empty content goes upstream as {"role":"assistant","content":null}. The upstream schema accepts string | array for content, never null — hence the 400, which the client treats as non-retryable and the turn dies permanently. Every subsequent request on that conversation re-sends the same poisoned message and fails identically, so the session is stuck.

Suggested fix

Skip messages that convert to no content parts, rather than sending them. Such a message carries no information for the model, and dropping it keeps tool-call/tool-result pairing intact (a message with neither text nor a tool call cannot be the target of a following tool result).

In ConvertMessages (internal/proxy/convert.go), both append sites:

// A message carrying no text and no tool call (an assistant turn whose
// only content was reasoning, for example) converts to an empty part
// list, which marshals as "content": null and is rejected upstream.
contentParts := parseContent(m.Content, toolNames)
if len(contentParts) == 0 {
    continue
}
ccMsgs = append(ccMsgs, api.CCMessage{Role: m.Role, Content: contentParts})

and the same guard on the assistant-with-tool-calls branch at :71.

Alternatively, return an empty non-nil slice from parseContent so it marshals as [] instead of null — but skipping is preferable, since an empty content array may itself be rejected.

Verification

Built the current main unmodified and reproduced the 400 with all five forms above. With the guard applied:

  • all five forms now return 200
  • assistant tool-calls and their matching tool results are unchanged (round-trip verified)
  • go vet ./... clean, go test ./... passes

Happy to send this as a PR if you'd like the patch.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions