Skip to content

Commit cd84e9a

Browse files
committed
fix: OpenAI 兼容解码接受省略 tool_calls[].index 的工具调用帧
OpenAI 规范里 index 只对流式片段必需;Google 的 OpenAI 兼容层把每个调用 一次性给全且不带 index,以前被判 invalid tool call,表现为用 Gemini 一调工具就整轮中止、正文为空。省略 index 时改由 slotForToolCall 按已见 id / position 占用情况归格,并行调用各归各格。
1 parent 94112fd commit cd84e9a

2 files changed

Lines changed: 73 additions & 5 deletions

File tree

‎src/main/kernel/upstream/__tests__/decode-openai.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,37 @@ describe('OpenAI Chat Completions decoding', () => {
7171
expect(accumulated(output).calls).toEqual([{ ok: true, callId: 'call-1', name: 'Echo', input: { text: 'ok' } }])
7272
})
7373

74+
it('accepts a frozen tool call that omits tool_calls[].index', async () => {
75+
// ★ 逐字复刻 Google 的 OpenAI 兼容层实测分片:一个调用一次性给全,没有 index,
76+
// 还多带一个我们不认识的 extra_content。以前这一帧被判成 invalid tool call,
77+
// 症状是「用 Gemini 只要调工具就整轮中止、正文为空」。
78+
const output = await collect(decodeOpenAIChat(events(
79+
chunk({ role: 'assistant', content: '' }),
80+
chunk({ tool_calls: [{
81+
extra_content: { google: { thought_signature: 'sig' } },
82+
function: { arguments: '{"text":"一"}', name: 'Echo' },
83+
id: 'call_297140', type: 'function'
84+
}] }),
85+
chunk({}, 'tool_calls'), '[DONE]'
86+
)))
87+
expect(accumulated(output).calls).toEqual([
88+
{ ok: true, callId: 'call_297140', name: 'Echo', input: { text: '一' } }
89+
])
90+
expect(output.at(-1)).toMatchObject({ stopReason: 'tool_use' })
91+
})
92+
93+
it('keeps index-less parallel tool calls in separate slots', async () => {
94+
const output = await collect(decodeOpenAIChat(events(
95+
chunk({ tool_calls: [{ id: 'call-a', function: { name: 'Echo', arguments: '{"text":"一"}' } }] }),
96+
chunk({ tool_calls: [{ id: 'call-b', function: { name: 'Echo', arguments: '{"text":"二"}' } }] }),
97+
chunk({}, 'tool_calls'), '[DONE]'
98+
)))
99+
expect(accumulated(output).calls).toEqual([
100+
{ ok: true, callId: 'call-a', name: 'Echo', input: { text: '一' } },
101+
{ ok: true, callId: 'call-b', name: 'Echo', input: { text: '二' } }
102+
])
103+
})
104+
74105
it('accepts a complete JSON response with message.tool_calls', async () => {
75106
const output = await collect(decodeOpenAIChat(events({ model: 'deepseek-test', choices: [{ index: 0, finish_reason: 'tool_calls', message: {
76107
reasoning_content: '先查一下。', content: null,

‎src/main/kernel/upstream/decode/openai-chat.ts‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ interface Call {
1010
started: boolean
1111
}
1212

13+
/**
14+
* 上游这一条 `tool_calls` 该并进哪一格。**只在它没给 `index` 时用。**
15+
*
16+
* ★★ 三步,顺序不能换 —— 每一步都对应一种真实存在的上游形状:
17+
*
18+
* 1. **同 id 的调用已经存在** → 并进去。`id` 是上游自己给的标识,比位置权威;
19+
* 它还在分片累积(`started === false`)时可能只对上前缀,所以那时按前缀认。
20+
* 2. **position 那一格还可用** → 用它。空着是常态;里面那个调用**还没 started**
21+
* 也算可用 —— 那说明它的 id/name 正在累积,这一条就是它的下一片。
22+
* 3. 否则**另开一格**。省略 index 的上游把并行调用一条一条发,第二条的 position
23+
* 同样是 0 —— 并进去会把两个调用串成一个:参数被拼成非法 JSON,而且回传时
24+
* 少一个 tool_use id,下一轮直接 400。
25+
*/
26+
function slotForToolCall(calls: Map<number, Call>, id: string, position: number): number {
27+
if (id !== '') {
28+
for (const [key, call] of calls) {
29+
if (call.id !== '' && (call.id === id || (!call.started && id.startsWith(call.id)))) return key
30+
}
31+
}
32+
const held = calls.get(position)
33+
if (held === undefined || !held.started || held.id === '' || held.id === id) return position
34+
// 走到这里 calls 至少有一格(held 就在里面),所以 Math.max 收不到空集合。
35+
return Math.max(...calls.keys()) + 1
36+
}
37+
1338
/** Chat Completions flattens text/reasoning and independently indexes tool calls. */
1439
export async function* decodeOpenAIChat(events: AsyncIterable<SseEvent>): AsyncGenerator<ProviderStreamEvent> {
1540
const usage = new OpenAIUsage()
@@ -74,9 +99,23 @@ export async function* decodeOpenAIChat(events: AsyncIterable<SseEvent>): AsyncG
7499
for (const [position, raw] of delta.tool_calls.entries()) {
75100
const tool = record(raw)
76101
const fn = record(tool?.function)
77-
// Non-streaming message.tool_calls omits index; streaming deltas must identify it.
78-
const upstreamIndex = count(tool?.index) ?? (choice.message !== undefined ? position : undefined)
79-
if (tool === undefined || (tool.function !== undefined && fn === undefined) || upstreamIndex === undefined
102+
const id = string(tool?.id) ?? ''
103+
const name = string(fn?.name) ?? ''
104+
/*
105+
需求:上游**合法地**省略 `tool_calls[].index` 时,这一帧也要能拼出调用。
106+
OpenAI 规范里 `index` 只对**流式片段**是必需的;而 Google 的 OpenAI
107+
兼容层把每个调用一次性给全(id/name/arguments 同帧到齐),一个 index
108+
都不带 —— 它没写错,是我们以前只认「片段」那一种形状。
109+
110+
不满足会怎样:第一次让 Gemini 调工具就得到
111+
「上游返回的响应格式不完整或不符合协议…(invalid tool call)」,
112+
正文一个字都没有,且 retryable:false,重试无用。
113+
实测分片形状与复现见
114+
`__tests__/decode-openai.test.ts` 的
115+
'accepts a frozen tool call that omits tool_calls[].index'。
116+
*/
117+
const upstreamIndex = count(tool?.index) ?? slotForToolCall(calls, id, position)
118+
if (tool === undefined || (tool.function !== undefined && fn === undefined)
80119
|| (tool.type !== undefined && tool.type !== 'function')) {
81120
yield { type: 'error', error: malformedResponse('invalid tool call') }
82121
return
@@ -86,8 +125,6 @@ export async function* decodeOpenAIChat(events: AsyncIterable<SseEvent>): AsyncG
86125
call = { index: nextIndex++, id: '', name: '', args: '', started: false }
87126
calls.set(upstreamIndex, call)
88127
}
89-
const id = string(tool.id) ?? ''
90-
const name = string(fn?.name) ?? ''
91128
if (call.started && ((id !== '' && id !== call.id) || (name !== '' && name !== call.name))) {
92129
yield { type: 'error', error: malformedResponse('tool identity changed during streaming') }
93130
return

0 commit comments

Comments
 (0)