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:
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
}
CCMessage.Content has no omitempty, so a nil slice marshals as null — internal/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.
Problem
ConvertMessagesforwards 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 emitcontent: nullon tool-call-only assistant turns.Reproduction
Minimal request against the proxy (no special setup needed):
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:"content": """content": nullcontentomitted entirely"content": []"content": [{"type":"text","text":""}]"content": "hello"(control)Root cause
Two lines combine to produce invalid JSON:
parseContentreturns a nil slice for empty content —internal/proxy/convert.go:209-212(and the same shape fornilinput at:207):CCMessage.Contenthas noomitempty, so a nil slice marshals asnull—internal/api/commandcode.go:23-26:Then
ConvertMessagesappends that message unconditionally (internal/proxy/convert.go:75):So an assistant message with empty content goes upstream as
{"role":"assistant","content":null}. The upstream schema acceptsstring | arrayforcontent, nevernull— 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
toolresult).In
ConvertMessages(internal/proxy/convert.go), both append sites:and the same guard on the assistant-with-tool-calls branch at
:71.Alternatively, return an empty non-nil slice from
parseContentso it marshals as[]instead ofnull— but skipping is preferable, since an emptycontentarray may itself be rejected.Verification
Built the current
mainunmodified and reproduced the 400 with all five forms above. With the guard applied:toolresults are unchanged (round-trip verified)go vet ./...clean,go test ./...passesHappy to send this as a PR if you'd like the patch.