mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00:00
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:
parent
f4fb0d310b
commit
c52df44121
6 changed files with 208 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue