mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 18:22:48 +03:00
fix(translator): keep Responses namespace identity across the hub-and-spoke pivot
Step 1 of the pivot (openai-responses -> openai) flattens namespace sub-tools to a qualified wire name (#8295) and records the `{namespace, name}` pair on a non-enumerable `_toolNameMap`. Step 2 (openai -> target) returns a brand-new object, so the property was dropped for every non-OpenAI target. chatCore then handed `null` to the #7936 response seam and namespace sub-tool calls reached the client under their flattened name, which Codex rejects with `unsupported call: <name>` — the symptom #7936 was opened to fix. Copying `_toolNameMap` through is not viable: openai-to-claude and openai-to-gemini publish their own `Map<string, string>` alias map on that same property during step 2, so it carries two incompatible types. This adds a dedicated `_namespaceToolIdentityMap`, propagated by translateRequest across the pivot; chatCore prefers it and falls back to `_toolNameMap` for the non-pivot producers. Both keys are stripped from the cliproxyapi wire body. Fixes #9780
This commit is contained in:
@@ -408,12 +408,13 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
|
||||
input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`);
|
||||
|
||||
// _toolNameMap is an in-memory channel to chatCore for response-side
|
||||
// tool name restoration; never send it over the wire.
|
||||
// _toolNameMap and _namespaceToolIdentityMap are in-memory channels to
|
||||
// chatCore for response-side tool name restoration; never send them over
|
||||
// the wire.
|
||||
const wireBody =
|
||||
transformedBody && typeof transformedBody === "object"
|
||||
? JSON.stringify(transformedBody, (key, value) =>
|
||||
key === "_toolNameMap" ? undefined : value
|
||||
key === "_toolNameMap" || key === "_namespaceToolIdentityMap" ? undefined : value
|
||||
)
|
||||
: JSON.stringify(transformedBody);
|
||||
|
||||
|
||||
@@ -2264,8 +2264,19 @@ export async function handleChatCore({
|
||||
// the latter is a Kiro/Claude passthrough alias channel with string values,
|
||||
// while namespace identities carry `{namespace, name}` for the #7936 response
|
||||
// seam. Extract first because Kiro merge may reuse `_toolNameMap` below.
|
||||
//
|
||||
// #9780 — prefer the dedicated channel: on a pivot the openai->claude/gemini
|
||||
// step publishes its own alias map on `_toolNameMap`, so that property alone
|
||||
// yields aliases here. The `_toolNameMap` read stays as the fallback for the
|
||||
// non-pivot producers (executors/base.ts, cliproxyapi.ts, antigravity).
|
||||
const namespaceIdentityMap = translatedBody._namespaceToolIdentityMap;
|
||||
const requestToolIdentityMap =
|
||||
translatedBody._toolNameMap instanceof Map ? translatedBody._toolNameMap : null;
|
||||
namespaceIdentityMap instanceof Map
|
||||
? namespaceIdentityMap
|
||||
: translatedBody._toolNameMap instanceof Map
|
||||
? translatedBody._toolNameMap
|
||||
: null;
|
||||
delete translatedBody._namespaceToolIdentityMap;
|
||||
delete translatedBody._toolNameMap;
|
||||
|
||||
// Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly
|
||||
|
||||
@@ -352,7 +352,27 @@ export function translateRequest(
|
||||
...(hasProvider ? { _provider: provider } : {}),
|
||||
}
|
||||
: credentials;
|
||||
result = fromOpenAI(model, result, stream, translationCredentials);
|
||||
// #9780 — carry the Responses namespace identity map across the pivot.
|
||||
// Target translators return a brand-new object (buildKiroPayload et
|
||||
// al.), dropping the non-enumerable property step 1 attached; the
|
||||
// #7936 seam then gets null and namespace sub-tool calls come back
|
||||
// flattened, which Codex rejects with `unsupported call: <name>`.
|
||||
const identityMap = (result as Record<string, unknown>)._namespaceToolIdentityMap;
|
||||
const translated = fromOpenAI(model, result, stream, translationCredentials);
|
||||
if (
|
||||
identityMap instanceof Map &&
|
||||
translated &&
|
||||
typeof translated === "object" &&
|
||||
!((translated as Record<string, unknown>)._namespaceToolIdentityMap instanceof Map)
|
||||
) {
|
||||
Object.defineProperty(translated, "_namespaceToolIdentityMap", {
|
||||
value: identityMap,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
result = translated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,8 +752,19 @@ export function openaiResponsesToOpenAIRequest(
|
||||
delete result.prompt_cache_retention;
|
||||
|
||||
if (namespaceToolIdentityMap.size > 0) {
|
||||
// chatCore extracts and deletes this transient side channel before dispatch.
|
||||
// chatCore extracts and deletes these transient side channels before dispatch.
|
||||
// Non-enumerability keeps internal request metadata off the upstream wire.
|
||||
//
|
||||
// Two properties on purpose (#9780): `_toolNameMap` is also the alias
|
||||
// channel for openai-to-claude/gemini, which overwrite it on a pivot, so
|
||||
// the identity map needs a name of its own. `_toolNameMap` stays populated
|
||||
// for the existing consumers (executors/base.ts, cliproxyapi, antigravity).
|
||||
Object.defineProperty(result, "_namespaceToolIdentityMap", {
|
||||
value: namespaceToolIdentityMap,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(result, "_toolNameMap", {
|
||||
value: namespaceToolIdentityMap,
|
||||
enumerable: false,
|
||||
|
||||
155
tests/unit/9780-namespace-identity-pivot.test.ts
Normal file
155
tests/unit/9780-namespace-identity-pivot.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// #9780 — the Responses namespace identity map must survive the hub-and-spoke
|
||||
// pivot in translator/index.ts. Step 1 flattens namespace sub-tools (#8295) and
|
||||
// records `{namespace, name}`; step 2 returns a new object and used to drop it,
|
||||
// leaving the #7936 seam with null and Codex rejecting `unsupported call`.
|
||||
// A naive copy-through is not an option: openai-to-claude/gemini publish their
|
||||
// own alias map on `_toolNameMap`, hence the dedicated channel asserted here.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
await import("../../open-sse/translator/bootstrap.ts");
|
||||
const { translateRequest, initState } = await import("../../open-sse/translator/index.ts");
|
||||
const { openaiToOpenAIResponsesResponse } = await import(
|
||||
"../../open-sse/translator/response/openai-responses.ts"
|
||||
);
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
type NamespaceIdentity = { namespace: string; name: string };
|
||||
|
||||
const NAMESPACE_REQUEST = {
|
||||
model: "any-model",
|
||||
instructions: "coding agent",
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }],
|
||||
tools: [
|
||||
{
|
||||
type: "namespace",
|
||||
name: "functions",
|
||||
tools: [
|
||||
{
|
||||
name: "exec",
|
||||
description: "Run a shell command",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { cmd: { type: "string" } },
|
||||
required: ["cmd"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function pivot(targetFormat: string): Record<string, unknown> {
|
||||
return translateRequest(
|
||||
"openai-responses",
|
||||
targetFormat,
|
||||
"any-model",
|
||||
structuredClone(NAMESPACE_REQUEST),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identityOf(body: Record<string, unknown>) {
|
||||
const map = body._namespaceToolIdentityMap;
|
||||
assert.ok(map instanceof Map, "expected a _namespaceToolIdentityMap after the pivot");
|
||||
return map as Map<string, NamespaceIdentity>;
|
||||
}
|
||||
|
||||
test("#9780: namespace identity survives the openai-responses -> kiro pivot", () => {
|
||||
const identity = identityOf(pivot("kiro"));
|
||||
|
||||
assert.equal(identity.size, 1);
|
||||
assert.deepEqual(identity.get("functions__exec"), { namespace: "functions", name: "exec" });
|
||||
});
|
||||
|
||||
test("#9780: namespace identity survives the openai-responses -> cursor pivot", () => {
|
||||
const identity = identityOf(pivot("cursor"));
|
||||
|
||||
assert.deepEqual(identity.get("functions__exec"), { namespace: "functions", name: "exec" });
|
||||
});
|
||||
|
||||
// Regression guard: these two appeared to "keep" a map before the fix, but it
|
||||
// was the alias map.
|
||||
for (const target of ["claude", "gemini"]) {
|
||||
test(`#9780: ${target} pivot keeps its alias map AND the namespace identity`, () => {
|
||||
const body = pivot(target);
|
||||
const identity = identityOf(body);
|
||||
|
||||
assert.deepEqual(identity.get("functions__exec"), { namespace: "functions", name: "exec" });
|
||||
|
||||
// The alias channel must be untouched: string values, not identities.
|
||||
const aliases = body._toolNameMap;
|
||||
assert.ok(aliases instanceof Map, `${target} must still publish its alias map`);
|
||||
for (const value of (aliases as Map<string, unknown>).values()) {
|
||||
assert.equal(typeof value, "string", `${target} alias values must stay strings`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Same-format requests are never flattened, so an absent map is correct here.
|
||||
test("#9780: same-format openai-responses request is not flattened at all", () => {
|
||||
const body = pivot("openai-responses");
|
||||
const tools = body.tools as Array<Record<string, unknown>>;
|
||||
|
||||
assert.equal(tools[0].type, "namespace");
|
||||
assert.equal((tools[0].tools as Array<{ name: string }>)[0].name, "exec");
|
||||
assert.equal(body._namespaceToolIdentityMap, undefined);
|
||||
});
|
||||
|
||||
test("#9780: the identity channel is non-enumerable and never serializes", () => {
|
||||
const body = pivot("kiro");
|
||||
|
||||
assert.ok(body._namespaceToolIdentityMap instanceof Map);
|
||||
assert.equal(
|
||||
Object.prototype.propertyIsEnumerable.call(body, "_namespaceToolIdentityMap"),
|
||||
false
|
||||
);
|
||||
assert.equal("_namespaceToolIdentityMap" in JSON.parse(JSON.stringify(body)), false);
|
||||
});
|
||||
|
||||
// End-to-end: request pivot + response seam, i.e. what the Codex adjudicator
|
||||
// actually receives. Before the fix every target emitted `functions__exec` with
|
||||
// no namespace, which is the reported `unsupported call`.
|
||||
for (const target of ["kiro", "cursor", "claude", "gemini"]) {
|
||||
test(`#9780: ${target} round-trip returns the declared name and its namespace`, () => {
|
||||
const body = pivot(target);
|
||||
const state = initState(FORMATS.OPENAI_RESPONSES) as Record<string, unknown>;
|
||||
state.requestToolIdentityMap = body._namespaceToolIdentityMap;
|
||||
|
||||
// The upstream echoes the flattened wire name (#8295).
|
||||
const events = openaiToOpenAIResponsesResponse(
|
||||
{
|
||||
id: "chatcmpl-9780",
|
||||
model: "any-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_9780",
|
||||
type: "function",
|
||||
function: { name: "functions__exec", arguments: '{"cmd":"git status"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
state
|
||||
) as Array<{ event: string; data: { item?: NamespaceIdentity } }>;
|
||||
|
||||
const added = events.find((e) => e.event === "response.output_item.added")?.data.item;
|
||||
assert.ok(added, "expected response.output_item.added");
|
||||
assert.deepEqual(
|
||||
{ name: added.name, namespace: added.namespace },
|
||||
{ name: "exec", namespace: "functions" }
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user