From 7686eaadd6a2c0f474626cb3aa75d4d67552bc49 Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:47:35 -0700 Subject: [PATCH] fix(executor): route OpenAI-compatible MCP Responses requests to /responses (#5483) Closes #5483. OpenAI-compatible providers receiving a Responses-shaped request carrying MCP / tool_search tools now route to the upstream /responses endpoint instead of downgrading to /chat/completions, preserving Codex deferred tool discovery. Detection helpers extracted to open-sse/executors/forceResponsesUpstream.ts. Thanks to @KooshaPari. --- CHANGELOG.md | 2 + config/quality/file-size-baseline.json | 7 ++- open-sse/executors/base.ts | 12 ++-- open-sse/executors/default.ts | 3 +- open-sse/executors/forceResponsesUpstream.ts | 60 ++++++++++++++++++++ tests/unit/executor-default-base.test.ts | 54 ++++++++++++++++-- 6 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 open-sse/executors/forceResponsesUpstream.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60bf7370c3..80afa8fae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### 🔧 Bug Fixes +- **providers (OpenAI-compatible):** Codex MCP / `tool_search` deferred discovery (and `apply_patch`) now works through a **Custom OpenAI-compatible provider**. When such a provider received a Responses-API-shaped request that carried MCP / `tool_search` tools, OmniRoute downgraded it to `/chat/completions`, which drops the deferred tool-discovery mechanism — so the MCP namespaces never surfaced to the model and `apply_patch` was mis-handled as a JSON tool. The executor now detects a Responses-shaped request (`input` / `previous_response_id` / `max_output_tokens` / `reasoning`) that carries `namespace` / `tool_search*` tools and routes it to the upstream `/responses` endpoint natively instead of downgrading (it can also be forced via `providerSpecificData._omnirouteForceResponsesUpstream`). This is a distinct code path from the official Codex OAuth backend (#3033 / #4539, which the earlier fix never touched). Regression guard: `tests/unit/executor-default-base.test.ts`. Thanks to @KooshaPari for the fix. ([#5483](https://github.com/diegosouzapw/OmniRoute/issues/5483)) + - **dashboard (routing):** selecting the **fusion** strategy on the Global Routing defaults tab now reveals fusion-specific config instead of only the generic resilience fields. Fusion's engine knobs — `judgeModel` (the model that synthesizes the panel answers) and `fusionTuning` (`minPanel` / `stragglerGraceMs` / `panelHardTimeoutMs`) — already existed in the schema and the per-combo editor, but the Global Routing tab never surfaced them, so picking "fusion" there was effectively a no-op. The fields are now shown (extracted into a new `FusionDefaultsFields` component). Voting / aggregation-mode / per-provider-weight are intentionally not shown — those don't exist in the fusion engine. Regression guard: `tests/unit/ui/combo-defaults-fusion-5598.test.tsx`. ([#5598](https://github.com/diegosouzapw/OmniRoute/issues/5598)) - **dashboard (free proxy pool):** the free proxy pool "Sync All" no longer fails silently with `Total: 0`. Three fixes: (1) the **IPLocate** source fetched `…/protocols/.json` and parsed it as JSON, but the upstream list is plain text (`.txt`, one `ip:port` per line) — every protocol 404'd / failed to parse; it now fetches `.txt` and parses the line list. (2) The sync route **isolates each source** in its own try/catch, so one provider throwing (e.g. a TLS handshake failure) no longer aborts the whole sync — the working sources still populate the pool. (3) The UI now **surfaces the per-source errors** the route already returns, instead of discarding the response, so a partial/empty sync explains itself. Regression guards: `tests/unit/free-proxy-providers.test.ts`, `tests/unit/proxy-pool-sync-4878.test.ts`, `tests/unit/free-pool-tab.test.tsx`. ([#5595](https://github.com/diegosouzapw/OmniRoute/issues/5595)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 31170f8a77..961fb3b85e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_30_5483_force_responses_upstream": "PR #5637 (@KooshaPari, #5483) own growth: open-sse/executors/base.ts 1500->1502 (+2 = import + the single `requestCredentials = withForcedResponsesUpstream(...)` const threaded through buildUrl/buildHeaders/applyConfiguredUserAgent/ccRequestDefaults/transformRequest at the existing fetch-loop chokepoint), open-sse/executors/default.ts 876->877 (+1 = the `_omnirouteForceResponsesUpstream` short-circuit in the buildUrl `/responses` vs `/chat/completions` decision), tests/unit/executor-default-base.test.ts 1477->1523 (+46 = the new regression test that asserts a Responses-shaped MCP request routes to /responses for openai-compatible providers). The detection helpers (shouldForceResponsesUpstream/withForcedResponsesUpstream/isRecord, ~50 LOC) were EXTRACTED out of base.ts into a new leaf open-sse/executors/forceResponsesUpstream.ts (60 LOC, 851 (+5 = import + the `strategy === \"fusion\"` conditional that mounts the new FusionDefaultsFields component). The bulk of the fusion-defaults UI (judgeModel + fusionTuning inputs) was EXTRACTED to a new src/app/(dashboard)/dashboard/settings/components/FusionDefaultsFields.tsx (92 LOC, 997 + chat 1632->1635 + chatHelpers 811->842 (#5351 opencode visible-rotation-logs/ProxyEgress/SQL-vars), base.ts 1475->1497 + openai-to-claude 805->823 (#5352 thinking hydrate/redacted-replay + #5342 empty-messages guard), accountFallback 1777->1783 (#5346 402 auto-disable depleted key) + account-fallback-service.test 1569->1572 (#5346 test), provider-validation-specialty.test 2801->2843 (#5358 grok cf_clearance + #5337 gemini catalog). Cycle-close drift from merged campaign PRs absorbed at release close per generate-release Phase 0 (the PR->release fast-gates do NOT run check:file-size); all irreducible chokepoint/feature growth next to existing branches, covered by per-PR tests. Structural shrink tracked under decomposition roadmap #3501.", "_rebaseline_2026_06_26_v3838_ownerprs_batch": "Lote /review-prs v3.8.38 (PRs do dono + contribuidores): src/lib/db/providers.ts 1093->1107 (+14 = #5121 cookie-dedup branch extraido para o helper findExistingCookieConnection — a extracao reduz a complexidade ciclomatica de createProviderConnection mas adiciona ~14 linhas ao arquivo; trade-off consciente complexity-vs-file-size) e src/lib/usage/usageHistory.ts 934->983 (+49 = #4940 dedup guard SELECT-before-INSERT + endpoint backfill + scheduleStatsEvent debounce). Crescimento de feature/fix legitimo recem-TDD'd; o fast-path PR->release nao roda check:file-size, entao o ramo acumula sem rebaselinar. Reducao estrutural fica como debt (#3501).", @@ -131,7 +132,7 @@ "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1806, - "open-sse/executors/base.ts": 1500, + "open-sse/executors/base.ts": 1502, "open-sse/executors/chatgpt-web.ts": 3206, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1541, @@ -250,7 +251,7 @@ "src/sse/handlers/chat.ts": 1635, "src/sse/handlers/chatHelpers.ts": 842, "src/sse/services/auth.ts": 2401, - "open-sse/executors/default.ts": 876, + "open-sse/executors/default.ts": 877, "open-sse/translator/request/openai-responses.ts": 902, "open-sse/executors/kiro.ts": 944, "open-sse/translator/request/openai-to-claude.ts": 823, @@ -277,7 +278,7 @@ "tests/unit/deepseek-web.test.ts": 1081, "tests/unit/executor-antigravity.test.ts": 942, "tests/unit/executor-codex.test.ts": 1347, - "tests/unit/executor-default-base.test.ts": 1477, + "tests/unit/executor-default-base.test.ts": 1523, "tests/unit/grok-web.test.ts": 2437, "tests/unit/image-generation-handler.test.ts": 2019, "tests/unit/model-sync-route.test.ts": 1016, diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 45e2853179..6717b3e1ba 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -65,6 +65,7 @@ import { stainlessRuntimeVersion, stripProxyToolPrefix, } from "./claudeIdentity.ts"; +import { withForcedResponsesUpstream } from "./forceResponsesUpstream.ts"; /** * Sanitizes a custom API path to prevent path traversal attacks. @@ -863,9 +864,10 @@ export class BaseExecutor { const strippedFields = new Set(); for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { - const url = this.buildUrl(model, stream, urlIndex, activeCredentials); - const headers = this.buildHeaders(activeCredentials, stream, clientHeaders, model); - applyConfiguredUserAgent(headers, activeCredentials?.providerSpecificData); + const requestCredentials = withForcedResponsesUpstream(this.provider, body, activeCredentials); + const url = this.buildUrl(model, stream, urlIndex, requestCredentials); + const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model); + applyConfiguredUserAgent(headers, requestCredentials?.providerSpecificData); // Strip OpenAI SDK (X-Stainless-*) metadata + normalize SDK-derived User-Agent // on OpenAI-compatible passthrough requests — some upstream gateways 403 on them. @@ -878,7 +880,7 @@ export class BaseExecutor { } const ccRequestDefaults = isClaudeCodeCompatible(this.provider) - ? getClaudeCodeCompatibleRequestDefaults(activeCredentials?.providerSpecificData) + ? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData) : {}; const shouldForwardExtendedContext = extendedContext && @@ -894,7 +896,7 @@ export class BaseExecutor { model, body, stream, - activeCredentials + requestCredentials ); let transformedBody = sanitizeReasoningEffortForProvider( rawTransformedBody, diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 21ebd0f07d..db15963796 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -186,8 +186,9 @@ export class DefaultExecutor extends BaseExecutor { const normalized = baseUrl.replace(/\/$/, ""); const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; if (customPath) return `${normalized}${customPath}`; + const forceResponses = psd?._omnirouteForceResponsesUpstream === true; const path = - getOpenAICompatibleType(this.provider, psd) === "responses" + forceResponses || getOpenAICompatibleType(this.provider, psd) === "responses" ? "/responses" : "/chat/completions"; return `${normalized}${path}`; diff --git a/open-sse/executors/forceResponsesUpstream.ts b/open-sse/executors/forceResponsesUpstream.ts new file mode 100644 index 0000000000..0c545d980e --- /dev/null +++ b/open-sse/executors/forceResponsesUpstream.ts @@ -0,0 +1,60 @@ +import { getOpenAICompatibleType } from "../services/provider.ts"; + +/** + * Force OpenAI-compatible upstreams onto the native `/responses` endpoint. + * + * A Responses-API-shaped request (`input` / `previous_response_id` / + * `max_output_tokens` / `reasoning`) that carries MCP (`namespace`) or + * `tool_search*` tools loses the Codex deferred tool-discovery mechanism when + * OmniRoute downgrades it to `/chat/completions` — so the MCP namespaces never + * surface to the model and `apply_patch` is mis-handled (#5483). Detecting that + * shape lets the executor pass it through natively instead of downgrading. + */ + +type CredentialsLike = { providerSpecificData?: Record | null }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function shouldForceResponsesUpstream( + provider: string, + body: unknown, + credentials: CredentialsLike | null +): boolean { + if (!provider.startsWith("openai-compatible-")) return false; + if (!isRecord(body)) return false; + + const providerSpecificData = credentials?.providerSpecificData ?? null; + if (providerSpecificData?._omnirouteForceResponsesUpstream === true) return true; + if (getOpenAICompatibleType(provider, providerSpecificData) === "responses") return false; + + const hasResponsesShape = + body.input !== undefined || + body.previous_response_id !== undefined || + body.max_output_tokens !== undefined || + body.reasoning !== undefined; + if (!hasResponsesShape) return false; + + const tools = Array.isArray(body.tools) ? body.tools : []; + return tools.some((toolValue) => { + if (!isRecord(toolValue)) return false; + const toolType = typeof toolValue.type === "string" ? toolValue.type : ""; + return toolType === "namespace" || /^tool_search/.test(toolType); + }); +} + +export function withForcedResponsesUpstream( + provider: string, + body: unknown, + credentials: T +): T { + if (!shouldForceResponsesUpstream(provider, body, credentials)) return credentials; + return { + ...credentials, + providerSpecificData: { + ...credentials.providerSpecificData, + _omnirouteForceResponsesUpstream: true, + }, + } as T; +} diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index bdb6546c59..001035c0a2 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -112,10 +112,7 @@ test("DefaultExecutor.buildUrl honors a custom providerSpecificData.baseUrl for const openai = new DefaultExecutor("openai"); // No override → hardcoded OpenAI endpoint (unchanged behavior). - assert.equal( - openai.buildUrl("gpt-4o", true), - "https://api.openai.com/v1/chat/completions" - ); + assert.equal(openai.buildUrl("gpt-4o", true), "https://api.openai.com/v1/chat/completions"); // Custom base URL (e.g. a proxy/gateway) must be used instead of api.openai.com. assert.equal( @@ -170,6 +167,15 @@ test("DefaultExecutor.buildUrl handles openai-compatible and anthropic-compatibl }), "https://proxy.example/v1/responses" ); + assert.equal( + openAICompat.buildUrl("gpt-4.1", true, 0, { + providerSpecificData: { + baseUrl: "https://proxy.example/v1/", + _omnirouteForceResponsesUpstream: true, + }, + }), + "https://proxy.example/v1/responses" + ); assert.equal( openAILegacyResponsesCompat.buildUrl("gpt-5.4", true, 0, { providerSpecificData: { @@ -871,6 +877,46 @@ test("DefaultExecutor.transformRequest only injects stream usage for OpenAI chat assert.equal((responsesResult as any).stream_options, undefined); }); +test("DefaultExecutor.execute routes Responses-shaped MCP requests to /responses for OpenAI-compatible providers", async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; body: any }> = []; + + globalThis.fetch = async (url, init = {}) => { + calls.push({ + url: String(url), + body: JSON.parse(String(init.body)), + }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const executor = new DefaultExecutor("openai-compatible-test"); + await executor.execute({ + model: "gpt-4.1", + body: { + model: "gpt-4.1", + input: "find tools", + tools: [{ type: "tool_search" }], + }, + stream: false, + credentials: { + apiKey: "test-key", + providerSpecificData: { baseUrl: "https://proxy.example/v1/" }, + }, + }); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://proxy.example/v1/responses"); + assert.equal(calls[0].body.stream_options, undefined); + assert.deepEqual(calls[0].body.tools, [{ type: "tool_search" }]); +}); + test("DefaultExecutor.transformRequest respects disableStreamOptions for OpenAI chat targets", () => { const openAICompat = new DefaultExecutor("openai-compatible-test"); const chatBody = { model: "gpt-4.1", messages: [{ role: "user", content: "hi" }] };