From 5240afed425c7b7cdbea41566f8cffb9d500ec86 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 18 Aug 2026 10:50:05 -0300 Subject: [PATCH] fix(antigravity): strip trailing model turn for native Gemini requests too (#10436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(antigravity): strip trailing model turn for native Gemini requests too 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 class Claude hits via Vertex. transformRequest() previously wired stripTrailingAntigravityAssistantTurn() only into the isClaude branch, so native Gemini models routed through Antigravity kept a trailing role:model entry and hit the 400. Extend the guarded strip (never empties contents) to native Gemini models too, gated by upstreamModel including "gemini". The Claude path is untouched (byte-identical), preserving PR #6114's live validation against Vertex Claude. Flips tests/unit/antigravity-claude-prefill-strip.test.ts test (b), which previously asserted the buggy pass-through, and adds (b2) for the gemini-3-flash-agent tier. Closes #10104 * fix(antigravity): scope Gemini trailing-turn workaround Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: adevwithpurpose --- .../10104-antigravity-trailing-model-turn.md | 1 + open-sse/executors/antigravity.ts | 36 ++++++++++++++++--- .../antigravity-claude-prefill-strip.test.ts | 34 ++++++++++++++++-- 3 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/10104-antigravity-trailing-model-turn.md 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 4cd1031cec..d239f6d7d1 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -442,9 +442,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 @@ -468,6 +469,20 @@ function stripTrailingAntigravityAssistantTurn( return request; } +/** + * Newer Antigravity Gemini chat families reject a request ending on a model turn. + * Keep this explicit rather than matching every model containing "gemini": image + * generation has a separate request contract, and the older 2.5 family is not part + * of the rejection evidence for #10104. + */ +function isAntigravityGeminiChatModel(upstreamModel: string): boolean { + const normalizedModel = upstreamModel.toLowerCase(); + if (/(?:^|-)image(?:-|$)/.test(normalizedModel)) { + return false; + } + return /^gemini-(?:3(?:\.\d+)?(?:-[a-z0-9-]+)?|pro-agent)$/.test(normalizedModel); +} + // Test-only export so the unit suite can exercise the strip logic directly. export const __test_stripTrailingAntigravityAssistantTurn = stripTrailingAntigravityAssistantTurn; @@ -684,6 +699,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 = isAntigravityGeminiChatModel(upstreamModel); const baseBody = bodyRecord; const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) ? stripCloudCodeThinkingConfig(baseBody) @@ -747,11 +770,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..e67ac5d83d 100644 --- a/tests/unit/antigravity-claude-prefill-strip.test.ts +++ b/tests/unit/antigravity-claude-prefill-strip.test.ts @@ -44,14 +44,42 @@ 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 3.6 Flash tiers get the strip (#10104)", async () => { + for (const tier of ["high", "medium", "low"]) { + const request = await transform(`antigravity/gemini-3.6-flash-${tier}`, [ + { 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, `${tier}: trailing model turn should be stripped`); + assert.equal(contents.at(-1)?.role, "user", `${tier}: request must end on user`); + } +}); + +test("(b3) image and older Gemini families keep their separate request contract", async () => { + for (const model of ["antigravity/gemini-3.1-flash-image", "antigravity/gemini-2.5-flash"]) { + const request = await transform(model, [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there" }] }, + ]); + const contents = request.contents as Array<{ role: string }>; + assert.equal(contents.length, 2, `${model}: non-target family must be unchanged`); + assert.equal(contents.at(-1)?.role, "model", `${model}: model turn must be preserved`); + } }); test("(c) a Claude conversation already ending on user is unchanged", async () => {