Skip to content

BYOK generic-chat-completion-api: cross-provider replay of parallel image tool calls produces an orphan role:"tool" message -> 400 #32

Description

@ysankpia

Summary

Same underlying defect as #31, observed from a different angle: the parallel image tool calls were issued by one BYOK model, and the 400 appears only after the session is switched to a different model that replays the history. The new model never produced the image reads itself.

Once the shape is in history, the session is permanently broken on that model until it is switched back to an image-stripping route.

Environment

  • Droid CLI 0.218.1 (daemon, subcommand: exec); 0.217.0 and 0.216.0 also appear in the same logs
  • macOS 24.6.0 (darwin), Apple Silicon
  • Custom model: provider: "generic-chat-completion-api", noImageSupport: false
  • Gateway: https://api.commandcode.ai/provider/v1, model deepseek/deepseek-v4.1-flash
  • Image-capable source model: custom:agy-gemini-0

Steps to reproduce

  1. Run a session on an image-capable BYOK model. In one assistant turn, have it issue 4 parallel Read calls where the results carry image content.
  2. While the session is still live, switch the model to a text/vision-capable model that serializes through the same generic-chat-completion-api path.
  3. The first request on the new model replays the full history and returns:
400 {"error":{"message":"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"}}

Live log line for the failing turn:

WARN: [Chat route failure] | {"sessionId":"<redacted>","reason":"invalidRequest",
"error":{"message":"400 {\"error\":{\"message\":\"Messages with role 'tool' must be a response
to a preceding message with 'tool_calls'\"}}"},"modelId":"deepseek/deepseek-v4.1-flash",
"tags":{"modelId":"custom:deepseek-v4-flash-0","isByok":"true","version":"0.218.1"}}

Retrying the prompt does not help: the invalid ordering is persisted, so every subsequent turn re-sends it and fails identically.

Evidence from a real session

The offending turn (4-way parallel image read, issued by the previous model):

msgs[2643] assistant (modelId: custom:agy-gemini-0)
           tool_calls: tc-1p1cqxbgvv3, tc-9mx6m3ds21v, tc-8ytom2k1vb7, tc-dbm6y04qc3   (4 x Read)
msgs[2644] user
           tool_result x4 — every result carries image content

Serialized with the shipped generic-chat-completion-api conversion, the assistant's later tool_calls are trimmed down to the ids present in the contiguous run of following tool messages. The image bridge user message interrupts that run, so tc-9mx6m3ds21v, tc-8ytom2k1vb7 and tc-dbm6y04qc3 are removed from the assistant while their tool messages stay in the array.

Result — 3 orphan role:"tool" messages, each one a result that carried an image:

orphan tool_call_id=tc-9mx6m3ds21v   prev-message role=user
orphan tool_call_id=tc-8ytom2k1vb7   prev-message role=user
orphan tool_call_id=tc-dbm6y04qc3    prev-message role=user

Isolation experiment

Rebuilt the real window into the exact outbound payload and toggled only the image bridge. The head of the payload is a clean user turn in both cases, so this is not a window-boundary artifact:

Payload Result (direct to upstream)
Clean head, no image bridge 200
Clean head, with image bridge 400 (same error text)

The image bridge alone flips the result.

Why this only appears once image support is enabled

With noImageSupport: true the images are stripped, no bridge user message is inserted, the tool run stays contiguous, and the trim never produces orphans. That matches the report: the same history was accepted for a long time while images were being dropped, and started failing immediately after vision was enabled for that model.

Repair that works

Tested against the real broken payload:

Transform Result
As-is (what Droid sends) 400
Reorder image-bridge user messages after the tool run 400 — not sufficient, the ids are already gone
Reorder + re-attach the trimmed tool_call ids to the assistant 200
Demote orphan tool messages to user 200, but loses tool semantics
Reorder + re-attach, replayed through a local protocol relay 200, and the image still reaches the model

Two observations that may help the fix:

  • Reordering alone is not enough. Because the ids are removed before the request leaves the client, the assistant message must also get its tool_calls back. Those calls did execute, so restoring the declarations restores the true structure rather than inventing content.
  • The repaired request preserves the image: with the fix applied, the model still read the test image correctly. So this does not require trading vision for message validity.

Suggested fix

Same as #31, now with a second observation in support:

  1. Emit image content only after the entire run of tool messages for that assistant turn (tool, tool, user[images]), not after each individual result.
  2. Harden the trimming pass so that when it removes an id from an assistant's tool_calls, it also drops the now-unmatched role:"tool" message — or better, does not remove ids at all when the corresponding result is present.

A capture of the outbound body for the failing turn would settle which layer performs the removal, but the client-side reproduction above is enough to show the client builds an invalid order.

Related

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

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions