mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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:
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
60
open-sse/executors/forceResponsesUpstream.ts
Normal file
60
open-sse/executors/forceResponsesUpstream.ts
Normal 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;
|
||||
}
|
||||
@@ -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" }] };
|
||||
|
||||
Reference in New Issue
Block a user