Title: Assistant messages with empty content are sent as `"content": null`, causing a non-retryable 400 · Issue #10 · dev2k6/command-code-proxy-server · GitHub
Open Graph Title: Assistant messages with empty content are sent as `"content": null`, causing a non-retryable 400 · Issue #10 · dev2k6/command-code-proxy-server
X Title: Assistant messages with empty content are sent as `"content": null`, causing a non-retryable 400 · Issue #10 · dev2k6/command-code-proxy-server
Description: 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: "as...
Open Graph Description: 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...
X Description: 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 f...
Opengraph URL: https://github.com/dev2k6/command-code-proxy-server/issues/10
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"Assistant messages with empty content are sent as `\"content\": null`, causing a non-retryable 400","articleBody":"## Problem\n\n`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.\n\nReal-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.\n\n## Reproduction\n\nMinimal request against the proxy (no special setup needed):\n\n```bash\ncurl -X POST http://127.0.0.1:55990/v1/chat/completions \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer $COMMANDCODE_API_KEY\" \\\n -d '{\n \"model\": \"deepseek/deepseek-v4-flash\",\n \"max_tokens\": 16,\n \"stream\": false,\n \"messages\": [\n {\"role\": \"user\", \"content\": \"hi\"},\n {\"role\": \"assistant\", \"content\": \"\"},\n {\"role\": \"user\", \"content\": \"say ok\"}\n ]\n }'\n```\n\nResponse:\n\n```json\n{\"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\"}}\n```\n\nNote the position in the error (`params.messages[1]`) points straight at the empty assistant message.\n\n### Trigger matrix\n\nEvery form below produces the same 400 on the current `main`:\n\n| message content | result |\n| --- | --- |\n| `\"content\": \"\"` | 400 |\n| `\"content\": null` | 400 |\n| `content` omitted entirely | 400 |\n| `\"content\": []` | 400 |\n| `\"content\": [{\"type\":\"text\",\"text\":\"\"}]` | 400 |\n| `\"content\": \"hello\"` (control) | 200 |\n\n## Root cause\n\nTwo lines combine to produce invalid JSON:\n\n1. `parseContent` returns a **nil** slice for empty content — `internal/proxy/convert.go:209-212` (and the same shape for `nil` input at `:207`):\n\n```go\ncase string:\n if v == \"\" {\n return nil\n }\n```\n\n2. `CCMessage.Content` has **no `omitempty`**, so a nil slice marshals as `null` — `internal/api/commandcode.go:23-26`:\n\n```go\ntype CCMessage struct {\n Role string `json:\"role\"`\n Content []CCContentPart `json:\"content\"`\n}\n```\n\nThen `ConvertMessages` appends that message unconditionally (`internal/proxy/convert.go:75`):\n\n```go\nccMsgs = append(ccMsgs, api.CCMessage{Role: m.Role, Content: parseContent(m.Content, toolNames)})\n```\n\nSo 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.\n\n## Suggested fix\n\nSkip 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).\n\nIn `ConvertMessages` (`internal/proxy/convert.go`), both append sites:\n\n```go\n// A message carrying no text and no tool call (an assistant turn whose\n// only content was reasoning, for example) converts to an empty part\n// list, which marshals as \"content\": null and is rejected upstream.\ncontentParts := parseContent(m.Content, toolNames)\nif len(contentParts) == 0 {\n continue\n}\nccMsgs = append(ccMsgs, api.CCMessage{Role: m.Role, Content: contentParts})\n```\n\nand the same guard on the assistant-with-tool-calls branch at `:71`.\n\nAlternatively, 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.\n\n## Verification\n\nBuilt the current `main` unmodified and reproduced the 400 with all five forms above. With the guard applied:\n\n- all five forms now return 200\n- assistant tool-calls and their matching `tool` results are unchanged (round-trip verified)\n- `go vet ./...` clean, `go test ./...` passes\n\nHappy to send this as a PR if you'd like the patch.\n","author":{"url":"https://github.com/xue77777777777","@type":"Person","name":"xue77777777777"},"datePublished":"2026-09-17T09:21:13Z","url":"https://github.com/dev2k6/command-code-proxy-server/issues/10"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:7edecf02-d1f6-e9cb-321e-ab8af569892f |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | CAEE:13C947:FF006:14D0F2:6AEC08F4 |
| html-safe-nonce | 9be9ca7ba332736dd30de70a29840866dd938f41c1cb471c75c65b634c11ae12 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJDQUVFOjEzQzk0NzpGRjAwNjoxNEQwRjI6NkFFQzA4RjQiLCJ2aXNpdG9yX2lkIjoiNjMxNTk0MjUwMzcwMTU0NzI1MiIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9 |
| visitor-hmac | 9d55e4094151919063b06f0a492bf93fa54ae94aab97e5b853ed00dabab05a71 |
| hovercard-subject-tag | issue:5485442881 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/dev2k6/command-code-proxy-server/10/issue_layout |
| twitter:image | https://opengraph.githubassets.com/621a78a06edf8b38e9e5d8e5867d48afa9a412b52b8c62f7b615313609fd7cb8/dev2k6/command-code-proxy-server/issues/10 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/621a78a06edf8b38e9e5d8e5867d48afa9a412b52b8c62f7b615313609fd7cb8/dev2k6/command-code-proxy-server/issues/10 |
| og:image:alt | 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... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | xue77777777777 |
| hostname | github.com |
| expected-hostname | github.com |
| None | d063d52022f5f270ef55a70761d126c8085d492d3c251084b07781784c02dad4 |
| turbo-cache-control | no-preview |
| go-import | github.com/dev2k6/command-code-proxy-server git https://github.com/dev2k6/command-code-proxy-server.git |
| octolytics-dimension-user_id | 234830861 |
| octolytics-dimension-user_login | dev2k6 |
| octolytics-dimension-repository_id | 1242849406 |
| octolytics-dimension-repository_nwo | dev2k6/command-code-proxy-server |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 1242849406 |
| octolytics-dimension-repository_network_root_nwo | dev2k6/command-code-proxy-server |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 56e59340194011ba3cb6809acfa979ccbd174288 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width