Compare commits

...

3 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
1d3dba924a Merge branch 'release/v3.8.50' into fix/10104-antigravity-trailing-model-turn 2026-08-17 11:56:36 -03:00
adevwithpurpose
af32519841 fix(antigravity): scope Gemini trailing-turn workaround
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-17 10:43:37 -03:00
adevwithpurpose
c4ee73dee0 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
2026-08-14 21:54:06 -03:00
3 changed files with 64 additions and 7 deletions

View File

@@ -0,0 +1 @@
- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104)

View File

@@ -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;
@@ -673,6 +688,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)
@@ -736,11 +759,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);

View File

@@ -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 () => {