mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* fix(sse): synthesize tool_calls for Gemini's malformed function-call abort reasons Live incident (dashboard log id 1784489701456-d8c0e9): Gemini terminates a stream with finishReason MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL when its own parser rejects an attempted tool call — there's no real functionCall part, only a human-readable finishMessage. gemini-to-openai.ts passed this through raw as finish_reason (9router#2462's fix, correctly keeping it off a clean "stop"/Claude end_turn), but a raw "malformed_function_call" isn't one of OpenAI's 5 documented finish_reason values, so a real OpenAI-format client (OpenClaw) has no handling for it at all and silently never notices the turn failed — confirmed live via tests/integration/live-gemini-workload.test.ts's [28] streaming case after the Gemini TPM/rebase work on this branch. Fix: synthesize a tool_calls entry (arguments carry the error code + Gemini's finishMessage, valid JSON) and finish_reason: "tool_calls" instead, routing the failure into the ordinary "tool call arguments didn't parse" path every OpenAI-compatible agent loop already handles. Defers to a real tool call if one already completed earlier in the same turn — the real call wins, no synthetic entry piles on top of it. Tests (TDD, each confirmed red-before-green): - 5 new unit tests in the existing 9router#2462 regression file, covering the synthesis itself, UNEXPECTED_TOOL_CALL, the real-tool-call-wins edge case, and no-regression on a clean STOP. - New fixture (tests/fixtures/translation/gemini-malformed-function-call-stream.json): the real 6-chunk event series from the live incident, sanitized (personal paths/URLs replaced with generic placeholders, structure preserved exactly). - New integration test chains the real translator into the real Responses API transformer using that same fixture, proving correct behavior on BOTH /v1/chat/completions and /v1/responses from one shared ground-truth event series. Co-authored-by: Markus Hartung <markus.hartung@gmail.com> * fix(sse): don't drop a malformed tool-call failure when it lands beside a real one Live incident (dashboard log id 1784589106014-2a42f8), analyzing why the prior malformed-function-call fix (3568c7259) still wasn't reaching the client in this case: Gemini can emit a REAL, valid functionCall AND finish the SAME candidate with MALFORMED_FUNCTION_CALL — the model attempted multiple tool calls in one turn (here: a real status-check call plus a malformed "exec"+"cron" multi-call attempt), one parsed cleanly and the other didn't. The first fix version skipped synthesizing a failure signal whenever a real tool call already existed (state.toolCalls.size > 0), on the assumption that meant the model was retrying a LATER, separate attempt after an earlier one already succeeded. That's indistinguishable, from the translator's state, from this same-turn case — so it silently discarded the malformed attempt's information entirely: the client saw the real call succeed and never learned the other tool calls were attempted and rejected. Fix: always synthesize the failure entry when a malformed abort reason is seen, appending it alongside any real tool call rather than skipping it. Multiple tool_calls in one response is normal, well-supported OpenAI behavior (parallel tool calls), so this adds the failure as an additional entry instead of replacing or hiding the real one. Tests (TDD, confirmed red-before-green): - Rewrote the unit test that encoded the old (wrong) assumption to assert both the real and synthesized calls are present. - New fixture (gemini-malformed-function-call-parallel-real-call-stream.json): the real event series from this incident, sanitized. - New integration tests (same file as 3568c7259's) prove both /v1/chat/completions and /v1/responses surface both tool calls correctly from this fixture. Co-authored-by: Markus Hartung <markus.hartung@gmail.com> * feat(sse): honor tool_choice when translating OpenAI requests to Gemini Investigating a live report that gemini-3.1-flash-lite frequently narrates an intended tool call in plain text instead of actually emitting one (dashboard log id 1784591483850-49c408 — 9 raw provider chunks, all plain text, zero functionCall parts, clean finishReason STOP): body.tool_choice was never read anywhere in the OpenAI->Gemini request translator. result.toolConfig was unconditionally hardcoded to { functionCallingConfig: { mode: "VALIDATED" } } whenever tools were present, regardless of what the caller sent. VALIDATED lets the model respond with plain text OR a schema-validated function call at its own discretion — it never forces a call the way OpenAI's tool_choice: "required" (Gemini's ANY mode) does, so a caller had no way to compel a tool call even when explicitly requesting one. Added convertOpenAIToolChoiceToGemini(), mirroring the existing convertOpenAIToolChoice() in openai-to-claude.ts for the same OpenAI tool_choice shapes (string "auto"/"none"/"required", or {type:"function",function:{name}} to force one specific tool): - unset/"auto" -> VALIDATED (unchanged default, no regression) - "required"/"any" -> ANY (forces a call) - "none" -> NONE (disables function calling) - {type:"function",...} -> ANY + allowedFunctionNames: [name] Wired into both Gemini request paths: the direct/base translator (openaiToGeminiBase) and the Antigravity/Cloud Code envelope (wrapInCloudCodeEnvelope), which now reuses the base translator's already- computed toolConfig instead of re-deriving its own hardcoded VALIDATED. This unblocks (but does not itself resolve) the live question — a tool_choice: "required" A/B test against gemini-3.1-flash-lite follows to confirm ANY mode actually changes the narrate-vs-act behavior in practice. Also updates the T11 any-budget allowlist for this file: the "any" string comparisons (tool_choice value "any", not a TypeScript type) are the same documented false-positive pattern already carved out for executors/base.ts. Tests (TDD, confirmed red-before-green): 9 new unit tests covering all tool_choice shapes on both the direct and Antigravity/Cloud Code paths, plus the no-tools and unset-default no-regression cases. Co-authored-by: Markus Hartung <markus.hartung@gmail.com> * chore(quality): file-size baseline for own-growth (#8211) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Markus Hartung <markus.hartung@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
154 lines
8.1 KiB
JavaScript
154 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const cwd = process.cwd();
|
|
|
|
/**
|
|
* T11 Phase-A budget:
|
|
* keep explicit `any` at zero in files already hardened.
|
|
*/
|
|
const budget = [
|
|
{ file: "src/app/api/settings/proxy/route.ts", maxAny: 0 },
|
|
{ file: "src/app/api/settings/proxy/test/route.ts", maxAny: 0 },
|
|
{ file: "src/shared/components/OAuthModal.tsx", maxAny: 0 },
|
|
{ file: "open-sse/translator/index.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/registry.ts", maxAny: 0 },
|
|
// Freeze legacy hot spots to avoid any-regression while strict migration continues.
|
|
{ file: "src/lib/db/apiKeys.ts", maxAny: 0 },
|
|
{ file: "src/lib/db/cliToolState.ts", maxAny: 0 },
|
|
{ file: "src/lib/db/encryption.ts", maxAny: 0 },
|
|
{ file: "src/lib/db/prompts.ts", maxAny: 0 },
|
|
{ file: "src/lib/db/providers.ts", maxAny: 0 },
|
|
{ file: "src/lib/db/settings.ts", maxAny: 0 },
|
|
// #3512: saveRequestUsage typed with UsageEntry (DB-entity 1:1 interface); the
|
|
// other any's in this file (getUsageHistory filter, nextCursor cast,
|
|
// appendRequestLog tokens, getRecentLogs catch) were cleaned in the same pass.
|
|
{ file: "src/lib/usage/usageHistory.ts", maxAny: 0 },
|
|
{ file: "open-sse/config/providerRegistry.ts", maxAny: 0 },
|
|
{ file: "open-sse/config/providerModels.ts", maxAny: 0 },
|
|
{ file: "open-sse/mcp-server/audit.ts", maxAny: 0 },
|
|
// 3 `(toolDef: any)` in the dynamic memory/skill/compression tool-registration
|
|
// loops (#3077) — heterogeneous tool defs accessed via existing `@ts-ignore`
|
|
// dynamic-zod paths; pragmatic dynamic dispatch, not a type-safety regression.
|
|
{ file: "open-sse/mcp-server/server.ts", maxAny: 3 },
|
|
{ file: "open-sse/mcp-server/tools/advancedTools.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/signatureCache.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/comboMetrics.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/sessionManager.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/provider.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/contextManager.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/comboConfig.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/accountSelector.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/wildcardRouter.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/rateLimitSemaphore.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/roleNormalizer.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/usage.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/rateLimitManager.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/tokenRefresh.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/backgroundTaskDetector.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/accountFallback.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/responseSanitizer.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/responseTranslator.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/stream.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/request/openai-responses.ts", maxAny: 0 },
|
|
// 2 FALSE POSITIVES: #4389 compares the Anthropic `tool_choice` value against the
|
|
// STRING literal "any" (`tb.tool_choice === "any"` and `.type === "any"`) to detect
|
|
// forced tool use. The checker strips comments but not strings, and there are zero
|
|
// actual TypeScript `any` types in this file. Budget set to the matched count.
|
|
{ file: "open-sse/executors/base.ts", maxAny: 2 },
|
|
{ file: "open-sse/executors/kiro.ts", maxAny: 0 },
|
|
// 3 FALSE POSITIVES: the word "any" appears in #3104's tool-commit / output-
|
|
// constraint prompt STRINGS ("not any other tool", "any text", "any of these
|
|
// sequences"). The checker strips comments but not strings, and there are zero
|
|
// actual TypeScript `any` types in this file. Budget set to the matched count.
|
|
{ file: "open-sse/executors/cursor.ts", maxAny: 3 },
|
|
{ file: "open-sse/executors/qoder.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/comfyuiClient.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/tlsClient.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/proxyFetch.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/error.ts", maxAny: 0 },
|
|
// 2 FALSE POSITIVES: convertOpenAIToolChoiceToGemini() compares the OpenAI
|
|
// `tool_choice` value against the STRING literal "any" (`choice === "any"` and
|
|
// `c.type === "any"`) — same tool_choice-detection pattern as the executors/base.ts
|
|
// entry above. The checker strips comments but not strings, and there are zero
|
|
// actual TypeScript `any` types in this file. Budget set to the matched count.
|
|
{ file: "open-sse/translator/request/openai-to-gemini.ts", maxAny: 2 },
|
|
{ file: "open-sse/translator/request/antigravity-to-openai.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/request/claude-to-openai.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/audioTranscription.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/sseParser.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/chatCore.ts", maxAny: 0 },
|
|
{ file: "open-sse/config/codexInstructions.ts", maxAny: 0 },
|
|
{ file: "open-sse/config/imageRegistry.ts", maxAny: 0 },
|
|
{ file: "open-sse/config/registryUtils.ts", maxAny: 0 },
|
|
{ file: "open-sse/executors/antigravity.ts", maxAny: 0 },
|
|
{ file: "open-sse/executors/default.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/audioSpeech.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/embeddings.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/imageGeneration.ts", maxAny: 3 },
|
|
{ file: "open-sse/handlers/moderations.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/rerank.ts", maxAny: 0 },
|
|
{ file: "open-sse/handlers/responsesHandler.ts", maxAny: 0 },
|
|
{ file: "open-sse/mcp-server/__tests__/advancedTools.test.ts", maxAny: 0 },
|
|
{ file: "open-sse/mcp-server/__tests__/essentialTools.test.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/combo.ts", maxAny: 0 },
|
|
{ file: "open-sse/services/thinkingBudget.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/helpers/geminiHelper.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/helpers/openaiHelper.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/helpers/responsesApiHelper.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/request/claude-to-gemini.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/request/gemini-to-openai.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/request/openai-to-claude.ts", maxAny: 1 }, // 1 = string literal "any" (Claude tool_choice value, not a TS type) — #1072
|
|
{ file: "open-sse/translator/request/openai-to-cursor.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/request/openai-to-kiro.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/response/claude-to-openai.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/response/gemini-to-openai.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/response/kiro-to-openai.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/response/openai-responses.ts", maxAny: 0 },
|
|
{ file: "open-sse/translator/response/openai-to-antigravity.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/bypassHandler.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/logger.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/networkProxy.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/ollamaTransform.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/proxyDispatcher.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/requestLogger.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/streamHandler.ts", maxAny: 0 },
|
|
{ file: "open-sse/utils/usageTracking.ts", maxAny: 0 },
|
|
];
|
|
|
|
const anyRegex = /\bany\b/g;
|
|
let hasFailure = false;
|
|
|
|
for (const item of budget) {
|
|
const absolutePath = path.resolve(cwd, item.file);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
console.error(`[t11:any-budget] FAIL - file not found: ${item.file}`);
|
|
hasFailure = true;
|
|
continue;
|
|
}
|
|
|
|
const content = fs.readFileSync(absolutePath, "utf8");
|
|
// Remove block and line comments to avoid false positives with the word "any" in comments
|
|
let cleanContent = content.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
cleanContent = cleanContent.replace(/\/\/.*$/gm, "");
|
|
const matches = cleanContent.match(anyRegex);
|
|
const count = matches ? matches.length : 0;
|
|
const status = count <= item.maxAny ? "OK" : "FAIL";
|
|
|
|
if (status === "FAIL") {
|
|
hasFailure = true;
|
|
}
|
|
|
|
console.log(
|
|
`[t11:any-budget] ${status} - ${item.file} (explicit any: ${count}, budget: ${item.maxAny})`
|
|
);
|
|
}
|
|
|
|
if (hasFailure) {
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("[t11:any-budget] PASS - explicit any budget respected.");
|