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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-06 11:39:10 +08:00
parent 4e9c6dbda1
commit f9f1f20e99
8 changed files with 134 additions and 29 deletions

View File

@ -124,6 +124,27 @@ describe('openaiChatStreamToAnthropic', () => {
expect((msgDelta.data.delta as Record<string, unknown>).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<string, unknown>)?.type === 'input_json_delta',
)
expect(jsonDeltas).toHaveLength(1)
expect((jsonDeltas[0].data.delta as Record<string, unknown>).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')

View File

@ -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',

View File

@ -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,
})
}
}

View File

@ -4,6 +4,8 @@
* Original work by Jason Young, MIT License
*/
import { stringifyOpenAIToolArguments } from '../transform/toolArguments.js'
type StreamState = {
nextContentIndex: number
indexByKey: Map<string, number> // 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,

View File

@ -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<string, unknown> = {}
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),
})
}
}

View File

@ -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<string, unknown> = {}
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
}

View File

@ -0,0 +1,25 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseOpenAIToolArguments(value: unknown): Record<string, unknown> {
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)
}

View File

@ -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 = {