diff --git a/CHANGELOG.md b/CHANGELOG.md index c262fadcf6..3f467c5c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index a66f58df06..5489e9fbb9 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -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 +): Record { + 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") diff --git a/tests/unit/antigravity-claude-prefill-strip.test.ts b/tests/unit/antigravity-claude-prefill-strip.test.ts new file mode 100644 index 0000000000..cac8d881ef --- /dev/null +++ b/tests/unit/antigravity-claude-prefill-strip.test.ts @@ -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>) { + 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).request as Record; +} + +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); +});