@@ -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. */
1439export 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