From a5f8869b3533f0b7203d472c48baaa8ffb9f59b1 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:22:47 -0500 Subject: [PATCH] fix(ai): reject unknown chat finishes (#46036) --- packages/ai/src/protocols/openai-chat.ts | 7 ++- packages/ai/test/provider/openai-chat.test.ts | 44 +++++++++++++++++++ .../provider/openai-compatible-chat.test.ts | 19 +++++--- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 1e830a15560..4a1e0d9d03a 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -815,7 +815,12 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event case "tool_calls": return "tool-calls" as const default: - return "unknown" as const + return yield* new AIError({ + reason: new UnknownProviderError({ + message: `Provider finish_reason: ${reason}`, + body: ProviderShared.encodeJson(event), + }), + }) } }) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index aeb907d7615..376c6a7b87e 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -1572,6 +1572,50 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("rejects unknown finish reasons without finalizing streamed tool calls", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }], + }), + deltaChunk({}, "future_reason"), + ) + const events = yield* Ref.make>([]) + const error = yield* LLMClient.stream( + LLMRequest.update(request, { + tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], + }), + ).pipe( + Stream.tap((event) => Ref.update(events, (current) => [...current, event])), + Stream.runDrain, + Effect.provide(fixedResponse(body)), + Effect.flip, + ) + + expect(error).toMatchObject({ + reason: { _tag: "UnknownProvider" }, + message: "Provider finish_reason: future_reason", + }) + expect(yield* Ref.get(events)).toEqual([ + { type: "step-start", index: 0 }, + { + type: "tool-input-start", + id: "call_1", + name: "lookup", + providerExecuted: undefined, + providerMetadata: undefined, + }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query":"weather"', + input: { query: "weather" }, + }, + ]) + }), + ) + it.effect("ignores empty identity fields on later tool call deltas", () => Effect.gen(function* () { const body = sseEvents( diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index a37739cfeba..722fba2e845 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -554,6 +554,19 @@ describe("OpenAI-compatible Chat route", () => { reason: { _tag: "UnknownProvider" }, message: "Provider reported an error (finish_reason: error)", }) + + const unknown = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))), + Effect.flip, + ) + expect(unknown).toMatchObject({ + reason: { _tag: "UnknownProvider" }, + message: "Provider finish_reason: future_reason", + }) + expect(decodeJson(unknown.reason.body ?? "")).toMatchObject({ + id: "chatcmpl_fixture", + choices: [{ finish_reason: "future_reason" }], + }) }), ) @@ -581,17 +594,13 @@ describe("OpenAI-compatible Chat route", () => { }), ) - it.effect("preserves provider finish outcomes in the common reason algebra", () => + it.effect("preserves content-filter finishes in the common reason algebra", () => Effect.gen(function* () { const filtered = yield* LLMClient.generate(request).pipe( Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))), ) - const future = yield* LLMClient.generate(request).pipe( - Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))), - ) expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" }) - expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" }) }), )