From 04e5dd1ce270a195220e2cedf2b03dc0253c1500 Mon Sep 17 00:00:00 2001 From: unohee Date: Thu, 20 Aug 2026 22:27:28 +0900 Subject: [PATCH 1/2] fix(openai): preserve tool calls across the messages bridge Function calls and their outputs are top-level Responses input items, while assistant text uses output_text. The reverse bridge now emits Anthropic tool_use blocks for JSON and SSE responses and preserves call IDs across the tool-result turn. --- README.md | 2 +- src/__tests__/anthropic-to-openai.test.ts | 46 ++---- src/__tests__/messages-cross-route.test.ts | 131 +++++++++++++++ .../openai-response-to-anthropic.test.ts | 18 +++ .../openai-stream-to-anthropic.test.ts | 74 +++++++++ src/__tests__/openai-to-anthropic.test.ts | 43 ++--- src/protocol/anthropic-to-openai.ts | 53 +++--- src/protocol/openai-response-to-anthropic.ts | 40 ++++- src/protocol/openai-responses-types.ts | 15 +- src/protocol/openai-stream-to-anthropic.ts | 151 +++++++++++++----- src/protocol/openai-to-anthropic.ts | 99 ++++++++---- src/proxy/messages-cross-route.ts | 74 +++++++-- 12 files changed, 563 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 621d994..e4159f4 100644 --- a/README.md +++ b/README.md @@ -422,7 +422,7 @@ Examples after the configuration above: Claude Code can also send a `/v1/messages` request with an `openai/*` model. CC-Router translates that Anthropic Messages request into an OpenAI Responses request and converts JSON or basic text SSE responses back into Anthropic-shaped message responses. -Current limitation: OpenAI-to-Anthropic streaming currently covers text deltas and final usage. Streaming tool-call normalization is still experimental. +OpenAI-to-Anthropic conversion supports text and function tool calls in both streaming and non-streaming responses. OpenAI subscription account records are separated from Claude accounts with `provider: "openai_subscription"` so they do not enter the Anthropic token pool: diff --git a/src/__tests__/anthropic-to-openai.test.ts b/src/__tests__/anthropic-to-openai.test.ts index 46d7750..cb42ec2 100644 --- a/src/__tests__/anthropic-to-openai.test.ts +++ b/src/__tests__/anthropic-to-openai.test.ts @@ -27,7 +27,7 @@ describe("anthropicToOpenAIResponses", () => { }); }); - it("maps Anthropic tools, assistant tool_use, and user tool_result", () => { + it("keeps assistant text and tool calls in valid Responses input items", () => { const result = anthropicToOpenAIResponses({ model: "openai/gpt-5.5", messages: [ @@ -40,42 +40,26 @@ describe("anthropicToOpenAIResponses", () => { }, { role: "user", - content: [ - { type: "tool_result", tool_use_id: "toolu_1", content: "CC-Router" }, - ], + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "CC-Router" }], }, ], - tools: [ - { - name: "read_file", - description: "Read a file", - input_schema: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - }, - }, - ], - }); - - expect(result.tools).toEqual([ - { - type: "function", + tools: [{ name: "read_file", description: "Read a file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - }, - }, - ]); - expect(result.input[0].content).toEqual([ - { type: "input_text", text: "I will inspect it." }, + input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + }], + }); + + expect(result.input).toEqual([ + { role: "assistant", content: [{ type: "output_text", text: "I will inspect it." }] }, { type: "function_call", call_id: "toolu_1", name: "read_file", arguments: "{\"path\":\"README.md\"}" }, - ]); - expect(result.input[1].content).toEqual([ { type: "function_call_output", call_id: "toolu_1", output: "CC-Router" }, ]); + expect(result.tools?.[0]).toEqual({ + type: "function", + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + }); }); }); diff --git a/src/__tests__/messages-cross-route.test.ts b/src/__tests__/messages-cross-route.test.ts index 8ea13c0..eaebdb3 100644 --- a/src/__tests__/messages-cross-route.test.ts +++ b/src/__tests__/messages-cross-route.test.ts @@ -312,6 +312,137 @@ describe("mountMessagesCrossProviderRoute", () => { } }); + it("streams function calls back as Anthropic tool_use events", async () => { + const app = express(); + + mountMessagesCrossProviderRoute(app, { + getOpenAIAccount: () => ({ + id: "openai-victor", + provider: "openai_subscription", + accessToken: "access", + refreshToken: "refresh", + expiresAt: Date.now() + 60 * 60 * 1000, + enabled: true, + }), + forwardOpenAI: async () => new Response( + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const push = (event: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + push({ type: "response.created", response: { id: "resp_tool", model: "gpt-5.5" } }); + push({ + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", call_id: "call_1", name: "read_file" }, + }); + push({ type: "response.function_call_arguments.delta", output_index: 0, delta: "{\"path\":\"README.md\"}" }); + push({ type: "response.output_item.done", output_index: 0 }); + push({ type: "response.completed", response: { id: "resp_tool", usage: { input_tokens: 8, output_tokens: 4 } } }); + controller.close(); + }, + }) as BodyInit, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + }); + + const server = createServer(app); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port"); + + try { + const res = await fetch(`http://127.0.0.1:${address.port}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-5.5", + messages: [{ role: "user", content: "Read README.md" }], + stream: true, + }), + }); + const body = await res.text(); + + expect(body).toContain('"content_block":{"type":"tool_use","id":"call_1","name":"read_file","input":{}}'); + expect(body).toContain('"delta":{"type":"input_json_delta","partial_json":"{\\"path\\":\\"README.md\\"}"}'); + expect(body).toContain('"stop_reason":"tool_use"'); + } finally { + await new Promise((resolve, reject) => { + server.close(err => err ? reject(err) : resolve()); + }); + } + }); + + + it("collapses a function-call stream into an Anthropic tool_use response", async () => { + const app = express(); + + mountMessagesCrossProviderRoute(app, { + getOpenAIAccount: () => ({ + id: "openai-victor", + provider: "openai_subscription", + accessToken: "access", + refreshToken: "refresh", + expiresAt: Date.now() + 60 * 60 * 1000, + enabled: true, + }), + forwardOpenAI: async () => new Response( + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const push = (event: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + push({ type: "response.created", response: { id: "resp_tool", model: "gpt-5.5" } }); + push({ + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", call_id: "call_1", name: "read_file", arguments: "" }, + }); + push({ + type: "response.function_call_arguments.done", + output_index: 0, + arguments: "{\"path\":\"README.md\"}", + }); + push({ type: "response.output_item.done", output_index: 0 }); + push({ type: "response.completed", response: { id: "resp_tool", model: "gpt-5.5", usage: { input_tokens: 8, output_tokens: 4 } } }); + controller.close(); + }, + }) as BodyInit, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + }); + + const server = createServer(app); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port"); + + try { + const res = await fetch(`http://127.0.0.1:${address.port}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-5.5", + messages: [{ role: "user", content: "Read README.md" }], + stream: false, + }), + }); + + expect(await res.json()).toEqual({ + id: "resp_tool", + type: "message", + role: "assistant", + model: "gpt-5.5", + content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "README.md" } }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 8, output_tokens: 4 }, + }); + } finally { + await new Promise((resolve, reject) => { + server.close(err => err ? reject(err) : resolve()); + }); + } + }); + it("passes non-openai models to later Anthropic proxy middleware with replayable raw body", async () => { const app = express(); const nextSpy = vi.fn(); diff --git a/src/__tests__/openai-response-to-anthropic.test.ts b/src/__tests__/openai-response-to-anthropic.test.ts index e7dc2d1..203c48e 100644 --- a/src/__tests__/openai-response-to-anthropic.test.ts +++ b/src/__tests__/openai-response-to-anthropic.test.ts @@ -35,4 +35,22 @@ describe("openAIResponseToAnthropicMessage", () => { }, }); }); + + it("maps function calls to ordered Anthropic tool blocks", () => { + const result = openAIResponseToAnthropicMessage({ + id: "resp_2", + model: "gpt-5.5", + output: [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "Checking." }] }, + { type: "function_call", id: "fc_1", call_id: "call_1", name: "get_weather", arguments: "{\"city\":\"Seoul\"}" }, + ], + usage: { input_tokens: 12, output_tokens: 3 }, + }); + + expect(result.content).toEqual([ + { type: "text", text: "Checking." }, + { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Seoul" } }, + ]); + expect(result.stop_reason).toBe("tool_use"); + }); }); diff --git a/src/__tests__/openai-stream-to-anthropic.test.ts b/src/__tests__/openai-stream-to-anthropic.test.ts index 2d18339..469b309 100644 --- a/src/__tests__/openai-stream-to-anthropic.test.ts +++ b/src/__tests__/openai-stream-to-anthropic.test.ts @@ -91,4 +91,78 @@ describe("openAIStreamEventToAnthropicEvents", () => { }, ]); }); + + it("streams function calls as Anthropic tool_use blocks", () => { + const normalizer = createOpenAIStreamToAnthropicNormalizer(); + const events = [ + ...normalizer.convert({ type: "response.created", response: { id: "resp_2", model: "gpt-5.5" } }), + ...normalizer.convert({ + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", call_id: "call_1", name: "get_weather" }, + }), + ...normalizer.convert({ + type: "response.function_call_arguments.delta", + output_index: 0, + delta: "{\"city\":\"Seoul\"}", + }), + ...normalizer.convert({ type: "response.output_item.done", output_index: 0 }), + ...normalizer.convert({ + type: "response.completed", + response: { id: "resp_2", usage: { input_tokens: 10, output_tokens: 4 } }, + }), + ]; + + expect(events).toEqual([ + { + type: "message_start", + message: { + id: "resp_2", + type: "message", + role: "assistant", + model: "gpt-5.5", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "call_1", name: "get_weather", input: {} }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: "{\"city\":\"Seoul\"}" }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "tool_use", stop_sequence: null }, + usage: { output_tokens: 4 }, + }, + { type: "message_stop" }, + ]); + }); + + it("keeps text and tool block indexes separate", () => { + const normalizer = createOpenAIStreamToAnthropicNormalizer(); + normalizer.convert({ type: "response.created", response: { id: "resp_3" } }); + + expect(normalizer.convert({ type: "response.output_text.delta", output_index: 0, delta: "Checking." })[0]) + .toEqual({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }); + normalizer.convert({ type: "response.output_item.done", output_index: 0 }); + + expect(normalizer.convert({ + type: "response.output_item.added", + output_index: 2, + item: { type: "function_call", call_id: "call_2", name: "read_file" }, + })[0]).toEqual({ + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "call_2", name: "read_file", input: {} }, + }); + }); }); diff --git a/src/__tests__/openai-to-anthropic.test.ts b/src/__tests__/openai-to-anthropic.test.ts index 7a05370..109d42b 100644 --- a/src/__tests__/openai-to-anthropic.test.ts +++ b/src/__tests__/openai-to-anthropic.test.ts @@ -27,47 +27,28 @@ describe("openAIResponsesToAnthropic", () => { }); }); - it("maps function calls and outputs to Anthropic tool blocks", () => { + it("maps top-level function call items to adjacent Anthropic messages", () => { const result = openAIResponsesToAnthropic({ model: "anthropic/claude-opus-4-1", input: [ - { - role: "assistant", - content: [ - { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{\"path\":\"README.md\"}" }, - ], - }, - { - role: "tool", - content: [ - { type: "function_call_output", call_id: "call_1", output: "CC-Router" }, - ], - }, - ], - tools: [ - { - type: "function", - name: "read_file", - parameters: { type: "object", properties: { path: { type: "string" } } }, - }, + { role: "user", content: [{ type: "input_text", text: "Read it." }] }, + { role: "assistant", content: [{ type: "output_text", text: "Reading." }] }, + { type: "function_call", call_id: "call_1", name: "read_file", arguments: "{\"path\":\"README.md\"}" }, + { type: "function_call_output", call_id: "call_1", output: "CC-Router" }, ], + tools: [{ type: "function", name: "read_file", parameters: { type: "object" } }], }); expect(result.messages).toEqual([ + { role: "user", content: "Read it." }, { role: "assistant", - content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "README.md" } }], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "call_1", content: "CC-Router" }], - }, - ]); - expect(result.tools).toEqual([ - { - name: "read_file", - input_schema: { type: "object", properties: { path: { type: "string" } } }, + content: [ + { type: "text", text: "Reading." }, + { type: "tool_use", id: "call_1", name: "read_file", input: { path: "README.md" } }, + ], }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "CC-Router" }] }, ]); }); }); diff --git a/src/protocol/anthropic-to-openai.ts b/src/protocol/anthropic-to-openai.ts index a0a874b..4c32413 100644 --- a/src/protocol/anthropic-to-openai.ts +++ b/src/protocol/anthropic-to-openai.ts @@ -1,5 +1,5 @@ import type { AnthropicContent, AnthropicMessagesRequest } from "./anthropic-types.js"; -import type { OpenAIInputContent, OpenAIResponsesRequest } from "./openai-responses-types.js"; +import type { OpenAIInputItem, OpenAIInputRole, OpenAIResponsesRequest } from "./openai-responses-types.js"; import { parseModelRef } from "./model-ref.js"; import type { ModelRoutingConfig } from "./model-ref.js"; @@ -9,28 +9,48 @@ function stringifySystem(system: AnthropicMessagesRequest["system"]): string | u return system.map(block => block.text).join("\n"); } -function contentToOpenAI(content: AnthropicContent): OpenAIInputContent[] { - if (typeof content === "string") return [{ type: "input_text", text: content }]; +function messageToOpenAIItems(role: OpenAIInputRole, content: AnthropicContent): OpenAIInputItem[] { + const textType = role === "assistant" ? "output_text" as const : "input_text" as const; + if (typeof content === "string") { + return [{ role, content: [{ type: textType, text: content }] }]; + } - return content.map(block => { - if (block.type === "text") return { type: "input_text", text: block.text }; + const items: OpenAIInputItem[] = []; + let pendingText: Array<{ type: typeof textType; text: string }> = []; + const flushText = () => { + if (pendingText.length === 0) return; + items.push({ role, content: pendingText }); + pendingText = []; + }; + + for (const block of content) { + if (block.type === "text") { + pendingText.push({ type: textType, text: block.text }); + continue; + } + + flushText(); if (block.type === "tool_use") { - return { + items.push({ type: "function_call", call_id: block.id, name: block.name, arguments: JSON.stringify(block.input ?? {}), - }; + }); + continue; } - const output = typeof block.content === "string" - ? block.content - : block.content.map(item => item.text).join("\n"); - return { + + items.push({ type: "function_call_output", call_id: block.tool_use_id, - output, - }; - }); + output: typeof block.content === "string" + ? block.content + : block.content.map(item => item.text).join("\n"), + }); + } + + flushText(); + return items; } export function anthropicToOpenAIResponses( @@ -41,10 +61,7 @@ export function anthropicToOpenAIResponses( return { model: parsed.upstreamModel, instructions: stringifySystem(req.system), - input: req.messages.map(message => ({ - role: message.role, - content: contentToOpenAI(message.content), - })), + input: req.messages.flatMap(message => messageToOpenAIItems(message.role, message.content)), tools: req.tools?.map(tool => ({ type: "function", name: tool.name, diff --git a/src/protocol/openai-response-to-anthropic.ts b/src/protocol/openai-response-to-anthropic.ts index d1a3786..3adea99 100644 --- a/src/protocol/openai-response-to-anthropic.ts +++ b/src/protocol/openai-response-to-anthropic.ts @@ -1,12 +1,16 @@ import type { OpenAIResponseCompleted } from "./openai-responses-types.js"; +export type AnthropicResponseContentBlock = + | { type: "text"; text: string } + | { type: "tool_use"; id: string; name: string; input: unknown }; + export interface AnthropicMessageResponse { id: string; type: "message"; role: "assistant"; model: string; - content: Array<{ type: "text"; text: string }>; - stop_reason: "end_turn"; + content: AnthropicResponseContentBlock[]; + stop_reason: "end_turn" | "tool_use"; stop_sequence: null; usage: { input_tokens: number; @@ -14,12 +18,32 @@ export interface AnthropicMessageResponse { }; } +function parseToolInput(argumentsJson: string): unknown { + try { + return JSON.parse(argumentsJson); + } catch { + return {}; + } +} + export function openAIResponseToAnthropicMessage(response: OpenAIResponseCompleted): AnthropicMessageResponse { - const content = (response.output ?? []) - .filter(item => item.type === "message") - .flatMap(item => item.content) - .filter(item => item.type === "output_text") - .map(item => ({ type: "text" as const, text: item.text })); + const content: AnthropicResponseContentBlock[] = []; + + for (const item of response.output ?? []) { + if (item.type === "message") { + for (const part of item.content) { + content.push({ type: "text", text: part.text }); + } + continue; + } + + content.push({ + type: "tool_use", + id: item.call_id, + name: item.name, + input: parseToolInput(item.arguments), + }); + } return { id: response.id, @@ -27,7 +51,7 @@ export function openAIResponseToAnthropicMessage(response: OpenAIResponseComplet role: "assistant", model: response.model ?? "", content, - stop_reason: "end_turn", + stop_reason: content.some(block => block.type === "tool_use") ? "tool_use" : "end_turn", stop_sequence: null, usage: { input_tokens: response.usage?.input_tokens ?? 0, diff --git a/src/protocol/openai-responses-types.ts b/src/protocol/openai-responses-types.ts index 80c9a73..797671a 100644 --- a/src/protocol/openai-responses-types.ts +++ b/src/protocol/openai-responses-types.ts @@ -24,17 +24,16 @@ export interface OpenAIFunctionCallOutput { output: string; } -export type OpenAIInputContent = - | OpenAIInputText - | OpenAIOutputText - | OpenAIFunctionCall - | OpenAIFunctionCallOutput; +export type OpenAIInputContent = OpenAIInputText | OpenAIOutputText; export interface OpenAIInputMessage { role: OpenAIInputRole; content: OpenAIInputContent[]; } +// Responses function calls and their outputs are top-level input items. +export type OpenAIInputItem = OpenAIInputMessage | OpenAIFunctionCall | OpenAIFunctionCallOutput; + export interface OpenAITool { type: "function"; name: string; @@ -45,7 +44,7 @@ export interface OpenAITool { export interface OpenAIResponsesRequest { model: string; instructions?: string; - input: OpenAIInputMessage[]; + input: OpenAIInputItem[]; tools?: OpenAITool[]; max_output_tokens?: number; stream?: boolean; @@ -58,10 +57,12 @@ export interface OpenAIResponseOutputMessage { content: OpenAIOutputText[]; } +export type OpenAIResponseOutputItem = OpenAIResponseOutputMessage | OpenAIFunctionCall; + export interface OpenAIResponseCompleted { id: string; model?: string; - output?: OpenAIResponseOutputMessage[]; + output?: OpenAIResponseOutputItem[]; usage?: { input_tokens?: number; output_tokens?: number; diff --git a/src/protocol/openai-stream-to-anthropic.ts b/src/protocol/openai-stream-to-anthropic.ts index 6d749e6..b0f63c3 100644 --- a/src/protocol/openai-stream-to-anthropic.ts +++ b/src/protocol/openai-stream-to-anthropic.ts @@ -1,6 +1,16 @@ +interface OpenAIStreamEventItem { + id?: string; + type?: string; + call_id?: string; + name?: string; +} + interface OpenAIStreamEvent { type?: string; delta?: string; + output_index?: number; + arguments?: string; + item?: OpenAIStreamEventItem; response?: { id?: string; model?: string; @@ -18,70 +28,127 @@ export interface OpenAIStreamToAnthropicNormalizer { reset(): void; } +interface OpenBlock { + index: number; + kind: "text" | "tool_use"; + sentArguments: boolean; +} + export function createOpenAIStreamToAnthropicNormalizer(): OpenAIStreamToAnthropicNormalizer { - let textBlockStarted = false; - - const ensureTextBlockStarted = (): AnthropicStreamEvent[] => { - if (textBlockStarted) return []; - textBlockStarted = true; - return [ - { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - }, - ]; - }; + let blocks = new Map(); + let nextIndex = 0; + let sawToolUse = false; const reset = () => { - textBlockStarted = false; + blocks = new Map(); + nextIndex = 0; + sawToolUse = false; + }; + + const openTextBlock = (outputIndex: number): AnthropicStreamEvent[] => { + if (blocks.has(outputIndex)) return []; + const block: OpenBlock = { index: nextIndex++, kind: "text", sentArguments: false }; + blocks.set(outputIndex, block); + return [{ + type: "content_block_start", + index: block.index, + content_block: { type: "text", text: "" }, + }]; + }; + + const closeBlock = (outputIndex: number): AnthropicStreamEvent[] => { + const block = blocks.get(outputIndex); + if (!block) return []; + blocks.delete(outputIndex); + return [{ type: "content_block_stop", index: block.index }]; }; return { reset, convert(event: OpenAIStreamEvent): AnthropicStreamEvent[] { + const outputIndex = event.output_index ?? 0; + if (event.type === "response.created") { reset(); - return [ - { - type: "message_start", - message: { - id: event.response?.id ?? "", - type: "message", - role: "assistant", - model: event.response?.model ?? "", - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0 }, - }, + return [{ + type: "message_start", + message: { + id: event.response?.id ?? "", + type: "message", + role: "assistant", + model: event.response?.model ?? "", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, }, - ]; + }]; } - if (event.type === "response.output_text.delta") { - return [ - ...ensureTextBlockStarted(), - { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: event.delta ?? "" }, + if (event.type === "response.output_item.added") { + if (event.item?.type !== "function_call" || blocks.has(outputIndex)) return []; + const block: OpenBlock = { index: nextIndex++, kind: "tool_use", sentArguments: false }; + blocks.set(outputIndex, block); + sawToolUse = true; + return [{ + type: "content_block_start", + index: block.index, + content_block: { + type: "tool_use", + id: event.item.call_id ?? event.item.id ?? "", + name: event.item.name ?? "", + input: {}, }, - ]; + }]; + } + + if (event.type === "response.output_text.delta") { + const prefix = openTextBlock(outputIndex); + const block = blocks.get(outputIndex); + return [...prefix, { + type: "content_block_delta", + index: block?.index ?? 0, + delta: { type: "text_delta", text: event.delta ?? "" }, + }]; + } + + if (event.type === "response.function_call_arguments.delta") { + const block = blocks.get(outputIndex); + if (!block || block.kind !== "tool_use") return []; + block.sentArguments = true; + return [{ + type: "content_block_delta", + index: block.index, + delta: { type: "input_json_delta", partial_json: event.delta ?? "" }, + }]; + } + + if (event.type === "response.function_call_arguments.done") { + const block = blocks.get(outputIndex); + if (!block || block.kind !== "tool_use" || block.sentArguments || !event.arguments) return []; + block.sentArguments = true; + return [{ + type: "content_block_delta", + index: block.index, + delta: { type: "input_json_delta", partial_json: event.arguments }, + }]; + } + + if (event.type === "response.output_item.done") { + return closeBlock(outputIndex); } if (event.type === "response.completed") { - const usage = event.response?.usage ?? {}; - const prefix = textBlockStarted - ? [{ type: "content_block_stop", index: 0 }] - : []; + const prefix = [...blocks.keys()].flatMap(closeBlock); + const stopReason = sawToolUse ? "tool_use" : "end_turn"; + const outputTokens = event.response?.usage?.output_tokens ?? 0; reset(); return [ ...prefix, { type: "message_delta", - delta: { stop_reason: "end_turn", stop_sequence: null }, - usage: { output_tokens: usage.output_tokens ?? 0 }, + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: outputTokens }, }, { type: "message_stop" }, ]; diff --git a/src/protocol/openai-to-anthropic.ts b/src/protocol/openai-to-anthropic.ts index 9a6e11d..c8bf4d8 100644 --- a/src/protocol/openai-to-anthropic.ts +++ b/src/protocol/openai-to-anthropic.ts @@ -5,7 +5,14 @@ import type { AnthropicToolResultBlock, AnthropicToolUseBlock, } from "./anthropic-types.js"; -import type { OpenAIInputContent, OpenAIInputMessage, OpenAIResponsesRequest } from "./openai-responses-types.js"; +import type { + OpenAIFunctionCall, + OpenAIFunctionCallOutput, + OpenAIInputContent, + OpenAIInputItem, + OpenAIInputMessage, + OpenAIResponsesRequest, +} from "./openai-responses-types.js"; import { parseModelRef } from "./model-ref.js"; function parseArguments(args: string): unknown { @@ -16,42 +23,73 @@ function parseArguments(args: string): unknown { } } -function textFromOpenAI(block: OpenAIInputContent): string | null { - if (block.type === "input_text" || block.type === "output_text") return block.text; - return null; +function textFromOpenAI(block: OpenAIInputContent): string { + return block.text; } function messageContentToAnthropic(message: OpenAIInputMessage): AnthropicContent { - const blocks = message.content.map((block): AnthropicTextBlock | AnthropicToolUseBlock | AnthropicToolResultBlock | null => { - const text = textFromOpenAI(block); - if (text !== null) return { type: "text", text }; + const blocks = message.content.map((block): AnthropicTextBlock => ({ + type: "text", + text: textFromOpenAI(block), + })); + if (blocks.length === 1) return blocks[0].text; + return blocks; +} - if (block.type === "function_call") { - return { - type: "tool_use", - id: block.call_id, - name: block.name, - input: parseArguments(block.arguments), - }; - } +function normalizeRole(role: OpenAIInputMessage["role"]): "user" | "assistant" { + return role === "assistant" ? "assistant" : "user"; +} - if (block.type === "function_call_output") { - return { - type: "tool_result", - tool_use_id: block.call_id, - content: block.output, - }; - } +type AnthropicMessage = AnthropicMessagesRequest["messages"][number]; +type AnthropicBlock = AnthropicTextBlock | AnthropicToolUseBlock | AnthropicToolResultBlock; - return null; - }).filter((block): block is AnthropicTextBlock | AnthropicToolUseBlock | AnthropicToolResultBlock => block !== null); +function isFunctionCall(item: OpenAIInputItem): item is OpenAIFunctionCall { + return "type" in item && item.type === "function_call"; +} - if (blocks.length === 1 && blocks[0].type === "text") return blocks[0].text; - return blocks; +function isFunctionCallOutput(item: OpenAIInputItem): item is OpenAIFunctionCallOutput { + return "type" in item && item.type === "function_call_output"; } -function normalizeRole(role: OpenAIInputMessage["role"]): "user" | "assistant" { - return role === "assistant" ? "assistant" : "user"; +function inputItemsToAnthropicMessages(input: OpenAIInputItem[]): AnthropicMessage[] { + const messages: AnthropicMessage[] = []; + const append = (role: "user" | "assistant", blocks: AnthropicBlock[]) => { + if (blocks.length === 0) return; + const last = messages.at(-1); + if (last?.role === role) { + const existing = typeof last.content === "string" + ? [{ type: "text" as const, text: last.content }] + : last.content; + last.content = [...existing, ...blocks]; + return; + } + messages.push({ role, content: blocks }); + }; + + for (const item of input) { + if (isFunctionCall(item)) { + append("assistant", [{ + type: "tool_use", + id: item.call_id, + name: item.name, + input: parseArguments(item.arguments), + }]); + } else if (isFunctionCallOutput(item)) { + append("user", [{ type: "tool_result", tool_use_id: item.call_id, content: item.output }]); + } else { + const content = messageContentToAnthropic(item); + append(normalizeRole(item.role), typeof content === "string" + ? [{ type: "text", text: content }] + : content); + } + } + + return messages.map(message => { + if (Array.isArray(message.content) && message.content.length === 1 && message.content[0].type === "text") { + return { ...message, content: message.content[0].text }; + } + return message; + }); } export function openAIResponsesToAnthropic(req: OpenAIResponsesRequest): AnthropicMessagesRequest { @@ -59,10 +97,7 @@ export function openAIResponsesToAnthropic(req: OpenAIResponsesRequest): Anthrop return { model: parsed.upstreamModel, system: req.instructions, - messages: req.input.map(message => ({ - role: normalizeRole(message.role), - content: messageContentToAnthropic(message), - })), + messages: inputItemsToAnthropicMessages(req.input), tools: req.tools?.map(tool => ({ name: tool.name, description: tool.description, diff --git a/src/proxy/messages-cross-route.ts b/src/proxy/messages-cross-route.ts index b9c8399..c6b92b3 100644 --- a/src/proxy/messages-cross-route.ts +++ b/src/proxy/messages-cross-route.ts @@ -7,7 +7,7 @@ import { createOpenAIStreamToAnthropicNormalizer } from "../protocol/openai-stre import { encodeSseEvent, parseSseLines } from "../protocol/sse.js"; import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js"; import type { AnthropicMessagesRequest } from "../protocol/anthropic-types.js"; -import type { OpenAIResponseCompleted } from "../protocol/openai-responses-types.js"; +import type { OpenAIFunctionCall, OpenAIResponseCompleted, OpenAIResponseOutputItem } from "../protocol/openai-responses-types.js"; import type { OpenAISubscriptionAccount } from "../providers/openai/token-refresher.js"; import type { ModelRoutingConfig } from "../protocol/model-ref.js"; @@ -71,20 +71,26 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream: globalThis.Respon let remainder = ""; let id = ""; let model = ""; - let text = ""; let usage: OpenAIResponseCompleted["usage"] = {}; + const textByIndex = new Map(); + const argumentsByIndex = new Map(); + const callsByIndex = new Map(); const applyEvent = (event: unknown) => { if (typeof event !== "object" || event === null) return; const openAIEvent = event as { type?: string; delta?: string; + arguments?: string; + output_index?: number; + item?: { type?: string; call_id?: string; name?: string; arguments?: string }; response?: { id?: string; model?: string; usage?: OpenAIResponseCompleted["usage"]; }; }; + const outputIndex = openAIEvent.output_index ?? 0; if (openAIEvent.type === "response.created") { id = openAIEvent.response?.id ?? id; @@ -93,7 +99,45 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream: globalThis.Respon } if (openAIEvent.type === "response.output_text.delta") { - text += openAIEvent.delta ?? ""; + textByIndex.set(outputIndex, (textByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? "")); + return; + } + + if (openAIEvent.type === "response.output_item.added") { + const item = openAIEvent.item; + if (item?.type === "function_call" && item.call_id) { + callsByIndex.set(outputIndex, { + type: "function_call", + call_id: item.call_id, + name: item.name ?? "", + arguments: item.arguments ?? "", + }); + } + return; + } + + if (openAIEvent.type === "response.function_call_arguments.delta") { + argumentsByIndex.set(outputIndex, (argumentsByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? "")); + return; + } + + if (openAIEvent.type === "response.function_call_arguments.done") { + if (!argumentsByIndex.has(outputIndex) && openAIEvent.arguments) { + argumentsByIndex.set(outputIndex, openAIEvent.arguments); + } + return; + } + + if (openAIEvent.type === "response.output_item.done") { + const item = openAIEvent.item; + if (item?.type === "function_call" && item.call_id) { + callsByIndex.set(outputIndex, { + type: "function_call", + call_id: item.call_id, + name: item.name ?? "", + arguments: item.arguments || argumentsByIndex.get(outputIndex) || "", + }); + } return; } @@ -118,16 +162,20 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream: globalThis.Respon parseSseLines(remainder + tail + "\n").events.forEach(applyEvent); } - return openAIResponseToAnthropicMessage({ - id, - model, - output: text ? [{ - type: "message", - role: "assistant", - content: [{ type: "output_text", text }], - }] : [], - usage, - }); + const output: OpenAIResponseOutputItem[] = [...new Set([...textByIndex.keys(), ...callsByIndex.keys()])] + .sort((a, b) => a - b) + .flatMap((index): OpenAIResponseOutputItem[] => { + const call = callsByIndex.get(index); + if (call) return [{ ...call, arguments: call.arguments || argumentsByIndex.get(index) || "" }]; + const text = textByIndex.get(index); + return text ? [{ + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }] : []; + }); + + return openAIResponseToAnthropicMessage({ id, model, output, usage }); } async function sendOpenAIStreamAsAnthropic(upstream: globalThis.Response, res: Response): Promise { From 08c69cbc6916e2dcea95fe367098528298d5a4aa Mon Sep 17 00:00:00 2001 From: unohee Date: Thu, 20 Aug 2026 22:34:43 +0900 Subject: [PATCH 2/2] fix(openai): accept atomic tool arguments in streams --- .../openai-stream-to-anthropic.test.ts | 24 +++++++++++++++++++ src/protocol/openai-stream-to-anthropic.ts | 11 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/__tests__/openai-stream-to-anthropic.test.ts b/src/__tests__/openai-stream-to-anthropic.test.ts index 469b309..94385e6 100644 --- a/src/__tests__/openai-stream-to-anthropic.test.ts +++ b/src/__tests__/openai-stream-to-anthropic.test.ts @@ -165,4 +165,28 @@ describe("openAIStreamEventToAnthropicEvents", () => { content_block: { type: "tool_use", id: "call_2", name: "read_file", input: {} }, }); }); + + it("uses arguments from output_item.done when no argument events were sent", () => { + const normalizer = createOpenAIStreamToAnthropicNormalizer(); + normalizer.convert({ type: "response.created", response: { id: "resp_4" } }); + normalizer.convert({ + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", call_id: "call_3", name: "read_file" }, + }); + + expect(normalizer.convert({ + type: "response.output_item.done", + output_index: 0, + item: { type: "function_call", arguments: "{\"path\":\"README.md\"}" }, + })).toEqual([ + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: "{\"path\":\"README.md\"}" }, + }, + { type: "content_block_stop", index: 0 }, + ]); + }); + }); diff --git a/src/protocol/openai-stream-to-anthropic.ts b/src/protocol/openai-stream-to-anthropic.ts index b0f63c3..4a322a9 100644 --- a/src/protocol/openai-stream-to-anthropic.ts +++ b/src/protocol/openai-stream-to-anthropic.ts @@ -3,6 +3,7 @@ interface OpenAIStreamEventItem { type?: string; call_id?: string; name?: string; + arguments?: string; } interface OpenAIStreamEvent { @@ -135,7 +136,15 @@ export function createOpenAIStreamToAnthropicNormalizer(): OpenAIStreamToAnthrop } if (event.type === "response.output_item.done") { - return closeBlock(outputIndex); + const block = blocks.get(outputIndex); + const argumentEvent = block?.kind === "tool_use" && !block.sentArguments && event.item?.arguments + ? [{ + type: "content_block_delta", + index: block.index, + delta: { type: "input_json_delta", partial_json: event.item.arguments }, + }] + : []; + return [...argumentEvent, ...closeBlock(outputIndex)]; } if (event.type === "response.completed") {