From f9f1f20e99b0306f82ac0b02fe219a2947f02104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 6 May 2026 11:39:10 +0800 Subject: [PATCH] Fix local proxy tool calls for real provider responses Local OpenAI-compatible proxies can return function arguments as parsed objects instead of JSON strings. Preserve object-shaped arguments directly and serialize them for streaming deltas so Write, Bash, and Edit tool calls keep the fields required by Anthropic tool validation. Constraint: Local proxy implementations such as OneAPI/NewAPI may not preserve OpenAI's string-only arguments shape. Rejected: Normalize every upstream response through JSON.stringify before parsing | loses already-valid object identity and leaves streaming object deltas vulnerable to [object Object]. Confidence: high Scope-risk: narrow Directive: Do not assume function.arguments is always a string on provider responses or stream deltas. Tested: bun test src/server/__tests__/proxy-streaming.test.ts src/server/__tests__/proxy-transform.test.ts Tested: bun run check:server Tested: bun run check:native Tested: bun run quality:pr Tested: agent-browser with real Ollama qwen3:4b provider for Write/Bash/Edit and streaming Write Not-tested: Real Windows OneAPI/NewAPI instance from the issue reporters was not available locally. --- src/server/__tests__/proxy-streaming.test.ts | 21 +++++++ src/server/__tests__/proxy-transform.test.ts | 61 +++++++++++++++++++ .../streaming/openaiChatStreamToAnthropic.ts | 26 +++++--- .../openaiResponsesStreamToAnthropic.ts | 4 +- .../proxy/transform/openaiChatToAnthropic.ts | 9 +-- .../transform/openaiResponsesToAnthropic.ts | 9 +-- src/server/proxy/transform/toolArguments.ts | 25 ++++++++ src/server/proxy/transform/types.ts | 8 +-- 8 files changed, 134 insertions(+), 29 deletions(-) create mode 100644 src/server/proxy/transform/toolArguments.ts diff --git a/src/server/__tests__/proxy-streaming.test.ts b/src/server/__tests__/proxy-streaming.test.ts index edd198c7..56dd9ece 100644 --- a/src/server/__tests__/proxy-streaming.test.ts +++ b/src/server/__tests__/proxy-streaming.test.ts @@ -124,6 +124,27 @@ describe('openaiChatStreamToAnthropic', () => { expect((msgDelta.data.delta as Record).stop_reason).toBe('tool_use') }) + test('tool call streaming preserves object arguments from local proxies', async () => { + const sseChunks = [ + 'data: {"id":"c-write","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_write","type":"function","function":{"name":"Write","arguments":{"file_path":"/tmp/issue-288.txt","content":"ok"}}}]},"finish_reason":null}]}\n\n', + 'data: {"id":"c-write","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ] + + const events = await collectSse(openaiChatStreamToAnthropic(makeStream(sseChunks), 'gpt-4')) + const jsonDeltas = events.filter( + (e) => e.event === 'content_block_delta' && (e.data.delta as Record)?.type === 'input_json_delta', + ) + expect(jsonDeltas).toHaveLength(1) + expect((jsonDeltas[0].data.delta as Record).partial_json).toBe( + '{"file_path":"/tmp/issue-288.txt","content":"ok"}', + ) + + const blockStops = events.filter((e) => e.event === 'content_block_stop') + expect(blockStops).toHaveLength(1) + expect(blockStops[0].data.index).toBe(0) + }) + test('empty stream (just DONE)', async () => { const upstream = makeStream(['data: [DONE]\n\n']) const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4') diff --git a/src/server/__tests__/proxy-transform.test.ts b/src/server/__tests__/proxy-transform.test.ts index caa66109..8ca4263d 100644 --- a/src/server/__tests__/proxy-transform.test.ts +++ b/src/server/__tests__/proxy-transform.test.ts @@ -252,6 +252,41 @@ describe('openaiChatToAnthropic', () => { } }) + test('tool_calls response preserves object arguments from local proxies', () => { + const res: OpenAIChatResponse = { + id: 'chatcmpl-write', + object: 'chat.completion', + created: 1234567890, + model: 'gpt-4', + choices: [{ + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_write', + type: 'function', + function: { + name: 'Write', + arguments: { file_path: '/tmp/issue-288.txt', content: 'ok' }, + }, + }], + }, + finish_reason: 'tool_calls', + }], + } + + const result = openaiChatToAnthropic(res, 'gpt-4') + expect(result.content[0].type).toBe('tool_use') + if (result.content[0].type === 'tool_use') { + expect(result.content[0].name).toBe('Write') + expect(result.content[0].input).toEqual({ + file_path: '/tmp/issue-288.txt', + content: 'ok', + }) + } + }) + test('finish_reason mapping', () => { const make = (reason: string) => ({ id: 'x', object: 'chat.completion', created: 0, model: 'gpt-4', @@ -443,6 +478,32 @@ describe('openaiResponsesToAnthropic', () => { } }) + test('function_call preserves object arguments from local proxies', () => { + const res: OpenAIResponsesResponse = { + id: 'resp_write', + object: 'response', + created_at: 0, + model: 'gpt-4o', + status: 'completed', + output: [{ + type: 'function_call', + id: 'fc_write', + call_id: 'call_write', + name: 'Write', + arguments: { file_path: '/tmp/issue-288.txt', content: 'ok' }, + }], + } + + const result = openaiResponsesToAnthropic(res, 'gpt-4o') + expect(result.content[0].type).toBe('tool_use') + if (result.content[0].type === 'tool_use') { + expect(result.content[0].input).toEqual({ + file_path: '/tmp/issue-288.txt', + content: 'ok', + }) + } + }) + test('reasoning → thinking', () => { const res: OpenAIResponsesResponse = { id: 'resp_3', diff --git a/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts index 054ecc7f..5fc13fe8 100644 --- a/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts @@ -21,6 +21,7 @@ */ import type { OpenAIChatStreamChunk } from '../transform/types.js' +import { stringifyOpenAIToolArguments } from '../transform/toolArguments.js' // ─── Types ───────────────────────────────────────────────── @@ -230,12 +231,17 @@ function closeAllToolBlocks(state: StreamState): void { } } state.toolBlocks.clear() + if (state.currentBlockType === 'tool_use') { + state.blockStopSent = true + } } function closeAllOpenBlocks(state: StreamState): void { - // Close current text/thinking block - closeCurrentBlock(state) - // Close all tool blocks + // Close current text/thinking block. Tool blocks are tracked separately + // because providers can stream multiple tool calls in parallel. + if (state.currentBlockType !== 'tool_use') { + closeCurrentBlock(state) + } closeAllToolBlocks(state) } @@ -344,11 +350,9 @@ function processChunk(chunk: OpenAIChatStreamChunk, state: StreamState): void { if (transition) { // Handle block transition: close previous block if type changed if (transition.isNew && state.blockStartSent && !state.blockStopSent) { - if (transition.type !== 'tool_use') { - // For text/thinking, close the current block - closeCurrentBlock(state) + if (state.currentBlockType === 'tool_use' && transition.type !== 'tool_use') { + closeAllToolBlocks(state) } else if (state.currentBlockType !== 'tool_use') { - // Switching TO tool_use from text/thinking: close current closeCurrentBlock(state) } } @@ -421,13 +425,15 @@ function handleToolCalls(delta: DeltaEx, state: StreamState): void { const block = state.toolBlocks.get(tcIndex)! if (tc.id) block.id = tc.id if (tc.function?.name) block.name += tc.function.name - if (tc.function?.arguments) block.argsBuffer += tc.function.arguments + const argumentsDelta = stringifyOpenAIToolArguments(tc.function?.arguments) + if (argumentsDelta) block.argsBuffer += argumentsDelta // Start tool block once we have id + name if (!block.started && block.id && block.name) { block.started = true block.anthropicIndex = state.nextContentIndex++ state.currentBlockType = 'tool_use' + state.currentBlockIndex = block.anthropicIndex state.blockStartSent = true state.blockStopSent = false @@ -443,9 +449,9 @@ function handleToolCalls(delta: DeltaEx, state: StreamState): void { type: 'input_json_delta', partial_json: block.argsBuffer, }) } - } else if (block.started && tc.function?.arguments) { + } else if (block.started && argumentsDelta) { emitDelta(state, block.anthropicIndex, { - type: 'input_json_delta', partial_json: tc.function.arguments, + type: 'input_json_delta', partial_json: argumentsDelta, }) } } diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts index 66790a17..8721c037 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts @@ -4,6 +4,8 @@ * Original work by Jason Young, MIT License */ +import { stringifyOpenAIToolArguments } from '../transform/toolArguments.js' + type StreamState = { nextContentIndex: number indexByKey: Map // content part key → Anthropic index @@ -216,7 +218,7 @@ function processEvent( const index = state.toolIndexByItemId.get(itemId) if (index === undefined) break - const delta = (data.delta as string) || '' + const delta = stringifyOpenAIToolArguments(data.delta) controller.enqueue(encoder.encode(formatSse('content_block_delta', { type: 'content_block_delta', index, diff --git a/src/server/proxy/transform/openaiChatToAnthropic.ts b/src/server/proxy/transform/openaiChatToAnthropic.ts index bf4fd66f..f405525b 100644 --- a/src/server/proxy/transform/openaiChatToAnthropic.ts +++ b/src/server/proxy/transform/openaiChatToAnthropic.ts @@ -9,6 +9,7 @@ import type { AnthropicResponse, AnthropicContentBlock, } from './types.js' +import { parseOpenAIToolArguments } from './toolArguments.js' /** * Convert OpenAI Chat Completions response to Anthropic Messages response. @@ -49,17 +50,11 @@ export function openaiChatToAnthropic(response: OpenAIChatResponse, model: strin // Convert tool calls if (choice.message.tool_calls) { for (const tc of choice.message.tool_calls) { - let input: Record = {} - try { - input = JSON.parse(tc.function.arguments) - } catch { - input = { raw: tc.function.arguments } - } content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, - input, + input: parseOpenAIToolArguments(tc.function.arguments), }) } } diff --git a/src/server/proxy/transform/openaiResponsesToAnthropic.ts b/src/server/proxy/transform/openaiResponsesToAnthropic.ts index 70a82265..c75c5558 100644 --- a/src/server/proxy/transform/openaiResponsesToAnthropic.ts +++ b/src/server/proxy/transform/openaiResponsesToAnthropic.ts @@ -10,6 +10,7 @@ import type { AnthropicResponse, AnthropicContentBlock, } from './types.js' +import { parseOpenAIToolArguments } from './toolArguments.js' /** * Convert OpenAI Responses API response to Anthropic Messages response. @@ -56,17 +57,11 @@ function convertOutputItem(item: OpenAIResponsesOutputItem, content: AnthropicCo break } case 'function_call': { - let input: Record = {} - try { - input = JSON.parse(item.arguments) - } catch { - input = { raw: item.arguments } - } content.push({ type: 'tool_use', id: item.call_id, name: item.name, - input, + input: parseOpenAIToolArguments(item.arguments), }) break } diff --git a/src/server/proxy/transform/toolArguments.ts b/src/server/proxy/transform/toolArguments.ts new file mode 100644 index 00000000..56c83172 --- /dev/null +++ b/src/server/proxy/transform/toolArguments.ts @@ -0,0 +1,25 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseOpenAIToolArguments(value: unknown): Record { + if (value == null || value === '') return {} + + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown + return isRecord(parsed) ? parsed : { raw: parsed } + } catch { + return { raw: value } + } + } + + if (isRecord(value)) return value + + return { raw: value } +} + +export function stringifyOpenAIToolArguments(value: unknown): string { + if (value == null || value === '') return '' + return typeof value === 'string' ? value : JSON.stringify(value) +} diff --git a/src/server/proxy/transform/types.ts b/src/server/proxy/transform/types.ts index da40132a..6196989f 100644 --- a/src/server/proxy/transform/types.ts +++ b/src/server/proxy/transform/types.ts @@ -23,7 +23,7 @@ export type OpenAIToolCall = { type: 'function' function: { name: string - arguments: string + arguments: unknown } } @@ -90,7 +90,7 @@ export type OpenAIChatStreamChunk = { type?: string function?: { name?: string - arguments?: string + arguments?: unknown } }> } @@ -103,7 +103,7 @@ export type OpenAIChatStreamChunk = { export type OpenAIResponsesInputItem = | { type: 'message'; role: 'user' | 'assistant' | 'system'; content: string | OpenAIChatContentPart[] } - | { type: 'function_call'; call_id: string; name: string; arguments: string } + | { type: 'function_call'; call_id: string; name: string; arguments: unknown } | { type: 'function_call_output'; call_id: string; output: string } export type OpenAIResponsesRequest = { @@ -127,7 +127,7 @@ export type OpenAIResponsesRequest = { export type OpenAIResponsesOutputItem = | { type: 'message'; role: string; content: Array<{ type: string; text?: string; refusal?: string }> } - | { type: 'function_call'; id: string; call_id: string; name: string; arguments: string } + | { type: 'function_call'; id: string; call_id: string; name: string; arguments: unknown } | { type: 'reasoning'; id: string; summary?: Array<{ type: string; text: string }> } export type OpenAIResponsesResponse = {