Compare commits

...
4 changed files with 759 additions and 48 deletions

No files matched your search

+362 -42
View File
@@ -193,6 +193,14 @@ const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
const StreamContent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
text: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -201,6 +209,8 @@ export const StreamItem = Schema.StructWithRest(
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
content: optionalArray(StreamContent),
summary: optionalArray(StreamContent),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
@@ -221,7 +231,10 @@ export const Event = Schema.StructWithRest(
type: Schema.String,
delta: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -232,6 +245,7 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
output: optionalArray(StreamItem),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
@@ -264,10 +278,16 @@ export interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly completedItems: ReadonlySet<string>
readonly functionArguments: Readonly<Record<string, string>>
readonly outputIndexes: Readonly<Record<string, number>>
readonly outputSequence: ReadonlyArray<string>
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly outputText: Readonly<Record<string, string>>
readonly reasoningText: Readonly<Record<string, string>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
@@ -642,24 +662,60 @@ export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const key =
event.content_index === undefined
? id
: event.content_index === 0 && state.outputText[`${id}:0`] === undefined && state.outputText[id] !== undefined
? id
: `${id}:${event.content_index}`
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
{
...state,
lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta),
outputText: { ...state.outputText, [key]: `${state.outputText[key] ?? ""}${event.delta}` },
},
events,
]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.messageItems.has(id)) {
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const authoritativeSuffix = Effect.fn("OpenResponses.authoritativeSuffix")(function* (
state: ParserState,
kind: string,
id: string,
current: string,
value: string,
) {
if (value.startsWith(current)) return value.slice(current.length)
return yield* ProviderShared.eventError(
state.id,
`${kind} ${id} completed with content that conflicts with its streamed deltas`,
value,
)
})
const onOutputTextDone = Effect.fn("OpenResponses.onOutputTextDone")(function* (
state: ParserState,
event: Event,
id: string,
) {
const key =
event.content_index === undefined
? id
: event.content_index === 0 && state.outputText[`${id}:0`] === undefined && state.outputText[id] !== undefined
? id
: `${id}:${event.content_index}`
const suffix =
event.text === undefined ? "" : yield* authoritativeSuffix(state, "output text", key, state.outputText[key] ?? "", event.text)
const [reconciled, deltaEvents] = onOutputTextDelta(state, { ...event, delta: suffix }, id)
if (reconciled.messageItems.has(id)) return [reconciled, deltaEvents] satisfies StepResult
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
const lifecycle = Lifecycle.textEnd(reconciled.lifecycle, events, id)
return [{ ...reconciled, lifecycle }, [...deltaEvents, ...events]] satisfies StepResult
})
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
@@ -670,12 +726,39 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
reasoningText: { ...state.reasoningText, [id]: `${state.reasoningText[id] ?? ""}${event.delta}` },
},
events,
]
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
export const onReasoningDone = Effect.fn("OpenResponses.onReasoningDone")(function* (
state: ParserState,
event: Event,
) {
if (!event.item_id || event.text === undefined) return [state, NO_EVENTS] satisfies StepResult
const id =
event.summary_index !== undefined || state.reasoningItems[event.item_id]
? `${event.item_id}:${event.summary_index ?? 0}`
: event.item_id
const suffix = yield* authoritativeSuffix(state, "reasoning", id, state.reasoningText[id] ?? "", event.text)
const [reconciled, events] = onReasoningDelta(state, { ...event, delta: suffix }, event.item_id)
const item = reconciled.reasoningItems[event.item_id]
if (!suffix || !item || event.summary_index === undefined) return [reconciled, events] satisfies StepResult
return [
{
...reconciled,
reasoningItems: {
...reconciled.reasoningItems,
[event.item_id]: {
...item,
summaryParts: { ...item.summaryParts, [event.summary_index]: "active" },
},
},
},
events,
] satisfies StepResult
})
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -858,38 +941,78 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
export const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
event: Event,
) {
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemID = item.id
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const messageItems = new Set([...state.messageItems, item.id])
const messagePhases = phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
const reconciledEvents: LLMEvent[] = []
const reconciled = yield* (item.content ?? [])
.map((part, contentIndex) => ({ part, contentIndex }))
.filter((entry) => entry.part.type === "output_text" && entry.part.text !== undefined)
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onOutputTextDone(
current,
{ ...event, content_index: entry.contentIndex, text: entry.part.text ?? "" },
itemID,
)
reconciledEvents.push(...nextEvents)
return next
}),
),
),
Effect.succeed({ ...state, messageItems, messagePhases }),
)
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
const { [item.id]: _phase, ...remainingPhases } = reconciled.messagePhases
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textEnd(reconciled.lifecycle, events, item.id, metadata)
if (
!events.length &&
Object.keys(state.outputText).some((id) => id === item.id || id.startsWith(`${item.id}:`)) &&
metadata
)
events.push(LLMEvent.textEnd({ id: item.id, providerMetadata: metadata }))
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
...reconciled,
lifecycle,
messageItems,
messagePhases,
messagePhases: remainingPhases,
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
[...reconciledEvents, ...events],
] satisfies StepResult
}
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedItems.has(item.id)) {
if (item.arguments === undefined || item.arguments === state.functionArguments[item.id])
return [state, NO_EVENTS] satisfies StepResult
return yield* ProviderShared.eventError(
state.id,
`function call arguments ${item.id} completed with content that conflicts with its previous snapshot`,
item.arguments,
)
}
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const authoritativeArguments = item.arguments ?? tools[item.id]?.input
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -905,6 +1028,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
hasFunctionCall:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
completedItems: new Set([...state.completedItems, item.id]),
functionArguments: {
...state.functionArguments,
...(authoritativeArguments === undefined ? {} : { [item.id]: authoritativeArguments }),
},
tools: result.tools,
},
events,
@@ -914,25 +1042,147 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
const summaries = item.summary?.filter((part) => part.type === "summary_text" && part.text !== undefined) ?? []
if (state.completedItems.has(item.id)) {
const reconciled = yield* summaries
.map((part, index) => ({ part, index }))
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onReasoningDone(current, {
...event,
item_id: item.id,
summary_index: entry.index,
text: entry.part.text,
})
events.push(...nextEvents)
const endEvents: LLMEvent[] = []
const lifecycle = Lifecycle.reasoningEnd(
next.lifecycle,
endEvents,
`${item.id}:${entry.index}`,
metadata,
)
events.push(
...(endEvents.length
? endEvents
: [LLMEvent.reasoningEnd({ id: `${item.id}:${entry.index}`, providerMetadata: metadata })]),
)
return { ...next, lifecycle }
}),
),
),
Effect.succeed(state),
)
if (!summaries.length) {
const id = Object.keys(state.reasoningText)
.filter((id) => id === item.id || id.startsWith(`${item.id}:`))
.at(-1) ?? `${item.id}:0`
events.push(LLMEvent.reasoningEnd({ id, providerMetadata: metadata }))
}
return [reconciled, events] satisfies StepResult
}
if (summaries.length === 1 && !state.reasoningItems[item.id] && state.reasoningText[item.id] !== undefined) {
const [reconciled, reconciledEvents] = yield* onReasoningDone(state, {
...event,
item_id: item.id,
text: summaries[0]?.text,
})
events.push(...reconciledEvents)
return [
{
...reconciled,
lifecycle: Lifecycle.reasoningEnd(reconciled.lifecycle, events, item.id, metadata),
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
const needsSummaryReconciliation = summaries.some(
(part, index) => part.text !== state.reasoningText[`${item.id}:${index}`],
)
const seeded =
needsSummaryReconciliation && !state.reasoningItems[item.id]
? onOutputItemAdded(state, { ...event, item })
: ([state, NO_EVENTS] satisfies StepResult)
events.push(...seeded[1])
const reconciled = yield* (needsSummaryReconciliation ? summaries : [])
.map((part, index) => ({ part, index }))
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const added = current.reasoningItems[item.id]?.summaryParts[entry.index]
? ([current, NO_EVENTS] satisfies StepResult)
: onReasoningSummaryPartAdded(current, {
...event,
item_id: item.id,
summary_index: entry.index,
})
events.push(...added[1])
const [next, nextEvents] = yield* onReasoningDone(added[0], {
...event,
item_id: item.id,
summary_index: entry.index,
text: entry.part.text,
})
events.push(...nextEvents)
const [done, doneEvents] = onReasoningSummaryPartDone(next, {
...event,
item_id: item.id,
summary_index: entry.index,
})
events.push(...doneEvents)
return done
}),
),
),
Effect.succeed(seeded[0]),
)
const reasoningItem = reconciled.reasoningItems[item.id]
if (reasoningItem) {
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
reconciled.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
const { [item.id]: _removed, ...reasoningItems } = reconciled.reasoningItems
return [
{
...reconciled,
lifecycle,
reasoningItems,
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
if (summaries.length) {
events.push(LLMEvent.reasoningEnd({ id: `${item.id}:${summaries.length - 1}`, providerMetadata: metadata }))
return [
{ ...reconciled, completedItems: new Set([...reconciled.completedItems, item.id]) },
events,
] satisfies StepResult
}
if (!reconciled.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(reconciled.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
return [
{ ...reconciled, lifecycle, completedItems: new Set([...reconciled.completedItems, item.id]) },
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
{
...reconciled,
lifecycle: Lifecycle.reasoningEnd(reconciled.lifecycle, events, item.id, metadata),
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
@@ -940,11 +1190,47 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
export const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
state: ParserState,
event: Event,
onItemDone: (state: ParserState, event: Event) => Effect.Effect<StepResult, LLMError> = onOutputItemDone,
) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
const output = event.response?.output ?? []
const terminalIndexes = Object.fromEntries(
output.flatMap((item, index) => (item.id === undefined ? [] : [[item.id, index] as const])),
)
const emitted = state.outputSequence.filter((id) => terminalIndexes[id] !== undefined)
const expected = output.flatMap((item) =>
item.id !== undefined && state.outputIndexes[item.id] !== undefined ? [item.id] : [],
)
const missingBeforeEmitted = output.some(
(item, index) =>
item.id !== undefined &&
state.outputIndexes[item.id] === undefined &&
Object.values(state.outputIndexes).some((seen) => seen > index),
)
if (missingBeforeEmitted || emitted.some((id, index) => id !== expected[index]))
return yield* ProviderShared.eventError(
state.id,
`${state.name} terminal output conflicts with the streamed output order`,
)
const reconciled = yield* output.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, item) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onItemDone(current, { ...event, item })
events.push(...nextEvents)
return next
}),
),
),
Effect.succeed(state),
)
const lifecycle = Lifecycle.finish(reconciled.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
normalized: mapFinishReason(event, reconciled.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -956,8 +1242,8 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
})
: undefined,
})
return [{ ...state, lifecycle }, events]
}
return [{ ...reconciled, lifecycle }, events] satisfies StepResult
})
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
@@ -982,14 +1268,26 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
})
}
export const step = (state: ParserState, event: Event) => {
export const step = (state: ParserState, event: Event): Effect.Effect<StepResult, LLMError> => {
const outputItemID = event.item_id ?? event.item?.id
if (
outputItemID &&
event.output_index !== undefined &&
state.outputIndexes[outputItemID] === undefined
)
return step(
{
...state,
outputIndexes: { ...state.outputIndexes, [outputItemID]: event.output_index },
outputSequence: [...state.outputSequence, outputItemID],
},
{ ...event, output_index: undefined },
)
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
event.type === "response.output_text.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, event, event.item_id),
)
return event.type === "response.output_text.delta"
? Effect.succeed(onOutputTextDelta(state, event, event.item_id))
: onOutputTextDone(state, event, event.item_id)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -997,7 +1295,7 @@ export const step = (state: ParserState, event: Event) => {
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event))
return onReasoningDone(state, event)
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
@@ -1013,13 +1311,29 @@ export const step = (state: ParserState, event: Event) => {
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.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.arguments === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing arguments`)
const tool = state.tools[event.item_id]
if (!tool)
return ProviderShared.eventError(state.id, `${state.name} completed tool arguments are missing their tool call`)
return Effect.succeed(
[
{
...state,
tools: { ...state.tools, [event.item_id]: { ...tool, input: event.arguments } },
},
NO_EVENTS,
] satisfies StepResult,
)
}
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
return onResponseFinish(state, event)
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
@@ -1037,11 +1351,17 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
completedItems: new Set<string>(),
functionArguments: {},
outputIndexes: {},
outputSequence: [],
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
outputText: {},
reasoningText: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
+24 -4
View File
@@ -194,6 +194,7 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
state: OpenResponses.ParserState,
item: HostedToolItem,
) {
if (state.completedItems.has(item.id)) return [state, []] satisfies OpenResponses.StepResult
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
@@ -214,20 +215,39 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
return [
{ ...state, lifecycle, completedItems: new Set([...state.completedItems, item.id]) },
events,
] satisfies OpenResponses.StepResult
})
const onOutputItemDone = (state: OpenResponses.ParserState, event: OpenResponses.Event) =>
event.item && isHostedToolItem(event.item)
? onHostedToolDone(state, event.item)
: OpenResponses.onOutputItemDone(state, event)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const outputItemID = event.item_id ?? event.item?.id
if (outputItemID && event.output_index !== undefined && state.outputIndexes[outputItemID] === undefined)
return step(
{
...state,
outputIndexes: { ...state.outputIndexes, [outputItemID]: event.output_index },
outputSequence: [...state.outputSequence, outputItemID],
},
{ ...event, output_index: undefined },
)
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
? OpenResponses.onReasoningDone(state, event)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
return onHostedToolDone(state, event.item)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return OpenResponses.onResponseFinish(state, event, onOutputItemDone)
return OpenResponses.step(state, event)
}
@@ -36,7 +36,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Generate a simple flat black triangle centered on a plain white background.\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqXOWpnAP0UBYvlSBekoHJCZH4q8xzRle879t05tnZpveHS-lRgcpzHWBcNwm8Qs8hBSGdQZgdOM-EKf0EJvBo27ynkLRL18hL29zaj_TqlNVZW3P9a0cij_UGUhhh7JFDXUJcxn2CNJs6hfZIs5vjS3zd0QvDSW-iCRPqT_NaT-VJIyXwZaZI8txwYY-buZg83BwkZidY1AGGv12xkyKCn7vhrTf_JM3tDgiWIIOd5bhaYLDlId3n_IKixf5mo8CKN0oyZyPz01XK82EwKVg8rkpdwW7XOiirQ7Ro0-oT_0TJCRa2uYxAyjzCoYWNXPsouRiyter3YdetjJkUR0-yNwmsJVv9ofkaeL5x5R7OcrA_YMsuqyXyyPIl1vpDqy7iA_U_4gwDnDJi5mOg3Y5fE4a3MwewwQgVcZx427UhTLltyaPCWZIcNVhey57hJxCt3RjWZGSWV-GMV2GC2dD5dHlXQiiCJ_Mkr3xyPXdjTyoe94KQlsfmO267RN3NyW74L2TqPEP_HnR0TyNtg-nClTYQPuhvx34nzqZQresJwksxHQU_W9AoBD2DTQMVL9DAoayVy5aB0fyyzwrT82xLkMP6XredNROM-qJSuBWRVVsG4f_mFriYv0yFgjwrtohLlGpPKEbjIsEKBFCyfQ2ERIVYYv8I80EaVTwPuKljMJFcYDa065ZNh8k-rFhg2Q_iQB-yLPH7kx4de3qvYrxZ7DTY2U5wVvZdPmW7sYzqN-8RnktWmUx-fSoGxVbClW6c7Gn_GkE9FmZ8qYcCRizpzSWfec41lS2dD-9g0Biow04K65k6R9Z1-TU-JMNgEn6t_1W5sOL8izzdXOTVZXxe-zlFbm_PNWpE4rMRuL8usVOB-cD4NVACqBvYQOrZ9Cp7scYfDX9KockxtPOpy6gKsn1VJdNWnwNgH_aKKrSO8WFePD3Mcw167HcPQpZ_wnJouWaPrFWKC_k0Iz6CFHvuu_WoTiREItFp10FibQEe-kQJ1PANwxWhjwBKsW7zJDOhhEtWrYpPUZq4eA6OyZgAzVQ6KMvgP38ZR0NxFoi4qwbcPoNY_Fp3AvcmTKLdQiOQ54DSM24LDQbT2LusMaQjr6pKuOwrj5glaGjK4JmBGu_6J05TIdZ6V7WLn39R3PU8tVI3RdEpam3Go6wBjU7yu4cp5O4eWc3XzKzh8bI5f1i_-toPgtJxodv6xGFMErYxxXgD5nWLo_0ELXd4SlcVKGA6o9vpctrkE9fNdWL6CDpXn6mh444E5gJMiPWGG5ZsdtolN1EVgesNwQBwFu4rkWQ5OwzT2liqOW4hEQTHI6W-L2jmKxejveBX0ai2FCmHBrb8Zboi17-CdK8yr3A6oUqUh6dcu5s_xiMAT-TyhOlCdWKJav-wjutKx4IJdACOSKZV6ePToTg43Hsm5PPKWgyg-hdb0zE6uCKKcjYT31zRqYCqhhrWbkAh4Dj7vZgNyZ0RhLfDcVZ5Oa-vAstRTv0MHzv9wOSitIQDMQdw8AJNQwgJfzc_TVtGNOi0-cR7tJjrcZZrMrLDXscgQc3MbQXoKtiiXNuR6nxX53rFFsEVC-J8T2T0pLFqsI9neSh69DGhdXS2PvggM1GD1ibNPbwcY_2uKPlImYoy8eyq1uXZ1GmgIfuFSV4wwct59BfV2l_WQ6nNOwmjCsY_KOO3yOkmdpQHOOwNbiHWpQ0CQ-KVnzpxqz9PaHpBhJwgcnOrAs9LXnWCDOETgnR8tc8MYQEAxgWLvEC1wpqGrOSZSCWoaZTUtQ52IrNJNB3qazGeIgOJSi_C42U2mnPhC34Bxcdutp0lYf_NVmiPzPil7L-qBSoyvAcrhJ6RM_39z77lXgNr7Zrl6wNbYej3qo6t8MoHyCxtnMHO8L_Dk2ug0EGl65TK6Hp3lqPTPY57XaXfxVkjUZczaKpxP4HeFAbRIVYh5Ygdi1beUUMqtgyG8_UF23ccgTLcwN4NminghUqdj0RJtZNxP8RIr-3F4qpWCAmTqthfZ1ncdaRHv90j0EnxoGdoYBY9jgkqFE8BOLrhD41ykwBSSiqx9JQTeMwZ5yLVYJSxshUhwP1OBeu97vaS6gfDqpG2anRXpf9dcqH5P-RCrPB1E-BphEQw3BFDeNC57Bwb4Sbt29wZTu87yqQO6WIrUOLK2psyG6lllPrlOz0MYko-rJMnDIGiQ92S4ddeh5Kqklia1q9SHIP36mxqN86gBczqJKmvutiIwBnZb8yLSNy04ppCb9fhqCFaIMazEXChkxwkQv2730Ffx4wxMRqJJbSFvC_4yg3sMgNPgBwbHGIpQTRh4R_4fKGaFnT1dNRrrPUraRcQZQSIpV81G3mxtyRWxxKyCZ-TWwOvE7SmTZu84UFPEUFA5MpLsM4SPw_loZTCTNs-4zkNbKD3dEYJjDafU3xS0eEZoBmHaGsG6UuRNjq_Es2qqOb7HPvHQU7OsDuvNju7t-RfBDFydfkTrBODvDLkKr8Y9erd81wzq8vwHswO-qT90coHnacAn7-AI6Dx-rR0wkSwJl9HIQ7pC5-zDHyDAa-Rt7gXZJxyEyoRdB6Xr-9xIrLvyWHjhtmmqY7kw9OpObgjw031cKnmcYtvykXT9dG4vb_ZJosy0froYxZMMlnmV3TT27Vhm85apQ53wCM2D8lp8xc4yUlkSBF-Mob28bm_z8gaymSIeMkMk46TROw86nKqJG_MwjE7394LbTQ4AQL63UdabVbJ-vE7w_D7ke3RQ93xFC2GYybqTQqA-oL4TGW4GTANjHNBYGFPrZLQgB57QWNsK0zOJ2w6X8zcF4pl4fkmOlRFUhjFMmOhCRRbuW3U-2h8012xhpYhTTGpWR0ODa891aN7NlxAC8Y6nMsYhrht0mJltMcep5D8lnXEytkWlIOnOVCDsj5jjYIgj5ZfLcmFcuoYFBr5XOGkt1PyCUOSPL8_WQOkousHn3sdONSyUmqpGar1YGsMAiBt-f4ZldsxN9pTslTwP_duhMPzGfRLXF4frnGBzaZHvxnV4eBCtEprRZAdM50bbD6QOhsudTVfZyv6I1c9XJgnxSVvfak4-niMex8lZ22pmnvi5-METukxdt0T9ZgI9CdPCs5aU3GpAZ9T1WMqRYox4MsuRAlIUGlyRDWa94fvtpoOi_6KI0r6-sM859y7xIaILPRdY0P2wnqVxtVOXhpi10mEU3BFz9eaBC1GifG7IzHH4rIqtohpK-Xi3J1U2iNEdO5JHqVkubzth-3iv0InsMYYrM3W0jHn9sbMYAGOvyFZLBIFStpCmLbIgBLT16G0nCEZWwaZZzv8o_o7yghO4I8f-G5G1u-55Lfa2P5073ILkQXyl-pYp8n3P2lrOHb3Ujq3KOZ78YNgKwUtg7JiVRs3Xz6AurppkNlauEDOOmIjY8OtcbPxFXoDrC1qXv5DZSD1RnTJSScF4YGXeFW1shMK6l6BI3Mi1_gQ4isftA_Ow-WDCZ1grgazDcpKOYzbKI8UBJwOLkueNx3ieQ9HTaVg3rQ2eLN_znh_StlW0GH0vZQCi2fBONUXD1OueNnKpkGy-iz5iXL7XOAnkLbLHqujcNhkL1pGJGQOHYaahnXb8gXMy6I7tIuGiIfe7GV8X3u0hac2s6ViKt4gyh1njIGdjYCyvm1bXu7pNNGUkdm2D-CsYEyjRwkeGYC4lVwbRqMM8IWB-baNLioS28dkviSTIX96a5FVrhzUGPe0TdLgCP77SSky09BXweWwXpYDeh0me1EAbI9ePGouKLvheoltIEF9C5m5XR-CKcaTPz0K1UBxWQLy8IWtU5D2F_Jhirc0nPsALWXvUxAZRMP1Jt4fGomIo8sX97PaDNXPalG2tc2lBxDHbI2g_AwzZGJj-97iMxTWuav3EJlqQThL1QAQ65kicLUeeZGvjT8vNK3AIKfp5tU6XimhtyZ2ezdZdywL68AbqVT6h6b3RTZufY7L82c96RRzx4csMp6WdGFHGtBvE7ZrCUQbxfE1MNfax2HlEViX4iIN1LA6rUisiG4ZGimb_YjkbtWW1MKVFbPUHAPVqOb4-w2vG5FVIhtdvhdk1KY2rFN6nSuR0rHKu3R5DZpAsplAVkGwi7EmFmPC7TbpS7fJDODubr69KD64xptKC9ebP1DbYZyRWMTwHnD7n2BVQT4vJtu4IaMwjlGduJyV-nV6r5k0WCwk5jwuV_S9Z5TZzPuJMFh7FhTzDEQzFPCzyjRfIYJIFOVjdWZKe2BPqi0r671xO0XoBCl-EHxqZ9QAU_ufz4PmhiL5WVcDG4ta0iNXzkQMcNIjfx0M79_f_DtFgfUPxaAVcOSFIupx2nN8nFqWqzmR1NbB6Swlssw62iYYenC9aCBGzdwxmun-EukSp83B_Mt9sJKk5ejeslc8iEFUTclxWcJkNjdbx8qvqTWzG2fedqcTsWdbk3Ezlr6uH-V9Wg4C8poFiRyztGfRxdwOFki_a3RM1M4DihrJMyca-FRrvudnp-AiJsgaxGRObHwnADvSCRh4cUrjOBYNMHaGiznk4MpEUEu3S2L5q1Slnc5cFs8Ww99H2Br-AN-wI5wYX6EmP-L5olxFUeMFNqELrk3FQJ8xEi_3yjYXT4mO2F_AoHzOVE3cdITR6LKmPI81_z7T2sOxszEnOn-hQNeC-BpwMVJ9bKgM1W3BbQGFBBB_izGAq0WGgajr-ryJ2tSsC5ijOl5usxis-oFwTmE7QVdW1Line truncated
"body": "{\"model\":\"gpt-5-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Generate a simple flat black triangle centered on a plain white background.\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqXOW2RoFJWpA81w_vweFr0iHQ5uCpsReoKbmPpyUhCwlm5lLubRBc3D2Ken8UNoPaPHzA0IAUTPoQ4CmwTAzRBn9nTz0upKnytFs2ilrchJskeslMO7WGA0otkbPG_ZDvYTSj1lfofNGbyDHiVnFyt5yhQ_MmIs2l5yjTK6TgHV3towKRrp7YFKbmVE9z3RJ2WBRFzZ3YIb4i4GuctmtedrplMjZR86kuvxZuePz_1iEV3sq0vpazrC1OMHtchHTeX_KSM1Xv_GoK9narTYcEv2V5yw3ssbu27qRAEzy_ZlwIphQdUP2wzRd0UxYKuEe6HEnyBUBnh8hymbhg7UC7-fKgBpcUHNnBID5m8Mr38yg-ZSALNhrJex_5PSpna0WdZRODUkJGxxiUf2LP7HS8hJe7aQafSAZWoPvv7d83urjyqF7rwHsDgWpvzd_wEUWsOns-W6cpNkdSotMOOcFgajCyY3RwsYLqR0Kdv3S3cMvxx55jUwXEcNT8JWkIH9yzS9OWwvo0a4aJPW_UbyfuAW4ube5Q1AuJngq2AUxOeuOFwHgVW-Vgunnt4xuFG0-EsoMsZDR7xBkhsV8wcx7QUTsbVhZPALDDgAxFFyJBrj3ST7JRFs9QcJG7a-uqpinkbhHeMid2Il8g2mN5Su6UjF024PGFLiYx_k3x9RBkpAaA60pzTsUzyHDRFPwiwQluSA9mYYB66M-mHNNFy_NmsHeZmJCFz6HHqInY3rjmG0VoPYitDFZWbjPk8-bm8X3j7oCofYrau-axXESc7D5XNrA1hzT6FYtn7QTg5SeeQ6Bd8LolJ2C54josvOzXIiWoR8rJnOSf6Q7JOC5jRely5rSoJ_pUVc-G3-m9wkgfquRj0hTQ_B8WpSzmVcyibAFjaSzHb2u-9J8bnmMT5NyYGxmI3XTQgPghAP6LnjA71PLCKEmYA4OxCqfhelwRZMdee9ggZFTqwGa9Q9WYj6WbI3Ke-JN0cdPa_BSTgv7fVOMY7e1hjNMtlcnzDp-VImKryaezu3Vj29H0nO-g1HNC3mqhx912UJ_dyuKlDSNjsnNDTVA_taLXT41AVJwM84eCA9N5ocIvmPh9zwngi8zKyLYzcoEM5OqmQgM04usrXG9z8A4mKuuMFFgVvKEh46TYlfGICLH2mK5ojxnQq5DBTuoOvlAqciaYwb4I4PhBUxvwFN3-_hLxVp8d21zI02mfjq4zdlN2BkfwE5uvZUucukx-dbhzbarMMybM3xfu5_ihFOg43JeABYTmjmTceBW2kpuVU6GiAb09Yr8kQkKsUD9RkjO6uCaSJIgxUEfUZMym1Flg7eHFPJns56YEMXOw_Gk-nz_HfDvdkw3NmW7J089jx7ixjTP6dPI2ZcQBwze_41Zy64CnuV2ddDytWM5-nKIHuTAZzXpTsdJ4l7TPkl3VdvkF4cWnKCY0Zh80RVAegFU4iNp2pchxbAUWBOAUSF4wI6ccOmstwiPk3-NXN3Q5hWKP_ut5Qu34GB4avJDFFDz9xLNNJMEuxP3q2Y_qe1inSxSWmMXPBrzsPufR_x2Og4lj4U3eMVlcby0YL3hXw8nRnmFLh3zTEfNneiEon3u2DpKhgXJJSulS3oGq2qLBxqBS2dkwZ9rdR-sg5j-B6M2IME0Vnmp_Mqpd4sjQYj31LMr9SYhlCzmRup6SZCLmkw8Ryi1zNy49Qk4K3SYsS8P11hCuXZP9FV9tRP7uszKElguPeD28vgZIUuHgyW15dscWwGcwuOjIrl2RhiKOdLWUJLWS54yQWwHbCPUDea-3mI6ZXH2cEb_jKoODFAKYPYXcD4-g5l8m1q9_J2TiDnXyHXpmddT84W8yDzmxS5TWhWyNvvaVBHcH2e2jsiSCZkKdBSsT9irmt7A68GX6kSmBfN6xBa0zgRKapKdB-t9lFMgKJX58LtIgkJ4GyEa3Vljqkr6p_ogxbDvxdLBQQQKmROCGYWsY8vwj7xKkvGvDzP9LSowmy1r7WAWQRvhVltWWXnFR6C7gAQY5jFz4JjTgoDehAZk16GEYAyUMsGPdFCw6DqcuTf70bAmp40fMBbiz-Ezvw4fHXbAyuxJDDmik-7SS_fGnO0apwMxrmICMhuMEfofiu0TXeAwBwq1ztLEkf1jvAqNLyx6VKIrhqumgtmAObIGqNyIvN6uDGQe-3EEEQOLEoNf-25OQotsq0xXRgnxoCnF8vX4Y2gf_wIGf3_5AueR-lJFfU_R81FmRYfWWxT9o510aIS6BOLi7Yaw4WDM9hWSyyOwD4wcCa1jY6-e1DAFmk24oqp_DzriJz3gh_m69YeRyAbxgmMec82XjbUTrIKszr__QodFVeSxwZxhBXkp2fNIRN3orgQ1iccdS7BExLrP8iboYGdvZMUHZwQzBl9rioLFk1mwdghBsV4v-63haXKAm7-UKZmSXnslygCCtZ5bsBcgudZBtaEzEbFDbQcPKyQYycxdN69KpRnc7g8NPrFtWoT1Mkg1uhqA6FEYsH0QIuM_xvob-_F04ihHLL-93VcHPxoSosHynh1epp9PcfUzfC74SI8_TaKJ5OjT81i3lJCdVUH9UlJf2xFSswM5JJfRKAdU8u-ljQ1-uxafK5w9UWnlDcyhtikDGB8WNH27EPgb35b9rZJ1HzkURy6_5OoZAyLmNzs8cbt53cQeZf1QL4HrzkQHjLPOF-DGJvP-YEEQh-_Jicy6_aagtgl7sQ3AHSJwa5fc3kALE7j-vkMxRmP9tINYAw0pADx5TstQNTirMTB7-qAMOqfW_UyCVtBgG84OcFvlrsFzTERYW8Pidv6olXc8Lb8IbykePvtxrhufCFv5jNbO3GpXp62wW3kbmBxvTszwWecfVHxZ23CstGoDwbAmJDYVtYE7rOlobLGWkI01Q4IZmmAvus6tbqG3Lyt5If1F0DPEjjSkGY_dxwxTlFlc9rGG13o5a_FTAV2pSS5odrUvxWsaGOsG1FgnJKwf1NH00BeJzVlEUfIwLjZdAhs-l18ekU3Yikq4IegvXkEydFvpoqnqYA1fncdX9fuaI1ibPCO3t1HIVivSM2jNG00z9yrWX-5gYbUlyJ-7r5EUYDZBgZpogzFg6CPBQrcMtrtPBLuTpyrrR_E6Ck1ZBRV_rO0DXqM-Ku-MWY5X_nLa2CPROqlDfgakbqn038ARmxMbD-xrJBtCG4puNpuUwhwxgGuoVyFqObUDB0gGVoPAu1KjSj4ZD8nLE3sH7_l2pCCuyPEJDZTDWYBCSA7toushvVQCOnHEUwNlIK6Qr9AMZVjJTZEK5tXq1DsvH76J73No_y9CZXUiphjvKOVChsZqymtwyaadUkipCDVNsnK9_mmUttXQKSUxGRo-hsQV72_7cIsjwsrLCGH7DRLEKbHODTjt7pswikdFZVR69acRtbiL_bmaAirG3TXYXn9kyZw6ogPnGojwesjXZbW6nCpqCihQFzGQ6w0vKhj7kIaWvQXgiX5I8FtaN5sC1HTFmXgSUY05C0fQ0CfsFTossamPcAQ5h4fphLTTkpPP2qowiI3BVEE02HKGxl9FCHpvExuwrojvwuNxy_7EE9zzOL_bvKDESeX_aogsc6aAakFo74oGyQVN6LB5rrUYThrgBED6r41pYHQ7opvA2ZY76ixV_pCwt9LtBF8s7Vp-xrreBc_Qq5uQjY6LmDQTVbJ2M4iBCnkhV3B4jSBnlKIxxq2ZkFZwZlIcrP9T5se0KXBvzGZLRE5m_6J9jT8FNKhhho9QIsGomWsw5LtR4ZgFgXbnNlQZ6WNPMnfbNXFtYSVAvOZpa3v3-54kmcfyRa2gBXVN5PIh85fbjn2PL2GM1biqZ_fHU3huAWdH78mBa9S90cF-4_PKCwtUE6M3Uwkaik24uZwohy4F67YmE1jfQqIdcLIPC9Mk9AJj-ja35PI-FYSAdjGdJj57mBnneTW6vP-SXrE99vPtZa0lYa4OYnXpYE8_YJG6WZMaSSrLSQAZX2pQV5u3fTKSXd6EgXCLBZtxkxbsubsgrWa7OpOFDGWnj25zulb2KOrZYN4cAf5VMMqBc9f14Z8MnLMvTJ2JsnJAyzOAH2EH00Kyi2axkNs76KcumtJbrzIMQkmXnCsm0CTIwqPuDR6PxWWmNjKzGT-YVeEzWe26vHvDYYISBZRrMLo5owx1fP592JKM_003-uABJAjiugeYrETonQA68Uhwj63M_LDhHWbS3dNAaEaJ-qjnNMnmZFEcMVjUmLEOTvBMlo1FUHerUXmyh-8S2O4rwwuevQvTsZIw-x8CbQ4YTny_oNa0KKJ-jT56ysCod9UE-OwzrWrgLQcFxDmUrFcAmHUA0eiTFJGCut3YT0UDJEDNrETT9Dv50Et9p_B2IbhpS0sYJVQrMGtXF968tSSrSkcN8YbFmRd-f22EXR7oOANEI185gtDiFMsBSEfXd1dra60ayMoqaXRQ2FM4-agzetZau8XyjF4Hunmly69zgX-SPknE0-XGoOTId0UhjSb77JuJBTvMZDxyCfRtUiMnxRnCHYyg1rrQyiIxsfMwB9Adx0LdQzBLBTCTAGEAiKiNrxlLwG3o7xdai-8A-3XFdbuKpMw2SMYPfHgTwzixuttX44uC4A1kjA1WC59roDEzLvuNnxje77WXqy9C2DR3OHIt_bS1k2q58wTwLXWp56v23AwNNoogOsDovtQ1qF4m0hAGtBzYmPZ3S8DczXaF5TeXIxLine truncated
},
"response": {
"status": 200,
@@ -882,6 +882,185 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles authoritative output text done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Hello!" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Hello!" }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Hello!")
expect(response.events.filter(LLMEvent.is.textDelta)).toEqual([
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
])
}),
)
it.effect("reconciles output text independently by content index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{
type: "response.output_text.delta",
item_id: "msg_1",
content_index: 0,
delta: "First",
},
{
type: "response.output_text.done",
item_id: "msg_1",
content_index: 0,
text: "First.",
},
{
type: "response.output_text.delta",
item_id: "msg_1",
content_index: 1,
delta: "Second",
},
{
type: "response.output_text.done",
item_id: "msg_1",
content_index: 1,
text: "Second.",
},
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [
{ type: "output_text", text: "First." },
{ type: "output_text", text: "Second." },
],
},
},
{ type: "response.completed", response: {} },
),
),
),
)
expect(response.text).toBe("First.Second.")
}),
)
it.effect("recovers output from the authoritative terminal response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Terminal only." }],
},
],
},
},
),
),
),
)
expect(response.text).toBe("Terminal only.")
expect(response.events.filter(LLMEvent.is.textDelta)).toEqual([
{ type: "text-delta", id: "msg_1", text: "Terminal only." },
])
}),
)
it.effect("rejects terminal output that cannot be restored to authoritative order", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 1,
item: { type: "message", id: "msg_later" },
},
{
type: "response.output_text.delta",
item_id: "msg_later",
output_index: 1,
content_index: 0,
delta: "Later",
},
{
type: "response.completed",
response: {
output: [
{
type: "message",
id: "msg_earlier",
content: [{ type: "output_text", text: "Earlier" }],
},
{
type: "message",
id: "msg_later",
content: [{ type: "output_text", text: "Later" }],
},
],
},
},
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("terminal output conflicts with the streamed output order")
}),
)
it.effect("rejects authoritative text that conflicts with streamed deltas", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Goodbye" },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("completed with content that conflicts with its streamed deltas")
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1086,6 +1265,110 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles authoritative reasoning done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "thinking..." },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("thinking...")
}),
)
it.effect("recovers terminal-only reasoning summaries sequentially", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.completed",
response: {
output: [
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
},
],
},
}),
),
),
)
expect(response.message.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning",
text: "Second",
providerMetadata: {
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
},
])
expect(
response.events
.filter((event) => event.type.startsWith("reasoning-"))
.map((event) => `${event.type}:${event.id}`),
).toEqual([
"reasoning-start:rs_1:0",
"reasoning-delta:rs_1:0",
"reasoning-end:rs_1:0",
"reasoning-start:rs_1:1",
"reasoning-delta:rs_1:1",
"reasoning-end:rs_1:1",
])
}),
)
it.effect("updates completed reasoning metadata without adding a phantom block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "stale", summary: [] },
},
{
type: "response.completed",
response: {
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "final", summary: [] }],
},
},
),
),
),
)
expect(response.message.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "final" } },
},
])
}),
)
it.effect("preserves encrypted reasoning metadata for continuation", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1568,6 +1851,57 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses authoritative function argument done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"stale"}' },
{
type: "response.function_call_arguments.done",
item_id: "item_1",
arguments: '{"query":"authoritative"}',
},
{
type: "response.output_item.done",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup" },
},
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"authoritative"}',
},
],
},
},
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
input: { query: "authoritative" },
})
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -1642,7 +1976,10 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
{
type: "response.completed",
response: { output: [item], usage: { input_tokens: 5, output_tokens: 1 } },
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
@@ -1701,6 +2038,40 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("recovers hosted tools from the terminal response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.completed",
response: {
output: [
{
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
},
],
},
}),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
providerExecuted: true,
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
})
}),
)
it.effect("rejects malformed image generation base64", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(