fix(provider): inject placeholder text for empty assistant wire messages on openai-compatible#38270
fix(provider): inject placeholder text for empty assistant wire messages on openai-compatible#38270codeg-dev wants to merge 1 commit into
Conversation
…ges on openai-compatible @ai-sdk/openai-compatible serializes assistant messages without any non-empty text as content: "" or null — tool-call-only turns, the per-step pieces produced by step-start splitting, and aborted reasoning-only turns all hit this. Strict providers (Kimi/Moonshot via chat/completions) reject the whole request with 400 "the message at position N with role 'assistant' must not be empty", permanently bricking the session as each failed retry appends another empty artifact message. Run a post-pass over the final ModelMessage list in toModelMessagesEffect when the target model uses @ai-sdk/openai-compatible and unshift a single-space text part into any assistant message lacking non-empty text, so every split piece is covered individually. Scoped to openai-compatible: OpenAI and Anthropic accept textless tool-call turns, and Anthropic signed thinking is handled before conversion. Fixes anomalyco#37946 Tests: 43/43 in test/session/message-v2.test.ts (7 new: tool-call-only, step-start split pieces, empty-text-only, reasoning-only passthrough, anthropic unaffected, step-start-only still dropped, existing text untouched)
|
The following comment was made by an LLM, it may be inaccurate: I found a related PR that's worth reviewing: PR #33899 - This PR appears to address a similar issue of ensuring non-empty assistant content for the openai-compatible provider. It may have already tackled part of this problem or provide relevant context for the current PR. The other results are either the current PR itself (#38270) or tangentially related PRs about empty content, reasoning parts, and provider fixes. You should verify whether PR #33899 already solves the issue or if this PR (#38270) is extending/improving upon that previous fix. |
|
Verified the overlap with #33899 — thanks for surfacing it. Short version: #33899 does not fix #37946; this PR covers a superset of #33899's cases. #33899 (still open, unmerged) patches #37946's failing shape is exactly that untouched case: an aborted turn persisted with only Coverage comparison:
Layer note: this PR runs the guard in |
Issue for this PR
Fixes #37946
Related: #37651 / #37624 (empty reasoning parts — complementary; that fix skips empty reasoning parts, while this PR covers assistant wire messages that still end up with no non-empty text at all, e.g. aborted reasoning-only turns and step-start split pieces). Also related: #31046 (empty text parts), #28507, #37852, #37372.
Type of change
What does this PR do?
@ai-sdk/openai-compatibleserializes assistant messages without any non-empty text ascontent: ""ornull. Strict providers (Kimi/Moonshot via chat/completions) reject the whole request with400 "the message at position N with role 'assistant' must not be empty". Once a session contains such a turn it is permanently bricked: every retry fails the same way and appends another empty artifact message.Shapes that produce an empty assistant wire message:
step-start/reasoning/patchparts (Aborted assistant turn persisted with zero content bricks the session: empty assistant message replayed to provider (400 "must not be empty") #37946)convertToModelMessagessplits a multi-step turn atstep-start(a guard applied only at the UIMessage stage covers the first piece but not the later ones)This PR runs a post-pass over the final
ModelMessagelist intoModelMessagesEffect: when the target model uses@ai-sdk/openai-compatible, any assistant message lacking non-empty text gets a single-space text part unshifted, so every split piece is covered individually.Why inject instead of dropping the empty message: dropping an assistant turn that carries tool calls would break tool-call/tool-result pairing, and dropping a reasoning-only turn would discard interleaved
reasoning_contentthat providers like Kimi use as history context. A single-space placeholder keeps history semantics intact while satisfying the non-empty-content requirement.Scoped to openai-compatible on purpose: OpenAI and Anthropic accept textless tool-call turns, and Anthropic signed thinking is handled before conversion.
How did you verify your code works?
test/session/message-v2.test.ts(tool-call-only, step-start split pieces, empty-text-only, reasoning-only passthrough, anthropic unaffected, step-start-only still dropped, existing text untouched): 43/43 pass.tsgo --noEmitclean.Screenshots / recordings
Not applicable.
Checklist
Prepared with AI assistance (OpenCode agent); reviewed and verified by the author before submission.