mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
fix(translator): flatten MCP namespace tools to functions on Responses->Chat path (#4340)
When a Codex CLI client routes a Responses-API request to a non-Codex
backend (e.g. kr/claude-opus-4.7), each MCP server is declared as a
`namespace` tool: { type:"namespace", name, tools:[{name, description,
parameters}] }. The Responses->Chat translator had no namespace branch, so
the whole group collapsed into one empty-schema function named
`mcp__<server>__` and every MCP call failed with
`unsupported call: mcp__<server>__`, breaking all MCP workflows for that
combination. The translator now expands a namespace into one Chat function
per sub-tool (name + parameters preserved); an empty namespace yields no
tools. The native Codex passthrough path was already correct.
Reported-by: V13t4nh (https://github.com/decolua/9router/issues/1534)
Co-authored-by: V13t4nh <201110185+V13t4nh@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
7a7b437b61
commit
8c96cdeade
@@ -36,6 +36,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
- **fix(providers): Cloudflare Workers AI model discovery shows model names, not UUIDs** — importing a Cloudflare Workers AI key listed models with internal UUID identifiers (e.g. `429b9e8b-d99e-…`) instead of their usable slugs (`@cf/meta/llama-3.1-8b-instruct`). Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/…" }`, and discovery was passing the raw objects through — so the UUID `id` became the callable model id. The `cloudflare-ai` discovery now maps each result's `name` → id, surfacing the real `@cf/…` model ids. ([#4259](https://github.com/diegosouzapw/OmniRoute/issues/4259) — thanks @FerLuisxd)
|
||||
- **fix(translator): clamp Responses API `call_id` to 64 characters** — the OpenAI Responses API rejects `call_id` values longer than 64 characters with a 400. Long upstream tool-call ids (some clients emit ids well over the limit) are now clamped deterministically on both the `function_call` item and its matching `function_call_output`, so the pair stays matched through the orphaned-output filter and the request is accepted. (thanks @anuragg-saxenaa, @ngapngap)
|
||||
- **fix(oauth): GitHub Copilot token refresh now sends the public client_id** — the `github` provider config never carried a `clientId`, so GitHub OAuth `refresh_token` exchanges either omitted `client_id` or sent the literal string `undefined` (and a bogus `client_secret=undefined`), which GitHub rejects — leaving a Copilot connection stuck once its short-lived token expired and the long-lived refresh path was needed. The provider now resolves its public device-flow `client_id` from the embedded public credential and omits `client_secret` entirely (GitHub's Copilot app is a public client with no secret). (thanks @baslr)
|
||||
- **fix(translator): MCP `namespace` tools flatten to individual functions on the Responses→Chat path** — when a Codex CLI client routes a Responses-API request to a non-Codex backend (e.g. `kr/claude-opus-4.7`), each MCP server is declared as a `namespace` tool (`{ type:"namespace", name, tools:[…] }`). The Responses→Chat translator had no `namespace` branch, so the whole group collapsed into a single empty-schema function named `mcp__<server>__` and every MCP call returned `unsupported call: mcp__<server>__`, breaking all MCP-based workflows (context7, codegraph, custom MCPs) for that combination. The translator now expands a namespace into one Chat function per sub-tool (preserving each sub-tool's name and parameters); an empty namespace yields no tools instead of a broken placeholder. The native Codex passthrough path was already correct. (thanks @V13t4nh)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -315,10 +315,33 @@ export function openaiResponsesToOpenAIRequest(
|
||||
!TOOL_SEARCH_TOOL_TYPES.test(toolType) && !IMAGE_GENERATION_TOOL_TYPES.test(toolType)
|
||||
);
|
||||
})
|
||||
.map((toolValue) => {
|
||||
.flatMap((toolValue) => {
|
||||
const tool = toRecord(toolValue);
|
||||
if (tool.function) return toolValue;
|
||||
const toolType = toString(tool.type);
|
||||
// MCP tool groups: Codex/OpenAI Responses clients declare each MCP server as a
|
||||
// `namespace` tool — { type:"namespace", name, tools:[{name, description, parameters}] }.
|
||||
// Non-Codex backends (Kiro/Claude) have no `namespace` type, so flatten each sub-tool
|
||||
// into a standalone Chat function (#1534). Without this the whole group collapsed into
|
||||
// one empty-schema function named `mcp__<server>__` and every MCP call failed with
|
||||
// `unsupported call: mcp__<server>__`.
|
||||
if (toolType === "namespace") {
|
||||
const subTools = Array.isArray(tool.tools) ? tool.tools : [];
|
||||
return subTools
|
||||
.map((subValue) => toRecord(subValue))
|
||||
.filter((sub) => toString(sub.name))
|
||||
.map((sub) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: toString(sub.name),
|
||||
description: toString(sub.description),
|
||||
parameters: sub.parameters ?? sub.input_schema ?? {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
// Pass web_search server tools through with their original type (versioned or plain).
|
||||
// These have no Chat Completions equivalent; preserve as-is so upstreams that understand
|
||||
// Anthropic-style web_search_YYYYMMDD naming receive the exact name they expect.
|
||||
|
||||
72
tests/unit/responses-namespace-flatten.test.ts
Normal file
72
tests/unit/responses-namespace-flatten.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Regression for port-from-9router#1534: when a Codex CLI client routes a
|
||||
// Responses-API request to a non-Codex backend (e.g. kr/claude-opus-4.7), MCP
|
||||
// servers are declared as `namespace` tools — { type:"namespace", name, tools:[...] }.
|
||||
// The Responses→Chat translator had no namespace branch, so each namespace
|
||||
// collapsed into a single empty-schema function named `mcp__<server>__`, dropping
|
||||
// every sub-tool. Any MCP call then failed with `unsupported call: mcp__<server>__`.
|
||||
const { openaiResponsesToOpenAIRequest } = await import(
|
||||
"../../open-sse/translator/request/openai-responses.ts"
|
||||
);
|
||||
|
||||
test("#1534: namespace MCP tools flatten into one Chat function per sub-tool", () => {
|
||||
const body = {
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "mcp__ctx7__",
|
||||
tools: [
|
||||
{
|
||||
name: "mcp__ctx7__get_docs",
|
||||
description: "Get library docs",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mcp__ctx7__search",
|
||||
description: "Search docs",
|
||||
parameters: { type: "object", properties: { q: { type: "string" } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const out = openaiResponsesToOpenAIRequest("kr/claude-opus-4.7", body, false, {}) as {
|
||||
tools: { type: string; function: { name: string; parameters?: unknown } }[];
|
||||
};
|
||||
|
||||
const names = out.tools.map((t) => t.function?.name).sort();
|
||||
assert.deepEqual(names, ["mcp__ctx7__get_docs", "mcp__ctx7__search"]);
|
||||
|
||||
// The empty namespace placeholder must NOT survive.
|
||||
assert.equal(
|
||||
out.tools.some((t) => t.function?.name === "mcp__ctx7__"),
|
||||
false,
|
||||
"the empty `mcp__ctx7__` namespace placeholder must not be emitted"
|
||||
);
|
||||
|
||||
// Each flattened function keeps its own parameters.
|
||||
const getDocs = out.tools.find((t) => t.function.name === "mcp__ctx7__get_docs");
|
||||
assert.ok(getDocs?.function.parameters, "sub-tool parameters must be preserved");
|
||||
assert.deepEqual((getDocs!.function.parameters as { required?: string[] }).required, ["id"]);
|
||||
});
|
||||
|
||||
test("#1534: an empty namespace (no sub-tools) is dropped, not turned into a broken function", () => {
|
||||
const body = {
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
tools: [{ type: "namespace", name: "mcp__empty__", tools: [] }],
|
||||
};
|
||||
|
||||
const out = openaiResponsesToOpenAIRequest("kr/claude-opus-4.7", body, false, {}) as {
|
||||
tools: unknown[];
|
||||
};
|
||||
|
||||
assert.equal(out.tools.length, 0, "an empty namespace yields no tools");
|
||||
});
|
||||
Reference in New Issue
Block a user