diff --git a/devlog/_plan/260912_muse_tool_name_alias/000_plan.md b/devlog/_plan/260912_muse_tool_name_alias/000_plan.md new file mode 100644 index 0000000000..ab077523a5 --- /dev/null +++ b/devlog/_plan/260912_muse_tool_name_alias/000_plan.md @@ -0,0 +1,90 @@ +# Meta Muse 64-char MCP tool-name aliasing (#4410) + +## Problem + +Meta Muse (https://api.meta.ai/v1, openai-responses adapter) rejects any request +whose function tool name exceeds 64 characters: HTTP 400 +'name' must be at most 64 characters, got 66. Real ZCode sessions carry +fully-namespaced MCP names (20 of 93 tools over the limit), so the whole turn +dies before any tool call. Repro: 66-char placeholder name -> 400, 64-char -> 200. +Only the name length matters; schemas, arguments, and message bodies are fine. + +## Prior art in this tree + +- src/adapters/kiro-wire.ts kiroToolName - deterministic, collision-safe + normalization to ^[a-zA-Z0-9_-]{1,64}$ with a nameMap that restores original + names on the way back. Same shape of problem, different transport. +- src/adapters/openai-responses.ts (~line 2465) - existing Muse-scoped outbound + transform stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url), + gated on the api.meta.ai Responses URL. The aliasing hook belongs at the same + seam so no other provider path changes behavior. +- src/responses/tool-name-aliases.ts plus src/responses/namespace-tool-compat.ts + and custom-tool-compat.ts - the existing alias/restore machinery for namespace, + custom-tool, and tool-search wire names. Inbound restore should reuse this + layer rather than inventing a second mapping channel. + +## Design + +1. Outbound (meta-muse / api.meta.ai Responses only): before the request body + leaves, rewrite every function tool name longer than 64 chars to a + deterministic collision-safe wire name: keep a readable prefix, append a + short stable hash suffix, clamp to 64, sanitize to the safe charset, and + dedupe within the request (same input -> same output across turns). +2. Record the alias map on the turn/request context. +3. Inbound: restore original names in streamed and non-streamed function_call / + tool_call outputs, in tool_choice echo, and in any history items that carry + the aliased name back upstream, using the existing alias-restore machinery. +4. Arguments, user text, and schema property names are never rewritten. Other + providers see zero behavioral change (scope strictly to the meta-muse + baseUrl / provider id). + +## Regression coverage + +- Unit: alias function - 64 passes through verbatim, 65/66/93-char names map + deterministically, collision-safe, charset-safe. +- Adapter-level: meta-muse outbound request with the issue's 93-tool catalog + fixture sends only <=64-char names; a second provider keeps names verbatim. +- Inbound: tool_call with aliased name restores the original MCP name; + tool_choice round-trips. + +## Delivery + +- Branch codex/260912-muse-64-tool-alias from dev (aa0dd50864), PR to dev with + full template, close #4410 manually after merge (PRs target dev; GitHub + auto-close only fires on main). +- Implementation and verification delegated to xai/grok-4.6 spawned subagents; + local suite NOT run; pushes use --no-verify; exact-head remote CI is the + passing evidence. + +## Audit amendments (grok-4.6 explorer, near-pass — blocking findings folded in) + +1. Do NOT copy stripMuseSparkUnsupportedWebSearchFields predicates (contributor-model + + URL set incl. Zen). Gate the new sibling transform on destination host api.meta.ai + so the default muse-spark-1.3 model is covered; place it at the same call site + (after namespace flattening ~openai-responses.ts:2453, before stringify). +2. Existing alias types cannot carry Map. Add a new sidecar + on AdapterRequest (e.g. convertedMuseToolNameAliases) and a new restore helper in + src/responses/ (e.g. muse-tool-name-alias.ts); wire restore at core.ts sites: + stream payload rewrites 6098-6107 (Muse rewrite BEFORE namespace restore), + block rewrites 6164 / undeclared guard 6150, non-stream 6362-6380, continuation + cache 5102-5104, inspection 5081, and every failover/rebuild refresh of + routed aliases (4808, 4905, 5357, 5474, 5595, 5822, 7270, 7415). +3. Outbound rewrite covers tools[] PLUS history function_call/custom_tool_call names, + tool_choice ({type:function|custom, name} and allowed_tools.tools[].name), + additional_tools, and chat-shaped tool.function.name (use wireToolInnerName). +4. Restore order is load-bearing: Muse hashed->original BEFORE namespace restore and + BEFORE the undeclared-tool guard (continuation turns declare only client originals). +5. Do not import kiro-wire.ts into src/responses/; copy the algorithm into a new + helper. Hash the ORIGINAL name (55-char prefix + _ + 8 hex sha256 = 64), charset + [^a-zA-Z0-9_-] -> _, two-phase claim (pass-through <=64 names claimed first), + salt loop wireName#N on collision, declaration-order processing. +6. Structure docs to update in the same change: structure/transports/responses.md + (alias contract), plus other owners of touched areas (runtime.md, + transports/inventory.md, data-planes/inbound-compat.md, providers/chat-compat.md, + adapters/registry.md as applicable). +7. Tests: unit helper tests/responses/responses-muse-tool-name-alias.test.ts; + adapter outbound (93-tool catalog fixture + second provider unchanged) + tests/providers/muse-tool-name-alias.test.ts; inbound restore/SSE in + tests/responses/ near openai-responses-passthrough/namespace-tool-compat. + New files need entries in BOTH scripts/test-layout/layout.json explicit and + tests/fixtures/test-layout-expected.json. Never put muse-* under tests/responses/. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b2eabcbcc9..f55eef862a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -883,6 +883,7 @@ "muse-passive-quota-observation.test.ts": "providers", "muse-spark-web-search-compat.test.ts": "providers", "muse-subscription-usage.test.ts": "providers", + "muse-tool-name-alias.test.ts": "providers", "namespace-tool-compat.test.ts": "responses", "native-alias-maintainer-regressions.test.ts": "codex-integration", "native-claude-code-toggle.test.ts": "codex-integration", @@ -1104,6 +1105,7 @@ "responses-inbound-store-default.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", "responses-json-events.test.ts": "responses", + "responses-muse-tool-name-alias.test.ts": "responses", "responses-native-main-refresh.test.ts": "responses", "responses-opaque-blob-recovery.test.ts": "responses", "responses-parser-agent-message.test.ts": "responses", diff --git a/src/adapters/base.ts b/src/adapters/base.ts index f5a22b2969..0affb33b55 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -91,6 +91,8 @@ export interface AdapterRequest { convertedRoutedToolSearchNames?: ReadonlySet; /** Upstream-only aliases for namespace tools flattened in this request. */ convertedRoutedNamespaceToolAliases?: ReadonlyMap; + /** Upstream-only <=64-char aliases for Meta Muse tool names rewritten in this request. */ + convertedMuseToolNameAliases?: ReadonlyMap; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 30e1c14c85..92de305f6d 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -24,6 +24,7 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; +import { isMetaAiResponsesDestination, rewriteMuseToolNamesForUpstream } from "../responses/muse-tool-name-alias"; import { openaiResponsesUrl } from "./openai-responses-url"; import { normalizeResponsesCodeMode } from "./responses-code-mode"; import { stripUnicodePropertyPatterns } from "./responses-tool-schema"; @@ -2370,6 +2371,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let routedCustomToolRepairNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; let convertedRoutedNamespaceToolAliases: Map | undefined; + let convertedMuseToolNameAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -2463,6 +2465,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripOpenAiOnlyWebSearchFields(outBody); } outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url); + // Host-only: api.meta.ai rejects function names over 64 chars on every Muse model, + // including default muse-spark-1.3. Do not reuse the contributor/Zen web_search + // predicates. Namespace flattening has already produced the public wire names. + if (isMetaAiResponsesDestination(url)) { + const rewritten = rewriteMuseToolNamesForUpstream(outBody); + outBody = rewritten.body; + convertedMuseToolNameAliases = rewritten.aliases; + } // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } @@ -2571,6 +2581,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), + ...(convertedMuseToolNameAliases ? { convertedMuseToolNameAliases } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/responses/muse-tool-name-alias.ts b/src/responses/muse-tool-name-alias.ts new file mode 100644 index 0000000000..1569c5785d --- /dev/null +++ b/src/responses/muse-tool-name-alias.ts @@ -0,0 +1,379 @@ +import { createHash } from "node:crypto"; +import { wireToolInnerName } from "./tool-name-aliases"; + +const MUSE_TOOL_NAME_MAX = 64; +const MUSE_TOOL_NAME_PREFIX = 55; +const MUSE_TOOL_NAME_HASH_LEN = 8; +const MUSE_SAFE_NAME = /^[a-zA-Z0-9_-]+$/; + +export type MuseToolNameAliases = ReadonlyMap; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Direct Meta Muse / Meta Model Responses host. Path, port, and model id do not matter. */ +export function isMetaAiResponsesDestination(responseUrl: string): boolean { + try { + return new URL(responseUrl).hostname.toLowerCase() === "api.meta.ai"; + } catch { + return false; + } +} + +function sanitizeMuseToolName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function isPassThroughMuseToolName(name: string): boolean { + return name.length >= 1 + && name.length <= MUSE_TOOL_NAME_MAX + && MUSE_SAFE_NAME.test(name) + && sanitizeMuseToolName(name) === name; +} + +function hashSuffix(originalName: string, salt: number): string { + const hashInput = salt === 0 ? originalName : originalName + "#" + salt; + return createHash("sha256").update(hashInput).digest("hex").slice(0, MUSE_TOOL_NAME_HASH_LEN); +} + +/** + * Collision-safe wire name for one original tool identity. Pass-through when the name is + * already `^[a-zA-Z0-9_-]{1,64}$` and unclaimed; otherwise a 55-char sanitized prefix plus + * an 8-hex sha256 of the ORIGINAL name, salted with `original#N` until unique. + */ +export function museWireToolName(originalName: string, used?: Set): string { + if (isPassThroughMuseToolName(originalName) && !(used?.has(originalName))) { + used?.add(originalName); + return originalName; + } + const base = sanitizeMuseToolName(originalName).slice(0, MUSE_TOOL_NAME_PREFIX) || "tool"; + for (let salt = 0; ; salt++) { + const candidate = base + "_" + hashSuffix(originalName, salt); + if (!(used?.has(candidate))) { + used?.add(candidate); + return candidate; + } + } +} + +/** + * Two-phase claim over a declaration-order name list: conforming <=64 names occupy the + * collision domain first, then long or charset-unsafe names alias in that same order. + * `aliases` is wireName -> originalName for identities that actually changed. + */ +export function buildMuseToolNameAliasPlan(originalNames: readonly string[]): { + wireByOriginal: Map; + aliases: Map; +} { + const used = new Set(); + const wireByOriginal = new Map(); + const unique: string[] = []; + const seen = new Set(); + for (const name of originalNames) { + if (name.length === 0 || seen.has(name)) continue; + seen.add(name); + unique.push(name); + } + for (const name of unique) { + if (!isPassThroughMuseToolName(name)) continue; + used.add(name); + wireByOriginal.set(name, name); + } + for (const name of unique) { + if (wireByOriginal.has(name)) continue; + wireByOriginal.set(name, museWireToolName(name, used)); + } + const aliases = new Map(); + for (const [original, wire] of wireByOriginal) { + if (wire !== original) aliases.set(wire, original); + } + return { wireByOriginal, aliases }; +} + +function addCollectedName(names: string[], seen: Set, name: string | undefined): void { + if (!name || seen.has(name)) return; + seen.add(name); + names.push(name); +} + +function collectDeclaredToolName(tool: unknown, names: string[], seen: Set): void { + addCollectedName(names, seen, wireToolInnerName(tool)); +} + +function collectToolChoiceNames(choice: unknown, names: string[], seen: Set): void { + if (!isPlainObject(choice)) return; + if ((choice.type === "function" || choice.type === "custom") && typeof choice.name === "string") { + addCollectedName(names, seen, choice.name); + return; + } + if (choice.type !== "allowed_tools" || !Array.isArray(choice.tools)) return; + for (const tool of choice.tools) { + if (!isPlainObject(tool)) continue; + if (tool.type !== "function" && tool.type !== "custom") continue; + if (typeof tool.name === "string") addCollectedName(names, seen, tool.name); + } +} + +function collectMuseToolNames(body: unknown): string[] { + if (!isPlainObject(body)) return []; + const names: string[] = []; + const seen = new Set(); + if (Array.isArray(body.tools)) { + for (const tool of body.tools) collectDeclaredToolName(tool, names, seen); + } + if (Array.isArray(body.input)) { + for (const item of body.input) { + if (!isPlainObject(item)) continue; + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + for (const tool of item.tools) collectDeclaredToolName(tool, names, seen); + continue; + } + if ( + (item.type === "function_call" || item.type === "custom_tool_call") + && typeof item.name === "string" + ) addCollectedName(names, seen, item.name); + } + } + collectToolChoiceNames(body.tool_choice, names, seen); + return names; +} + +function rewriteDeclaredTool(tool: unknown, wireByOriginal: ReadonlyMap): unknown { + if (!isPlainObject(tool)) return tool; + const original = wireToolInnerName(tool); + if (!original) return tool; + const wire = wireByOriginal.get(original); + if (wire === undefined || wire === original) return tool; + let next: Record = tool; + if (typeof tool.name === "string" && tool.name === original) { + next = { ...next, name: wire }; + } + if ( + tool.type === "function" + && isPlainObject(tool.function) + && typeof tool.function.name === "string" + && tool.function.name === original + ) { + next = { ...next, function: { ...tool.function, name: wire } }; + } + return next; +} + +function rewriteToolChoice(choice: unknown, wireByOriginal: ReadonlyMap): unknown { + if (!isPlainObject(choice)) return choice; + if ((choice.type === "function" || choice.type === "custom") && typeof choice.name === "string") { + const wire = wireByOriginal.get(choice.name); + return wire === undefined || wire === choice.name ? choice : { ...choice, name: wire }; + } + if (choice.type !== "allowed_tools" || !Array.isArray(choice.tools)) return choice; + let changed = false; + const tools = choice.tools.map(tool => { + if (!isPlainObject(tool) || (tool.type !== "function" && tool.type !== "custom")) return tool; + if (typeof tool.name !== "string") return tool; + const wire = wireByOriginal.get(tool.name); + if (wire === undefined || wire === tool.name) return tool; + changed = true; + return { ...tool, name: wire }; + }); + return changed ? { ...choice, tools } : choice; +} + +function rewriteInputItem(item: unknown, wireByOriginal: ReadonlyMap): unknown { + if (!isPlainObject(item)) return item; + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + let changed = false; + const tools = item.tools.map(tool => { + const next = rewriteDeclaredTool(tool, wireByOriginal); + changed ||= next !== tool; + return next; + }); + return changed ? { ...item, tools } : item; + } + if ( + (item.type === "function_call" || item.type === "custom_tool_call") + && typeof item.name === "string" + ) { + const wire = wireByOriginal.get(item.name); + return wire === undefined || wire === item.name ? item : { ...item, name: wire }; + } + return item; +} + +/** + * Keep restoration inside the caller's per-turn authorization boundary, matching + * `authorizedAliases` in namespace-tool-compat. Upstream sees every declaration even when + * `tool_choice` narrows what it may call, so a wire name appearing in that catalog is not + * on its own evidence that restoring it into an executable client name is permitted. + * The selector is already rewritten to wire names here, so it compares against alias keys. + */ +function authorizedMuseAliases( + aliases: Map, + toolChoice: unknown, +): Map { + if (toolChoice === undefined || toolChoice === "auto" || toolChoice === "required") return aliases; + if (toolChoice === "none" || !isPlainObject(toolChoice)) return new Map(); + + const authorized = new Set(); + if ( + (toolChoice.type === "function" || toolChoice.type === "custom") + && typeof toolChoice.name === "string" + ) { + authorized.add(toolChoice.name); + } else if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { + for (const tool of toolChoice.tools) { + if (!isPlainObject(tool)) continue; + if (tool.type !== "function" && tool.type !== "custom") continue; + if (typeof tool.name === "string") authorized.add(tool.name); + } + } else { + // An explicit selector for another tool kind authorizes no function/custom call. + return new Map(); + } + + const kept = new Map(); + for (const [wire, original] of aliases) { + if (authorized.has(wire)) kept.set(wire, original); + } + return kept; +} + +/** + * Rewrite function/custom tool identities for the Meta Muse 64-char wire limit. + * Arguments, user text, and schema property names stay untouched. + */ +export function rewriteMuseToolNamesForUpstream(body: unknown): { + body: unknown; + aliases: Map; +} { + if (!isPlainObject(body)) return { body, aliases: new Map() }; + const { wireByOriginal, aliases } = buildMuseToolNameAliasPlan(collectMuseToolNames(body)); + if (aliases.size === 0) return { body, aliases }; + + let tools = body.tools; + if (Array.isArray(body.tools)) { + let changed = false; + const rewritten = body.tools.map(tool => { + const next = rewriteDeclaredTool(tool, wireByOriginal); + changed ||= next !== tool; + return next; + }); + if (changed) tools = rewritten; + } + + let input = body.input; + if (Array.isArray(body.input)) { + let changed = false; + const rewritten = body.input.map(item => { + const next = rewriteInputItem(item, wireByOriginal); + changed ||= next !== item; + return next; + }); + if (changed) input = rewritten; + } + + const toolChoice = rewriteToolChoice(body.tool_choice, wireByOriginal); + // Upstream still receives the whole aliased catalog; only what may be restored narrows. + const restorable = authorizedMuseAliases(aliases, toolChoice); + if (tools === body.tools && input === body.input && toolChoice === body.tool_choice) { + return { body, aliases: restorable }; + } + return { + body: { + ...body, + ...(tools !== body.tools ? { tools } : {}), + ...(input !== body.input ? { input } : {}), + ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), + }, + aliases: restorable, + }; +} + +/** + * Payload shapes whose `name` is a tool identity the client (and the undeclared-tool guard) + * reads. `response.function_call_arguments.done` carries the name outside any `function_call` + * item, so a hashed alias there would still reach the guard as an undeclared tool. + */ +function isMuseToolIdentityType(type: unknown): boolean { + return type === "function_call" + || type === "custom_tool_call" + || type === "function" + || type === "custom" + || type === "response.function_call_arguments.done" + || type === "response.function_call_arguments.delta"; +} + +function restoreNamedIdentity( + value: Record, + aliases: MuseToolNameAliases, +): { value: Record; changed: boolean } { + let restored = value; + let changed = false; + if (isMuseToolIdentityType(value.type) && typeof restored.name === "string") { + const original = aliases.get(restored.name); + if (original && original !== restored.name) { + restored = { ...restored, name: original }; + changed = true; + } + } + const fn = restored.function; + if ( + restored.type === "function" + && isPlainObject(fn) + && typeof fn.name === "string" + ) { + const original = aliases.get(fn.name); + if (original && original !== fn.name) { + restored = { ...restored, function: { ...fn, name: original } }; + changed = true; + } + } + return { value: restored, changed }; +} + +export function restoreMuseToolNames( + value: unknown, + aliases: MuseToolNameAliases, +): { value: unknown; changed: boolean } { + if (aliases.size === 0) return { value, changed: false }; + if (Array.isArray(value)) { + let changed = false; + const restored = value.map(entry => { + const result = restoreMuseToolNames(entry, aliases); + changed ||= result.changed; + return result.value; + }); + return changed ? { value: restored, changed: true } : { value, changed: false }; + } + if (!isPlainObject(value)) return { value, changed: false }; + + let changed = false; + const restored: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const result = restoreMuseToolNames(entry, aliases); + restored[key] = result.value; + changed ||= result.changed; + } + const node = changed ? restored : value; + const identity = restoreNamedIdentity(node, aliases); + if (identity.changed) return { value: identity.value, changed: true }; + return changed ? { value: restored, changed: true } : { value, changed: false }; +} + +export function restoreMuseToolNamesInJson(text: string, aliases: MuseToolNameAliases): string { + if (aliases.size === 0) return text; + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return text; + } + const restored = restoreMuseToolNames(payload, aliases); + return restored.changed ? JSON.stringify(restored.value) : text; +} + +export function createMuseToolNameRestoreRewrite( + aliases: MuseToolNameAliases, +): (payload: string) => string { + return payload => restoreMuseToolNamesInJson(payload, aliases); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8b499d5111..3952db7afc 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -431,6 +431,12 @@ import { restoreRoutedNamespaceCallsInJson, type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; +import { + createMuseToolNameRestoreRewrite, + restoreMuseToolNames, + restoreMuseToolNamesInJson, + type MuseToolNameAliases, +} from "../../responses/muse-tool-name-alias"; import { collectDeclaredBareWireToolNames, collectDeclaredNamelessClientCallTypes, @@ -4804,8 +4810,10 @@ async function handleResponsesInner( } let routedNamespaceToolAliases: RoutedNamespaceToolAliases = new Map(); + let routedMuseToolNameAliases: MuseToolNameAliases = new Map(); const refreshRoutedNamespaceToolAliases = (builtRequest: AdapterRequest): void => { routedNamespaceToolAliases = builtRequest.convertedRoutedNamespaceToolAliases ?? new Map(); + routedMuseToolNameAliases = builtRequest.convertedMuseToolNameAliases ?? new Map(); }; if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { @@ -5078,7 +5086,9 @@ async function handleResponsesInner( // provider) every name looks undeclared, and flipping this would stop recording continuation // state for exactly the passthrough traffic the guard deliberately stands down for. if (undeclaredToolGuardActive && !inspectionSawUndeclaredTool && undeclaredToolCallName( - restoreAuthorizedBareNamespaceToolCalls(payload), + restoreAuthorizedBareNamespaceToolCalls( + restoreMuseToolNames(payload, routedMuseToolNameAliases).value, + ), declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, @@ -5100,7 +5110,12 @@ async function handleResponsesInner( ) => { if (inspectionSawUndeclaredTool) return; const restored = restoreRoutedCustomCalls( - restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), + restoreAuthorizedBareNamespaceToolCalls( + restoreRoutedNamespaceCalls( + restoreMuseToolNames(response, routedMuseToolNameAliases).value, + routedNamespaceToolAliases, + ).value, + ), routedCustomToolNames, routedCustomToolRepairNames, declaredWireToolNames, @@ -6100,6 +6115,9 @@ async function handleResponsesInner( createImageGenCallRestoreRewrite(imageGenCallAliases), // #3217: a call whose namespace repeats its own name is unroutable in codex-rs. createSelfNamedToolCallNamespaceScrubRewrite(selfNamedNamespaceScrubAuthorization), + routedMuseToolNameAliases.size > 0 + ? createMuseToolNameRestoreRewrite(routedMuseToolNameAliases) + : undefined, routedNamespaceToolAliases.size > 0 ? createRoutedNamespaceCallRestoreRewrite(routedNamespaceToolAliases) : undefined, @@ -6361,7 +6379,10 @@ async function handleResponsesInner( let clientJson = (() => { const restoredNamespace = restoreRoutedNamespaceCallsInJson( scrubSelfNamedToolCallNamespaceInJson( - restoreImageGenCallsInJson(text, imageGenCallAliases), + restoreMuseToolNamesInJson( + restoreImageGenCallsInJson(text, imageGenCallAliases), + routedMuseToolNameAliases, + ), selfNamedNamespaceScrubAuthorization, ), routedNamespaceToolAliases, diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index db26a7cb4d..918e92c5f5 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -11,6 +11,8 @@ Runtime adapter construction has one authority: `src/adapters/registry.ts`. Some adapters share another adapter's routed-tool semantics while retaining independent runtime construction: - `azure` and `azure-openai` inherit the `openai-responses` contract. + The inherited contract includes Meta Muse's host-gated 64-character tool-name alias when the + constructed send URL is `api.meta.ai` (`src/responses/muse-tool-name-alias.ts`). - `mimo-free` inherits the `openai-chat` contract. - `cursor` stays direct because its `runTurn` transport and gated native-file fallback are distinct. - `devin-cli` stays direct for the same reason, one layer further out: it has no HTTP transport at diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index ee84baebee..0593da7e6a 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -21,6 +21,8 @@ take the Chat -> Responses -> Chat bridge below. `parallel_tool_calls` is emitte parallel tools (or pinned false by the existing provider opt-out contract). Combo/policy routes and requests that need Responses-only hosted tools, continuation, background, or storage semantics retain the existing Chat -> Responses -> Chat bridge. +Chat-to-Responses traffic that lands on `api.meta.ai` inherits the same 64-character tool-name +aliasing as native Responses; see [`responses.md`](../transports/responses.md). The direct SSE relay accepts CRLF and arbitrary transport chunk boundaries while retaining at most one bounded event. EOF with an unterminated event and an event above the translator limit are typed diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..7cf84ef7ec 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -38,6 +38,11 @@ request-local alias. Raw API-key continuations deliberately preserve ids because continuation may reference a call stored upstream under its original id; proxy-expanded API-key replays are explicit and receive the same repair. +Separately, Meta Muse Responses (`src/responses/muse-tool-name-alias.ts`) aliases function *tool +names* that exceed 64 characters or contain characters outside `[a-zA-Z0-9_-]` on `api.meta.ai` +only. That map is not the call-id repair: it covers tools, `additional_tools`, history calls, and +`tool_choice`, then restores original names inbound. + These compatibility guards are covered by focused tests and should stay close to the adapters that need them. diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 4650e912dc..4881f628f4 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -10,6 +10,10 @@ while accepting the client hint and translating the ordinary tool catalog normal > Decision record: [ADR-0060](../decisions/ADR-0060-kiro-client-parallel-tool-hint.md) +Kiro's own `kiroToolName` rewrite in `src/adapters/kiro-wire.ts` is CodeWhisperer-only and +reserves the private completion tool. Meta Muse 64-character MCP aliases live in +`src/responses/muse-tool-name-alias.ts` and must not import that Kiro helper. + ## Kiro Responses text controls Kiro refuses structured output and tolerates every other Responses `text` member. `text.format` diff --git a/structure/runtime.md b/structure/runtime.md index 89276a1839..ac2eb378e1 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -147,6 +147,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | `src/providers/derive.ts` | Enrichment from provider presets into user config. | | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | +| `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. | | `src/adapters/google.ts` | Gemini bridge. | diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ec75ff03d4..7a7d2f9b4d 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -8,6 +8,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Transport | Owner | Invariant worth knowing | | --- | --- | --- | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | +| Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | | Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6e975af2ac..6d206195a3 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -327,6 +327,21 @@ Muse Spark's Responses sanitizer also drops the provider-rejected `search_conten `indexed_web_access` fields from plain `web_search` tools while preserving preview tools and unrelated models. +Direct Meta Muse / Meta Model Responses (`https://api.meta.ai/v1`) also rejects function tool +names longer than 64 characters or containing characters outside `[a-zA-Z0-9_-]`. After namespace +flattening, `src/responses/muse-tool-name-alias.ts` rewrites those identities on the `api.meta.ai` +host only — every model, including default `muse-spark-1.3` — and records +`convertedMuseToolNameAliases` on the adapter request. Restore runs hashed-to-original before +namespace restore and before the undeclared-tool guard, covering stream payloads, non-stream JSON, +continuation cache, inspection, and failover rebuilds. +Restore matches the tool identity on `function_call`, `custom_tool_call`, `function`, and +`custom` objects and on `response.function_call_arguments.{done,delta}`, whose `name` sits +outside any item and is read directly by the undeclared-tool guard. +The restorable map is narrowed by `tool_choice` the same way `authorizedAliases` narrows the +namespace layer: upstream still receives the whole aliased catalog, but a tool the caller +disabled for the turn cannot be restored back into an executable client name. +Arguments, user text, and schema property names are never rewritten. + > Decision record: [ADR-0042](../decisions/ADR-0042-responses-http-sse.md) > Decision record: [ADR-0043](../decisions/ADR-0043-responses-http-sse.md) diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b56ab03905..b87d20b8f2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -714,6 +714,7 @@ "muse-passive-quota-observation.test.ts": "providers", "muse-spark-web-search-compat.test.ts": "providers", "muse-subscription-usage.test.ts": "providers", + "muse-tool-name-alias.test.ts": "providers", "namespace-tool-compat.test.ts": "responses", "native-alias-maintainer-regressions.test.ts": "codex-integration", "native-claude-code-toggle.test.ts": "codex-integration", @@ -936,6 +937,7 @@ "responses-inbound-store-default.test.ts": "responses", "responses-item-id-repair.test.ts": "responses", "responses-json-events.test.ts": "responses", + "responses-muse-tool-name-alias.test.ts": "responses", "responses-native-main-refresh.test.ts": "responses", "responses-opaque-blob-recovery.test.ts": "responses", "responses-parser-agent-message.test.ts": "responses", @@ -1204,4 +1206,4 @@ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers" -} \ No newline at end of file +} diff --git a/tests/providers/muse-tool-name-alias.test.ts b/tests/providers/muse-tool-name-alias.test.ts new file mode 100644 index 0000000000..52228c20bd --- /dev/null +++ b/tests/providers/muse-tool-name-alias.test.ts @@ -0,0 +1,167 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import type { OcxProviderConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +const META_PROVIDER = { + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + apiKey: "test-key", +} as unknown as OcxProviderConfig; + +const META_PATH_PROVIDER = { + ...META_PROVIDER, + baseUrl: "https://api.meta.ai", + responsesPath: "/v1/responses", +} as unknown as OcxProviderConfig; + +const XAI_PROVIDER = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + apiKey: "test-key", +} as unknown as OcxProviderConfig; + +const HF = "mcp__plugin_huggingface-skills_huggingface-skills__"; +const AE = "mcp__plugin_android-emulator_android-emulator__"; +const MD = "mcp__plugin_microsoft-docs_microsoft-docs__"; + +/** Issue #4410: 20 of 93 ZCode MCP names exceed Meta Muse's 64-char wire limit. */ +const LONG_ISSUE_NAMES = [ + HF + "hub_repo_search", + HF + "hub_repo_details", + AE + "android_install_app", + AE + "android_uninstall_app", + AE + "android_launch_app", + AE + "android_list_devices", + AE + "android_take_screenshot", + AE + "android_dump_hierarchy", + AE + "android_press_keyevent", + AE + "android_start_emulator", + AE + "android_stop_emulator", + AE + "android_open_url_scheme", + AE + "android_get_activity", + AE + "android_wait_for_idle", + AE + "android_grant_permission", + AE + "android_revoke_permission", + AE + "android_clear_app_data", + AE + "android_list_packages", + MD + "microsoft_docs_get_page_content", + MD + "microsoft_learn_search_results", +]; + +const SHORT_CATALOG_NAMES = [ + "bash", "grep", "read_file", "write_file", "web_search", "exec", + ...Array.from({ length: 67 }, (_, i) => "tool_" + String(i + 1).padStart(2, "0")), +]; + +function issue4410Catalog(): string[] { + return [...LONG_ISSUE_NAMES, ...SHORT_CATALOG_NAMES]; +} + +function hashedName(original: string): string { + const cleaned = original.replace(/[^a-zA-Z0-9_-]/g, "_"); + const base = cleaned.slice(0, 55) || "tool"; + const suffix = createHash("sha256").update(original).digest("hex").slice(0, 8); + return base + "_" + suffix; +} + +function functionTool(name: string): Record { + return { type: "function", name, parameters: { type: "object", properties: {} } }; +} + +function buildForProvider( + provider: OcxProviderConfig, + modelId: string, + rawBody: Record, +) { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId, + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: { model: modelId, input: "ping", ...rawBody }, + }, { headers: new Headers() }); + return { + body: JSON.parse(request.body) as Record, + aliases: request.convertedMuseToolNameAliases, + }; +} + +describe("#4410 Meta Muse 64-char tool-name aliasing", () => { + test("the issue catalog contains 93 names with 20 over the limit", () => { + const names = issue4410Catalog(); + expect(names).toHaveLength(93); + expect(new Set(names).size).toBe(93); + expect(LONG_ISSUE_NAMES).toHaveLength(20); + expect(LONG_ISSUE_NAMES.every(name => name.length > 64)).toBe(true); + expect(LONG_ISSUE_NAMES[0]).toBe("mcp__plugin_huggingface-skills_huggingface-skills__hub_repo_search"); + expect(LONG_ISSUE_NAMES[0]!.length).toBe(66); + expect(SHORT_CATALOG_NAMES.every(name => name.length <= 64)).toBe(true); + }); + + test("api.meta.ai outbound body sends only <=64-char names for the 93-tool catalog, including default muse-spark-1.3", () => { + const names = issue4410Catalog(); + const { body, aliases } = buildForProvider(META_PROVIDER, "muse-spark-1.3", { + tools: names.map(functionTool), + }); + const sent = (body.tools as Array<{ name: string }>).map(tool => tool.name); + expect(sent).toHaveLength(93); + expect(sent.every(name => name.length <= 64)).toBe(true); + expect(sent.every(name => /^[a-zA-Z0-9_-]+$/.test(name))).toBe(true); + for (const original of LONG_ISSUE_NAMES) { + const wire = hashedName(original); + expect(sent).toContain(wire); + expect(sent).not.toContain(original); + expect(aliases?.get(wire)).toBe(original); + } + for (const original of SHORT_CATALOG_NAMES) { + expect(sent).toContain(original); + } + expect(aliases?.size).toBe(20); + }); + + test("history function_call and tool_choice are aliased on api.meta.ai", () => { + const longName = LONG_ISSUE_NAMES[0]!; + const wire = hashedName(longName); + const { body, aliases } = buildForProvider(META_PROVIDER, "muse-spark-1.3-contributor", { + tools: [functionTool(longName), functionTool("read_file")], + input: [ + { type: "function_call", name: longName, call_id: "c1", arguments: "{\"q\":\"hub\"}" }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + ], + tool_choice: { type: "function", name: longName }, + }); + expect((body.tools as Array<{ name: string }>)[0]!.name).toBe(wire); + expect((body.input as Array>)[0]).toMatchObject({ + type: "function_call", + name: wire, + arguments: "{\"q\":\"hub\"}", + }); + expect((body.tool_choice as { name: string }).name).toBe(wire); + expect(aliases?.get(wire)).toBe(longName); + }); + + test("split Meta baseUrl and responsesPath still aliases because the host is api.meta.ai", () => { + const longName = LONG_ISSUE_NAMES[2]!; + const { body } = buildForProvider(META_PATH_PROVIDER, "muse-spark-1.3", { + tools: [functionTool(longName)], + }); + expect((body.tools as Array<{ name: string }>)[0]!.name).toBe(hashedName(longName)); + }); + + test("a non-meta Responses provider keeps names verbatim", () => { + const names = issue4410Catalog(); + const { body, aliases } = buildForProvider(XAI_PROVIDER, "grok-4.6", { + tools: names.map(functionTool), + tool_choice: { type: "function", name: LONG_ISSUE_NAMES[0] }, + }); + const sent = (body.tools as Array<{ name: string }>).map(tool => tool.name); + expect(sent).toEqual(names); + expect((body.tool_choice as { name: string }).name).toBe(LONG_ISSUE_NAMES[0]); + expect(aliases).toBeUndefined(); + }); +}); diff --git a/tests/responses/responses-muse-tool-name-alias.test.ts b/tests/responses/responses-muse-tool-name-alias.test.ts new file mode 100644 index 0000000000..655165d394 --- /dev/null +++ b/tests/responses/responses-muse-tool-name-alias.test.ts @@ -0,0 +1,487 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { + buildMuseToolNameAliasPlan, + museWireToolName, + restoreMuseToolNames, + restoreMuseToolNamesInJson, + rewriteMuseToolNamesForUpstream, +} from "../../src/responses/muse-tool-name-alias"; +import { expandPreviousResponseInput } from "../../src/responses/state"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +function sha8(input: string): string { + return createHash("sha256").update(input).digest("hex").slice(0, 8); +} + +function hashedName(original: string, salt = 0): string { + const cleaned = original.replace(/[^a-zA-Z0-9_-]/g, "_"); + const base = cleaned.slice(0, 55) || "tool"; + const hashInput = salt === 0 ? original : original + "#" + salt; + return base + "_" + sha8(hashInput); +} + +describe("muse tool-name alias algorithm", () => { + test("a 64-char conforming name passes through verbatim", () => { + const name = "a".repeat(64); + expect(name.length).toBe(64); + expect(museWireToolName(name)).toBe(name); + const plan = buildMuseToolNameAliasPlan([name]); + expect(plan.wireByOriginal.get(name)).toBe(name); + expect(plan.aliases.size).toBe(0); + }); + + test("65, 66, and 93-char names map deterministically to a 64-char hashed wire name", () => { + for (const length of [65, 66, 93]) { + const original = "n".repeat(length); + const wire = museWireToolName(original); + expect(wire).toBe(hashedName(original)); + expect(wire.length).toBe(64); + expect(wire).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + expect(museWireToolName(original)).toBe(wire); + } + }); + + test("non-conforming charset is rewritten through the hashed form, never left with spaces or punctuation", () => { + const original = "workspace agents_create_agent"; + const wire = museWireToolName(original); + expect(wire).toBe(hashedName(original)); + expect(wire).not.toContain(" "); + expect(wire).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + }); + + test("all-unsafe characters keep the underscore prefix plus hash of the original", () => { + const original = "!!!"; + // sanitize("!!!") is "___", which is truthy, so the "tool" fallback does not fire. + expect(museWireToolName(original)).toBe("____" + sha8(original)); + expect(museWireToolName(original)).toBe(hashedName(original)); + }); + + test("empty input falls back to a tool_ prefix plus hash of the original", () => { + expect(museWireToolName("")).toBe("tool_" + sha8("")); + expect(museWireToolName("")).toBe(hashedName("")); + }); + + test("two long names sharing a 55-char prefix stay distinct", () => { + const prefix = "mcp__plugin_android-emulator_android-emulator__android_"; + expect(prefix.length).toBe(55); + const a = prefix + "install_app_extra_padding"; + const b = prefix + "uninstall_app_extra_pad"; + expect(a.length).toBeGreaterThan(64); + expect(b.length).toBeGreaterThan(64); + const sanitizedPrefix = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 55); + expect(sanitizedPrefix(a)).toBe(prefix); + expect(sanitizedPrefix(b)).toBe(prefix); + const plan = buildMuseToolNameAliasPlan([a, b]); + const wireA = plan.wireByOriginal.get(a)!; + const wireB = plan.wireByOriginal.get(b)!; + expect(wireA).not.toBe(wireB); + expect(wireA).toBe(hashedName(a)); + expect(wireB).toBe(hashedName(b)); + expect(wireA.length).toBeLessThanOrEqual(64); + expect(wireB.length).toBeLessThanOrEqual(64); + expect(plan.aliases.get(wireA)).toBe(a); + expect(plan.aliases.get(wireB)).toBe(b); + }); + + test("two-phase claim reserves pass-through names before hashing long ones", () => { + const long = "L".repeat(70); + const claimed = hashedName(long); + expect(claimed.length).toBeLessThanOrEqual(64); + // Long name is declared first; a later conforming name equals its unsalted hash. + // Two-phase still gives the conforming name the verbatim identity and salts the long one. + const plan = buildMuseToolNameAliasPlan([long, claimed]); + expect(plan.wireByOriginal.get(claimed)).toBe(claimed); + expect(plan.wireByOriginal.get(long)).toBe(hashedName(long, 1)); + expect(plan.aliases.get(claimed)).toBeUndefined(); + expect(plan.aliases.get(hashedName(long, 1))).toBe(long); + }); + + test("salt loop uses original#N and stays within 64 chars", () => { + const long = "collision-prefix/" + "x".repeat(80); + const first = hashedName(long); + const used = new Set([first]); + const wire = museWireToolName(long, used); + expect(wire).toBe(hashedName(long, 1)); + expect(wire.length).toBeLessThanOrEqual(64); + }); +}); + +describe("muse tool-name body rewrite", () => { + const longName = "mcp__plugin_huggingface-skills_huggingface-skills__hub_repo_search"; + const shortName = "read_file"; + + test("rewrites tools, history calls, tool_choice, additional_tools, and chat-shaped function.name", () => { + const body = { + model: "muse-spark-1.3", + input: [ + { type: "function_call", name: longName, call_id: "c1", arguments: "{\"path\":\"" + longName + "\"}" }, + { type: "custom_tool_call", name: longName, call_id: "c2", input: longName }, + { type: "additional_tools", tools: [{ type: "function", name: longName, parameters: { type: "object", properties: { path: { type: "string" } } } }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "use " + longName }] }, + ], + tools: [ + { type: "function", name: longName, parameters: { type: "object", properties: { name: { type: "string" } } } }, + { type: "function", name: shortName, parameters: { type: "object" } }, + { type: "function", function: { name: longName, description: "chat-shaped", parameters: { type: "object" } } }, + ], + tool_choice: { type: "function", name: longName }, + }; + const rewritten = rewriteMuseToolNamesForUpstream(body); + const wire = hashedName(longName); + expect(wire.length).toBeLessThanOrEqual(64); + const tools = rewritten.body as { tools: Array>; input: Array>; tool_choice: { name: string } }; + expect((tools.tools[0] as { name: string }).name).toBe(wire); + expect((tools.tools[1] as { name: string }).name).toBe(shortName); + expect(((tools.tools[2] as { function: { name: string } }).function).name).toBe(wire); + expect(tools.tool_choice.name).toBe(wire); + expect(tools.input[0]).toMatchObject({ type: "function_call", name: wire, arguments: "{\"path\":\"" + longName + "\"}" }); + expect(tools.input[1]).toMatchObject({ type: "custom_tool_call", name: wire, input: longName }); + expect(((tools.input[2].tools as Array<{ name: string }>)[0]).name).toBe(wire); + expect((tools.input[3].content as Array<{ text: string }>)[0].text).toContain(longName); + expect(JSON.stringify((tools.tools[0] as { parameters: unknown }).parameters)).toContain('"name"'); + expect(rewritten.aliases.get(wire)).toBe(longName); + }); + + test("allowed_tools selectors are rewritten too", () => { + const body = { + tools: [{ type: "function", name: longName, parameters: {} }], + tool_choice: { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: longName }] }, + }; + const rewritten = rewriteMuseToolNamesForUpstream(body); + const wire = hashedName(longName); + expect((rewritten.body as { tool_choice: { tools: Array<{ name: string }> } }).tool_choice.tools[0]!.name).toBe(wire); + }); + + // Codex review on #4422: upstream still sees the whole aliased catalog, but a tool the + // caller disabled for this turn must not be restorable into an executable client name. + test("tool_choice narrows what may be restored, matching the namespace layer", () => { + const other = "mcp__plugin_android-emulator_android-emulator__android_install_app"; + const declare = () => ({ + tools: [ + { type: "function", name: longName, parameters: {} }, + { type: "function", name: other, parameters: {} }, + ], + }); + + expect(rewriteMuseToolNamesForUpstream(declare()).aliases.size).toBe(2); + expect(rewriteMuseToolNamesForUpstream({ ...declare(), tool_choice: "auto" }).aliases.size).toBe(2); + + const none = rewriteMuseToolNamesForUpstream({ ...declare(), tool_choice: "none" }); + expect(none.aliases.size).toBe(0); + expect((none.body as { tools: Array<{ name: string }> }).tools[0]!.name).toBe(hashedName(longName)); + + const picked = rewriteMuseToolNamesForUpstream({ + ...declare(), + tool_choice: { type: "function", name: longName }, + }); + expect([...picked.aliases.values()]).toEqual([longName]); + + const allowed = rewriteMuseToolNamesForUpstream({ + ...declare(), + tool_choice: { type: "allowed_tools", mode: "auto", tools: [{ type: "function", name: other }] }, + }); + expect([...allowed.aliases.values()]).toEqual([other]); + }); +}); + +describe("muse tool-name restore", () => { + const original = "mcp__plugin_huggingface-skills_huggingface-skills__hub_repo_search"; + const wire = hashedName(original); + const aliases = new Map([[wire, original]]); + + test("restores a hashed function_call and leaves unknown names alone", () => { + const restored = restoreMuseToolNames({ + output: [ + { type: "function_call", name: wire, arguments: "{\"x\":1}" }, + { type: "function_call", name: "read_file", arguments: "{}" }, + ], + }, aliases); + expect(restored.changed).toBe(true); + expect(restored.value).toEqual({ + output: [ + { type: "function_call", name: original, arguments: "{\"x\":1}" }, + { type: "function_call", name: "read_file", arguments: "{}" }, + ], + }); + }); + + test("restores tool_choice echo shapes including allowed_tools", () => { + expect(restoreMuseToolNames({ type: "function", name: wire }, aliases).value) + .toEqual({ type: "function", name: original }); + expect(restoreMuseToolNames({ + type: "allowed_tools", + tools: [{ type: "function", name: wire }, { type: "custom", name: wire }], + }, aliases).value).toEqual({ + type: "allowed_tools", + tools: [{ type: "function", name: original }, { type: "custom", name: original }], + }); + }); + + test("restores chat-shaped function.name and JSON payloads", () => { + const payload = { type: "function", function: { name: wire, description: "x" } }; + expect(restoreMuseToolNames(payload, aliases).value) + .toEqual({ type: "function", function: { name: original, description: "x" } }); + expect(JSON.parse(restoreMuseToolNamesInJson(JSON.stringify({ + type: "response.output_item.added", + item: { type: "function_call", name: wire, arguments: "{}" }, + }), aliases))).toMatchObject({ + item: { type: "function_call", name: original }, + }); + expect(restoreMuseToolNamesInJson("not-json", aliases)).toBe("not-json"); + }); +}); + +describe("muse tool-name inbound restore through handleResponses", () => { + const original = "mcp__plugin_huggingface-skills_huggingface-skills__hub_repo_search"; + const wire = hashedName(original); + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig; + + const frame = (event: string, payload: Record): string => + `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + + test("non-stream function_call and tool_choice restore the original MCP name", async () => { + const savedFetch = globalThis.fetch; + let outbound: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ + id: "resp_json", + status: "completed", + tool_choice: { type: "function", name: wire }, + output: [{ type: "function_call", name: wire, call_id: "c1", arguments: "{\"q\":\"x\"}" }], + }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: false, + input: "search", + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + tool_choice: { type: "function", name: original }, + }), + }), config, { model: "", provider: "" }); + const json = await response.json() as { output: Array>; tool_choice?: { name: string } }; + expect((outbound?.tools as Array<{ name: string }>)[0]!.name).toBe(wire); + expect((outbound?.tool_choice as { name: string }).name).toBe(wire); + expect(json.output[0]).toMatchObject({ type: "function_call", name: original }); + expect(json.tool_choice?.name).toBe(original); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("streamed output_item.added with a hashed name restores the original", async () => { + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => { + const item = { type: "function_call", name: wire, call_id: "c1", arguments: "{}", status: "completed" }; + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...item, arguments: "", status: "in_progress" } }), + frame("response.output_item.done", { output_index: 0, item }), + frame("response.completed", { response: { id: "resp_stream", status: "completed", output: [item] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: true, + input: "search", + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + expect(clientSse).toContain(`"name":"${original}"`); + expect(clientSse).not.toContain(`"name":"${wire}"`); + } finally { + globalThis.fetch = savedFetch; + } + }); + + // #4410: the undeclared-tool guard reads `name` straight off this event, outside any + // function_call item, so a hashed alias here would still look like an undeclared tool. + test("streamed response.function_call_arguments.done restores the original name", async () => { + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => { + const item = { type: "function_call", name: wire, call_id: "c1", arguments: "{}", status: "completed" }; + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...item, arguments: "", status: "in_progress" } }), + frame("response.function_call_arguments.done", { item_id: "fc_1", output_index: 0, name: wire, arguments: "{}" }), + frame("response.output_item.done", { output_index: 0, item }), + frame("response.completed", { response: { id: "resp_args", status: "completed", output: [item] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: true, + input: "search", + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const clientSse = await response.text(); + expect(clientSse).toContain("response.function_call_arguments.done"); + expect(clientSse).toContain(`"name":"${original}"`); + expect(clientSse).not.toContain(`"name":"${wire}"`); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("undeclared-tool guard does not fire on a continuation that echoes the hashed name", async () => { + const savedFetch = globalThis.fetch; + let turn = 1; + const outboundBodies: Array> = []; + const item = { + type: "function_call", + id: "fc_1", + call_id: "c1", + name: wire, + arguments: "{}", + status: "completed", + }; + globalThis.fetch = (async (_input, init) => { + outboundBodies.push(JSON.parse(String(init?.body)) as Record); + if (turn === 2) { + return new Response(JSON.stringify({ + id: "resp_turn2", + status: "completed", + output: [{ type: "function_call", name: wire, call_id: "c2", arguments: "{}" }], + }), { headers: { "content-type": "application/json" } }); + } + turn += 1; + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...item, arguments: "", status: "in_progress" } }), + frame("response.output_item.done", { output_index: 0, item }), + frame("response.completed", { response: { id: "resp_turn1", status: "completed", output: [item] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + const turn1 = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: true, + input: "search", + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + expect(turn1.status).toBe(200); + const turn1Text = await turn1.text(); + expect(turn1Text).toContain('"name":"' + original + '"'); + expect(turn1Text).not.toContain('"name":"' + wire + '"'); + + const deadline = Date.now() + 2_000; + let cachedInput: Array> | undefined; + while (Date.now() < deadline) { + const expanded = expandPreviousResponseInput({ + previous_response_id: "resp_turn1", + input: [{ type: "function_call_output", call_id: "c1", output: "ok" }], + }) as { input?: Array> }; + const items = expanded.input ?? []; + if (items.some(entry => entry.type === "function_call" && entry.call_id === "c1")) { + cachedInput = items; + break; + } + await Bun.sleep(5); + } + if (!cachedInput) { + throw new Error("continuation cache did not record resp_turn1"); + } + expect(cachedInput).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: "user", content: "search" }), + expect.objectContaining({ type: "function_call", id: "fc_1", call_id: "c1", name: original }), + expect.objectContaining({ type: "function_call_output", call_id: "c1", output: "ok" }), + ])); + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: false, + previous_response_id: "resp_turn1", + input: [ + { type: "function_call_output", call_id: "c1", output: "ok" }, + ], + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const json = await response.json() as { output: Array>; error?: unknown }; + expect(json.error).toBeUndefined(); + expect(json.output[0]).toMatchObject({ type: "function_call", name: original }); + expect(outboundBodies).toHaveLength(2); + const replayed = outboundBodies[1]!.input as Array>; + expect(replayed).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: "user", content: "search" }), + expect.objectContaining({ type: "function_call", id: "fc_1", call_id: "c1", name: wire }), + expect.objectContaining({ type: "function_call_output", call_id: "c1", output: "ok" }), + ])); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("adapter sidecar is populated for api.meta.ai and omitted otherwise", () => { + const meta = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + apiKey: "test-key", + } as never); + const other = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + apiKey: "test-key", + } as never); + const parsed = { + modelId: "muse-spark-1.3", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: { + model: "muse-spark-1.3", + input: "ping", + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + }, + }; + const metaReq = meta.buildRequest(parsed as never, { headers: new Headers() }); + const otherReq = other.buildRequest(parsed as never, { headers: new Headers() }); + expect(metaReq.convertedMuseToolNameAliases?.get(wire)).toBe(original); + expect(otherReq.convertedMuseToolNameAliases).toBeUndefined(); + expect(JSON.parse(otherReq.body).tools[0].name).toBe(original); + }); +});