Skip to content

fix(core): drop assistant turns with no provider content at the ai-sdk wire boundary - #56942

Open
jpvelasco wants to merge 1 commit into
anomalyco:devfrom
jpvelasco:assistant-empty-guard
Open

jpvelasco wants to merge 1 commit into
anomalyco:devfrom
jpvelasco:assistant-empty-guard

Conversation

@jpvelasco

@jpvelasco jpvelasco commented Oct 19, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #23260

The remaining half of #23260 that the 2.0 rewrite did not absorb: the AI SDK route still emits assistant turns with no provider-visible content (content: [], or only empty text fragments), which strict openai-compatible providers (DeepSeek, Kimi, LiteLLM-fronted Bedrock) reject with a 400. V2's toLLMMessages already filters these at the session layer, but the wire boundary itself kept only the tool-message guard, so anything that does not go through toLLMMessages can still forward one.

Type of change

  • Bug fix

What does this PR do?

In prompt() (packages/core/src/aisdk.ts) the mapped assistant content drops empty text fragments, and if nothing provider-visible remains the turn is not emitted. This mirrors the guard that already turns empty tool messages into no output. Reasoning parts are untouched, so signed Anthropic thinking blocks and Gemini thought signatures still replay; this also matches the session-layer meaningful filter, which already discards empty text parts.

How did you verify your code works?

New test in test/aisdk.test.ts: compileRequest with an empty assistant turn and an empty-text-only turn; both were reaching the wire prompt before this change and do not after (the test failed against plain dev, which is how I confirmed the gap is still there).

bun run check passes, as do the conversion-related suites locally: aisdk, aisdk-native, session-runner, session-runner-message, session-model-request(+hooks), session-provider-context, on session-compaction(&transport/native), session/generate.

Screenshots / recordings

N/A.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #46881

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. I ran the new test against the base code, where it fails (both empty assistant turns reach the outgoing prompt), and it passes with this change. The aisdk, aisdk-native and session-runner* suites pass, and the packages/core type check is clean.

The filter matches what the session layer's meaningful filter in to-llm-message.ts already does: it drops empty text parts even when they carry provider metadata. Reasoning-only turns are still sent, so signed reasoning still replays. A turn that gets dropped has no tool calls, so the worst case is two user messages in a row, and the AI SDK converters handle that.

The public API is unchanged.

No blocking concerns.

Related: #46881

@jpvelasco

Copy link
Copy Markdown
Author

Validation of this branch against real Bedrock: about 45 standalone runs, one tiny prompt per
model, SigV4 through the ambient AWS chain (AWS_PROFILE + AWS_REGION, no API keys), stock
opencode run --standalone. Reporting it here since these surfaces rarely get a matrix.

Default route (@ai-sdk/amazon-bedrock, Converse runtime), all returned normal completions:
gpt-oss 20b/120b, glm-4.7/-flash/glm-5, minimax m2/m2.1/m2.5, kimi-k2.5/kimi-k2-thinking,
deepseek v3/v3.2, qwen3-32b/coder-30b/coder-480b/next-80b/vl-235b, nemotron nano-9b/12b and
super-120b, gemma-3 4b/12b/27b, ministral-3 3b/14b, devstral-2-123b, magistral-small.
No guard-related regression observed anywhere.

Mantle override (npm: "@ai-sdk/amazon-bedrock/mantle"):

  • openai.gpt-5.6-terra passes on /v1/responses
  • openai.gpt-oss-20b passes on /v1/chat/completions, but only with the bare ID; the -1:0
    revision suffix that Converse serves gives "model does not exist" on mantle. The catalog ships
    both IDs, so it works, just not with the same reference on both surfaces.
  • zai.glm-5 passes on Converse but is refused by mantle /v1/responses ("does not support the
    /v1/responses API"). The "everything else" tier in aisdk-native.ts maps some families to a
    surface Bedrock does not offer them.

Items hit during validation, all pre-existing on dev, none blocking this PR (glad to file
separately instead):

  1. qwen.qwen3-32b has a 32768-token window. The stock agent prompt exceeds the auto-compaction
    ceiling instantly, so a fresh session gets a compaction on turn one, and the small model then
    fails the summary template. compaction.auto: false sidesteps it.
  2. models.dev reports output limit 131072 for qwen.qwen3-235b-a22b-2507, but Bedrock rejects
    max_completion_tokens at that scale, so that catalog entry can never complete a request.
  3. meta.llama3.3/llama4 work through cross-region profile IDs for plain streaming but Bedrock
    rejects tool use in streaming mode, and the default agent always sends tools, so those entries
    fail under stock config. Related: the BEDROCK_PROFILE_ONLY_IDS blocklist correctly hides the
    bare IDs, but some entries (deepseek.r1, meta.llama4) have no catalog twin with a profile ID,
    leaving them unselectable in most regions.

cc @kitlangton

@jpvelasco
jpvelasco force-pushed the assistant-empty-guard branch 3 times, most recently from 7e5f715 to 9d20b21 Compare October 20, 2026 16:31

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: message pipeline has no final empty-content guard, causing ValidationException on Bedrock and hard failures on other providers

1 participant