fix(ai): reject unknown chat finishes (#46036)

This commit is contained in:
Aiden Cline 2026-08-28 18:22:47 -05:00 committed by GitHub
parent 1c9c5305a6
commit a5f8869b35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 64 additions and 6 deletions

View file

@ -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),
}),
})
}
})

View file

@ -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<ReadonlyArray<LLMEvent>>([])
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(

View file

@ -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" })
}),
)