diff --git a/packages/ai/example/tutorial.ts b/packages/ai/example/tutorial.ts index b109ef6230e..3fc0603b734 100644 --- a/packages/ai/example/tutorial.ts +++ b/packages/ai/example/tutorial.ts @@ -78,7 +78,10 @@ const streamText = LLM.stream(request).pipe( Stream.tap((event) => Effect.sync(() => { if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) - if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`) + if (event.type === "finish") + process.stdout.write( + `\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`, + ) }), ), Stream.runDrain, @@ -194,7 +197,7 @@ const FakeProtocol = Protocol.make({ event: Schema.String, initial: () => undefined, step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), - onHalt: () => [{ type: "finish", reason: "stop" }], + onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }], }, }) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index fc4ce9ab539..4f5ad902a03 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -601,7 +601,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques // ============================================================================= const mapFinishReason = (reason: string | null | undefined): FinishReason => { if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop" - if (reason === "max_tokens") return "length" + if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length" if (reason === "tool_use") return "tool-calls" if (reason === "refusal") return "content-filter" return "unknown" @@ -836,7 +836,10 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult = const usage = mergeUsage(state.usage, mapUsage(event.usage)) const events: LLMEvent[] = [] const lifecycle = Lifecycle.finish(state.lifecycle, events, { - reason: mapFinishReason(event.delta?.stop_reason), + reason: { + normalized: mapFinishReason(event.delta?.stop_reason), + raw: event.delta?.stop_reason ?? undefined, + }, usage, providerMetadata: event.delta?.stop_sequence ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 8393cd511f0..0f0316c5179 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -8,6 +8,7 @@ import { Usage, type CacheHint, type FinishReason, + type FinishReasonDetails, type JsonSchema, type LLMRequest, type ModelToolSchemaCompatibility, @@ -435,9 +436,10 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: // ============================================================================= const mapFinishReason = (reason: string): FinishReason => { if (reason === "end_turn" || reason === "stop_sequence") return "stop" - if (reason === "max_tokens") return "length" + if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length" if (reason === "tool_use") return "tool-calls" if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter" + if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error" return "unknown" } @@ -466,7 +468,7 @@ interface ParserState { // Bedrock splits the finish into `messageStop` (carries `stopReason`) and // `metadata` (carries usage). Hold the terminal event in state so `onHalt` // can emit exactly one finish after both chunks have had a chance to arrive. - readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined + readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined readonly hasToolCalls: boolean readonly lifecycle: Lifecycle.State readonly reasoningSignatures: Readonly> @@ -583,7 +585,13 @@ const step = (state: ParserState, event: BedrockEvent) => return [ { ...state, - pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage }, + pendingFinish: { + reason: { + normalized: mapFinishReason(event.messageStop.stopReason), + raw: event.messageStop.stopReason, + }, + usage: state.pendingFinish?.usage, + }, }, [], ] as const @@ -591,7 +599,16 @@ const step = (state: ParserState, event: BedrockEvent) => if (event.metadata) { const usage = mapUsage(event.metadata.usage) - return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const + return [ + { + ...state, + pendingFinish: { + reason: state.pendingFinish?.reason ?? { normalized: "stop" }, + usage, + }, + }, + [], + ] as const } const exception = ( @@ -624,8 +641,13 @@ const onHalt = (state: ParserState): ReadonlyArray => ? (() => { const events: LLMEvent[] = [] Lifecycle.finish(state.lifecycle, events, { - reason: - state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason, + reason: { + ...state.pendingFinish.reason, + normalized: + state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls + ? "tool-calls" + : state.pendingFinish.reason.normalized, + }, usage: state.pendingFinish.usage, }) return events diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 57ad0660372..1c285da235d 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -382,10 +382,22 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean finishReason === "SAFETY" || finishReason === "BLOCKLIST" || finishReason === "PROHIBITED_CONTENT" || - finishReason === "SPII" + finishReason === "SPII" || + finishReason === "MODEL_ARMOR" || + finishReason === "IMAGE_PROHIBITED_CONTENT" || + finishReason === "IMAGE_RECITATION" || + finishReason === "LANGUAGE" ) return "content-filter" - if (finishReason === "MALFORMED_FUNCTION_CALL") return "error" + if ( + finishReason === "MALFORMED_FUNCTION_CALL" || + finishReason === "UNEXPECTED_TOOL_CALL" || + finishReason === "NO_IMAGE" || + finishReason === "TOO_MANY_TOOL_CALLS" || + finishReason === "MISSING_THOUGHT_SIGNATURE" || + finishReason === "MALFORMED_RESPONSE" + ) + return "error" return "unknown" } @@ -402,7 +414,10 @@ const finish = (state: ParserState): ReadonlyArray => ) : state.lifecycle Lifecycle.finish(lifecycle, events, { - reason: mapFinishReason(state.finishReason, state.hasToolCalls), + reason: { + normalized: mapFinishReason(state.finishReason, state.hasToolCalls), + raw: state.finishReason, + }, usage: state.usage, }) return events diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index e423137fca0..f0f5dbfb6b5 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -5,9 +5,11 @@ import { Endpoint } from "../route/endpoint" import { HttpTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMError, LLMEvent, Usage, type FinishReason, + type FinishReasonDetails, type JsonSchema, type LLMRequest, type MediaPart, @@ -17,6 +19,7 @@ import { type ToolDefinition, type ToolContent, } from "../schema" +import { classifyProviderFailure } from "../provider-error" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" @@ -164,11 +167,18 @@ const OpenAIChatDelta = Schema.StructWithRest( const OpenAIChatChoice = Schema.Struct({ delta: optionalNull(OpenAIChatDelta), finish_reason: optionalNull(Schema.String), + native_finish_reason: optionalNull(Schema.String), +}) + +const OpenAIChatError = Schema.Struct({ + code: optionalNull(Schema.Union([Schema.String, Schema.Number])), + message: Schema.String, }) export const OpenAIChatEvent = Schema.Struct({ - choices: Schema.Array(OpenAIChatChoice), + choices: optionalNull(Schema.Array(OpenAIChatChoice)), usage: optionalNull(OpenAIChatUsage), + error: optionalNull(OpenAIChatError), }) export type OpenAIChatEvent = Schema.Schema.Type type OpenAIChatRequestMessage = LLMRequest["messages"][number] @@ -184,7 +194,7 @@ export interface ParserState { readonly pendingTools: Partial> readonly toolCallEvents: ReadonlyArray readonly usage?: Usage - readonly finishReason?: FinishReason + readonly finishReason?: FinishReasonDetails readonly lifecycle: Lifecycle.State readonly reasoningField?: string readonly reasoningDetails: Array @@ -439,6 +449,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { if (reason === "length") return "length" if (reason === "content_filter") return "content-filter" if (reason === "function_call" || reason === "tool_calls") return "tool-calls" + if (reason === "error") return "error" return "unknown" } @@ -532,10 +543,22 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado const step = (state: ParserState, event: OpenAIChatEvent) => Effect.gen(function* () { + if (event.error) + return yield* new LLMError({ + module: ADAPTER, + method: "stream", + reason: classifyProviderFailure({ + message: event.error.message, + code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code), + status: typeof event.error.code === "number" ? event.error.code : undefined, + }), + }) const events: LLMEvent[] = [] const usage = mapUsage(event.usage) ?? state.usage - const choice = event.choices[0] - const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason + const choice = event.choices?.[0] + const finishReason = choice?.finish_reason + ? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason } + : state.finishReason const delta = choice?.delta const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools @@ -627,7 +650,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const finishEvents = (state: ParserState): ReadonlyArray => { const events: LLMEvent[] = [] const hasToolCalls = state.toolCallEvents.length > 0 - const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason + const reason = state.finishReason + ? { + ...state.finishReason, + normalized: + state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized, + } + : undefined const metadata = reasoningMetadata( state.reasoningField, state.reasoningDetailsObserved ? state.reasoningDetails : undefined, diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 2973acf0005..53fc62d3a0b 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -979,7 +979,10 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const events: LLMEvent[] = [] const lifecycle = Lifecycle.finish(state.lifecycle, events, { - reason: mapFinishReason(event, state.hasFunctionCall), + reason: { + normalized: mapFinishReason(event, state.hasFunctionCall), + raw: event.response?.incomplete_details?.reason, + }, usage: mapUsage(event.response?.usage), providerMetadata: event.response?.id || event.response?.service_tier diff --git a/packages/ai/src/protocols/utils/lifecycle.ts b/packages/ai/src/protocols/utils/lifecycle.ts index 6d0189df9a2..761cff3690f 100644 --- a/packages/ai/src/protocols/utils/lifecycle.ts +++ b/packages/ai/src/protocols/utils/lifecycle.ts @@ -1,4 +1,4 @@ -import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema" +import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema" export interface State { readonly stepStarted: boolean @@ -81,7 +81,7 @@ export const finish = ( state: State, events: LLMEvent[], input: { - readonly reason: FinishReason + readonly reason: FinishReasonDetails readonly usage?: Usage readonly providerMetadata?: ProviderMetadata }, diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 18454b84707..d2195d8c190 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -191,10 +191,16 @@ export const ToolError = Schema.Struct({ }).annotate({ identifier: "LLM.Event.ToolError" }) export type ToolError = Schema.Schema.Type +export const FinishReasonDetails = Schema.Struct({ + normalized: FinishReason, + raw: Schema.optional(Schema.String), +}).annotate({ identifier: "LLM.FinishReasonDetails" }) +export type FinishReasonDetails = Schema.Schema.Type + export const StepFinish = Schema.Struct({ type: Schema.tag("step-finish"), index: Schema.Number, - reason: FinishReason, + reason: FinishReasonDetails, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.StepFinish" }) @@ -202,7 +208,7 @@ export type StepFinish = Schema.Schema.Type export const Finish = Schema.Struct({ type: Schema.tag("finish"), - reason: FinishReason, + reason: FinishReasonDetails, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.Finish" }) @@ -365,7 +371,7 @@ interface ResponseState { readonly events: ReadonlyArray readonly message: Message readonly usage?: Usage - readonly finishReason?: FinishReason + readonly finishReason?: FinishReasonDetails readonly textParts: Readonly> readonly reasoningParts: Readonly> readonly toolInputs: Readonly> @@ -393,7 +399,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => { return { ...state, events, - finishReason: state.finishReason ?? "error", + finishReason: state.finishReason ?? { normalized: "error" }, } } return { @@ -580,7 +586,7 @@ export class LLMResponse extends Schema.Class("LLM.Response")({ message: Message, events: Schema.Array(LLMEvent), usage: Schema.optional(Usage), - finishReason: FinishReason, + finishReason: FinishReasonDetails, }) { /** Concatenated assistant text assembled from streamed `text-delta` events. */ get text() { diff --git a/packages/ai/test/adapter.test.ts b/packages/ai/test/adapter.test.ts index 912d89d1e69..346013ced6d 100644 --- a/packages/ai/test/adapter.test.ts +++ b/packages/ai/test/adapter.test.ts @@ -40,7 +40,7 @@ const fakeFraming: FramingDef = { const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => event.type === "finish" - ? { type: "finish", reason: event.reason } + ? { type: "finish", reason: { normalized: event.reason } } : { type: "text-delta", id: "text-0", text: event.text } const fakeProtocol = Protocol.make({ diff --git a/packages/ai/test/lib/tool-runtime.ts b/packages/ai/test/lib/tool-runtime.ts index 55fce8e0010..56c7d91ea37 100644 --- a/packages/ai/test/lib/tool-runtime.ts +++ b/packages/ai/test/lib/tool-runtime.ts @@ -83,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => { const stepState = (events: ReadonlyArray) => { const assistantContent: ContentPart[] = [] const toolCalls: ToolCallPart[] = [] - let reason: Extract["reason"] = "unknown" + let reason: Extract["reason"] = { normalized: "unknown" } let usage: Usage | undefined let providerMetadata: ProviderMetadata | undefined diff --git a/packages/ai/test/llm.test.ts b/packages/ai/test/llm.test.ts index 64346a8bb03..ca882935856 100644 --- a/packages/ai/test/llm.test.ts +++ b/packages/ai/test/llm.test.ts @@ -191,7 +191,7 @@ describe("llm constructors", () => { LLMResponse.text({ events: [ { type: "text-delta", id: "text-0", text: "hi" }, - { type: "finish", reason: "stop" }, + { type: "finish", reason: { normalized: "stop" } }, ], }), ).toBe("hi") diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 0b319f573b8..a6fcd628a28 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -448,12 +448,50 @@ describe("Anthropic Messages route", () => { ]) expect(response.events.at(-1)).toMatchObject({ type: "finish", - reason: "stop", + reason: { normalized: "stop", raw: "end_turn" }, providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, }) }), ) + it.effect("maps context-window truncation to length", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "message_delta", + delta: { stop_reason: "model_context_window_exceeded" }, + usage: { output_tokens: 1 }, + }, + ), + ), + ), + ) + + expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" }) + }), + ) + + it.effect("preserves pause_turn while normalizing it to stop", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } }, + ), + ), + ), + ) + + expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" }) + }), + ) + it.effect("assembles streamed tool call input", () => Effect.gen(function* () { const body = sseEvents( @@ -503,10 +541,16 @@ describe("Anthropic Messages route", () => { providerExecuted: undefined, providerMetadata: undefined, }, - { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "step-finish", + index: 0, + reason: { normalized: "tool-calls", raw: "tool_use" }, + usage, + providerMetadata: undefined, + }, { type: "finish", - reason: "tool-calls", + reason: { normalized: "tool-calls", raw: "tool_use" }, providerMetadata: undefined, usage, }, @@ -674,7 +718,10 @@ describe("Anthropic Messages route", () => { }, }) expect(response.text).toBe("Found it.") - expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ + type: "finish", + reason: { normalized: "stop", raw: "end_turn" }, + }) }), ) diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index a7280e09cd9..809e74a0cb3 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -290,7 +290,10 @@ describe("Bedrock Converse route", () => { // `metadata` (carries usage). We consolidate them into a single // terminal `finish` event with both. expect(finishes).toHaveLength(1) - expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" }) + expect(finishes[0]).toMatchObject({ + type: "finish", + reason: { normalized: "stop", raw: "end_turn" }, + }) expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, @@ -299,6 +302,23 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("maps truncation and malformed output stop reasons", () => + Effect.gen(function* () { + const reasons = [ + ["model_context_window_exceeded", "length"], + ["malformed_model_output", "error"], + ["malformed_tool_use", "error"], + ] as const + + for (const [raw, normalized] of reasons) { + const response = yield* LLMClient.generate(baseRequest).pipe( + Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))), + ) + expect(response.finishReason).toEqual({ normalized, raw }) + } + }), + ) + it.effect("adds cache reads and writes to Bedrock input usage", () => Effect.gen(function* () { const body = eventStreamBody( @@ -362,7 +382,10 @@ describe("Bedrock Converse route", () => { { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ + type: "finish", + reason: { normalized: "tool-calls", raw: "tool_use" }, + }) }), ) @@ -388,7 +411,7 @@ describe("Bedrock Converse route", () => { name: "lookup", raw: '{"query":"partial', }) - expect(response.finishReason).toBe("tool-calls") + expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" }) }), ) diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 50b5dbb1d25..66cfb348202 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -373,10 +373,16 @@ describe("Gemini route", () => { { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, { type: "text-end", id: "text-0" }, - { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, + { + type: "step-finish", + index: 0, + reason: { normalized: "stop", raw: "STOP" }, + usage, + providerMetadata: undefined, + }, { type: "finish", - reason: "stop", + reason: { normalized: "stop", raw: "STOP" }, usage, }, ]) @@ -529,10 +535,16 @@ describe("Gemini route", () => { providerExecuted: undefined, providerMetadata: undefined, }, - { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "step-finish", + index: 0, + reason: { normalized: "tool-calls", raw: "STOP" }, + usage, + providerMetadata: undefined, + }, { type: "finish", - reason: "tool-calls", + reason: { normalized: "tool-calls", raw: "STOP" }, usage, }, ]) @@ -571,7 +583,10 @@ describe("Gemini route", () => { }, { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ + type: "finish", + reason: { normalized: "tool-calls", raw: "STOP" }, + }) }), ) @@ -591,9 +606,41 @@ describe("Gemini route", () => { ) expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) - expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" }) + expect(length.events.at(-1)).toMatchObject({ + type: "finish", + reason: { normalized: "length", raw: "MAX_TOKENS" }, + }) expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) - expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" }) + expect(filtered.events.at(-1)).toMatchObject({ + type: "finish", + reason: { normalized: "content-filter", raw: "SAFETY" }, + }) + }), + ) + + it.effect("maps current blocking and invalid-output finish reasons", () => + Effect.gen(function* () { + const reasons = [ + ["MODEL_ARMOR", "content-filter"], + ["IMAGE_PROHIBITED_CONTENT", "content-filter"], + ["IMAGE_RECITATION", "content-filter"], + ["LANGUAGE", "content-filter"], + ["UNEXPECTED_TOOL_CALL", "error"], + ["NO_IMAGE", "error"], + ["IMAGE_OTHER", "unknown"], + ["TOO_MANY_TOOL_CALLS", "error"], + ["MISSING_THOUGHT_SIGNATURE", "error"], + ["MALFORMED_RESPONSE", "error"], + ] as const + + for (const [raw, normalized] of reasons) { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })), + ), + ) + expect(response.finishReason).toEqual({ normalized, raw }) + } }), ) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 50bc34df15f..fb926c03080 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -569,10 +569,16 @@ describe("OpenAI Chat route", () => { { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, { type: "text-end", id: "text-0" }, - { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, + { + type: "step-finish", + index: 0, + reason: { normalized: "stop", raw: "stop" }, + usage, + providerMetadata: undefined, + }, { type: "finish", - reason: "stop", + reason: { normalized: "stop", raw: "stop" }, usage, }, ]) @@ -1037,8 +1043,14 @@ describe("OpenAI Chat route", () => { providerExecuted: undefined, providerMetadata: undefined, }, - { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, - { type: "finish", reason: "tool-calls", usage: undefined }, + { + type: "step-finish", + index: 0, + reason: { normalized: "tool-calls", raw: "tool_calls" }, + usage: undefined, + providerMetadata: undefined, + }, + { type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined }, ]) }), ) diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index 43ae283e9f7..f32b2bc2d9a 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -232,7 +232,10 @@ describe("OpenAI-compatible Chat route", () => { expect(response.text).toBe("Hello!") expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) - expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ + type: "finish", + reason: { normalized: "stop", raw: "stop" }, + }) }), ) }) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index ffbd3294341..72a2383b041 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -856,13 +856,13 @@ describe("OpenAI Responses route", () => { { type: "step-finish", index: 0, - reason: "stop", + reason: { normalized: "stop", raw: undefined }, providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, usage, }, { type: "finish", - reason: "stop", + reason: { normalized: "stop", raw: undefined }, providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, usage, }, @@ -887,11 +887,18 @@ describe("OpenAI Responses route", () => { const length = yield* generate({ reason: "max_output_tokens" }) const contentFilter = yield* generate({ reason: "content_filter" }) const unknown = yield* generate({}) + const custom = yield* generate({ reason: "provider_limit" }) - expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([ - "length", - "content-filter", - "unknown", + expect([ + length.finishReason, + contentFilter.finishReason, + unknown.finishReason, + custom.finishReason, + ]).toEqual([ + { normalized: "length", raw: "max_output_tokens" }, + { normalized: "content-filter", raw: "content_filter" }, + { normalized: "unknown", raw: undefined }, + { normalized: "unknown", raw: "provider_limit" }, ]) }), ) @@ -946,8 +953,8 @@ describe("OpenAI Responses route", () => { { type: "text-delta", id: "msg_1", text: "Hello" }, { type: "reasoning-end", id: "rs_1" }, { type: "text-end", id: "msg_1" }, - { type: "step-finish", index: 0, reason: "stop" }, - { type: "finish", reason: "stop" }, + { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } }, + { type: "finish", reason: { normalized: "stop", raw: undefined } }, ]) expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) expect(response.message.content).toEqual([ @@ -1038,8 +1045,8 @@ describe("OpenAI Responses route", () => { id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, - { type: "step-finish", index: 0, reason: "stop" }, - { type: "finish", reason: "stop" }, + { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } }, + { type: "finish", reason: { normalized: "stop", raw: undefined } }, ]) }), ) @@ -1422,10 +1429,16 @@ describe("OpenAI Responses route", () => { providerExecuted: undefined, providerMetadata: { openai: { itemId: "item_1" } }, }, - { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "step-finish", + index: 0, + reason: { normalized: "tool-calls", raw: undefined }, + usage, + providerMetadata: undefined, + }, { type: "finish", - reason: "tool-calls", + reason: { normalized: "tool-calls", raw: undefined }, providerMetadata: undefined, usage, }, @@ -1465,7 +1478,7 @@ describe("OpenAI Responses route", () => { name: "lookup", raw: '{"query":"partial', }) - expect(response.finishReason).toBe("tool-calls") + expect(response.finishReason.normalized).toBe("tool-calls") expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse() }), ) @@ -1492,7 +1505,7 @@ describe("OpenAI Responses route", () => { name: "lookup", raw: '{"query":"partial', }) - expect(response.finishReason).toBe("tool-calls") + expect(response.finishReason.normalized).toBe("tool-calls") }), ) diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts index ead7e7ea7c8..b4ac2fe2e52 100644 --- a/packages/ai/test/provider/openrouter.test.ts +++ b/packages/ai/test/provider/openrouter.test.ts @@ -4,6 +4,8 @@ import { LLM, Message } from "../../src" import { LLMClient } from "../../src/route" import * as OpenRouter from "../../src/providers/openrouter" import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" describe("OpenRouter", () => { it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () => @@ -54,6 +56,42 @@ describe("OpenRouter", () => { }), ) + it.effect("preserves the upstream provider finish reason", () => + Effect.gen(function* () { + const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6") + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }], + }), + ), + ), + ) + + expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" }) + }), + ) + + it.effect("fails on a mid-stream provider error", () => + Effect.gen(function* () { + const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini") + const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + error: { code: 502, message: "Provider disconnected" }, + }), + ), + ), + Effect.flip, + ) + + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(error.message).toContain("Provider disconnected") + }), + ) + it.effect("preserves manually supplied reasoning details", () => Effect.gen(function* () { const details = [ diff --git a/packages/ai/test/provider/pdf.recorded.test.ts b/packages/ai/test/provider/pdf.recorded.test.ts index c3827a651c1..1a4c65b7356 100644 --- a/packages/ai/test/provider/pdf.recorded.test.ts +++ b/packages/ai/test/provider/pdf.recorded.test.ts @@ -106,7 +106,7 @@ const readPdfRuntime = Tool.make({ }) const expectCode = (response: LLMResponse) => { - expect(response.finishReason).toBe("stop") + expect(response.finishReason.normalized).toBe("stop") expect(response.text.toUpperCase()).toContain(CODE) } @@ -166,7 +166,7 @@ describe("PDF recorded", () => { tools: { read_pdf: readPdfRuntime }, }).pipe(Stream.runCollect), ) - expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) + expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } }) expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE) return } diff --git a/packages/ai/test/recorded-scenarios.ts b/packages/ai/test/recorded-scenarios.ts index daf57cae240..cd762dc4fd9 100644 --- a/packages/ai/test/recorded-scenarios.ts +++ b/packages/ai/test/recorded-scenarios.ts @@ -125,8 +125,8 @@ const assistantContent = (events: ReadonlyArray) => export const expectFinish = ( events: ReadonlyArray, - reason: Extract["reason"], -) => expect(events.at(-1)).toMatchObject({ type: "finish", reason }) + reason: FinishReason, +) => expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } }) export const expectWeatherToolCall = (response: LLMResponse) => expect(response.toolCalls).toMatchObject([ @@ -136,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) => export const expectWeatherToolLoop = (events: ReadonlyArray) => { const finishes = events.filter(LLMEvent.is.finish) expect(finishes).toHaveLength(1) - expect(finishes[0]?.reason).toBe("stop") + expect(finishes[0]?.reason.normalized).toBe("stop") const stepFinishes = events.filter(LLMEvent.is.stepFinish) - expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"]) + expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"]) const toolCalls = events.filter(LLMEvent.is.toolCall) expect(toolCalls).toHaveLength(1) @@ -503,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray) => { continue } if (event.type === "finish") { - summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) }) + summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) }) } } return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined))) diff --git a/packages/ai/test/response.test.ts b/packages/ai/test/response.test.ts index d3402be6c32..f05f8e167e5 100644 --- a/packages/ai/test/response.test.ts +++ b/packages/ai/test/response.test.ts @@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => { LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }), LLMEvent.textDelta({ id: "t1", text: "Answer" }), LLMEvent.textEnd({ id: "t1" }), - LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }), + LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }), ] const response = LLMResponse.fromEvents(events) - expect(response?.finishReason).toBe("stop") + expect(response?.finishReason).toEqual({ normalized: "stop" }) expect(response?.usage).toMatchObject({ outputTokens: 5 }) expect(response?.events).toEqual(events) expect(response?.events.map((event) => event.type)).toEqual([ @@ -62,18 +62,26 @@ describe("LLMResponse reducer", () => { test("uses terminal usage when present and keeps prior usage when finish omits it", () => { const withFinishUsage = LLMResponse.fromEvents([ - LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), - LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }), ]) const withoutFinishUsage = LLMResponse.fromEvents([ - LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ]) expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 }) expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 }) }) + test("preserves the raw finish reason", () => { + const response = LLMResponse.fromEvents([ + LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }), + ]) + + expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" }) + }) + test("assembles tool-call content only after the completed tool call event", () => { const pending = reduce([ LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }), @@ -88,7 +96,7 @@ describe("LLMResponse reducer", () => { LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }), LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }), LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ]) expect(response?.message.content).toEqual([ diff --git a/packages/ai/test/schema.test.ts b/packages/ai/test/schema.test.ts index 3c6628c2e51..4fdada7d143 100644 --- a/packages/ai/test/schema.test.ts +++ b/packages/ai/test/schema.test.ts @@ -48,8 +48,12 @@ describe("llm schema", () => { }) test("finish constructors accept usage input", () => { - expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage) - expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage) + expect( + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage, + ).toBeInstanceOf(Usage) + expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf( + Usage, + ) }) test("content part tagged union exposes guards", () => { diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 33a4d670af3..7c9e54b4f75 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -659,12 +659,12 @@ function streamPartEvents( return Effect.succeed([ LLMEvent.stepFinish({ index: state.step++, - reason: finishReason(event.finishReason), + reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw }, usage: usage(event.usage), providerMetadata: providerMetadata(event.providerMetadata), }), LLMEvent.finish({ - reason: finishReason(event.finishReason), + reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw }, usage: usage(event.usage), providerMetadata: providerMetadata(event.providerMetadata), }), diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index d3d21fbfc82..06fedf6fcf4 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -58,7 +58,7 @@ export const createLLMEventPublisher = (events: Pick["reason"] + readonly finish: Extract["reason"]["normalized"] readonly tokens: ReturnType } | undefined @@ -449,8 +449,8 @@ export const createLLMEventPublisher = (events: Pick }) expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue() expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse() + expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" }) }), ) diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index c0ad30d3831..575b0199fb9 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -73,7 +73,7 @@ const client = Layer.mock(LLMClient.Service)({ LLMEvent.textStart({ id: "generate" }), LLMEvent.textDelta({ id: "generate", text: "OK" }), LLMEvent.textEnd({ id: "generate" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ]) if (!response) throw new Error("Incomplete generate response") return response diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 40386568b68..4111ac222fc 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -49,7 +49,7 @@ const client = Layer.mock(LLMClient.Service)({ LLMEvent.textDelta({ id: "summary", text: "manual summary" }), LLMEvent.stepFinish({ index: 0, - reason: "stop", + reason: { normalized: "stop" }, usage: { inputTokens: 15, outputTokens: 6, @@ -60,7 +60,7 @@ const client = Layer.mock(LLMClient.Service)({ }, }), LLMEvent.finish({ - reason: "stop", + reason: { normalized: "stop" }, }), ) }, diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 7471564610b..b53dcb826dc 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -59,8 +59,8 @@ const client = Layer.mock(LLMClient.Service)({ LLMEvent.textStart({ id: "generate" }), LLMEvent.textDelta({ id: "generate", text: "Transient answer" }), LLMEvent.textEnd({ id: "generate" }), - LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 100, outputTokens: 10 } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ]) if (!response) throw new Error("Incomplete generate response") return response diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 702999719c3..4d6dc3c4400 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -255,7 +255,7 @@ test("success event data can carry provider-executed result state", () => { test("step finish records settlement without publishing step ended", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) - await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" }))) + await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }))) expect(published.some((event) => event.type === "step.ended.2")).toBe(false) expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) @@ -268,7 +268,7 @@ test("content-filter finish retains failure evidence until step closeout", async publisher.publish( LLMEvent.stepFinish({ index: 0, - reason: "content-filter", + reason: { normalized: "content-filter" }, usage: { nonCachedInputTokens: 8, outputTokens: 3, @@ -311,7 +311,7 @@ test("content-filter finish preserves partial streamed text and never ends the s LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text" }), LLMEvent.textDelta({ id: "text", text: "Partial" }), - LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }), ], (event) => publisher.publish(event), { discard: true }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 77046d6d9ff..2b1c386cd4e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -119,8 +119,8 @@ const client = Layer.succeed( const reply = { stop: () => [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ], text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents, textWithUsage: (text: string, id: string, inputTokens: number) => @@ -136,8 +136,8 @@ const reply = { tool: (id: string, name: string, input: unknown) => [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id, name, input }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ], } const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) @@ -682,8 +682,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string completeEvents: [ ...partialEvents, LLMEvent.textEnd({ id }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ], expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] }, expectedContent, @@ -702,8 +702,8 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string completeEvents: [ ...partialEvents, LLMEvent.reasoningEnd({ id }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ], expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] }, expectedContent, @@ -999,8 +999,8 @@ describe("SessionRunnerLLM", () => { [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ], [], ] @@ -2377,7 +2377,7 @@ describe("SessionRunnerLLM", () => { }), LLMEvent.stepFinish({ index: 0, - reason: "tool-calls", + reason: { normalized: "tool-calls" }, usage: { inputTokens: 10, nonCachedInputTokens: 8, @@ -2386,7 +2386,7 @@ describe("SessionRunnerLLM", () => { cacheReadInputTokens: 2, }, }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ] yield* session.resume(sessionID) @@ -2535,8 +2535,8 @@ describe("SessionRunnerLLM", () => { anthropic: { ignored: true }, }, }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ] yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) @@ -2600,8 +2600,8 @@ describe("SessionRunnerLLM", () => { providerExecuted: true, providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ] yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) @@ -2648,8 +2648,8 @@ describe("SessionRunnerLLM", () => { ), ]) const final = Stream.fromIterable([ - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ]) responseStream = Stream.concat( initial, @@ -3605,7 +3605,7 @@ describe("SessionRunnerLLM", () => { yield* admit(session, "Reject permission") responses = [ reply.tool("call-permission", "permissionfail", {}), - [LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })], + [LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })], ] yield* session.resume(sessionID) @@ -3954,10 +3954,10 @@ describe("SessionRunnerLLM", () => { LLMEvent.textDelta({ id: "partial", text: "Partial" }), LLMEvent.stepFinish({ index: 0, - reason: "content-filter", + reason: { normalized: "content-filter" }, usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 }, }), - LLMEvent.finish({ reason: "content-filter" }), + LLMEvent.finish({ reason: { normalized: "content-filter" } }), ] expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response") @@ -3990,8 +3990,8 @@ describe("SessionRunnerLLM", () => { response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }), - LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), - LLMEvent.finish({ reason: "content-filter" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }), + LLMEvent.finish({ reason: { normalized: "content-filter" } }), ] const run = yield* session.resume(sessionID).pipe(Effect.forkChild) @@ -4282,8 +4282,8 @@ describe("SessionRunnerLLM", () => { name: "echo", raw, }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ], reply.stop(), ] @@ -4379,8 +4379,8 @@ describe("SessionRunnerLLM", () => { name: "echo", raw: '{"text":"partial', }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ], reply.stop(), ] @@ -4421,8 +4421,8 @@ describe("SessionRunnerLLM", () => { name: "echo", raw: '{"text":"partial', }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ] const run = yield* session.resume(sessionID).pipe(Effect.forkChild) @@ -4530,8 +4530,8 @@ describe("SessionRunnerLLM", () => { name: "echo", raw: '{"text":"partial', }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ] responses = [ malformed("call-first"), @@ -4565,8 +4565,8 @@ describe("SessionRunnerLLM", () => { name: "echo", raw: '{"text":"partial', }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), + LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ] responses = [malformed("call-first"), malformed("call-at-limit")] @@ -4727,8 +4727,8 @@ describe("SessionRunnerLLM", () => { response = [ LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-clean-end", "effect"), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ] yield* session.resume(sessionID) @@ -4852,8 +4852,8 @@ describe("SessionRunnerLLM", () => { LLMEvent.textStart({ id: "text-2" }), LLMEvent.textDelta({ id: "text-2", text: "Second" }), LLMEvent.textEnd({ id: "text-2" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ] yield* session.resume(sessionID) @@ -4906,8 +4906,8 @@ describe("SessionRunnerLLM", () => { LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }), LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }), hostedCall("call-parsed", "hello"), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + LLMEvent.finish({ reason: { normalized: "stop" } }), ] yield* session.resume(sessionID) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 3673509ae55..ce7f7f89bd3 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -47,7 +47,7 @@ const client = Layer.mock(LLMClient.Service)({ LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }), LLMEvent.stepFinish({ index: 0, - reason: "stop", + reason: { normalized: "stop" }, usage: { inputTokens: 15, outputTokens: 6, @@ -58,7 +58,7 @@ const client = Layer.mock(LLMClient.Service)({ }, }), LLMEvent.finish({ - reason: "stop", + reason: { normalized: "stop" }, }), ) },