diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 83095f4d20..f6490b835f 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -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); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2b110b47f4..1b31d6a871 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -207,7 +207,6 @@ import { stageTrace } from "./chatCore/stageTrace.ts"; import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts"; import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts"; import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts"; - import { getCallLogPipelineCaptureStreamChunks, getCallLogPipelineMaxSizeBytes, @@ -367,9 +366,7 @@ import { isTpmExhausted, isRpmExhausted, } from "../services/geminiRateLimitTracker.ts"; - import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; - /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -389,10 +386,8 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; * @param {boolean} options.isCombo - Whether this request is from a combo * @param {string} options.connectionId - Connection ID for settings lookup */ - // extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so // existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here. - export async function handleChatCore({ body, modelInfo, @@ -428,7 +423,6 @@ export async function handleChatCore({ /* fail open */ } } - // Per-request model-routing metadata (first extracted slice of the request-setup phase). const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup( modelInfo, @@ -442,7 +436,6 @@ export async function handleChatCore({ // (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id // is a log-correlation token, not a security secret. const traceId = globalThis.crypto.randomUUID().slice(0, 6); - // Emit request.started event for real-time dashboard setImmediate(() => { emit("request.started", { @@ -526,7 +519,6 @@ export async function handleChatCore({ `long-running goal mode enabled: readinessMax=${agentGoalPolicy.readinessMaxTimeoutMs}ms streamRecovery=${agentGoalPolicy.streamRecoveryEnabled}` ); } - let effectiveServiceTier: EffectiveServiceTier = "standard"; // Codex service-tier resolvers extracted to chatCore/serviceTier.ts (#3501); bind the per-request // provider/credentials once and delegate so the existing call sites stay byte-identical. @@ -555,7 +547,6 @@ export async function handleChatCore({ }) ).catch(() => {}); }; - // Key-health updater extracted to chatCore/keyHealth.ts (#3501); bind the per-request log once // and delegate so the existing call sites stay byte-identical. const recordKeyHealthStatus = ( @@ -563,11 +554,9 @@ export async function handleChatCore({ creds: Record | null | undefined, transport?: string ): void => recordKeyHealthStatusFor(status, creds, log, transport); - const persistCodexQuotaState = async (headers: Record | null, status = 0) => { const currentConnectionId = getCurrentConnectionId(); if (provider !== "codex" || !currentConnectionId || !headers) return; - try { const existingProviderData = credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" @@ -582,28 +571,23 @@ export async function handleChatCore({ status, }); if (!built) return; - if (built.exhaustionLog) { log?.debug?.("CODEX", built.exhaustionLog); } - // Invalidate the preflight cache for this connection so the next // isModelAvailable check fetches fresh quota data. if (status === 429) { invalidateCodexQuotaCache(currentConnectionId); } - await updateProviderConnection(currentConnectionId, { providerSpecificData: built.nextProviderData, }); - credentials.providerSpecificData = built.nextProviderData; } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); } }; - // ── Phase 9.2: Idempotency check ── // Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below, // rather than re-deriving it. (#3821-review LEDGER-6) @@ -622,13 +606,11 @@ export async function handleChatCore({ if (idempotencyHit) { return idempotencyHit; } - // T07: Inject connectionId into credentials so executors can rotate API keys // using providerSpecificData.extraApiKeys (API Key Round-Robin feature) if (connectionId && credentials && !credentials.connectionId) { credentials.connectionId = connectionId; } - // Endpoint/format resolution extracted to chatCore/requestFormat.ts (#3501); pure derivation // from the inbound request, destructured so every downstream use stays byte-identical. const { @@ -2264,8 +2246,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 @@ -5025,7 +5018,6 @@ export async function handleChatCore({ }), }; } - export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { if (!expiresAt) return false; const expiresAtMs = new Date(expiresAt).getTime(); diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 8497989f09..b81acb71e0 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -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: `. + const identityMap = (result as Record)._namespaceToolIdentityMap; + const translated = fromOpenAI(model, result, stream, translationCredentials); + if ( + identityMap instanceof Map && + translated && + typeof translated === "object" && + !((translated as Record)._namespaceToolIdentityMap instanceof Map) + ) { + Object.defineProperty(translated, "_namespaceToolIdentityMap", { + value: identityMap, + enumerable: false, + configurable: true, + writable: true, + }); + } + result = translated; } } } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 6d7a79b8a4..4a15437527 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -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, diff --git a/tests/unit/9780-namespace-identity-pivot.test.ts b/tests/unit/9780-namespace-identity-pivot.test.ts new file mode 100644 index 0000000000..b49cae6747 --- /dev/null +++ b/tests/unit/9780-namespace-identity-pivot.test.ts @@ -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 { + return translateRequest( + "openai-responses", + targetFormat, + "any-model", + structuredClone(NAMESPACE_REQUEST), + true, + null, + null, + null + ) as Record; +} + +function identityOf(body: Record) { + const map = body._namespaceToolIdentityMap; + assert.ok(map instanceof Map, "expected a _namespaceToolIdentityMap after the pivot"); + return map as Map; +} + +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).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>; + + 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; + 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" } + ); + }); +}