Compare commits

...

5 Commits

Author SHA1 Message Date
Aiden Cline ab03b05c00 fix(ai): replay function call item ids 2026-08-07 22:30:52 -05:00
Aiden Cline 0f67def34d fix(ai): keep response ids provider-owned 2026-08-07 17:15:00 -05:00
Aiden Cline 0657dcbad2 fix(ai): stop generating response item ids 2026-08-07 14:26:50 -05:00
Aiden Cline 82afcfd4e0 fix(ai): generate uuidv7 item ids 2026-08-07 14:04:15 -05:00
Aiden Cline a1cbcc8641 fix(ai): preserve responses item ids 2026-08-07 12:57:48 -05:00
24 changed files with 753 additions and 282 deletions
+125 -64
View File
@@ -90,10 +90,15 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
]) ])
export const InputItem = Schema.Union([ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }), Schema.Struct({
role: Schema.tag("user"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesInputContent),
}),
Schema.Struct({ Schema.Struct({
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesOutputText), content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase), phase: Schema.optionalKey(MessagePhase),
}), }),
@@ -101,19 +106,23 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference, OpenResponsesItemReference,
Schema.Struct({ Schema.Struct({
type: Schema.tag("function_call"), type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String, call_id: Schema.String,
name: Schema.String, name: Schema.String,
arguments: Schema.String, arguments: Schema.String,
}), }),
Schema.Struct({ Schema.Struct({
type: Schema.tag("function_call_output"), type: Schema.tag("function_call_output"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String, call_id: Schema.String,
output: OpenResponsesFunctionCallOutput, output: OpenResponsesFunctionCallOutput,
}), }),
]) ])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem> type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
type LoweredInputItem = type LoweredInputItem =
| OpenResponsesInputItem | OpenResponsesInputItem
| ProviderInputItem
| { | {
readonly role: "assistant" readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }> readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -128,7 +137,7 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }> summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null encrypted_content?: string | null
} }
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
export const Tool = Schema.Struct({ export const Tool = Schema.Struct({
type: Schema.tag("function"), type: Schema.tag("function"),
@@ -254,6 +263,11 @@ export interface Extension {
readonly request: LLMRequest readonly request: LLMRequest
}) => MediaInput | undefined }) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
readonly lowerProviderItem?: (
part: ToolResultPart,
providerMetadataKey: string,
store: boolean | undefined,
) => ProviderInputItem | undefined
} }
const BASE: Extension = { id: ADAPTER, name: NAME } const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -310,36 +324,48 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }), tool: (toolName) => ({ type: "function" as const, name: toolName }),
}) })
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({ const metadataItemID = (
type: "function_call", part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
call_id: part.id, providerMetadataKey: string,
name: part.name, ) => {
arguments: ProviderShared.encodeJson(part.input), if (part.itemId) return part.itemId
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey] const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0 return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId ? metadata.itemId
: undefined : undefined
} }
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const itemId = metadataItemID(part, providerMetadataKey)
return {
type: "function_call",
...(itemId === undefined ? {} : { id: itemId }),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const itemId = metadataItemID(part, providerMetadataKey)
if (!itemId) return undefined
const encryptedContent =
ProviderShared.isRecord(metadata) &&
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
metadataItemID(part, providerMetadataKey)
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* ( const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart, part: MediaPart,
request: LLMRequest, request: LLMRequest,
@@ -397,17 +423,18 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
}) })
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) { const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const options = OpenResponsesOptions.resolve(request)
const system: LoweredInputItem[] = const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system] const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store const store = options.store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses" const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) { for (const message of request.messages) {
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message) const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1) const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user") if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
input[input.length - 1] = { input[input.length - 1] = {
role: "user", role: "user",
content: [...previous.content, { type: "input_text", text: part.text }], content: [...previous.content, { type: "input_text", text: part.text }],
@@ -427,24 +454,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") { if (message.role === "assistant") {
const content: TextPart[] = [] const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {} const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>() const hostedToolItems = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => { const flushText = () => {
if (content.length === 0) return if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>( const groups = content.reduce<
(groups, part) => { Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
const metadata = part.providerMetadata?.[providerMetadataKey] >((groups, part) => {
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined const metadata = part.providerMetadata?.[providerMetadataKey]
const group = groups.at(-1) const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
if (group && group.phase === phase) group.parts.push(part) const itemId = metadataItemID(part, providerMetadataKey)
else groups.push({ phase, parts: [part] }) const group = groups.at(-1)
return groups if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
}, else groups.push({ phase, itemId, parts: [part] })
[], return groups
) }, [])
input.push( input.push(
...groups.map((group) => ({ ...groups.map((group) => ({
role: "assistant" as const, role: "assistant" as const,
...(group.itemId === undefined ? {} : { id: group.itemId }),
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })), content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }), ...(group.phase === undefined ? {} : { phase: group.phase }),
})), })),
@@ -460,11 +487,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText() flushText()
const reasoning = lowerReasoning(part, providerMetadataKey) const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id] const existing = reasoningItems[reasoning.id]
if (existing) { if (existing) {
existing.summary.push(...reasoning.summary) existing.summary.push(...reasoning.summary)
@@ -474,6 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
} }
const replay = { const replay = {
type: reasoning.type, type: reasoning.type,
id: reasoning.id,
summary: reasoning.summary, summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content, encrypted_content: reasoning.encrypted_content,
} }
@@ -484,22 +507,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") { if (part.type === "tool-call") {
flushText() flushText()
if (part.providerExecuted === true) continue if (part.providerExecuted === true) continue
input.push(lowerToolCall(part)) input.push(lowerToolCall(part, providerMetadataKey))
continue continue
} }
if (part.type === "tool-result" && part.providerExecuted === true) { if (part.type === "tool-result" && part.providerExecuted === true) {
flushText() flushText()
const itemID = hostedToolItemID(part, providerMetadataKey) const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID)) const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
input.push({ type: "item_reference", id: itemID }) input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") { if (!providerItem && store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value const content: ReadonlyArray<Content> = part.result.value
input.push({ input.push({
role: "user", role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)), content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
}) })
} }
if (itemID) hostedToolReferences.add(itemID) if (itemID) hostedToolItems.add(itemID)
continue continue
} }
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [ return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -641,9 +666,9 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS] if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const phase = state.messagePhases[id] const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase }) const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata) const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events] return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
} }
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => { const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -652,7 +677,13 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return onOutputTextDelta(state, { ...event, delta: event.text }, id) return onOutputTextDelta(state, { ...event, delta: event.text }, id)
} }
const events: LLMEvent[] = [] const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events] return [
{
...state,
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id }), id),
},
events,
]
} }
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => { export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -663,7 +694,14 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
return [ return [
{ {
...state, ...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), lifecycle: Lifecycle.reasoningDelta(
state.lifecycle,
events,
id,
event.delta,
providerMetadata(state, { itemId: itemID }),
itemID,
),
}, },
events, events,
] ]
@@ -705,7 +743,13 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [ return [
{ {
...state, ...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)), lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${item.id}:0`,
reasoningMetadata(state, item),
item.id,
),
reasoningItems: { reasoningItems: {
...state.reasoningItems, ...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } }, [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
@@ -724,6 +768,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle, lifecycle,
tools: ToolStream.start(state.tools, item.id, { tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id, id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "", name: item.name ?? "",
input: item.arguments ?? "", input: item.arguments ?? "",
providerMetadata: metadata, providerMetadata: metadata,
@@ -731,7 +776,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}, },
[ [
...events, ...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }), LLMEvent.toolInputStart({
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
providerMetadata: metadata,
}),
], ],
] ]
} }
@@ -750,6 +800,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events, events,
`${event.item_id}:0`, `${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }), providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
event.item_id,
), ),
reasoningItems: { reasoningItems: {
...state.reasoningItems, ...state.reasoningItems,
@@ -770,6 +821,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events, events,
`${event.item_id}:${entry[0]}`, `${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }), providerMetadata(state, { itemId: event.item_id }),
event.item_id,
), ),
state.lifecycle, state.lifecycle,
) )
@@ -781,6 +833,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events, events,
`${event.item_id}:${event.summary_index}`, `${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }), providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
event.item_id,
), ),
reasoningItems: { reasoningItems: {
...state.reasoningItems, ...state.reasoningItems,
@@ -816,6 +869,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
events, events,
`${event.item_id}:${event.summary_index}`, `${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }), providerMetadata(state, { itemId: event.item_id }),
event.item_id,
) )
: state.lifecycle, : state.lifecycle,
reasoningItems: { reasoningItems: {
@@ -870,7 +924,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle, state.lifecycle,
events, events,
item.id, item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }), providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
item.id,
), ),
messageItems, messageItems,
messagePhases, messagePhases,
@@ -881,9 +936,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") { if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const tools = state.tools[item.id] const tools = state.tools[item.id]
? state.tools ? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name }) : ToolStream.start(state.tools, item.id, {
id: item.call_id,
itemId: item.id,
name: item.name,
providerMetadata: metadata,
})
const result = const result =
item.arguments === undefined item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id) ? yield* ToolStream.finish(state.id, tools, item.id)
@@ -913,7 +974,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const lifecycle = Object.entries(reasoningItem.summaryParts) const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude") .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce( .reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata), (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
state.lifecycle, state.lifecycle,
) )
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
@@ -921,12 +982,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
} }
if (!state.lifecycle.reasoning.has(item.id)) { if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events) const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata })) events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata })) events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult return [{ ...state, lifecycle }, events] satisfies StepResult
} }
return [ return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) }, { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
events, events,
] satisfies StepResult ] satisfies StepResult
} }
+32 -3
View File
@@ -38,10 +38,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ Schema.Struct({
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })), content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)), phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}), }),
OpenResponses.InputItem, OpenResponses.InputItem,
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
]) ])
const OpenAIResponsesCoreFields = { const OpenAIResponsesCoreFields = {
@@ -80,6 +84,25 @@ const extension = {
mime_type: media.mime, mime_type: media.mime,
} }
}, },
lowerProviderItem: (part, providerMetadataKey, store) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || !ProviderShared.isRecord(metadata.item)) return undefined
if (typeof metadata.item.type !== "string") return undefined
const id = typeof metadata.item.id === "string" ? metadata.item.id : undefined
// The public API requires stored state to replay image-generation items. In
// stateless mode, lower the generated file through the existing user-image fallback.
if (metadata.item.type === "image_generation_call" && store === false) return undefined
if (metadata.item.type === "image_generation_call")
return {
type: metadata.item.type,
...(id === undefined ? {} : { id }),
...(typeof metadata.item.status === "string" ? { status: metadata.item.status } : {}),
...(typeof metadata.item.revised_prompt === "string" ? { revised_prompt: metadata.item.revised_prompt } : {}),
...(typeof metadata.item.result === "string" ? { result: metadata.item.result } : {}),
}
const item: Record<string, unknown> & { type: string } = { ...metadata.item, type: metadata.item.type }
return item
},
} satisfies OpenResponses.Extension } satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => { const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -195,23 +218,29 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
item: HostedToolItem, item: HostedToolItem,
) { ) {
const tool = HOSTED_TOOLS[item.type] const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id }) const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const resultMetadata = OpenResponses.providerMetadata(
state,
item.type === "image_generation_call" ? { itemId: item.id } : { itemId: item.id, item },
)
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events) const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push( events.push(
LLMEvent.toolCall({ LLMEvent.toolCall({
id: item.id, id: item.id,
itemId: item.id,
name: tool.name, name: tool.name,
input: tool.input(item), input: tool.input(item),
providerExecuted: true, providerExecuted: true,
providerMetadata, providerMetadata: callMetadata,
}), }),
LLMEvent.toolResult({ LLMEvent.toolResult({
id: item.id, id: item.id,
itemId: item.id,
name: tool.name, name: tool.name,
result: yield* hostedToolResult(item), result: yield* hostedToolResult(item),
providerExecuted: true, providerExecuted: true,
providerMetadata, providerMetadata: resultMetadata,
}), }),
) )
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
+40 -12
View File
@@ -1,4 +1,10 @@
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema" import {
LLMEvent,
type FinishReasonDetails,
type ProviderMetadata,
type ResponseItemID,
type Usage,
} from "../../schema"
export interface State { export interface State {
readonly stepStarted: boolean readonly stepStarted: boolean
@@ -14,16 +20,29 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true } return { ...state, stepStarted: true }
} }
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { export const textStart = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (state.text.has(id)) return state if (state.text.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, providerMetadata })) events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) } return { ...stepped, text: new Set([...stepped.text, id]) }
} }
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { export const textDelta = (
const started = textStart(state, events, id) state: State,
events.push(LLMEvent.textDelta({ id, text })) events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = textStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.textDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
return started return started
} }
@@ -32,10 +51,11 @@ export const reasoningStart = (
events: LLMEvent[], events: LLMEvent[],
id: string, id: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => { ): State => {
if (state.reasoning.has(id)) return state if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningStart({ id, providerMetadata })) events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
} }
@@ -45,9 +65,10 @@ export const reasoningDelta = (
id: string, id: string,
text: string, text: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => { ): State => {
const started = reasoningStart(state, events, id, providerMetadata) const started = reasoningStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata })) events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
return started return started
} }
@@ -56,19 +77,26 @@ export const reasoningEnd = (
events: LLMEvent[], events: LLMEvent[],
id: string, id: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => { ): State => {
if (!state.reasoning.has(id)) return state if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, providerMetadata })) events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
const reasoning = new Set(stepped.reasoning) const reasoning = new Set(stepped.reasoning)
reasoning.delete(id) reasoning.delete(id)
return { ...stepped, reasoning } return { ...stepped, reasoning }
} }
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (!state.text.has(id)) return state if (!state.text.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, providerMetadata })) events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
const text = new Set(stepped.text) const text = new Set(stepped.text)
text.delete(id) text.delete(id)
return { ...stepped, text } return { ...stepped, text }
+23 -2
View File
@@ -1,5 +1,12 @@
import { Effect } from "effect" import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema" import {
AIError,
LLMEvent,
type ProviderMetadata,
type ResponseItemID,
type ToolCall,
type ToolInputError,
} from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared" import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number type StreamKey = string | number
@@ -10,6 +17,7 @@ type StreamKey = string | number
* so far, not the parsed object. * so far, not the parsed object.
*/ */
export interface PendingTool extends ToolAccumulator { export interface PendingTool extends ToolAccumulator {
readonly itemId?: ResponseItemID
readonly providerExecuted?: boolean readonly providerExecuted?: boolean
readonly providerMetadata?: ProviderMetadata readonly providerMetadata?: ProviderMetadata
} }
@@ -52,6 +60,7 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
const inputStart = (tool: PendingTool) => const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({ LLMEvent.toolInputStart({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined, providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata, providerMetadata: tool.providerMetadata,
@@ -60,6 +69,7 @@ const inputStart = (tool: PendingTool) =>
const inputDelta = (tool: PendingTool, text: string) => const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({ LLMEvent.toolInputDelta({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
text, text,
}) })
@@ -70,6 +80,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
Effect.map((input): ToolCall | ToolInputError => Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({ LLMEvent.toolCall({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
input, input,
providerExecuted: tool.providerExecuted ? true : undefined, providerExecuted: tool.providerExecuted ? true : undefined,
@@ -82,6 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
: Effect.succeed( : Effect.succeed(
LLMEvent.toolInputError({ LLMEvent.toolInputError({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
raw, raw,
}), }),
@@ -93,7 +105,15 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> => const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error" event.type === "tool-input-error"
? [event] ? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event] : [
LLMEvent.toolInputEnd({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerMetadata: tool.providerMetadata,
}),
event,
]
/** Store the updated tool and produce the optional public delta event. */ /** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>( const appendTool = <K extends StreamKey>(
@@ -148,6 +168,7 @@ export const appendOrStart = <K extends StreamKey>(
id, id,
name, name,
input: `${current?.input ?? ""}${delta.text}`, input: `${current?.input ?? ""}${delta.text}`,
itemId: current?.itemId,
providerExecuted: current?.providerExecuted, providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata, providerMetadata: current?.providerMetadata,
} }
+72 -24
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids" import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors" import { ProviderFailureClassification } from "./errors"
@@ -84,6 +84,7 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({ export const TextStart = Schema.Struct({
type: Schema.tag("text-start"), type: Schema.tag("text-start"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" }) }).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart> export type TextStart = Schema.Schema.Type<typeof TextStart>
@@ -92,6 +93,7 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"), type: Schema.tag("text-delta"),
id: ContentBlockID, id: ContentBlockID,
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" }) }).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta> export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -99,6 +101,7 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({ export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"), type: Schema.tag("text-end"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" }) }).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd> export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -106,6 +109,7 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({ export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"), type: Schema.tag("reasoning-start"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" }) }).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart> export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
@@ -114,6 +118,7 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"), type: Schema.tag("reasoning-delta"),
id: ContentBlockID, id: ContentBlockID,
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" }) }).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta> export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
@@ -121,6 +126,7 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({ export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"), type: Schema.tag("reasoning-end"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" }) }).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd> export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -129,6 +135,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"), type: Schema.tag("tool-input-start"),
id: ToolCallID, id: ToolCallID,
name: Schema.String, name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" }) }).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -137,6 +144,7 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({ export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"), type: Schema.tag("tool-input-delta"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
text: Schema.String, text: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputDelta" }) }).annotate({ identifier: "LLM.Event.ToolInputDelta" })
@@ -146,6 +154,7 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"), type: Schema.tag("tool-input-end"),
id: ToolCallID, id: ToolCallID,
name: Schema.String, name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" }) }).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd> export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -154,6 +163,7 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolInputError = Schema.Struct({ export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"), type: Schema.tag("tool-input-error"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
raw: Schema.String, raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" }) }).annotate({ identifier: "LLM.Event.ToolInputError" })
@@ -162,6 +172,7 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({ export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"), type: Schema.tag("tool-call"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
input: Schema.Unknown, input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -172,6 +183,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({ export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"), type: Schema.tag("tool-result"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
result: ToolResultValue, result: ToolResultValue,
output: Schema.optional(ToolOutput), output: Schema.optional(ToolOutput),
@@ -183,6 +195,7 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({ export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"), type: Schema.tag("tool-error"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
message: Schema.String, message: Schema.String,
error: Schema.optional(Schema.Defect()), error: Schema.optional(Schema.Defect()),
@@ -334,12 +347,14 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
interface ContentAssembly { interface ContentAssembly {
readonly contentIndex: number readonly contentIndex: number
readonly text: string readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata readonly providerMetadata?: ProviderMetadata
} }
interface ToolInputAssembly { interface ToolInputAssembly {
readonly name: string readonly name: string
readonly text: string readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata readonly providerMetadata?: ProviderMetadata
} }
@@ -385,11 +400,27 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
} }
} }
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => const textContent = (
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "text",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => const reasoningContent = (
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "reasoning",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({ const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
...state, ...state,
@@ -404,26 +435,32 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)), state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
) )
const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { const ensureText = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
if (state.textParts[id]) return state if (state.textParts[id]) return state
return { return {
...appendContent(state, textContent("", providerMetadata)), ...appendContent(state, textContent("", itemId, providerMetadata)),
textParts: { textParts: {
...state.textParts, ...state.textParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
}, },
} }
} }
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
const started = ensureText(state, event.id, event.providerMetadata) const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
const current = started.textParts[event.id] const current = started.textParts[event.id]
if (!current) return started if (!current) return started
const text = current.text + event.text const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
return { return {
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)), ...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
} }
} }
@@ -431,32 +468,39 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id] const current = state.textParts[event.id]
if (!current) return state if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return { return {
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)), ...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
} }
} }
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { const ensureReasoning = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
if (state.reasoningParts[id]) return state if (state.reasoningParts[id]) return state
return { return {
...appendContent(state, reasoningContent("", providerMetadata)), ...appendContent(state, reasoningContent("", itemId, providerMetadata)),
reasoningParts: { reasoningParts: {
...state.reasoningParts, ...state.reasoningParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
}, },
} }
} }
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
const started = ensureReasoning(state, event.id, event.providerMetadata) const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
const current = started.reasoningParts[event.id] const current = started.reasoningParts[event.id]
if (!current) return started if (!current) return started
const text = current.text + event.text const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
return { return {
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)), ...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
} }
} }
@@ -464,9 +508,10 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
const current = state.reasoningParts[event.id] const current = state.reasoningParts[event.id]
if (!current) return state if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return { return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)), ...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
} }
} }
@@ -474,7 +519,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state, ...state,
toolInputs: { toolInputs: {
...state.toolInputs, ...state.toolInputs,
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, [event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
}, },
}) })
@@ -495,6 +540,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: { [event.id]: {
...current, ...current,
name: event.name, name: event.name,
itemId: event.itemId ?? current.itemId,
providerMetadata: event.providerMetadata ?? current.providerMetadata, providerMetadata: event.providerMetadata ?? current.providerMetadata,
}, },
}, },
@@ -504,6 +550,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
const toolCallContent = (event: ToolCall): ContentPart => const toolCallContent = (event: ToolCall): ContentPart =>
ToolCallPart.make({ ToolCallPart.make({
id: event.id, id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name, name: event.name,
input: event.input, input: event.input,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -513,6 +560,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
const toolResultContent = (event: ToolResult): ContentPart => const toolResultContent = (event: ToolResult): ContentPart =>
ToolResultPart.make({ ToolResultPart.make({
id: event.id, id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name, name: event.name,
result: event.result, result: event.result,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -528,13 +576,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
const next = appendEvent(state, event) const next = appendEvent(state, event)
switch (event.type) { switch (event.type) {
case "text-start": case "text-start":
return ensureText(next, event.id, event.providerMetadata) return ensureText(next, event.id, event.itemId, event.providerMetadata)
case "text-delta": case "text-delta":
return reduceTextDelta(next, event) return reduceTextDelta(next, event)
case "text-end": case "text-end":
return reduceTextEnd(next, event) return reduceTextEnd(next, event)
case "reasoning-start": case "reasoning-start":
return ensureReasoning(next, event.id, event.providerMetadata) return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
case "reasoning-delta": case "reasoning-delta":
return reduceReasoningDelta(next, event) return reduceReasoningDelta(next, event)
case "reasoning-end": case "reasoning-end":
+3
View File
@@ -21,6 +21,9 @@ export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID> export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ResponseItemID = Schema.String
export type ResponseItemID = Schema.Schema.Type<typeof ResponseItemID>
export const ContentBlockID = Schema.String export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID> export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
+7 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record" import { isRecord } from "../utils/record"
@@ -25,6 +25,7 @@ export const SystemPart = Object.assign(systemPartSchema, {
export const TextPart = Schema.Struct({ export const TextPart = Schema.Struct({
type: Schema.Literal("text"), type: Schema.Literal("text"),
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
cache: Schema.optional(CacheHint), cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
@@ -121,6 +122,7 @@ export const ToolCallPart = Object.assign(
Schema.Struct({ Schema.Struct({
type: Schema.Literal("tool-call"), type: Schema.Literal("tool-call"),
id: Schema.String, id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
input: Schema.Unknown, input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -138,6 +140,7 @@ export const ToolResultPart = Object.assign(
Schema.Struct({ Schema.Struct({
type: Schema.Literal("tool-result"), type: Schema.Literal("tool-result"),
id: Schema.String, id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
result: ToolResultValue, result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -154,6 +157,7 @@ export const ToolResultPart = Object.assign(
): ToolResultPart => ({ ): ToolResultPart => ({
type: "tool-result", type: "tool-result",
id: input.id, id: input.id,
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
name: input.name, name: input.name,
result: ToolResultValue.make(input.result, input.resultType), result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted, providerExecuted: input.providerExecuted,
@@ -168,6 +172,7 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
export const ReasoningPart = Schema.Struct({ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"), type: Schema.Literal("reasoning"),
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
encrypted: Schema.optional(Schema.String), encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint), cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -181,7 +186,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
export type ContentPart = Schema.Schema.Type<typeof ContentPart> export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({ export class Message extends Schema.Class<Message>("LLM.Message")({
id: Schema.optional(Schema.String), id: Schema.optional(ResponseItemID),
role: MessageRole, role: MessageRole,
content: Schema.Array(ContentPart), content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+2 -2
View File
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
id: call.id, id: call.id,
name: call.name, name: call.name,
result: settlement.result, result: settlement.result,
providerMetadata: call.providerMetadata, ...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
}), }),
] ]
: [ : [
@@ -88,7 +88,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
name: call.name, name: call.name,
result: settlement.result, result: settlement.result,
output: settlement.output, output: settlement.output,
providerMetadata: call.providerMetadata, ...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
}), }),
], ],
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -5
View File
@@ -8,7 +8,6 @@ import {
type ProviderMetadata, type ProviderMetadata,
type ToolCallPart, type ToolCallPart,
ToolResultPart, ToolResultPart,
type ToolResultValue,
type Usage, type Usage,
} from "../../src/schema" } from "../../src/schema"
import { type Tools, toDefinitions } from "../../src/tool" import { type Tools, toDefinitions } from "../../src/tool"
@@ -61,9 +60,10 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...dispatched.map(([call, dispatched]) => ...dispatched.map(([call, dispatched]) =>
Message.tool({ Message.tool({
id: call.id, id: call.id,
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
name: call.name, name: call.name,
result: dispatched.result, result: dispatched.result,
providerMetadata: call.providerMetadata, providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
}), }),
), ),
], ],
@@ -89,9 +89,15 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
for (const event of events) { for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") { if (event.type === "text-delta" || event.type === "reasoning-delta") {
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text) appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
} else if (event.type === "text-end" || event.type === "reasoning-end") { } else if (event.type === "text-end" || event.type === "reasoning-end") {
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata) appendText(
assistantContent,
event.type === "text-end" ? "text" : "reasoning",
"",
event.itemId,
event.providerMetadata,
)
} else if (event.type === "tool-call") { } else if (event.type === "tool-call") {
assistantContent.push(event) assistantContent.push(event)
if (!event.providerExecuted) toolCalls.push(event) if (!event.providerExecuted) toolCalls.push(event)
@@ -99,6 +105,7 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
assistantContent.push( assistantContent.push(
ToolResultPart.make({ ToolResultPart.make({
id: event.id, id: event.id,
itemId: event.itemId,
name: event.name, name: event.name,
result: event.result, result: event.result,
providerExecuted: true, providerExecuted: true,
@@ -118,6 +125,7 @@ const appendText = (
content: ContentPart[], content: ContentPart[],
type: "text" | "reasoning", type: "text" | "reasoning",
text: string, text: string,
itemId?: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
) => { ) => {
const last = content.at(-1) const last = content.at(-1)
@@ -125,11 +133,12 @@ const appendText = (
content[content.length - 1] = { content[content.length - 1] = {
...last, ...last,
text: `${last.text}${text}`, text: `${last.text}${text}`,
itemId: itemId ?? last.itemId,
providerMetadata: providerMetadata ?? last.providerMetadata, providerMetadata: providerMetadata ?? last.providerMetadata,
} }
return return
} }
content.push({ type, text, providerMetadata }) content.push({ type, text, itemId, providerMetadata })
} }
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => { const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
}, },
}, },
}) })
expect(prepared.body).toEqual({ expect(prepared.body).toMatchObject({
model: "example-model", model: "example-model",
input: [ input: [
{ role: "system", content: "You are concise." }, { role: "system", content: "You are concise." },
@@ -53,6 +53,8 @@ describe("Open Responses-compatible route", () => {
], ],
stream: true, stream: true,
}) })
expect(prepared.body.input[0]).not.toHaveProperty("id")
expect(prepared.body.input[1]).not.toHaveProperty("id")
}), }),
) )
@@ -69,6 +69,9 @@ describe("OpenAI Responses route", () => {
stream: true, stream: true,
max_output_tokens: 20, max_output_tokens: 20,
temperature: 0, temperature: 0,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
}) })
}), }),
) )
@@ -329,7 +332,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate( yield* LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
model: Azure.configure({ model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key", apiKey: "azure-key",
headers: { authorization: "Bearer stale" }, headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"), }).responses("gpt-4.1-mini"),
@@ -410,7 +413,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
expect(prepared.body).toEqual({ expect(prepared.body).toMatchObject({
model: "gpt-4.1-mini", model: "gpt-4.1-mini",
input: [ input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] }, { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
@@ -425,6 +428,65 @@ describe("OpenAI Responses route", () => {
tools: undefined, tools: undefined,
top_p: undefined, top_p: undefined,
}) })
const call = prepared.body.input.find((item) => "type" in item && item.type === "function_call")
const output = prepared.body.input.find((item) => "type" in item && item.type === "function_call_output")
expect(call?.id).toBeUndefined()
expect(output?.id).toBeUndefined()
}),
)
it.effect("does not generate response item ids for client-created history", () =>
Effect.sync(() => {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Working." },
{ type: "reasoning", text: "Thinking." },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "done" }),
],
})
expect(canonical.messages[0]?.id).toBeUndefined()
expect(canonical.messages[1]?.id).toBeUndefined()
expect(canonical.messages[0]?.content.every((part) => part.type === "media" || part.itemId === undefined)).toBe(
true,
)
expect(canonical.messages[1]?.content[0]).not.toHaveProperty("itemId")
}),
)
it.effect("replays provider function call item ids without assigning output ids", () =>
Effect.gen(function* () {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Calling.", itemId: "plain-text" },
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: {},
providerMetadata: { openai: { itemId: "plain-call" } },
}),
]),
Message.tool({ id: "call_1", itemId: "plain-output", name: "lookup", result: "done" }),
],
})
const prepared = yield* compileRequest(canonical)
expect(canonical.messages[0]?.content.map((part) => (part.type === "media" ? undefined : part.itemId))).toEqual([
"plain-text",
undefined,
])
expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: "plain-output" })
expect(prepared.body.input).toEqual([
{ role: "assistant", id: "plain-text", content: [{ type: "output_text", text: "Calling." }] },
{ type: "function_call", id: "plain-call", call_id: "call_1", name: "lookup", arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: '"done"' },
])
}), }),
) )
@@ -864,9 +926,21 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" }, {
{ type: "text-delta", id: "msg_1", text: "!" }, type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "Hello",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "!",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1" },
{ {
type: "step-finish", type: "step-finish",
@@ -923,17 +997,20 @@ describe("OpenAI Responses route", () => {
{ {
type: "text", type: "text",
text: "Checking.", text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } }, itemId: "msg_commentary",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
}, },
{ {
type: "text", type: "text",
text: "Finished.", text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } }, itemId: "msg_final",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
}, },
{ {
type: "text", type: "text",
text: "Unclassified.", text: "Unclassified.",
providerMetadata: { openai: { phase: null } }, itemId: "msg_null",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
}, },
]) ])
@@ -941,16 +1018,19 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
role: "assistant", role: "assistant",
id: "msg_commentary",
content: [{ type: "output_text", text: "Checking." }], content: [{ type: "output_text", text: "Checking." }],
phase: "commentary", phase: "commentary",
}, },
{ {
role: "assistant", role: "assistant",
id: "msg_final",
content: [{ type: "output_text", text: "Finished." }], content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer", phase: "final_answer",
}, },
{ {
role: "assistant", role: "assistant",
id: "msg_null",
content: [{ type: "output_text", text: "Unclassified." }], content: [{ type: "output_text", text: "Unclassified." }],
phase: null, phase: null,
}, },
@@ -1043,12 +1123,24 @@ describe("OpenAI Responses route", () => {
) )
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" }, {
{ type: "text-end", id: "msg_1" }, type: "text-delta",
{ type: "text-start", id: "msg_2" }, id: "msg_1",
{ type: "text-delta", id: "msg_2", text: "Second" }, itemId: "msg_1",
{ type: "text-end", id: "msg_2" }, text: "First",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{
type: "text-delta",
id: "msg_2",
itemId: "msg_2",
text: "Second",
providerMetadata: { openai: { itemId: "msg_2" } },
},
{ type: "text-end", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
]) ])
}), }),
) )
@@ -1068,9 +1160,15 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello") expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([ expect(response.events).toMatchObject([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" }, { type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" }, {
{ type: "text-start", id: "msg_1" }, type: "reasoning-delta",
id: "rs_1",
itemId: "rs_1",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" }, { type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1" },
@@ -1079,8 +1177,8 @@ describe("OpenAI Responses route", () => {
]) ])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([ expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" }, { type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "text", text: "Hello" }, { type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
]) ])
}), }),
) )
@@ -1111,6 +1209,7 @@ describe("OpenAI Responses route", () => {
expect.objectContaining({ expect.objectContaining({
type: "reasoning-end", type: "reasoning-end",
id: "rs_1", id: "rs_1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}), }),
) )
@@ -1151,19 +1250,34 @@ describe("OpenAI Responses route", () => {
{ {
type: "reasoning-start", type: "reasoning-start",
id: "rs_1:0", id: "rs_1:0",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, 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-delta",
id: "rs_1:0",
itemId: "rs_1",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ {
type: "reasoning-start", type: "reasoning-start",
id: "rs_1:1", id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
}, },
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" }, {
type: "reasoning-delta",
id: "rs_1:1",
itemId: "rs_1",
text: "Second",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ {
type: "reasoning-end", type: "reasoning-end",
id: "rs_1:1", id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}, },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } }, { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -1201,8 +1315,8 @@ describe("OpenAI Responses route", () => {
) )
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([ 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:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } }, { type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
]) ])
}), }),
) )
@@ -1250,7 +1364,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
], ],
}) })
expect(body.input[1]).not.toHaveProperty("id") expect(body.input[1]).toHaveProperty("id", "rs_1")
return input.respond( return input.respond(
sseEvents( sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." }, { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1297,6 +1411,7 @@ describe("OpenAI Responses route", () => {
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] }, { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }], summary: [{ type: "summary_text", text: "Checked order." }],
}, },
@@ -1305,7 +1420,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("references stored reasoning items by id", () => it.effect("replays complete stored reasoning items with their id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
@@ -1323,7 +1438,14 @@ describe("OpenAI Responses route", () => {
}), }),
) )
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }]) expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: undefined,
},
])
}), }),
) )
@@ -1432,6 +1554,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [ summary: [
{ type: "summary_text", text: "First" }, { type: "summary_text", text: "First" },
@@ -1511,6 +1634,7 @@ describe("OpenAI Responses route", () => {
outputTokens: 1, outputTokens: 1,
nonCachedInputTokens: 5, nonCachedInputTokens: 5,
cacheReadInputTokens: undefined, cacheReadInputTokens: undefined,
cacheWriteInputTokens: undefined,
reasoningTokens: undefined, reasoningTokens: undefined,
totalTokens: 6, totalTokens: 6,
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
@@ -1521,30 +1645,35 @@ describe("OpenAI Responses route", () => {
{ {
type: "tool-input-start", type: "tool-input-start",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } }, providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-input-delta", type: "tool-input-delta",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
text: '{"query"', text: '{"query"',
}, },
{ {
type: "tool-input-delta", type: "tool-input-delta",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
text: ':"weather"}', text: ':"weather"}',
}, },
{ {
type: "tool-input-end", type: "tool-input-end",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } }, providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-call", type: "tool-call",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
input: { query: "weather" }, input: { query: "weather" },
providerExecuted: undefined, providerExecuted: undefined,
@@ -1564,6 +1693,17 @@ describe("OpenAI Responses route", () => {
usage, usage,
}, },
]) ])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
])
}), }),
) )
@@ -1596,6 +1736,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({ expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error", type: "tool-input-error",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
raw: '{"query":"partial', raw: '{"query":"partial',
}) })
@@ -1652,6 +1793,7 @@ describe("OpenAI Responses route", () => {
{ {
type: "tool-call", type: "tool-call",
id: "ws_1", id: "ws_1",
itemId: "ws_1",
name: "web_search", name: "web_search",
input: { type: "search", query: "effect 4" }, input: { type: "search", query: "effect 4" },
providerExecuted: true, providerExecuted: true,
@@ -1660,11 +1802,35 @@ describe("OpenAI Responses route", () => {
{ {
type: "tool-result", type: "tool-result",
id: "ws_1", id: "ws_1",
itemId: "ws_1",
name: "web_search", name: "web_search",
result: { type: "json", value: item }, result: { type: "json", value: item },
providerExecuted: true, providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
output: undefined,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } }, providerMetadata: { openai: { itemId: "ws_1" } },
}, },
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
metadata: undefined,
cache: undefined,
},
]) ])
}), }),
) )
@@ -1742,6 +1908,7 @@ describe("OpenAI Responses route", () => {
expect(toolCall).toEqual({ expect(toolCall).toEqual({
type: "tool-call", type: "tool-call",
id: "ci_1", id: "ci_1",
itemId: "ci_1",
name: "code_interpreter", name: "code_interpreter",
input: { code: "print(1+1)", container_id: "cnt_xyz" }, input: { code: "print(1+1)", container_id: "cnt_xyz" },
providerExecuted: true, providerExecuted: true,
@@ -1751,10 +1918,12 @@ describe("OpenAI Responses route", () => {
expect(toolResult).toEqual({ expect(toolResult).toEqual({
type: "tool-result", type: "tool-result",
id: "ci_1", id: "ci_1",
itemId: "ci_1",
name: "code_interpreter", name: "code_interpreter",
result: { type: "json", value: item }, result: { type: "json", value: item },
providerExecuted: true, providerExecuted: true,
providerMetadata: { openai: { itemId: "ci_1" } }, providerMetadata: { openai: { itemId: "ci_1", item } },
output: undefined,
}) })
}), }),
) )
+37
View File
@@ -49,6 +49,43 @@ describe("LLMResponse reducer", () => {
expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
}) })
test("assembles response item identity and provider metadata", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.textDelta({
id: "text-block",
itemId: "msg_existing",
text: "Answer",
providerMetadata: { openai: { itemId: "msg_existing" } },
}),
LLMEvent.textEnd({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.reasoningStart({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.reasoningDelta({
id: "reasoning-block",
itemId: "rs_existing",
text: "Thought",
providerMetadata: { openai: { itemId: "rs_existing" } },
}),
LLMEvent.reasoningEnd({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{
type: "text",
text: "Answer",
itemId: "msg_existing",
providerMetadata: { openai: { itemId: "msg_existing" } },
},
{
type: "reasoning",
text: "Thought",
itemId: "rs_existing",
providerMetadata: { openai: { itemId: "rs_existing" } },
},
])
})
test("does not complete ended content without a terminal finish", () => { test("does not complete ended content without a terminal finish", () => {
const state = reduce([ const state = reduce([
LLMEvent.textStart({ id: "t1" }), LLMEvent.textStart({ id: "t1" }),
+29 -5
View File
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }]) expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" }) expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] }) expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_projected", id: "call_projected",
name: "projected", name: "projected",
@@ -180,6 +180,7 @@ describe("LLMClient tools", () => {
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] }, output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
}), }),
]) ])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}), }),
) )
@@ -197,7 +198,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }), LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
) )
expect(dispatched.events).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_1", id: "call_1",
name: "tool", name: "tool",
@@ -206,12 +207,13 @@ describe("LLMClient tools", () => {
providerMetadata, providerMetadata,
}), }),
]) ])
expect(dispatched.events[0]?.itemId).toBeUndefined()
const failed = yield* ToolRuntime.dispatch( const failed = yield* ToolRuntime.dispatch(
{}, {},
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }), LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
) )
expect(failed.events).toEqual([ expect(failed.events).toMatchObject([
LLMEvent.toolError({ LLMEvent.toolError({
id: "call_2", id: "call_2",
name: "missing", name: "missing",
@@ -225,6 +227,27 @@ describe("LLMClient tools", () => {
providerMetadata, providerMetadata,
}), }),
]) ])
const errorItemID = failed.events.find(LLMEvent.is.toolError)?.itemId
const resultItemID = failed.events.find(LLMEvent.is.toolResult)?.itemId
expect(errorItemID).toBeUndefined()
expect(resultItemID).toBeUndefined()
}),
)
it.effect("does not derive a function output item id from the function call item id", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", itemId: "fc_existing", name: "tool", input: {} }),
)
expect(dispatched.events.find(LLMEvent.is.toolResult)?.itemId).toBeUndefined()
}), }),
) )
@@ -437,7 +460,7 @@ describe("LLMClient tools", () => {
) )
expect(dispatched.result).toEqual(callerOwned) expect(dispatched.result).toEqual(callerOwned)
expect(dispatched.events).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_1", id: "call_1",
name: "eventful", name: "eventful",
@@ -445,6 +468,7 @@ describe("LLMClient tools", () => {
output: { structured: { ok: true }, content: [] }, output: { structured: { ok: true }, content: [] },
}), }),
]) ])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}), }),
) )