From 1c74811736e35debaa60ce462e4fd3bec8529faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=9C=E5=BC=A6?= Date: Thu, 27 Aug 2026 16:11:38 +0800 Subject: [PATCH 1/2] feat(chat-completions): complete Messages protocol conversion --- .../__tests__/messages.test.ts | 195 +++++++++++++++++- src/chat-completions/messages.ts | 157 +++++++++++--- 2 files changed, 326 insertions(+), 26 deletions(-) diff --git a/src/chat-completions/__tests__/messages.test.ts b/src/chat-completions/__tests__/messages.test.ts index 5b22894..3b59cd7 100644 --- a/src/chat-completions/__tests__/messages.test.ts +++ b/src/chat-completions/__tests__/messages.test.ts @@ -217,6 +217,29 @@ describe("ChatCompletionToMessagesConverter", () => { }); }); + it("tolerates malformed function arguments", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [ + { + role: "assistant", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: "not-json" }, + }, + ], + }, + ], + }); + + expect(result.messages[0]).toEqual({ + role: "assistant", + content: [{ type: "tool_use", id: "call_1", name: "get_weather", input: {} }], + }); + }); + it("merges consecutive tool messages into one user message", () => { const input: OpenAI.ChatCompletionCreateParams = { model: "gpt-4o", @@ -301,6 +324,54 @@ describe("ChatCompletionToMessagesConverter", () => { source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" }, }); }); + + it("converts file_data to an Anthropic document", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [ + { + role: "user", + content: [ + { + type: "file", + file: { file_data: "data:application/pdf;base64,JVBERi0=" }, + }, + ], + }, + ], + }); + + expect(result.messages[0]).toEqual({ + role: "user", + content: [ + { + type: "document", + source: { type: "base64", media_type: "application/pdf", data: "JVBERi0=" }, + }, + ], + }); + }); + + it("rejects missing or non-data-URI file_data", () => { + expect(() => + converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: [{ type: "file", file: {} }] }], + }) + ).toThrow("no file data found"); + + expect(() => + converter.convertRequest({ + model: "gpt-4o", + messages: [ + { + role: "user", + content: [{ type: "file", file: { file_data: "not-a-data-uri" } }], + }, + ], + }) + ).toThrow("invalid base64 file data"); + }); }); describe("params mapping", () => { @@ -331,6 +402,16 @@ describe("ChatCompletionToMessagesConverter", () => { expect(result.max_tokens).toBe(500); }); + it("prefers max_completion_tokens when both token limits are present", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 1000, + max_completion_tokens: 500, + }); + expect(result.max_tokens).toBe(500); + }); + it("maps stop as string to stop_sequences array", () => { const result = converter.convertRequest({ model: "gpt-4o", @@ -372,6 +453,7 @@ describe("ChatCompletionToMessagesConverter", () => { expect(result.tools).toEqual([ { + type: "custom", name: "get_weather", description: "Get weather info", input_schema: { @@ -383,6 +465,54 @@ describe("ChatCompletionToMessagesConverter", () => { ]); }); + it("converts custom tools to Anthropic custom tools", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + tools: [ + { + type: "custom", + custom: { + name: "shell", + description: "Run shell input", + format: { + type: "grammar", + grammar: { syntax: "lark", definition: "start: WORD" }, + }, + }, + }, + ], + }); + + expect(result.tools).toEqual([ + { + type: "custom", + name: "shell", + description: "Run shell input", + input_schema: { + type: "object", + properties: { + content: { + type: "string", + description: "Content must follow grammar (lark): start: WORD", + }, + }, + required: ["content"], + }, + }, + ]); + }); + + it("converts web_search_options to the Anthropic web search tool", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + web_search_options: { max_uses: 3 } as any, + }); + + expect(result.tools).toEqual([{ type: "web_search_20250305", name: "web_search" }]); + }); + it('maps tool_choice "auto"', () => { const result = converter.convertRequest({ model: "gpt-4o", @@ -505,6 +635,16 @@ describe("ChatCompletionToMessagesConverter", () => { }); expect(result.metadata).toEqual({ user_id: "user-123" }); }); + + it("prefers metadata.user_id over user", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + user: "legacy-user", + metadata: { user_id: "metadata-user" }, + }); + expect(result.metadata).toEqual({ user_id: "metadata-user" }); + }); }); describe("parallel_tool_calls mapping", () => { @@ -529,6 +669,16 @@ describe("ChatCompletionToMessagesConverter", () => { } as any); expect(result.tool_choice).toEqual({ type: "auto" }); }); + + it("creates an auto tool choice when only parallel_tool_calls=false is provided", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + tools: [{ type: "function", function: { name: "fn", parameters: { type: "object" } } }], + parallel_tool_calls: false, + } as any); + expect(result.tool_choice).toEqual({ type: "auto", disable_parallel_tool_use: true }); + }); }); describe("stream mapping", () => { @@ -541,6 +691,15 @@ describe("ChatCompletionToMessagesConverter", () => { expect((result as any).stream).toBe(true); }); + it("passes stream=false through", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + stream: false, + }); + expect((result as any).stream).toBe(false); + }); + it("does not set stream when not provided", () => { const result = converter.convertRequest({ model: "gpt-4o", @@ -606,7 +765,7 @@ describe("ChatCompletionToMessagesConverter", () => { ); expect(result.choices[0].finish_reason).toBe("tool_calls"); - expect(result.choices[0].message.content).toBeNull(); + expect(result.choices[0].message.content).toBe(""); expect(result.choices[0].message.tool_calls).toEqual([ { id: "toolu_123", @@ -616,6 +775,38 @@ describe("ChatCompletionToMessagesConverter", () => { ]); }); + it("restores custom tool calls after converting the matching request", () => { + const c = new ChatCompletionToMessagesConverter(); + c.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Run a command" }], + tools: [{ type: "custom", custom: { name: "shell" } }], + }); + + const result = c.convertResponse( + makeMessage({ + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "shell", + input: { content: "pwd" }, + caller: { type: "direct" }, + }, + ], + stop_reason: "tool_use", + }) + ); + + expect(result.choices[0].message.tool_calls).toEqual([ + { + id: "toolu_123", + type: "custom", + custom: { name: "shell", input: '{"content":"pwd"}' }, + }, + ]); + }); + it("converts mixed text + tool_use response", () => { const result = converter.convertResponse( makeMessage({ @@ -819,6 +1010,7 @@ describe("ChatCompletionToMessagesConverter", () => { expect(result).not.toBeNull(); expect(result!.id).toBe("msg_123"); expect(result!.model).toBe("claude-sonnet-4-20250514"); + expect(result!.service_tier).toBeNull(); expect(result!.choices[0].delta.role).toBe("assistant"); }); @@ -879,6 +1071,7 @@ describe("ChatCompletionToMessagesConverter", () => { expect(result!.usage?.prompt_tokens).toBe(10); expect(result!.usage?.completion_tokens).toBe(5); expect(result!.usage?.total_tokens).toBe(15); + expect(result!.service_tier).toBeNull(); expect(result!.usage?.completion_tokens_details).toEqual({ reasoning_tokens: 0 }); }); }); diff --git a/src/chat-completions/messages.ts b/src/chat-completions/messages.ts index 9548d61..9f8ea83 100644 --- a/src/chat-completions/messages.ts +++ b/src/chat-completions/messages.ts @@ -22,6 +22,7 @@ interface StreamState { export class ChatCompletionToMessagesConverter { private streamState: StreamState; + private customToolNames = new Set(); constructor() { this.streamState = this.createStreamState(); @@ -30,6 +31,7 @@ export class ChatCompletionToMessagesConverter { // --- Request conversion (CC → Messages, forward) --- convertRequest(params: OpenAI.ChatCompletionCreateParams): Anthropic.MessageCreateParams { + this.customToolNames.clear(); const systemBlocks: Anthropic.TextBlockParam[] = []; const messages: AnthropicMessage[] = []; @@ -53,7 +55,7 @@ export class ChatCompletionToMessagesConverter { const result: Anthropic.MessageCreateParams = { model: params.model, - max_tokens: params.max_tokens ?? params.max_completion_tokens ?? DEFAULT_MAX_TOKENS, + max_tokens: params.max_completion_tokens ?? params.max_tokens ?? DEFAULT_MAX_TOKENS, messages, }; @@ -70,11 +72,18 @@ export class ChatCompletionToMessagesConverter { const stop = params.stop; result.stop_sequences = typeof stop === "string" ? [stop] : (stop as string[]); } - if (params.tools) { - result.tools = this.convertTools(params.tools); + const tools = this.convertTools(params.tools ?? []); + const webSearchTool = this.convertWebSearchOptions((params as any).web_search_options); + if (webSearchTool) { + tools.push(webSearchTool); + } + if (tools.length > 0) { + result.tools = tools; } if (params.tool_choice !== undefined) { result.tool_choice = this.convertToolChoice(params.tool_choice, params.parallel_tool_calls); + } else if (params.parallel_tool_calls === false) { + result.tool_choice = { type: "auto", disable_parallel_tool_use: true }; } if (params.response_format) { result.output_config = this.convertResponseFormat(params.response_format); @@ -82,8 +91,9 @@ export class ChatCompletionToMessagesConverter { if (params.reasoning_effort != null) { result.thinking = this.convertReasoningEffort(params.reasoning_effort as string); } - if (params.user) { - result.metadata = { user_id: params.user }; + const metadataUserId = (params.metadata as Record | null | undefined)?.user_id; + if (metadataUserId != null || params.user != null) { + result.metadata = { user_id: metadataUserId ?? params.user! }; } if (params.service_tier != null) { const tier = params.service_tier as string; @@ -91,8 +101,8 @@ export class ChatCompletionToMessagesConverter { result.service_tier = tier; } } - if (params.stream === true) { - (result as any).stream = true; + if (params.stream != null) { + (result as any).stream = params.stream; } return result; @@ -120,14 +130,25 @@ export class ChatCompletionToMessagesConverter { index: 0, }); } else if (block.type === "tool_use" || block.type === "server_tool_use") { - toolCalls.push({ - id: block.id, - type: "function", - function: { - name: block.name, - arguments: JSON.stringify(block.input), - }, - }); + if (this.customToolNames.has(block.name)) { + toolCalls.push({ + id: block.id, + type: "custom", + custom: { + name: block.name, + input: JSON.stringify(block.input), + }, + }); + } else { + toolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.stringify(block.input), + }, + }); + } } else if (block.type === "web_search_tool_result") { const content = block.content; if (Array.isArray(content)) { @@ -148,7 +169,7 @@ export class ChatCompletionToMessagesConverter { const assistantMessage: OpenAI.ChatCompletionMessage = { role: "assistant", - content: textParts.length > 0 ? textParts.join("") : null, + content: textParts.join(""), refusal: null, }; @@ -264,6 +285,7 @@ export class ChatCompletionToMessagesConverter { object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: this.streamState.model, + service_tier: null, choices: [{ index: 0, delta, finish_reason }], }; } @@ -435,6 +457,7 @@ export class ChatCompletionToMessagesConverter { object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: state.model, + service_tier: null, choices: [], usage, }; @@ -486,6 +509,8 @@ export class ChatCompletionToMessagesConverter { return { type: "text", text: part.text }; case "image_url": return this.convertImageUrl(part); + case "file": + return this.convertFile(part); default: return { type: "text", text: `[Unsupported content type: ${part.type}]` }; } @@ -536,7 +561,14 @@ export class ChatCompletionToMessagesConverter { type: "tool_use", id: tc.id, name: tc.function.name, - input: JSON.parse(tc.function.arguments || "{}"), + input: this.safeParseObject(tc.function.arguments), + }); + } else if (tc.type === "custom") { + blocks.push({ + type: "tool_use", + id: tc.id, + name: tc.custom.name, + input: this.safeParseObject(tc.custom.input), }); } } @@ -570,14 +602,89 @@ export class ChatCompletionToMessagesConverter { messages.push({ role: "user", content: [toolResult] }); } - private convertTools(tools: OpenAI.ChatCompletionTool[]): Anthropic.Tool[] { - return tools - .filter((t): t is OpenAI.ChatCompletionFunctionTool => t.type === "function") - .map(t => ({ - name: t.function.name, - description: t.function.description, - input_schema: (t.function.parameters ?? { type: "object" }) as Anthropic.Tool.InputSchema, - })); + private convertTools(tools: OpenAI.ChatCompletionTool[]): Anthropic.ToolUnion[] { + return tools.map(tool => { + if (tool.type === "function") { + return { + type: "custom", + name: tool.function.name, + description: tool.function.description, + input_schema: (tool.function.parameters ?? { + type: "object", + }) as Anthropic.Tool.InputSchema, + }; + } + + this.customToolNames.add(tool.custom.name); + return { + type: "custom", + name: tool.custom.name, + description: tool.custom.description ?? "", + input_schema: this.customToolInputSchema(tool.custom.format), + }; + }); + } + + private customToolInputSchema( + format: OpenAI.ChatCompletionCustomTool.Custom["format"] + ): Anthropic.Tool.InputSchema { + if (format?.type === "grammar") { + return { + type: "object", + properties: { + content: { + type: "string", + description: `Content must follow grammar (${format.grammar.syntax}): ${format.grammar.definition}`, + }, + }, + required: ["content"], + }; + } + + return { + type: "object", + properties: { content: { type: "string" } }, + required: ["content"], + }; + } + + private convertWebSearchOptions(options: unknown): Anthropic.WebSearchTool20250305 | null { + if (options == null) return null; + + return { + name: "web_search", + type: "web_search_20250305", + }; + } + + private convertFile(part: OpenAI.ChatCompletionContentPart.File): Anthropic.DocumentBlockParam { + const fileData = part.file.file_data; + if (!fileData) { + throw new Error("no file data found"); + } + + const dataUriMatch = fileData.match(/^data:([^;]+);base64,(.+)$/); + if (!dataUriMatch) { + throw new Error("invalid base64 file data"); + } + + return { + type: "document", + source: { + type: "base64", + media_type: dataUriMatch[1] as "application/pdf", + data: dataUriMatch[2], + }, + }; + } + + private safeParseObject(value: string | null | undefined): Record { + if (!value) return {}; + try { + return JSON.parse(value) as Record; + } catch { + return {}; + } } private convertToolChoice( From b7586b58e7888dd714604c8fa69360ccb9f21f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=9C=E5=BC=A6?= Date: Thu, 27 Aug 2026 16:15:16 +0800 Subject: [PATCH 2/2] chore: update version to 1.0.18 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 117ac7c..df91d75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenmux/rosetta-ai", - "version": "1.0.17", + "version": "1.0.18", "description": "Universal translator between AI provider protocols", "main": "dist/index.js", "types": "dist/index.d.ts",