diff --git a/changelog.d/fixes/10104-antigravity-trailing-model-turn.md b/changelog.d/fixes/10104-antigravity-trailing-model-turn.md new file mode 100644 index 0000000000..80af15279f --- /dev/null +++ b/changelog.d/fixes/10104-antigravity-trailing-model-turn.md @@ -0,0 +1 @@ +- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104) diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 7f949284c6..4b3d5c28c6 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -397,9 +397,10 @@ function sanitizeAntigravityGeminiRequest( * `"assistant"`). Mirrors the trailing-strip pop-loop already used for Mistral * (#3396), Copilot (#5802), and the CC-bridge in `claudeCodeCompatible.ts`. * - * Scoped strictly to the Claude path by the caller (`isClaude` branch only) — native - * Gemini models via Antigravity must be unaffected, since Vertex-Claude is the only - * documented rejection surface. + * Wired in by the caller for both the Claude path (`isClaude`) and native Gemini + * models (`isGemini`, #10104) — newer Gemini endpoints reject a trailing `model` turn + * with the same "ending with a model turn" class of 400 that Claude hits via Vertex. + * Other model families routed through Antigravity are left untouched. * * Guard: never strip `contents` down to empty — an empty `contents` array is itself * an invalid request, so at least one entry (even a lone trailing "model" turn) is @@ -597,6 +598,14 @@ export class AntigravityExecutor extends BaseExecutor { const upstreamModel = await cleanModelName(model, modelIdOverride); const isClaude = upstreamModel.toLowerCase().includes("claude"); + // #10104: newer Gemini endpoints reject a request ending on a `model` turn with + // HTTP 400 "Requests ending with a model turn are not supported" — the same + // rejection surface Claude hits via Vertex (see stripTrailingAntigravityAssistantTurn's + // doc comment above). Native Gemini models routed through Antigravity (`agy/gemini-*`, + // e.g. the Gemini 3.x Flash/Pro tiers from PR #8013's catalog) need the same guarded + // strip. Scoped to models whose id names Gemini so unrelated model families are + // untouched; the strip itself never empties `contents` (see the guard above). + const isGemini = upstreamModel.toLowerCase().includes("gemini"); const baseBody = bodyRecord; const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) ? stripCloudCodeThinkingConfig(baseBody) @@ -660,11 +669,16 @@ export class AntigravityExecutor extends BaseExecutor { : normalizedRequest?.toolConfig, }; + // Note: sanitizeAntigravityGeminiRequest() applies a Claude-only field whitelist + // (dropping fields native Gemini requests may legitimately carry), so the Gemini + // branch only runs the trailing-turn strip — never the sanitize/whitelist step. const transformedRequest = isClaude ? stripTrailingAntigravityAssistantTurn( sanitizeAntigravityGeminiRequest(rawTransformedRequest) ) - : rawTransformedRequest; + : isGemini + ? stripTrailingAntigravityAssistantTurn(rawTransformedRequest) + : rawTransformedRequest; applyAntigravityGenerationDefaults(transformedRequest, upstreamModel); diff --git a/tests/unit/antigravity-claude-prefill-strip.test.ts b/tests/unit/antigravity-claude-prefill-strip.test.ts index cac8d881ef..d5e29bf87c 100644 --- a/tests/unit/antigravity-claude-prefill-strip.test.ts +++ b/tests/unit/antigravity-claude-prefill-strip.test.ts @@ -44,14 +44,28 @@ test("(a) strips a single trailing assistant (model) turn for Claude models", as assert.equal(contents.at(-1)?.role, "user"); }); -test("(b) does NOT strip a trailing model turn for non-Claude (native Gemini) models", async () => { +test("(b) strips a trailing model turn for native Gemini models too (#10104)", async () => { + // Newer Gemini endpoints reject a request ending on a `model` turn with the same + // class of 400 Claude hits via Vertex ("Requests ending with a model turn are not + // supported"), so native Gemini models routed through Antigravity get the same + // guarded strip as the Claude path. const request = await transform("antigravity/gemini-3.1-pro", [ { role: "user", parts: [{ text: "Hello" }] }, { role: "model", parts: [{ text: "Hi there" }] }, ]); const contents = request.contents as Array<{ role: string }>; - assert.equal(contents.length, 2); - assert.equal(contents.at(-1)?.role, "model", "native Gemini requests via Antigravity are untouched"); + assert.equal(contents.length, 1); + assert.equal(contents.at(-1)?.role, "user", "transformed native Gemini request must end on user"); +}); + +test("(b2) native Gemini agent tier (gemini-3-flash-agent, #10104) also gets the strip", async () => { + const request = await transform("antigravity/gemini-3-flash-agent", [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there" }] }, // trailing model turn -> 400 source + ]); + const contents = request.contents as Array<{ role: string }>; + assert.equal(contents.length, 1, "trailing model turn should be stripped for native Gemini"); + assert.equal(contents.at(-1)?.role, "user", "transformed native Gemini request must end on user"); }); test("(c) a Claude conversation already ending on user is unchanged", async () => {