ix(ai): reject incomplete pending tool calls
Only finalize pending Responses tool calls after response.completed. Reject incomplete responses with pending calls so partial input cannot be dispatched.
This commit is contained in:
parent
8952cef65f
commit
b77dbb511e
2 changed files with 36 additions and 2 deletions
|
|
@ -882,7 +882,10 @@ const onResponseFinish = Effect.fn("OpenAIResponses.onResponseFinish")(function*
|
||||||
state: ParserState,
|
state: ParserState,
|
||||||
event: OpenAIResponsesEvent,
|
event: OpenAIResponsesEvent,
|
||||||
) {
|
) {
|
||||||
const pending = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
const pending =
|
||||||
|
event.type === "response.completed"
|
||||||
|
? yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||||
|
: { tools: state.tools, events: NO_EVENTS }
|
||||||
const events = [...pending.events]
|
const events = [...pending.events]
|
||||||
const hasFunctionCall = state.hasFunctionCall || pending.events.some(LLMEvent.is.toolCall)
|
const hasFunctionCall = state.hasFunctionCall || pending.events.some(LLMEvent.is.toolCall)
|
||||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||||
|
|
@ -944,6 +947,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||||
|
if (event.type === "response.incomplete" && Object.keys(state.tools).length > 0)
|
||||||
|
return ProviderShared.eventError(ADAPTER, "OpenAI Responses response incomplete with pending tool calls")
|
||||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||||
if (event.type === "response.failed") return providerError(event, "OpenAI Responses response failed")
|
if (event.type === "response.failed") return providerError(event, "OpenAI Responses response failed")
|
||||||
if (event.type === "error") return providerError(event, "OpenAI Responses stream error")
|
if (event.type === "error") return providerError(event, "OpenAI Responses stream error")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
|
|
@ -1286,6 +1286,35 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("rejects an incomplete pending function call", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "patch", arguments: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.incomplete",
|
||||||
|
response: { incomplete_details: { reason: "max_output_tokens" } },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
const error = yield* LLMClient.stream(
|
||||||
|
LLM.updateRequest(request, {
|
||||||
|
tools: [{ name: "patch", description: "Apply a patch", inputSchema: { type: "object" } }],
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
|
||||||
|
Effect.flip,
|
||||||
|
Effect.provide(fixedResponse(body)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||||
|
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||||
|
expect(error.message).toContain("OpenAI Responses response incomplete with pending tool calls")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const item = {
|
const item = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue