fix(antigravity): strip trailing assistant prefill turn for Vertex Claude models (#6114)

fix(antigravity): strip trailing assistant prefill for Vertex Claude models (#6114). TDD-covered (6/6), merged on TDD strength per owner. Reds are pre-existing base-red drift on release/v3.8.45. Thanks @anki1kr. Integrated into release/v3.8.45.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 04:21:38 -03:00
committed by GitHub
parent 9827ae6137
commit 9ebb53e432
3 changed files with 144 additions and 1 deletions

View File

@@ -10,6 +10,8 @@
- **fix(api):** `/v1/messages/count_tokens` now counts `tool_use`, `tool_result` and `thinking` content blocks (and array-form `system` prompts) in the local-estimation path, instead of only `text`. Real agentic conversations keep ~95% of their tokens inside tool results; the previous estimate returned near-zero for them, which silently broke Claude Code's auto-compaction (context grew past the window with no compaction until the upstream API rejected the request). The real provider-side count path is unchanged. Regression guard: `tests/unit/messages-count-tokens-route.test.ts`. ([#6221](https://github.com/diegosouzapw/OmniRoute/pull/6221) — thanks @luweiCN)
- **fix(antigravity):** strip a trailing assistant prefill turn for Vertex Claude models to avoid upstream 400s ([#6114](https://github.com/diegosouzapw/OmniRoute/pull/6114)). Regression guard: `tests/unit/antigravity-claude-prefill-strip.test.ts`. (thanks @anki1kr)
---
## [3.8.43] — 2026-07-02

View File

@@ -480,6 +480,46 @@ function sanitizeAntigravityGeminiRequest(
return clean;
}
/**
* Ported from decolua/9router#2321 (anki1kr): Vertex AI (used by Antigravity for
* Claude-branded models) rejects a conversation ending on an assistant turn —
* "This model does not support assistant message prefill" — so the request must
* always end on a user turn. Upstream patched `openaiToClaudeRequestForAntigravity`
* (dead code here, zero callers — see `open-sse/translator/request/openai-to-claude.ts`);
* this relocates the same strip to the LIVE Antigravity dispatch path, where Claude
* requests are converted to Gemini `contents` (assistant role is `"model"`, not
* `"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.
*
* 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
* always preserved.
*/
function stripTrailingAntigravityAssistantTurn(
request: Record<string, unknown>
): Record<string, unknown> {
const contents = request.contents;
if (!Array.isArray(contents) || contents.length === 0) {
return request;
}
while (
contents.length > 1 &&
(contents[contents.length - 1] as AntigravityContent)?.role === "model"
) {
contents.pop();
}
return request;
}
// Test-only export so the unit suite can exercise the strip logic directly.
export const __test_stripTrailingAntigravityAssistantTurn = stripTrailingAntigravityAssistantTurn;
export class AntigravityExecutor extends BaseExecutor {
constructor() {
super("antigravity", PROVIDERS.antigravity);
@@ -660,7 +700,7 @@ export class AntigravityExecutor extends BaseExecutor {
};
const transformedRequest = isClaude
? sanitizeAntigravityGeminiRequest(rawTransformedRequest)
? stripTrailingAntigravityAssistantTurn(sanitizeAntigravityGeminiRequest(rawTransformedRequest))
: rawTransformedRequest;
// Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor")

View File

@@ -0,0 +1,101 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
AntigravityExecutor,
__test_stripTrailingAntigravityAssistantTurn,
} from "../../open-sse/executors/antigravity.ts";
/**
* Ports decolua/9router#2321 (anki1kr): Vertex AI (used by Antigravity for
* Claude-branded models) rejects a conversation ending on an assistant turn —
* "This model does not support assistant message prefill" — so the request must
* always end on a user turn.
*
* Upstream's diff patched `openaiToClaudeRequestForAntigravity` in
* `open-sse/translator/request/openai-to-claude.ts`, which has ZERO callers in
* OmniRoute (dead code). The live Antigravity Claude dispatch path converts to
* Gemini `contents` (`role: "user"/"model"`) in `AntigravityExecutor.transformRequest`
* via `sanitizeAntigravityGeminiRequest` — this test drives THAT function end-to-end.
*/
async function transform(model: string, contents: Array<Record<string, unknown>>) {
const executor = new AntigravityExecutor();
const body = {
request: {
contents,
generationConfig: {},
},
};
const result = await executor.transformRequest(model, body, true, {
projectId: "project-1",
});
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
return (result as Record<string, unknown>).request as Record<string, unknown>;
}
test("(a) strips a single trailing assistant (model) turn for Claude models", async () => {
const request = await transform("antigravity/claude-opus-4-8", [
{ role: "user", parts: [{ text: "Hello" }] },
{ role: "model", parts: [{ text: "Hi there" }] }, // prefill to strip
]);
const contents = request.contents as Array<{ role: string }>;
assert.equal(contents.length, 1);
assert.equal(contents.at(-1)?.role, "user");
});
test("(b) does NOT strip a trailing model turn for non-Claude (native Gemini) models", async () => {
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");
});
test("(c) a Claude conversation already ending on user is unchanged", async () => {
const request = await transform("antigravity/claude-opus-4-8", [
{ role: "user", parts: [{ text: "Hello" }] },
{ role: "model", parts: [{ text: "Hi" }] },
{ role: "user", parts: [{ text: "What is 2+2?" }] },
]);
const contents = request.contents as Array<{ role: string }>;
assert.equal(contents.length, 3);
assert.equal(contents.at(-1)?.role, "user");
});
test("(d) multiple trailing model turns are all stripped", () => {
// Under normal executor flow, adjacent same-role turns are merged before this
// helper runs — this directly exercises the helper's robustness for an input
// that (defensively) still carries multiple consecutive trailing "model" turns.
const request = __test_stripTrailingAntigravityAssistantTurn({
contents: [
{ role: "user", parts: [{ text: "Hello" }] },
{ role: "model", parts: [{ text: "A" }] },
{ role: "model", parts: [{ text: "B" }] },
],
});
const contents = request.contents as Array<{ role: string }>;
assert.equal(contents.length, 1);
assert.equal(contents.at(-1)?.role, "user");
});
test("(e) never strips contents down to empty", () => {
const request = __test_stripTrailingAntigravityAssistantTurn({
contents: [{ role: "model", parts: [{ text: "solo prefill" }] }],
});
const contents = request.contents as Array<{ role: string }>;
// A lone trailing "model" turn is preserved rather than emptying `contents`
// (an empty contents array is itself an invalid upstream request).
assert.equal(contents.length, 1);
assert.equal(contents[0].role, "model");
});
test("empty/missing contents does not throw", () => {
const request = __test_stripTrailingAntigravityAssistantTurn({ contents: [] });
assert.deepEqual(request.contents, []);
const request2 = __test_stripTrailingAntigravityAssistantTurn({});
assert.equal(request2.contents, undefined);
});