From 7de443de64c66f1532fb8c6131cd5c5f4e82e7d6 Mon Sep 17 00:00:00 2001 From: mackong Date: Fri, 7 Aug 2026 10:45:47 +0800 Subject: [PATCH 1/4] fix(responses): use double-underscore separator for namespaced tool names (dima-2026080600118044658) --- src/responses/__tests__/chat-completions.test.ts | 2 +- src/responses/__tests__/gemini.test.ts | 2 +- src/responses/__tests__/messages.test.ts | 2 +- src/responses/utils.ts | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/responses/__tests__/chat-completions.test.ts b/src/responses/__tests__/chat-completions.test.ts index 4ee66d1..dd5a7c6 100644 --- a/src/responses/__tests__/chat-completions.test.ts +++ b/src/responses/__tests__/chat-completions.test.ts @@ -221,7 +221,7 @@ describe("ResponsesToChatCompletionConverter", () => { { type: "function", function: { - name: "agents_spawn_agent", + name: "agents__spawn_agent", description: "Spawn a child agent.", strict: false, parameters: { diff --git a/src/responses/__tests__/gemini.test.ts b/src/responses/__tests__/gemini.test.ts index 6fce9e0..6654f33 100644 --- a/src/responses/__tests__/gemini.test.ts +++ b/src/responses/__tests__/gemini.test.ts @@ -156,7 +156,7 @@ describe("ResponsesToGeminiConverter", () => { const tools = result.config?.tools as any[]; expect(tools[0].functionDeclarations[0]).toEqual({ - name: "agents_spawn_agent", + name: "agents__spawn_agent", description: "Spawn a child agent.", parametersJsonSchema: { type: "object", diff --git a/src/responses/__tests__/messages.test.ts b/src/responses/__tests__/messages.test.ts index bc9aff2..b00704f 100644 --- a/src/responses/__tests__/messages.test.ts +++ b/src/responses/__tests__/messages.test.ts @@ -330,7 +330,7 @@ describe("ResponsesToMessagesConverter", () => { }); expect(result.tools![0]).toEqual({ - name: "agents_spawn_agent", + name: "agents__spawn_agent", description: "Spawn a child agent.", input_schema: { type: "object", diff --git a/src/responses/utils.ts b/src/responses/utils.ts index 0108798..7508e74 100644 --- a/src/responses/utils.ts +++ b/src/responses/utils.ts @@ -14,9 +14,9 @@ import type OpenAI from "openai"; * * Downstream provider protocols (chat-completions / messages / gemini) do not * understand the `namespace` tool type, so each inner tool is flattened into a - * top-level function tool whose name is namespaced as `_` - * (e.g. `agents_spawn_agent`). An underscore separator is used (not a dot) so - * the name matches the provider's required pattern `^[a-zA-Z0-9_-]+$`. + * top-level function tool whose name is namespaced as `__` + * (e.g. `agents__spawn_agent`). A double-underscore separator is used (not a + * dot) so the name matches the provider's required pattern `^[a-zA-Z0-9_-]+$`. * * Non-namespace tools pass through untouched and the original order is * preserved (a namespace expands in place). When no namespace tool is present @@ -44,7 +44,7 @@ export function expandNamespaceTools( expanded.push({ ...inner, type: "function", - name: nsName ? `${nsName}_${innerName}` : innerName, + name: nsName ? `${nsName}__${innerName}` : innerName, }); } continue; From c6782cae425f5d85a07ff2f0068ff28d159222ab Mon Sep 17 00:00:00 2001 From: mackong Date: Fri, 7 Aug 2026 10:49:09 +0800 Subject: [PATCH 2/4] fix(responses): use triple-underscore separator for namespaced tool names (dima-2026080600118044658) --- src/responses/__tests__/chat-completions.test.ts | 2 +- src/responses/__tests__/gemini.test.ts | 2 +- src/responses/__tests__/messages.test.ts | 2 +- src/responses/utils.ts | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/responses/__tests__/chat-completions.test.ts b/src/responses/__tests__/chat-completions.test.ts index dd5a7c6..c0f0e70 100644 --- a/src/responses/__tests__/chat-completions.test.ts +++ b/src/responses/__tests__/chat-completions.test.ts @@ -221,7 +221,7 @@ describe("ResponsesToChatCompletionConverter", () => { { type: "function", function: { - name: "agents__spawn_agent", + name: "agents___spawn_agent", description: "Spawn a child agent.", strict: false, parameters: { diff --git a/src/responses/__tests__/gemini.test.ts b/src/responses/__tests__/gemini.test.ts index 6654f33..c1830e7 100644 --- a/src/responses/__tests__/gemini.test.ts +++ b/src/responses/__tests__/gemini.test.ts @@ -156,7 +156,7 @@ describe("ResponsesToGeminiConverter", () => { const tools = result.config?.tools as any[]; expect(tools[0].functionDeclarations[0]).toEqual({ - name: "agents__spawn_agent", + name: "agents___spawn_agent", description: "Spawn a child agent.", parametersJsonSchema: { type: "object", diff --git a/src/responses/__tests__/messages.test.ts b/src/responses/__tests__/messages.test.ts index b00704f..c912117 100644 --- a/src/responses/__tests__/messages.test.ts +++ b/src/responses/__tests__/messages.test.ts @@ -330,7 +330,7 @@ describe("ResponsesToMessagesConverter", () => { }); expect(result.tools![0]).toEqual({ - name: "agents__spawn_agent", + name: "agents___spawn_agent", description: "Spawn a child agent.", input_schema: { type: "object", diff --git a/src/responses/utils.ts b/src/responses/utils.ts index 7508e74..c4729a6 100644 --- a/src/responses/utils.ts +++ b/src/responses/utils.ts @@ -14,8 +14,8 @@ import type OpenAI from "openai"; * * Downstream provider protocols (chat-completions / messages / gemini) do not * understand the `namespace` tool type, so each inner tool is flattened into a - * top-level function tool whose name is namespaced as `__` - * (e.g. `agents__spawn_agent`). A double-underscore separator is used (not a + * top-level function tool whose name is namespaced as `___` + * (e.g. `agents___spawn_agent`). A triple-underscore separator is used (not a * dot) so the name matches the provider's required pattern `^[a-zA-Z0-9_-]+$`. * * Non-namespace tools pass through untouched and the original order is @@ -44,7 +44,7 @@ export function expandNamespaceTools( expanded.push({ ...inner, type: "function", - name: nsName ? `${nsName}__${innerName}` : innerName, + name: nsName ? `${nsName}___${innerName}` : innerName, }); } continue; From c9c609a9cb643cb16c23d5cb25fbc37fc6666ac1 Mon Sep 17 00:00:00 2001 From: mackong Date: Fri, 7 Aug 2026 11:02:32 +0800 Subject: [PATCH 3/4] feat(responses): reverse namespace mapping in responses output (dima-2026080600118044658) On the response side, split namespaced function_call names (namespace___tool) into { namespace, name } and echo tools back as nested namespace tools. Applied to convertResponse and streaming events across chat-completions, messages, and gemini converters. --- .../__tests__/chat-completions.test.ts | 88 +++++++++++++ src/responses/__tests__/gemini.test.ts | 28 +++++ src/responses/__tests__/messages.test.ts | 21 ++++ src/responses/chat-completions.ts | 27 +++- src/responses/gemini.ts | 10 +- src/responses/messages.ts | 10 +- src/responses/utils.ts | 117 +++++++++++++++++- 7 files changed, 289 insertions(+), 12 deletions(-) diff --git a/src/responses/__tests__/chat-completions.test.ts b/src/responses/__tests__/chat-completions.test.ts index c0f0e70..96b451d 100644 --- a/src/responses/__tests__/chat-completions.test.ts +++ b/src/responses/__tests__/chat-completions.test.ts @@ -486,6 +486,70 @@ describe("ResponsesToChatCompletionConverter", () => { expect(result.status).toBe("completed"); }); + it("splits a namespaced function_call name into { namespace, name }", () => { + const result = converter.convertResponse( + makeCCResponse({ + choices: [ + { + index: 0, + message: { + role: "assistant", + content: null, + refusal: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "agents___spawn_agent", arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", + logprobs: null, + }, + ], + }) + ); + + const fcOutput = result.output.find((o: any) => o.type === "function_call") as any; + expect(fcOutput.name).toBe("spawn_agent"); + expect(fcOutput.namespace).toBe("agents"); + }); + + it("echoes namespace tools back as nested namespace tools", () => { + const result = converter.convertResponse(makeCCResponse(), { + model: "gpt-4o", + input: "Hi", + tools: [ + { + type: "namespace", + name: "agents", + description: "Multi-agent collaboration tools.", + tools: [ + { + type: "function", + name: "spawn_agent", + description: "Spawn a child agent.", + strict: false, + parameters: { + type: "object", + properties: { task_name: { type: "string" } }, + required: ["task_name"], + additionalProperties: false, + }, + }, + ], + }, + ] as any, + }); + + const tools = result.tools as any[]; + expect(tools).toHaveLength(1); + expect(tools[0].type).toBe("namespace"); + expect(tools[0].name).toBe("agents"); + expect(tools[0].tools[0]).toMatchObject({ type: "function", name: "spawn_agent" }); + }); + it("maps finish_reason length to incomplete status", () => { const result = converter.convertResponse( makeCCResponse({ @@ -737,6 +801,30 @@ describe("ResponsesToChatCompletionConverter", () => { expect(itemAdded.item.name).toBe("get_weather"); }); + it("splits a namespaced function_call name in streaming events", () => { + const c = new ResponsesToChatCompletionConverter(); + c.convertStreamChunk(makeChunk({ delta: { role: "assistant" } })); + + const events = c.convertStreamChunk( + makeChunk({ + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "agents___spawn_agent", arguments: "" }, + }, + ], + }, + }) + ); + + const itemAdded = events.find(e => e.type === "response.output_item.added") as any; + expect(itemAdded.item.name).toBe("spawn_agent"); + expect(itemAdded.item.namespace).toBe("agents"); + }); + it("emits function_call_arguments.delta for tool arguments", () => { const c = new ResponsesToChatCompletionConverter(); c.convertStreamChunk(makeChunk({ delta: { role: "assistant" } })); diff --git a/src/responses/__tests__/gemini.test.ts b/src/responses/__tests__/gemini.test.ts index c1830e7..f3002eb 100644 --- a/src/responses/__tests__/gemini.test.ts +++ b/src/responses/__tests__/gemini.test.ts @@ -312,6 +312,34 @@ describe("ResponsesToGeminiConverter", () => { expect(result.status).toBe("completed"); }); + it("splits a namespaced functionCall name into { namespace, name }", () => { + const result = converter.convertResponse( + makeResponse({ + candidates: [ + { + content: { + role: "model", + parts: [ + { + functionCall: { + id: "call_1", + name: "agents___spawn_agent", + args: { task_name: "child" }, + }, + }, + ], + }, + finishReason: "STOP", + } as any, + ], + }) + ); + + const fcOutput = result.output.find((o: any) => o.type === "function_call") as any; + expect(fcOutput.name).toBe("spawn_agent"); + expect(fcOutput.namespace).toBe("agents"); + }); + it("converts thought parts to reasoning output", () => { const result = converter.convertResponse( makeResponse({ diff --git a/src/responses/__tests__/messages.test.ts b/src/responses/__tests__/messages.test.ts index c912117..d240a94 100644 --- a/src/responses/__tests__/messages.test.ts +++ b/src/responses/__tests__/messages.test.ts @@ -585,6 +585,27 @@ describe("ResponsesToMessagesConverter", () => { expect(result.status).toBe("completed"); }); + it("splits a namespaced tool_use name into { namespace, name }", () => { + const result = converter.convertResponse( + makeMessage({ + content: [ + { + type: "tool_use", + id: "tu_1", + name: "agents___spawn_agent", + input: { task_name: "child" }, + caller: { type: "direct" }, + } as any, + ], + stop_reason: "tool_use", + }) + ); + + const fcOutput = result.output.find((o: any) => o.type === "function_call") as any; + expect(fcOutput.name).toBe("spawn_agent"); + expect(fcOutput.namespace).toBe("agents"); + }); + it("converts thinking blocks to reasoning output", () => { const result = converter.convertResponse( makeMessage({ diff --git a/src/responses/chat-completions.ts b/src/responses/chat-completions.ts index 33d6fa5..a8ba6ab 100644 --- a/src/responses/chat-completions.ts +++ b/src/responses/chat-completions.ts @@ -1,5 +1,5 @@ import type OpenAI from "openai"; -import { expandNamespaceTools } from "./utils"; +import { expandNamespaceTools, denamespaceResponse, denamespaceStreamEvents } from "./utils"; type RespResponse = OpenAI.Responses.Response; type RespStreamEvent = OpenAI.Responses.ResponseStreamEvent; @@ -265,7 +265,7 @@ export class ResponsesToChatCompletionConverter { // that does not round-trip the request. const echoed = this.echoRequestFields(params); - return { + const respResult = { id: response.id, object: "response", created_at: response.created, @@ -323,6 +323,11 @@ export class ResponsesToChatCompletionConverter { } : undefined, } as unknown as RespResponse; + + // Reverse the request-side namespace flattening: split namespaced + // function_call names into { namespace, name } and re-nest echoed tools. + denamespaceResponse(respResult); + return respResult; } /** @@ -362,7 +367,7 @@ export class ResponsesToChatCompletionConverter { }; } - const tools = (expandNamespaceTools(params.tools) ?? []) + const tools = (params.tools ?? []) .map(t => this.toRespTool(t)) .filter(Boolean) as RespResponse["tools"]; @@ -390,6 +395,18 @@ export class ResponsesToChatCompletionConverter { */ private toRespTool(tool: OpenAI.Responses.Tool): RespResponse["tools"][number] | undefined { const t = tool as any; + if (t.type === "namespace") { + // Echo the client's namespace tool back in its original nested shape, + // mapping each inner tool through the same function-tool normalization. + return { + type: "namespace", + name: t.name, + description: t.description, + tools: (Array.isArray(t.tools) ? t.tools : []) + .map((inner: any) => this.toRespTool(inner)) + .filter(Boolean), + } as any; + } if (t.type === "function") { return { type: "function", @@ -482,7 +499,7 @@ export class ResponsesToChatCompletionConverter { if (chunk.usage) { events.push(...this.emitCompleted(chunk.usage)); } - return events; + return denamespaceStreamEvents(events); } const delta = choice.delta; @@ -718,7 +735,7 @@ export class ResponsesToChatCompletionConverter { } } - return events; + return denamespaceStreamEvents(events); } /** Open a message output item and its first content part. */ diff --git a/src/responses/gemini.ts b/src/responses/gemini.ts index 315eb49..971525f 100644 --- a/src/responses/gemini.ts +++ b/src/responses/gemini.ts @@ -9,7 +9,7 @@ import type { FunctionCallingConfigMode, FinishReason, } from "@google/genai"; -import { expandNamespaceTools } from "./utils"; +import { expandNamespaceTools, denamespaceResponse, denamespaceStreamEvents } from "./utils"; type RespResponse = OpenAI.Responses.Response; type RespStreamEvent = OpenAI.Responses.ResponseStreamEvent; @@ -173,7 +173,7 @@ export class ResponsesToGeminiConverter { const thoughtsTokens = usage?.thoughtsTokenCount ?? 0; const candidatesTokens = (usage?.candidatesTokenCount ?? 0) + thoughtsTokens; - return { + const respResult = { id: response.responseId ?? `resp_${this.generateId()}`, object: "response", created_at: Math.floor(Date.now() / 1000), @@ -207,6 +207,10 @@ export class ResponsesToGeminiConverter { }, }, } as unknown as RespResponse; + + // Split namespaced function_call names into { namespace, name }. + denamespaceResponse(respResult); + return respResult; } // --- Stream conversion (Gemini → Responses, backward) --- @@ -399,7 +403,7 @@ export class ResponsesToGeminiConverter { } } - return events; + return denamespaceStreamEvents(events); } // --- Private: request helpers --- diff --git a/src/responses/messages.ts b/src/responses/messages.ts index eee88b2..c2bf23c 100644 --- a/src/responses/messages.ts +++ b/src/responses/messages.ts @@ -1,7 +1,7 @@ import type OpenAI from "openai"; import type Anthropic from "@anthropic-ai/sdk"; import { APIError } from "@anthropic-ai/sdk"; -import { expandNamespaceTools } from "./utils"; +import { expandNamespaceTools, denamespaceResponse, denamespaceStreamEvents } from "./utils"; type RespResponse = OpenAI.Responses.Response; type RespStreamEvent = OpenAI.Responses.ResponseStreamEvent; @@ -252,7 +252,7 @@ export class ResponsesToMessagesConverter { const totalInputTokens = usage.input_tokens + cacheRead + (usage.cache_creation_input_tokens ?? 0); - return { + const respResult = { id: message.id, object: "response", created_at: Math.floor(Date.now() / 1000), @@ -290,6 +290,10 @@ export class ResponsesToMessagesConverter { }, }, } as unknown as RespResponse; + + // Split namespaced function_call names into { namespace, name }. + denamespaceResponse(respResult); + return respResult; } // --- Stream conversion (Messages → Responses, backward) --- @@ -812,7 +816,7 @@ export class ResponsesToMessagesConverter { } } - return events; + return denamespaceStreamEvents(events); } // --- Private: request helpers --- diff --git a/src/responses/utils.ts b/src/responses/utils.ts index c4729a6..d1f45b6 100644 --- a/src/responses/utils.ts +++ b/src/responses/utils.ts @@ -1,5 +1,8 @@ import type OpenAI from "openai"; +/** Separator joining a namespace and tool name in a flattened function name. */ +export const NAMESPACE_SEPARATOR = "___"; + /** * Expand Responses "namespace" tools into individual "function" tools. * @@ -44,7 +47,7 @@ export function expandNamespaceTools( expanded.push({ ...inner, type: "function", - name: nsName ? `${nsName}___${innerName}` : innerName, + name: nsName ? `${nsName}${NAMESPACE_SEPARATOR}${innerName}` : innerName, }); } continue; @@ -54,3 +57,115 @@ export function expandNamespaceTools( return (hasNamespace ? expanded : tools) as OpenAI.Responses.ResponseCreateParams["tools"]; } + +/** + * Split a flattened `___` name back into its parts. Returns + * `{ name }` (no namespace) when the name is not namespaced. + */ +export function splitNamespacedToolName(name: string): { namespace?: string; name: string } { + if (typeof name !== "string") return { name }; + const idx = name.indexOf(NAMESPACE_SEPARATOR); + if (idx <= 0) return { name }; + const namespace = name.slice(0, idx); + const bare = name.slice(idx + NAMESPACE_SEPARATOR.length); + if (!bare) return { name }; + return { namespace, name: bare }; +} + +/** + * Reverse of {@link expandNamespaceTools}: re-nest flattened + * `___` function tools back into `namespace` tools. Already + * nested namespace tools and non-namespaced tools pass through untouched; + * order is preserved (a namespace appears at its first member's position). + */ +export function nestNamespaceTools(tools: any): any { + if (!Array.isArray(tools)) return tools; + + let changed = false; + const result: any[] = []; + const nsByName = new Map(); + + for (const tool of tools) { + const t = tool as any; + if (t && typeof t === "object" && t.type === "function" && typeof t.name === "string") { + const { namespace, name } = splitNamespacedToolName(t.name); + if (namespace) { + changed = true; + let ns = nsByName.get(namespace); + if (!ns) { + ns = { type: "namespace", name: namespace, tools: [] }; + nsByName.set(namespace, ns); + result.push(ns); + } + ns.tools.push({ ...t, name }); + continue; + } + } + result.push(tool); + } + + return changed ? result : tools; +} + +/** + * If a function_call output item's `name` is a flattened `___`, + * split it into a `namespace` field plus the bare `name`. Mutates in place. + */ +function denamespaceFunctionCallItem(item: any): void { + if (!item || item.type !== "function_call" || typeof item.name !== "string") return; + const { namespace, name } = splitNamespacedToolName(item.name); + if (namespace) { + item.name = name; + item.namespace = namespace; + } +} + +/** + * Apply the reverse namespace transform to a full Responses response (mutates): + * split any namespaced `function_call` output items into `{ namespace, name }` + * and re-nest the echoed `tools` into namespace tools. + */ +export function denamespaceResponse(resp: any): void { + if (!resp || typeof resp !== "object") return; + if (Array.isArray(resp.output)) { + for (const item of resp.output) denamespaceFunctionCallItem(item); + } + if (Array.isArray(resp.tools)) { + resp.tools = nestNamespaceTools(resp.tools); + } +} + +/** + * Apply the reverse namespace transform to a batch of streaming events + * (mutates in place, returns the same array). Covers function_call item events, + * function_call argument events, and terminal events carrying a full response. + */ +export function denamespaceStreamEvents(events: T[]): T[] { + for (const event of events) { + const e = event as any; + if (!e || typeof e !== "object") continue; + switch (e.type) { + case "response.output_item.added": + case "response.output_item.done": + denamespaceFunctionCallItem(e.item); + break; + case "response.function_call_arguments.delta": + case "response.function_call_arguments.done": { + if (typeof e.name === "string") { + const { namespace, name } = splitNamespacedToolName(e.name); + if (namespace) { + e.name = name; + e.namespace = namespace; + } + } + break; + } + case "response.completed": + case "response.incomplete": + case "response.failed": + denamespaceResponse(e.response); + break; + } + } + return events; +} From eaac456eb09419b08c82849e8b6596c69ee46b04 Mon Sep 17 00:00:00 2001 From: mackong Date: Fri, 7 Aug 2026 11:03:41 +0800 Subject: [PATCH 4/4] chore: bump version to 1.0.16 (dima-2026080600118044658) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 45628ea..a397e73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenmux/rosetta-ai", - "version": "1.0.15", + "version": "1.0.16", "description": "Universal translator between AI provider protocols", "main": "dist/index.js", "types": "dist/index.d.ts",