mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
4e9c6dbda1
commit
f9f1f20e99
@ -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')
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
25
src/server/proxy/transform/toolArguments.ts
Normal file
25
src/server/proxy/transform/toolArguments.ts
Normal 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)
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user