mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
9827ae6137
commit
9ebb53e432
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
101
tests/unit/antigravity-claude-prefill-strip.test.ts
Normal file
101
tests/unit/antigravity-claude-prefill-strip.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user