fix(sse): strip trailing assistant prefill on official Claude OAuth (#13572)

Adds `claude` to the providers whose trailing text-only assistant turn is stripped before dispatch — official Claude rejects assistant prefill with `400 This model does not support assistant message prefill`.

Maintainer note: the strip applies to the whole `claude` provider family (API key as well as OAuth), matching what the Vertex-hosted Claude path (`open-sse/executors/antigravity.ts`), the Copilot path (`open-sse/executors/github.ts`) and the MITM handler already do unconditionally.

Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thanks @HouMinXi!
This commit is contained in:
Bob.Hou
2026-09-16 01:18:20 -04:00
committed by GitHub
parent 67f6c039f3
commit 2b3847e775
5 changed files with 32 additions and 20 deletions

View File

@@ -0,0 +1 @@
- Strip a trailing text-only assistant turn before official Claude OAuth dispatch. Claude returns 400 `This model does not support assistant message prefill` for that shape; the shared strip only covered Mistral.

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_09_15_13572_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/executors/base.ts->1754. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.",
"_rebaseline_2026_09_15_13445_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/utils/proxyFetch.ts->1296. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.",
"_rebaseline_2026_09_15_13643_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/executors/codex.ts->1528. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.",
"_rebaseline_2026_09_15_13609_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/services/accountFallback.ts->2507. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.",
@@ -450,7 +451,7 @@
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
"open-sse/executors/antigravity.ts": 1665,
"open-sse/executors/base.ts": 1753,
"open-sse/executors/base.ts": 1754,
"open-sse/executors/chatgpt-web.ts": 5056,
"open-sse/executors/codex.ts": 1528,
"open-sse/executors/cursor.ts": 1759,

View File

@@ -1354,8 +1354,9 @@ export class BaseExecutor {
// drop any tool_result orphaned by that strip (discussion #2410).
const adjacent = isClaude ? fixToolPairs(fixToolAdjacency(fixed)) : fixed;
const stripped = stripTrailingAssistantOrphanToolUse(adjacent);
// Some providers (e.g. Mistral) require the last message to be user
// or tool and reject trailing assistant text messages with 400 (#3396).
// Some providers (Mistral #3396, official Claude OAuth) reject a
// trailing text-only assistant turn with 400. Strip here so combo
// failover does not burn the next account on the same body.
tb.messages = stripTrailingAssistantForProvider(stripped, this.provider);
}
}

View File

@@ -937,12 +937,13 @@ export function stripTrailingAssistantOrphanToolUse(
}
/**
* Providers that strictly require the last message to be `user` or `tool`.
* A trailing `assistant` message with plain text content (no tool_use) is
* valid for Anthropic/OpenAI (signals "continue from here") but rejected by
* Mistral with: "Expected last role User or Tool … but got assistant" (#3396).
* Some providers reject a trailing text-only assistant turn.
* Mistral: "Expected last role User or Tool but got assistant" (#3396).
* Official Claude OAuth: "This model does not support assistant message
* prefill. The conversation must end with a user message." (live 2026-09-13
* on claude/claude-opus-5).
*/
const PROVIDERS_REQUIRING_USER_LAST_MESSAGE = new Set(["mistral"]);
const PROVIDERS_REQUIRING_USER_LAST_MESSAGE = new Set(["mistral", "claude"]);
/**
* Strip a trailing `assistant` message that contains ONLY plain text (no

View File

@@ -1,16 +1,13 @@
/**
* Regression test for #3396: Mistral returns 400 when the last message is
* `role: "assistant"` with plain text content.
* Regression test for stripTrailingAssistantForProvider.
*
* `stripTrailingAssistantOrphanToolUse` only removed tool_use blocks — it left
* trailing text-only assistant messages intact. Mistral (and providers sharing
* the same constraint) reject such requests with:
* "400: Expected last role User or Tool (or Assistant with prefix True)
* for serving but got assistant"
* Mistral (#3396) returns 400 when the last message is role assistant with
* plain text. Official Claude OAuth (claude-opus-5 live 2026-09-13) returns
* 400 "This model does not support assistant message prefill" for the same
* shape. stripTrailingAssistantOrphanToolUse only removes tool_use blocks.
*
* The fix adds `stripTrailingAssistantForProvider(messages, provider)` which
* also drops a trailing text-only assistant message for providers that require
* user-last format (e.g. "mistral").
* stripTrailingAssistantForProvider drops a trailing text-only assistant
* message for providers in PROVIDERS_REQUIRING_USER_LAST_MESSAGE.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
@@ -56,10 +53,21 @@ describe("stripTrailingAssistantForProvider (#3396)", () => {
assert.strictEqual(result.length, 2);
});
it("does NOT strip trailing text assistant for anthropic/claude", () => {
it("strips trailing text-only assistant message for claude", () => {
const msgs = [user("hi"), assistant("continue from here")];
const result = stripTrailingAssistantForProvider(msgs, "claude");
assert.strictEqual(result.length, 2);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].role, "user");
});
it("strips trailing assistant with array-string content for claude", () => {
const msgs = [
user("hi"),
{ role: "assistant", content: [{ type: "text", text: "continue from here" }] },
];
const result = stripTrailingAssistantForProvider(msgs, "claude");
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].role, "user");
});
it("returns messages unchanged when last message is user", () => {