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.
This commit is contained in:
KooshaPari
2026-06-30 10:47:35 -07:00
committed by GitHub
parent 24ae0b32f8
commit 7686eaadd6
6 changed files with 125 additions and 13 deletions

View File

@@ -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/<proto>.json` and parsed it as JSON, but the upstream list is plain text (`<proto>.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))

View File

@@ -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, <cap) so the frozen god-file only takes the irreducible 2-line wiring. The contributor PR's incidental prettier reformats were reverted to keep the diff scoped to the fix. Covered by tests/unit/executor-default-base.test.ts.",
"_rebaseline_2026_06_30_5598_fusion_defaults": "PR #5598 own growth: ComboDefaultsTab.tsx 846->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, <cap) to keep the frozen god-file's growth to the minimal wiring. Mirrors the per-combo Fusion editor already rebaselined under #5074 (combos/page.tsx +109). Covered by tests/unit/ui/combo-defaults-fusion-5598.test.tsx.",
"_rebaseline_2026_06_29_v3841_release": "callLogs 985->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,

View File

@@ -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<string>();
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,

View File

@@ -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}`;

View File

@@ -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<string, unknown> | null };
function isRecord(value: unknown): value is Record<string, unknown> {
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<T extends CredentialsLike>(
provider: string,
body: unknown,
credentials: T
): T {
if (!shouldForceResponsesUpstream(provider, body, credentials)) return credentials;
return {
...credentials,
providerSpecificData: {
...credentials.providerSpecificData,
_omnirouteForceResponsesUpstream: true,
},
} as T;
}

View File

@@ -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" }] };