Skip to content

Commit 1a076af

Browse files
committed
fix(ai): reject ambiguous tool deltas
1 parent 138d71e commit 1a076af

2 files changed

Lines changed: 61 additions & 8 deletions

File tree

‎packages/ai/src/protocols/openai-chat.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,6 @@ export interface ParserState {
239239
readonly reasoningDetails: Array<unknown>
240240
readonly reasoningDetailsObserved: boolean
241241
readonly reasoningEmitted: boolean
242-
readonly latestToolIndex?: number
243242
readonly nextToolIndex: number
244243
}
245244

@@ -697,7 +696,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
697696
const toolDeltas = delta?.tool_calls ?? []
698697
let tools = state.tools
699698
let pendingTools = state.pendingTools
700-
let latestToolIndex = state.latestToolIndex
701699
let nextToolIndex = state.nextToolIndex
702700

703701
let lifecycle = state.lifecycle
@@ -742,11 +740,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
742740
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
743741
}
744742

745-
// Compatible providers may omit indexes. Prefer durable identity, then use
746-
// batch position for parallel deltas or the latest call for sparse chunks.
743+
// Compatible providers may omit indexes. Prefer durable identity, use batch
744+
// position for parallel deltas, and only infer a singleton continuation
745+
// when exactly one call is active.
747746
for (const [position, tool] of toolDeltas.entries()) {
748747
const matched = toolIndexByID(tools, pendingTools, tool.id || undefined)
749-
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
748+
const active = Object.keys({ ...pendingTools, ...tools }).map(Number)
749+
if ((tool.index === undefined || tool.index === null) && matched === undefined && !tool.id && active.length > 1)
750+
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta has an ambiguous index")
751+
const fallback = toolDeltas.length > 1 ? position : (active[0] ?? position)
750752
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
751753
const index =
752754
tool.index ?? matched ??
@@ -756,7 +758,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
756758
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
757759
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
758760
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
759-
latestToolIndex = index
760761
nextToolIndex = Math.max(nextToolIndex, index + 1)
761762
if (!current && (!id || !name)) {
762763
pendingTools = {
@@ -804,7 +805,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
804805
reasoningDetails: state.reasoningDetails,
805806
reasoningDetailsObserved,
806807
reasoningEmitted,
807-
latestToolIndex,
808808
nextToolIndex,
809809
},
810810
events,

‎packages/ai/test/provider/openai-compatible-chat.test.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ describe("OpenAI-compatible Chat route", () => {
312312
{ index: null, id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
313313
],
314314
}),
315-
deltaChunk({ tool_calls: [{ function: { arguments: 'London"}' } }] }),
315+
deltaChunk({ tool_calls: [{ id: "call_london", function: { arguments: 'London"}' } }] }),
316316
deltaChunk({ tool_calls: [{ id: "call_paris", function: { arguments: 'Paris"}' } }] }),
317317
deltaChunk({}, "tool_calls"),
318318
),
@@ -327,6 +327,59 @@ describe("OpenAI-compatible Chat route", () => {
327327
}),
328328
)
329329

330+
it.effect("assembles one indexless tool call across chunks", () =>
331+
Effect.gen(function* () {
332+
const response = yield* LLMClient.generate(
333+
LLMRequest.update(request, {
334+
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
335+
}),
336+
).pipe(
337+
Effect.provide(
338+
fixedResponse(
339+
sseEvents(
340+
deltaChunk({
341+
tool_calls: [{ id: "call_paris", function: { name: "weather", arguments: '{"city":"' } }],
342+
}),
343+
deltaChunk({ tool_calls: [{ function: { arguments: 'Paris"}' } }] }),
344+
deltaChunk({}, "tool_calls"),
345+
),
346+
),
347+
),
348+
)
349+
350+
expect(response.toolCalls).toMatchObject([
351+
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
352+
])
353+
}),
354+
)
355+
356+
it.effect("rejects ambiguous indexless parallel tool deltas", () =>
357+
Effect.gen(function* () {
358+
const error = yield* LLMClient.generate(
359+
LLMRequest.update(request, {
360+
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
361+
}),
362+
).pipe(
363+
Effect.provide(
364+
fixedResponse(
365+
sseEvents(
366+
deltaChunk({
367+
tool_calls: [
368+
{ id: "call_paris", function: { name: "weather", arguments: '{"city":"' } },
369+
{ id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
370+
],
371+
}),
372+
deltaChunk({ tool_calls: [{ function: { arguments: 'Paris"}' } }] }),
373+
),
374+
),
375+
),
376+
Effect.flip,
377+
)
378+
379+
expect(error.message).toContain("OpenAI Chat tool call delta has an ambiguous index")
380+
}),
381+
)
382+
330383
it.effect("treats an empty finish reason as terminal", () =>
331384
Effect.gen(function* () {
332385
const response = yield* LLMClient.generate(request).pipe(

0 commit comments

Comments
 (0)