Skip to content

# [Bug] Assistant messages whose content is a content-parts array are silently blanked (HTTP 200, content lost) #820

Description

@xjbsenkfi

[Bug] Assistant messages whose content is a content-parts array are silently blanked (HTTP 200, content lost)

Component: POST /provider/v1/chat/completions
Endpoint: https://api.commandcode.ai/provider/v1
Model tested: deepseek/deepseek-v4-flash
Client: QwenPaw 2.2.0 (OpenAI-compatible client, AsyncOpenAI/Python 2.33.0)
Severity: High — silent data loss; no error is returned, the caller cannot detect it.


Summary

When a request contains an assistant message whose content is a content-parts array
(e.g. [{"type": "text", "text": "…"}]) instead of a plain string, the message text is
silently dropped by the gateway. The request returns 200 OK and the response looks
normal, but the model behaves as if that assistant turn never existed.

The identical shape is accepted for user / system messages, so this is specific to the
assistant role.

This format is legal per the OpenAI Chat Completions spec — the official openai Python
SDK (v1.88.0, generated from OpenAI's OpenAPI spec) declares:

# openai/types/chat/chat_completion_assistant_message_param.py
ContentArrayOfContentPart: TypeAlias = Union[
    ChatCompletionContentPartTextParam,
    ChatCompletionContentPartRefusalParam,
]

class ChatCompletionAssistantMessageParam(TypedDict, total=False):
    role: Required[Literal["assistant"]]
    content: Union[str, Iterable[ContentArrayOfContentPart], None]

Note that text is the only non-refusal part type allowed for assistant — so this is not a
"multimodal" case: the dropped part is plain text.


Minimal reproduction

curl -s https://api.commandcode.ai/provider/v1/chat/completions \
  -H "Authorization: Bearer $CCG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek/deepseek-v4-flash",
    "stream": false,
    "messages": [
      {"role": "user",      "content": "Pick a random 4-digit number. Reply with the number only."},
      {"role": "assistant", "content": [{"type": "text", "text": "My number is 7391."}]},
      {"role": "user",      "content": "What number did you just say? Reply with the number only."}
    ]
  }'

Expected: the model replies 7391.

Actual: the model does not know the number — it asks the user to repeat it, or answers as if
no assistant turn existed.

Control (same request, only content shape changed):

{"role": "assistant", "content": "My number is 7391."}

→ the model replies 7391 correctly.

The payload above is the shape taken from captured production traffic (see Evidence); the A/B
behaviour was verified end-to-end, not only by hand-crafted curl.


Evidence from real traffic

Captured with a transparent local logging reverse proxy placed in front of
https://api.commandcode.ai/provider/v1 (the proxy forwards the body unmodified; it only
records it). Three consecutive experiments, same provider / same model / same task:

Group reasoning_content relay content shape on the wire Assistant msg present in request Model behaviour
A ON array [{"type","text"}] ✅ present (67 chars) ❌ "cannot see previous turns"
B OFF array [{"type","text"}] ✅ present (108 chars) ❌ still "cannot see previous turn"
C OFF plain string ✅ present (66 chars) ✅ chained correctly 5/5 turns

Group B/C were run after disabling the client's reasoning-relay option, so reasoning_content
was absent from every message — the only remaining variable is the content shape.

Exact wire object (group B, messages[2])

{
  "role": "assistant",
  "content": [
    {"type": "text", "text": "**天空** 🌤️\n\n(接龙规则:…)\n\n要不要继续?…"}
  ]
}

Request is HTTP 200. The model's own reasoning then says:

"my previous response seems to have been cut off — there's no assistant message shown between the user's first message and second"
"looking at the history, I don't see my previous responses actually recorded (the assistant turns seem missing/empty in this display)"

and one assistant reply even apologises to the user:

「抱歉,前面的回复似乎没有发出去,现在重新接上…」

After the client-side rewrite (content → plain string, group C)

Five consecutive turns with only 「继续接,禁止recall」 (recall explicitly banned, so the model
must rely on its own history):

messages=4   「天空」→ 空调
messages=6   「调」  → 调味
messages=8   「味」  → 味道
messages=10  「道」  → 道路
messages=12  「路」  → …
chain: 天 → 天空 → 空调 → 调味 → 味道 → 道路 → 路

All five correct. Same provider, same model, same request path — only the content encoding
differs.


Why this is a defect rather than "unsupported feature"

  • user messages with the identical array shape are processed normally in the same
    requests, so array support exists — it is the assistant branch that is missing.
  • The dropped part is type: "text" — the one part type the spec allows for assistant.
  • If content-parts were intentionally unsupported for assistant, the correct behaviour is to
    reject the request (4xx) or to ignore only the unsupported parts — not to blank the
    whole message and return 200 OK.

Impact

Any OpenAI-compatible client that emits content-parts for assistant messages (QwenPaw /
agentscope does this for all roles, including pure-text system prompts) will silently lose
conversation history. Symptoms seen downstream: the model repeatedly claims it cannot see its
own previous replies, re-asks for information the user already received, and calls its own
history-search tools to recover its last output. This is very hard to diagnose because every
HTTP call succeeds and token accounting looks normal.


Suggested fix

Either:

  1. Support content as an array for the assistant role (join text parts in order;
    refusal parts can be handled or ignored), or
  2. Reject the request with a clear 400 / 422 explaining that assistant content-parts are
    unsupported.

Please do not return 200 while dropping the message.


Appendix — capture bundle

  • capture/*_request.json — the actual outgoing request bodies (unmodified except header
    masking), including the raw JSON above.
  • capture/requests.jsonl — one line per request: time, model, message roles,
    last_assistant_len, last_assistant_has_reasoning_content, rewrite flags.
  • session_*.redacted.json — QwenPaw session state (structure preserved).
  • qwenpaw.log.excerpt.txt — server-side log for the same window.

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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions