diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 5948e3f90bc..43a86b4403c 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -285,6 +285,7 @@ export const Event = Schema.StructWithRest( Schema.Struct({ type: Schema.String, delta: Schema.optional(Schema.String), + arguments: Schema.optional(Schema.String), text: Schema.optional(Schema.String), item_id: Schema.optional(Schema.String), summary_index: Schema.optional(Schema.Number), @@ -996,12 +997,24 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu state: ParserState, event: Event, ) { - if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult + if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult + const tool = state.tools[event.item_id] + if (!tool) return [state, NO_EVENTS] satisfies StepResult + const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined + if (event.type === "response.function_call_arguments.done" && final === undefined) + return [state, NO_EVENTS] satisfies StepResult + if (final !== undefined && !final.startsWith(tool.input)) + return [ + { ...state, tools: ToolStream.start(state.tools, event.item_id, { ...tool, input: final }) }, + NO_EVENTS, + ] satisfies StepResult + const delta = final === undefined ? event.delta : final.slice(tool.input.length) + if (!delta) return [state, NO_EVENTS] satisfies StepResult const result = ToolStream.appendExisting( state.id, state.tools, event.item_id, - event.delta, + delta, `${state.name} tool argument delta is missing its tool call`, ) if (ToolStream.isError(result)) return yield* result @@ -1212,7 +1225,7 @@ export const step = (state: ParserState, event: Event) => { return ProviderShared.eventError(state.id, `${event.type} message is missing id`) return Effect.succeed(onOutputItemAdded(state, event)) } - if (event.type === "response.function_call_arguments.delta") + if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done") return event.item_id ? onFunctionCallArgumentsDelta(state, event) : ProviderShared.eventError(state.id, `${event.type} is missing item_id`) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 193d1a15ed5..1d1e2966347 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -1986,6 +1986,11 @@ describe("OpenAI Responses route", () => { item_id: "fc_missing", delta: '{"orphaned":true}', }, + { + type: "response.function_call_arguments.done", + item_id: "fc_missing", + arguments: '{"orphaned":true}', + }, { type: "response.completed", response: { id: "resp_1" } }, ), ), @@ -1998,22 +2003,22 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("rejects function argument deltas without the spec-required item id", () => + it.effect("rejects function argument events without the spec-required item id", () => Effect.gen(function* () { - const error = yield* LLMClient.generate(request).pipe( - Effect.provide( - fixedResponse( - sseEvents( - { type: "response.function_call_arguments.delta", delta: "{}" }, - { type: "response.completed", response: { id: "resp_1" } }, - ), - ), - ), - Effect.flip, - ) + const events = [ + { type: "response.function_call_arguments.delta", delta: "{}" }, + { type: "response.function_call_arguments.done", arguments: "{}" }, + ] - expect(error.reason._tag).toBe("InvalidProviderOutput") - expect(error.message).toContain("response.function_call_arguments.delta is missing item_id") + for (const event of events) { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))), + Effect.flip, + ) + + expect(error.reason._tag).toBe("InvalidProviderOutput") + expect(error.message).toContain(`${event.type} is missing item_id`) + } }), ) @@ -2857,6 +2862,172 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("emits only missing function arguments from the arguments done event", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' }, + { + type: "response.function_call_arguments.done", + item_id: "fc_item_1", + arguments: '{"query":"weather"}', + }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "fc_item_1", + call_id: "call_1", + name: "lookup", + arguments: '{"query":"weather"}', + }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } }, + ]) + expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1) + expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([ + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: { openai: { itemId: "fc_item_1" } }, + }, + ]) + }), + ) + + it.effect("streams complete function arguments supplied only by the arguments done event", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { + type: "response.function_call_arguments.done", + item_id: "fc_item_1", + arguments: '{"query":"weather"}', + }, + { type: "response.completed", response: { id: "resp_1" } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([ + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query":"weather"}', + input: { query: "weather" }, + }, + ]) + expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } }) + expect(response.finishReason.normalized).toBe("tool-calls") + }), + ) + + it.effect("does not repeat function arguments already supplied by deltas", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { + type: "response.function_call_arguments.delta", + item_id: "fc_item_1", + delta: '{"query":"weather"}', + }, + { + type: "response.function_call_arguments.done", + item_id: "fc_item_1", + arguments: '{"query":"weather"}', + }, + { type: "response.completed", response: { id: "resp_1" } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events.filter((event) => event.type === "tool-input-delta")).toHaveLength(1) + expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } }) + }), + ) + + it.effect("uses authoritative arguments done input without emitting a mismatched delta", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { + type: "response.function_call_arguments.delta", + item_id: "fc_item_1", + delta: '{"query":"streamed"}', + }, + { + type: "response.function_call_arguments.done", + item_id: "fc_item_1", + arguments: '{"query":"final"}', + }, + { type: "response.completed", response: { id: "resp_1" } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([ + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query":"streamed"}', + input: { query: "streamed" }, + }, + ]) + expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "final" } }) + }), + ) + + it.effect("lets completed output item arguments override the arguments done event", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { + type: "response.function_call_arguments.done", + item_id: "fc_item_1", + arguments: '{"query":"arguments-done"}', + }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "fc_item_1", + call_id: "call_1", + name: "lookup", + arguments: '{"query":"output-item-done"}', + }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "output-item-done" } }) + expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1) + }), + ) + it.effect("finalizes a pending function call at response completion", () => Effect.gen(function* () { const body = sseEvents(