Files
OmniRoute/open-sse/utils/finishReason.ts
Diego Rodrigues de Sa e Souza 2a52c402ce fix(antigravity): surface aborted Gemini tool calls off end_turn (#6713)
* 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

* chore(6713): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first)

---------

Co-authored-by: anhdiepmmk <n08ni.dieppn@gmail.com>
2026-07-10 19:39:44 -03:00

62 lines
2.0 KiB
TypeScript

const OPENAI_FINISH_REASONS = new Set([
"stop",
"length",
"tool_calls",
"content_filter",
"function_call",
]);
const SAFETY_FINISH_REASONS = new Set([
"safety",
"recitation",
"blocklist",
"prohibited_content",
"content_filtered",
"policy_violation",
"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;
const normalized = value.toLowerCase();
if (OPENAI_FINISH_REASONS.has(normalized)) return normalized;
if (normalized === "max_tokens") return "length";
if (SAFETY_FINISH_REASONS.has(normalized)) return "content_filter";
return normalized;
}
export function normalizeOpenAICompatibleFinishReasonString(
value: unknown,
fallback = "stop"
): string {
const normalized = normalizeOpenAICompatibleFinishReason(value);
return typeof normalized === "string" && normalized ? normalized : fallback;
}