fix(antigravity): surface aborted Gemini tool calls off end_turn

Gemini/Antigravity aborts a turn with finishReason MALFORMED_FUNCTION_CALL
(or a sibling like UNEXPECTED_TOOL_CALL) instead of completing cleanly. Both
Claude-facing translators collapsed these to a clean end_turn, hiding the
aborted tool call as a successful completion:
- the OpenAI hub path (openai-to-claude.ts convertFinishReason default), and
- the DIRECT Gemini->Claude path (gemini-to-claude.ts), which is the one
  Claude Code actually hits through an antigravity/Gemini-routed model.
Add isAbortFinishReason() to finishReason.ts and map these reasons to
tool_use on both paths; genuinely unknown reasons still fall back to end_turn.

Co-authored-by: anhdiepmmk <n08ni.dieppn@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2462
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-07-09 04:05:06 -03:00
parent f4fb0d310b
commit c52df44121
6 changed files with 208 additions and 1 deletions

View file

@ -49,6 +49,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **fix(api):** editing any existing OpenAI Codex provider connection in the dashboard returned "Invalid request" and the edit could never be saved ([#6562](https://github.com/diegosouzapw/OmniRoute/issues/6562)) — `createProviderConnection()` (`src/lib/db/providers.ts`) auto-increments a new connection's `priority` to `MAX(priority)+1` per provider with no upper bound, and OAuth-imported connections (Codex `codex-auth/import` / `import-bulk`, up to 50 accounts per call, callable repeatedly — the standard Codex bulk-account-rotation workflow) never pass through `createProviderSchema`'s Zod validation at all, so nothing ever capped that value; `EditConnectionModal`'s `handleSubmit` always resends the connection's current `priority` unchanged on every save, and `updateProviderConnectionSchema` capped `priority`/`globalPriority` at `max(100)` — a UI-only ceiling the create path never enforced — so the first edit of any connection whose priority had already grown past 100 (routine once a Codex account count exceeds 100) failed validation regardless of which field the user actually changed. Raised the ceiling to `max(100_000)` on both fields — still bounded (a genuinely out-of-range value is still rejected), just wide enough to accept priorities the app itself already produces. Regression guard: `tests/unit/codex-connection-edit-6562.test.ts` (a Codex OAuth connection whose priority already exceeds the old 100 cap now validates + persists on edit; a control payload with a still-genuinely-invalid priority is still rejected with "Invalid request").
- **mimocode**: rotate accounts on MiMoCode's rate-limit-style 400s (body-classified) instead of failing on the first account; malformed 400s still fail fast with the real upstream error (#6648 — thanks @pizzav-xyz)
- **fix(cli):** compression CLI REST fallback now reads/writes the canonical `defaultMode` field (surfaced as `strategy`) instead of a nonexistent `engine` key, and table output renders nested objects as JSON instead of `[object Object]` (#6571 — thanks @charleszolot)
- **fix(antigravity):** surface aborted/malformed Gemini tool calls (e.g. `MALFORMED_FUNCTION_CALL`) as an explicit non-`end_turn` finish reason instead of a silent clean completion. (thanks @anhdiepmmk)
### 📝 Maintenance

View file

@ -1,5 +1,6 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
/**
* Direct Gemini Claude response translator.
@ -178,6 +179,14 @@ export function geminiToClaudeResponse(chunk, state) {
// reason has already been emitted to the client — this is unavoidable in
// SSE streaming. Map to end_turn (Claude has no "content blocked" reason).
stopReason = "end_turn";
} else if (isAbortFinishReason(reason)) {
// Aborted/malformed tool call (e.g. MALFORMED_FUNCTION_CALL,
// UNEXPECTED_TOOL_CALL). Surface as tool_use rather than a clean end_turn
// so the client sees the turn did not complete normally. Same fix as the
// hub path (openai-to-claude.ts) — this direct Gemini→Claude translator is
// the one Claude Code hits through an antigravity/Gemini-routed model.
// Port of decolua/9router#2462 by @anhdiepmmk.
stopReason = "tool_use";
} else {
stopReason = "end_turn";
}

View file

@ -729,6 +729,12 @@ export function geminiToOpenAIResponse(chunk, state) {
// normalizeOpenAICompatibleFinishReasonString lowercases, maps max_tokens→length,
// and folds Gemini safety reasons (safety/recitation/blocklist/...) → content_filter
// so downstream clients can distinguish a blocked completion from a normal stop.
// Abort reasons (MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALL, ...) are NOT in
// either mapped set, so they surface here unchanged (e.g. raw
// "malformed_function_call") rather than being folded into a misleading "stop" —
// isAbortFinishReason() (finishReason.ts) is what the openai→claude hub step
// uses downstream to recognize this raw value and keep it off a clean end_turn
// (9router#2462 sub-bug #2).
let finishReason = normalizeOpenAICompatibleFinishReasonString(candidate.finishReason);
if (finishReason === "stop" && state.toolCalls.size > 0) {
finishReason = "tool_calls";

View file

@ -3,6 +3,7 @@ import { FORMATS } from "../formats.ts";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts";
import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
// Helper: stop thinking block if started
function stopThinkingBlock(state, results) {
@ -281,7 +282,16 @@ function convertFinishReason(reason) {
case "tool_calls":
return "tool_use";
default:
return "end_turn";
// Gemini/Antigravity abort reasons (e.g. MALFORMED_FUNCTION_CALL,
// UNEXPECTED_TOOL_CALL — see isAbortFinishReason) reach here unrecognized
// after the OpenAI hub normalization. Collapsing them to a clean
// "end_turn" presents an aborted tool call to the client as a successful
// completion (9router#2462 sub-bug #2). Surface them as "tool_use" —
// the same non-clean-stop signal already used for real tool_calls above —
// so the client does not treat the turn as done. Genuinely unknown future
// reasons still fall back to "end_turn" so a benign new value does not
// start misreporting every Gemini-family turn as an unfinished tool call.
return isAbortFinishReason(reason) ? "tool_use" : "end_turn";
}
}

View file

@ -16,6 +16,31 @@ const SAFETY_FINISH_REASONS = new Set([
"malformed_response",
]);
// Gemini/Antigravity finish reasons that mean the model ABORTED the turn before
// completing it — most commonly a tool call the model started narrating but
// Gemini could not parse/execute (MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALL).
// Distinct from SAFETY_FINISH_REASONS: those are deliberate, deterministic
// content blocks; these are execution failures mid tool-call. Left un-mapped
// here (still passed through raw, e.g. "malformed_function_call") so an
// OpenAI-format client at least sees a non-standard-but-honest value instead of
// a misleading "stop" — downstream Claude translation classifies them via
// isAbortFinishReason() so it does not collapse them to a clean "end_turn"
// (9router#2462 sub-bug #2: an aborted tool call must not present to the client
// as a successful completion).
const ABORT_FINISH_REASONS = new Set([
"malformed_function_call",
"unexpected_tool_call",
"finish_reason_unspecified",
"other",
"language",
"no_image",
]);
export function isAbortFinishReason(value: unknown): boolean {
if (typeof value !== "string") return false;
return ABORT_FINISH_REASONS.has(value.toLowerCase());
}
export function normalizeOpenAICompatibleFinishReason(value: unknown): unknown {
if (typeof value !== "string") return value;

View file

@ -0,0 +1,156 @@
import test from "node:test";
import assert from "node:assert/strict";
// Upstream: decolua/9router#2462 sub-bug #2 (@anhdiepmmk).
//
// Gemini/Antigravity aborts a turn mid tool-call with finishReason
// MALFORMED_FUNCTION_CALL (or a sibling abort reason like UNEXPECTED_TOOL_CALL)
// instead of completing it cleanly. Before this fix:
// - open-sse/utils/finishReason.ts had no notion of these reasons, so they
// passed through the OpenAI hub unchanged (harmless on their own).
// - open-sse/translator/response/openai-to-claude.ts's convertFinishReason()
// collapsed ANY unrecognized OpenAI finish_reason to a clean "end_turn" in
// its default case — presenting an aborted tool call to the Claude client
// as a successful completion.
// This regression guard chains the real Gemini -> OpenAI -> Claude translator
// pipeline (mirroring translateResponse's hub-and-spoke Step 1 + Step 2) and
// asserts the Claude stop_reason is never a silent "end_turn" for these
// abort/error finish reasons, while a genuine clean STOP still maps to
// "end_turn" (no regression).
const { geminiToOpenAIResponse } = await import(
"../../open-sse/translator/response/gemini-to-openai.ts"
);
const { openaiToClaudeResponse } = await import(
"../../open-sse/translator/response/openai-to-claude.ts"
);
const { geminiToClaudeResponse } = await import(
"../../open-sse/translator/response/gemini-to-claude.ts"
);
// Direct Gemini -> Claude translator (the path Claude Code hits through an
// antigravity/Gemini-routed model — sourceFormat=CLAUDE, targetFormat=GEMINI —
// which bypasses the OpenAI hub). Its finishReason classifier had the identical
// bug: any unrecognized reason (incl. MALFORMED_FUNCTION_CALL) fell through to
// a clean "end_turn".
function runDirectGeminiToClaude(finishReason: string) {
const state: Record<string, unknown> = {};
const events =
geminiToClaudeResponse(
{
responseId: "resp-direct",
modelVersion: "gemini-2.5-pro",
candidates: [{ content: { parts: [{ text: "partial" }] }, finishReason, index: 0 }],
},
state
) || [];
const messageDelta = (events as Array<Record<string, unknown>>).find(
(event) => event.type === "message_delta"
);
return (messageDelta?.delta as { stop_reason?: string } | undefined)?.stop_reason;
}
function runGeminiToClaude(geminiChunk) {
const geminiState: { toolCalls: Map<number, unknown> } = { toolCalls: new Map() };
const openaiEvents = geminiToOpenAIResponse(geminiChunk, geminiState) || [];
const claudeState: { toolCalls: Map<number, unknown> } = { toolCalls: new Map() };
const claudeEvents: Array<Record<string, unknown>> = [];
for (const chunk of openaiEvents) {
const converted = openaiToClaudeResponse(chunk, claudeState);
if (converted) claudeEvents.push(...converted);
}
return { openaiEvents, claudeEvents };
}
test("Gemini MALFORMED_FUNCTION_CALL does not surface as a clean Claude end_turn", () => {
const { openaiEvents, claudeEvents } = runGeminiToClaude({
responseId: "resp-malformed",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: { parts: [{ text: "partial text" }] },
finishReason: "MALFORMED_FUNCTION_CALL",
index: 0,
},
],
});
// Sanity: the OpenAI hub must not silently rewrite it to a clean "stop" either.
const openaiFinish = openaiEvents.at(-1)?.choices?.[0]?.finish_reason;
assert.notEqual(openaiFinish, "stop");
const messageDelta = claudeEvents.find((event) => event.type === "message_delta");
assert.ok(messageDelta, "expected a Claude message_delta terminal event");
const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason;
assert.notEqual(stopReason, "end_turn");
});
test("Gemini UNEXPECTED_TOOL_CALL does not surface as a clean Claude end_turn", () => {
const { claudeEvents } = runGeminiToClaude({
responseId: "resp-unexpected",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: { parts: [{ text: "partial text" }] },
finishReason: "UNEXPECTED_TOOL_CALL",
index: 0,
},
],
});
const messageDelta = claudeEvents.find((event) => event.type === "message_delta");
assert.ok(messageDelta, "expected a Claude message_delta terminal event");
const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason;
assert.notEqual(stopReason, "end_turn");
});
test("Gemini clean STOP still maps to Claude end_turn (no regression)", () => {
const { claudeEvents } = runGeminiToClaude({
responseId: "resp-clean",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: { parts: [{ text: "All done." }] },
finishReason: "STOP",
index: 0,
},
],
});
const messageDelta = claudeEvents.find((event) => event.type === "message_delta");
assert.ok(messageDelta, "expected a Claude message_delta terminal event");
const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason;
assert.equal(stopReason, "end_turn");
});
test("direct Gemini->Claude: MALFORMED_FUNCTION_CALL does not surface as a clean end_turn", () => {
assert.notEqual(runDirectGeminiToClaude("MALFORMED_FUNCTION_CALL"), "end_turn");
});
test("direct Gemini->Claude: UNEXPECTED_TOOL_CALL does not surface as a clean end_turn", () => {
assert.notEqual(runDirectGeminiToClaude("UNEXPECTED_TOOL_CALL"), "end_turn");
});
test("direct Gemini->Claude: clean STOP still maps to end_turn (no regression)", () => {
assert.equal(runDirectGeminiToClaude("STOP"), "end_turn");
});
test("Gemini MAX_TOKENS still maps to Claude max_tokens (no regression)", () => {
const { claudeEvents } = runGeminiToClaude({
responseId: "resp-length",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: { parts: [{ text: "Truncated" }] },
finishReason: "MAX_TOKENS",
index: 0,
},
],
});
const messageDelta = claudeEvents.find((event) => event.type === "message_delta");
assert.ok(messageDelta, "expected a Claude message_delta terminal event");
const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason;
assert.equal(stopReason, "max_tokens");
});