Compare commits

...
8 changed files with 231 additions and 268 deletions
+76 -147
View File
@@ -346,6 +346,7 @@ export const Event = Schema.StructWithRest(
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
item: optionalNull(StreamItem),
response: Schema.optional(
@@ -400,19 +401,11 @@ export interface ParserState {
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
readonly rawDeltaIndexes: ReadonlySet<number>
readonly summaryDeltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -471,10 +464,13 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
// A raw-only block remains visible locally, but cannot be replayed as a native summary.
const raw = metadata.reasoningTextIsRaw === true
if (raw && encryptedContent === undefined) return undefined
return {
type: "reasoning",
...(id === undefined ? {} : { id }),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
summary: !raw && part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
@@ -860,95 +856,41 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
export const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const text = event.delta ?? event.text
if (!text || !item?.open) return [state, NO_EVENTS]
const raw =
event.type === "response.reasoning.delta" ||
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_text.delta" ||
event.type === "response.reasoning_text.done"
const index = raw ? (event.content_index ?? 0) : (event.summary_index ?? 0)
const indexes = raw ? item.rawDeltaIndexes : item.summaryDeltaIndexes
if (event.type.endsWith(".done") && indexes.has(index)) return [state, NO_EVENTS]
const separator = !raw && !indexes.has(index) && indexes.size > 0 ? "\n\n" : ""
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
.filter((entry) => entry[1] !== "concluded")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:0`, separator + text),
reasoningItems: {
...state.reasoningItems,
[itemID]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
),
),
[index]: "active",
},
},
[itemID]: raw
? { ...item, rawDeltaIndexes: new Set([...item.rawDeltaIndexes, index]) }
: { ...item, summaryDeltaIndexes: new Set([...item.summaryDeltaIndexes, index]) },
},
},
events,
]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
const current = started.reasoningItems[itemID]
if (!current) return [started, emitted]
const events: LLMEvent[] = [...emitted]
return [
{
...started,
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...started.reasoningItems,
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
},
},
events,
]
}
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
@@ -991,8 +933,8 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
rawDeltaIndexes: new Set(),
summaryDeltaIndexes: new Set(),
},
},
},
@@ -1021,34 +963,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
]
}
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item?.open) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
return [
{
...state,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: "can-conclude",
},
},
},
},
NO_EVENTS,
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
state: ParserState,
event: Event,
@@ -1162,7 +1076,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const tracked = state.reasoningItems[item.id]
const encryptedContent = item.encrypted_content === undefined ? tracked?.encryptedContent : item.encrypted_content
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
for (const part of summaryParts) {
@@ -1176,28 +1091,31 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
if (decoded) content.push(decoded.text)
}
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
const summaryText = joinReasoningText(summary)
const itemText = summaryText ?? joinReasoningText(content)
const events: LLMEvent[] = []
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
const fragments = Object.entries(reasoningItem.summaryParts)
let lifecycle = state.lifecycle
for (const [index, status] of fragments) {
if (status === "concluded") continue
// Do not repeat earlier summaries that were already emitted as separate fragments.
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
}
const raw = summaryText === undefined && (itemText !== undefined || tracked?.rawDeltaIndexes.size)
const finalMetadata = providerMetadata(
state,
raw
? {
itemId: item.id,
reasoningTextIsRaw: true,
...(encryptedContent === undefined ? {} : { reasoningEncryptedContent: encryptedContent }),
}
: { itemId: item.id, reasoningEncryptedContent: encryptedContent ?? null },
)
if (tracked) {
return [
{
...state,
lifecycle,
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, `${item.id}:0`, finalMetadata, itemText),
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
...tracked,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
encryptedContent,
},
},
},
@@ -1206,11 +1124,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: finalMetadata }))
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
providerMetadata: finalMetadata,
text: itemText,
}),
)
@@ -1223,8 +1141,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
rawDeltaIndexes: new Set(),
summaryDeltaIndexes: new Set(),
},
},
},
@@ -1232,7 +1150,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, finalMetadata) },
events,
] satisfies StepResult
}
@@ -1253,6 +1171,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
events.push(...emitted)
}
}
for (const [itemID, item] of Object.entries(current.reasoningItems)) {
if (!item.open || item.rawDeltaIndexes.size === 0) continue
current = {
...current,
lifecycle: Lifecycle.reasoningEnd(
current.lifecycle,
events,
`${itemID}:0`,
providerMetadata(current, {
itemId: itemID,
reasoningTextIsRaw: true,
...(item.encryptedContent === undefined ? {} : { reasoningEncryptedContent: item.encryptedContent }),
}),
),
}
}
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
@@ -1337,25 +1271,20 @@ export const step = (state: ParserState, input: Event) => {
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (
event.type === "response.reasoning.delta" ||
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_text.delta" ||
event.type === "response.reasoning_text.done" ||
event.type === "response.reasoning_summary_text.delta" ||
event.type === "response.reasoning_summary_text.done"
) {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
if (event.type === "response.reasoning_summary_part.added" || event.type === "response.reasoning_summary_part.done")
return event.item_id !== undefined
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_summary_part.done")
return event.item_id !== undefined
? Effect.succeed(onReasoningSummaryPartDone(state, event))
? Effect.succeed([state, NO_EVENTS] satisfies StepResult)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
@@ -185,12 +185,6 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
File diff suppressed because one or more lines are too long
@@ -138,13 +138,17 @@ describe("Open Responses completed item reasoning", () => {
expect(response.reasoning).toBe(fixture.text)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
"openai-compatible": {
itemId: "rs_1",
...(fixture.name === "raw text" ? { reasoningTextIsRaw: true } : {}),
reasoningEncryptedContent: "encrypted",
},
})
}),
)
})
it.effect("replaces only the still-open summary without repeating earlier text", () =>
it.effect("replaces all streamed summary parts with completed text", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
@@ -164,8 +168,8 @@ describe("Open Responses completed item reasoning", () => {
},
completed,
)
expect(response.reasoning).toBe("First final")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
expect(response.reasoning).toBe("First \n\nfinal")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual(["First \n\nfinal"])
}),
)
})
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
it.effect("keeps summary parts in one block and ignores late events for completed reasoning", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
@@ -106,23 +106,11 @@ describe("Open Responses basic-item lifecycles", () => {
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
{ type: "reasoning-delta", id: "rs_1:0", text: "\n\nSecond" },
{ type: "reasoning-delta", id: "rs_1:0", text: "\n\nThird" },
{
type: "reasoning-end",
id: "rs_1:2",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
@@ -386,7 +386,15 @@ describe("Open Responses-compatible route", () => {
type: "reasoning",
text: "Preserved",
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
"openai-compatible": {
itemId: routing.id,
...(event.type === "response.reasoning.delta" ||
event.type === "response.reasoning.done" ||
event.type.startsWith("response.reasoning_text")
? { reasoningTextIsRaw: true }
: {}),
reasoningEncryptedContent: "encrypted-state",
},
},
},
])
@@ -437,25 +445,14 @@ describe("Open Responses-compatible route", () => {
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "First.",
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
text: "First.\n\nSecond.",
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: `${routing.id}:0`,
text: undefined,
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{ type: "reasoning-end", id: `${routing.id}:1` },
{ type: "reasoning-end", id: `${routing.id}:0` },
])
}),
)
@@ -0,0 +1,125 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "reasoning-model" })
const request = LLM.request({ model, prompt: "Think it through." })
const completed = { type: "response.completed", response: { id: "resp_1" } }
const generate = (...events: OpenAIResponses.Event[]) =>
LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...events))))
describe("OpenAI Responses reasoning items", () => {
it.effect("streams raw and summary events into one reasoning block", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", output_index: 2, item: { type: "reasoning", id: "rs_1" } },
{
type: "response.reasoning_text.delta",
output_index: 2,
item_id: "wrong",
content_index: 0,
delta: "Raw delta. ",
},
{
type: "response.reasoning_summary_text.delta",
output_index: 2,
item_id: "wrong",
summary_index: 0,
delta: "Summary delta.",
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 1,
text: "Second summary.",
},
{ type: "response.reasoning.delta", item_id: "rs_1", content_index: 1, delta: " Raw tail." },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "Corrected summary." },
{ type: "summary_text", text: "Second summary." },
],
content: [{ type: "reasoning_text", text: "Completed raw." }],
encrypted_content: "state",
},
},
completed,
)
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual([
"Raw delta. ",
"Summary delta.",
"\n\nSecond summary.",
" Raw tail.",
])
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([
"Corrected summary.\n\nSecond summary.",
])
expect(response.reasoning).toBe("Corrected summary.\n\nSecond summary.")
}),
)
it.effect("tracks raw and summary final indexes independently", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_text.delta", item_id: "rs_1", content_index: 0, delta: "Raw" },
{ type: "response.reasoning_text.done", item_id: "rs_1", content_index: 0, text: "Raw duplicate" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Summary" },
{ type: "response.reasoning.done", item_id: "rs_1", content_index: 1, text: " raw final" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
completed,
)
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual([
"Raw",
"Summary",
" raw final",
])
expect(response.reasoning).toBe("RawSummary raw final")
}),
)
it.effect("replays raw-only reasoning without serializing it as a summary", () =>
Effect.gen(function* () {
for (const done of [
undefined,
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", content: [{ type: "reasoning_text", text: "Internal detail." }] },
} satisfies OpenAIResponses.Event,
]) {
const response = yield* generate(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
},
{ type: "response.reasoning_text.delta", item_id: "rs_1", delta: "Internal detail." },
...(done ? [done] : []),
completed,
)
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
)
expect(prepared.body.input).toEqual([
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "state" },
])
}
}),
)
})
@@ -2264,7 +2264,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "Raw",
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "state" } },
providerMetadata: { openai: { itemId: "", reasoningTextIsRaw: true, reasoningEncryptedContent: "state" } },
},
])
}),
@@ -2680,7 +2680,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
it.effect("streams reasoning summary parts in one block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
@@ -2708,7 +2708,7 @@ describe("OpenAI Responses route", () => {
),
)
expect(response.reasoning).toBe("FirstSecond")
expect(response.reasoning).toBe("First\n\nSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{
@@ -2717,16 +2717,10 @@ describe("OpenAI Responses route", () => {
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{ type: "reasoning-delta", id: "rs_1:0", text: "\n\nSecond" },
{
type: "reasoning-end",
id: "rs_1:1",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -2735,73 +2729,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("concludes reasoning at implicit summary boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
// The next part is enough to conclude the previous one even when
// its done event is delayed.
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
// Some compatible providers begin the next part with its first delta.
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecondThird")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -3083,10 +3010,9 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-end",
id: "rs_1:1",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])