mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 01:59:46 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab03b05c00 | |||
| 0f67def34d | |||
| 0657dcbad2 | |||
| 82afcfd4e0 | |||
| a1cbcc8641 |
@@ -90,10 +90,15 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
])
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("user"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
content: Schema.Array(OpenResponsesInputContent),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
@@ -101,19 +106,23 @@ export const InputItem = Schema.Union([
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call_output"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| ProviderInputItem
|
||||
| {
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
@@ -128,7 +137,7 @@ type OpenResponsesReasoningInput = {
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
|
||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
@@ -254,6 +263,11 @@ export interface Extension {
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | 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 }
|
||||
@@ -310,36 +324,48 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||
})
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
|
||||
type: "function_call",
|
||||
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]
|
||||
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 metadataItemID = (
|
||||
part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
|
||||
providerMetadataKey: string,
|
||||
) => {
|
||||
if (part.itemId) return part.itemId
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||
? metadata.itemId
|
||||
: 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* (
|
||||
part: MediaPart,
|
||||
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 options = OpenResponsesOptions.resolve(request)
|
||||
const system: LoweredInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const store = options.store
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
|
||||
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] = {
|
||||
role: "user",
|
||||
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") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const hostedToolItems = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
(groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ phase, parts: [part] })
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
)
|
||||
const groups = content.reduce<
|
||||
Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||
const itemId = metadataItemID(part, providerMetadataKey)
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
|
||||
else groups.push({ phase, itemId, parts: [part] })
|
||||
return groups
|
||||
}, [])
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
role: "assistant" as const,
|
||||
...(group.itemId === undefined ? {} : { id: group.itemId }),
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
@@ -460,11 +487,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
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]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
@@ -474,6 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
const replay = {
|
||||
type: reasoning.type,
|
||||
id: reasoning.id,
|
||||
summary: reasoning.summary,
|
||||
encrypted_content: reasoning.encrypted_content,
|
||||
}
|
||||
@@ -484,22 +507,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part))
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
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 })
|
||||
if (store === false && part.result.type === "content") {
|
||||
if (!providerItem && store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
})
|
||||
}
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
if (itemID) hostedToolItems.add(itemID)
|
||||
continue
|
||||
}
|
||||
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]
|
||||
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) }, events]
|
||||
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
|
||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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 => {
|
||||
@@ -663,7 +694,14 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
return [
|
||||
{
|
||||
...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,
|
||||
]
|
||||
@@ -705,7 +743,13 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
return [
|
||||
{
|
||||
...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: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||
@@ -724,6 +768,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id ?? item.id,
|
||||
itemId: item.id,
|
||||
name: item.name ?? "",
|
||||
input: item.arguments ?? "",
|
||||
providerMetadata: metadata,
|
||||
@@ -731,7 +776,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
},
|
||||
[
|
||||
...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,
|
||||
`${event.item_id}:0`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
|
||||
event.item_id,
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
@@ -770,6 +821,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
event.item_id,
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
@@ -781,6 +833,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
event.item_id,
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
@@ -816,6 +869,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
event.item_id,
|
||||
)
|
||||
: state.lifecycle,
|
||||
reasoningItems: {
|
||||
@@ -870,7 +924,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
item.id,
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
@@ -881,9 +936,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (item.type === "function_call") {
|
||||
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]
|
||||
? 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 =
|
||||
item.arguments === undefined
|
||||
? 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)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.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,
|
||||
)
|
||||
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)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, itemId: 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: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -38,10 +38,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
]),
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
@@ -80,6 +84,25 @@ const extension = {
|
||||
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
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
@@ -195,23 +218,29 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
item: HostedToolItem,
|
||||
) {
|
||||
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 lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
itemId: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
providerMetadata: callMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
itemId: item.id,
|
||||
name: tool.name,
|
||||
result: yield* hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
providerMetadata: resultMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
|
||||
@@ -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 {
|
||||
readonly stepStarted: boolean
|
||||
@@ -14,16 +20,29 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
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
|
||||
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]) }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = textStart(state, events, id)
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
export const textDelta = (
|
||||
state: State,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,10 +51,11 @@ export const reasoningStart = (
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
if (state.reasoning.has(id)) return state
|
||||
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]) }
|
||||
}
|
||||
|
||||
@@ -45,9 +65,10 @@ export const reasoningDelta = (
|
||||
id: string,
|
||||
text: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
const started = reasoningStart(state, events, id, providerMetadata)
|
||||
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
|
||||
const started = reasoningStart(state, events, id, providerMetadata, itemId)
|
||||
events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
|
||||
return started
|
||||
}
|
||||
|
||||
@@ -56,19 +77,26 @@ export const reasoningEnd = (
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
if (!state.reasoning.has(id)) return state
|
||||
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)
|
||||
reasoning.delete(id)
|
||||
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
|
||||
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)
|
||||
text.delete(id)
|
||||
return { ...stepped, text }
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -10,6 +17,7 @@ type StreamKey = string | number
|
||||
* so far, not the parsed object.
|
||||
*/
|
||||
export interface PendingTool extends ToolAccumulator {
|
||||
readonly itemId?: ResponseItemID
|
||||
readonly providerExecuted?: boolean
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
@@ -52,6 +60,7 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
||||
const inputStart = (tool: PendingTool) =>
|
||||
LLMEvent.toolInputStart({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
@@ -60,6 +69,7 @@ const inputStart = (tool: PendingTool) =>
|
||||
const inputDelta = (tool: PendingTool, text: string) =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
text,
|
||||
})
|
||||
@@ -70,6 +80,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
Effect.map((input): ToolCall | ToolInputError =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
@@ -82,6 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
: Effect.succeed(
|
||||
LLMEvent.toolInputError({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
raw,
|
||||
}),
|
||||
@@ -93,7 +105,15 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||
event.type === "tool-input-error"
|
||||
? [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. */
|
||||
const appendTool = <K extends StreamKey>(
|
||||
@@ -148,6 +168,7 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
id,
|
||||
name,
|
||||
input: `${current?.input ?? ""}${delta.text}`,
|
||||
itemId: current?.itemId,
|
||||
providerExecuted: current?.providerExecuted,
|
||||
providerMetadata: current?.providerMetadata,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ProviderFailureClassification } from "./errors"
|
||||
|
||||
@@ -84,6 +84,7 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
|
||||
export const TextStart = Schema.Struct({
|
||||
type: Schema.tag("text-start"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||
@@ -92,6 +93,7 @@ export const TextDelta = Schema.Struct({
|
||||
type: Schema.tag("text-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.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({
|
||||
type: Schema.tag("text-end"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.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({
|
||||
type: Schema.tag("reasoning-start"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
||||
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
||||
@@ -114,6 +118,7 @@ export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.tag("reasoning-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.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({
|
||||
type: Schema.tag("reasoning-end"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
@@ -129,6 +135,7 @@ export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
@@ -137,6 +144,7 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||
export const ToolInputDelta = Schema.Struct({
|
||||
type: Schema.tag("tool-input-delta"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
@@ -146,6 +154,7 @@ export const ToolInputEnd = Schema.Struct({
|
||||
type: Schema.tag("tool-input-end"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.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({
|
||||
type: Schema.tag("tool-input-error"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
raw: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||
@@ -162,6 +172,7 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||
export const ToolCall = Schema.Struct({
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -172,6 +183,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
export const ToolResult = Schema.Struct({
|
||||
type: Schema.tag("tool-result"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
output: Schema.optional(ToolOutput),
|
||||
@@ -183,6 +195,7 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||
export const ToolError = Schema.Struct({
|
||||
type: Schema.tag("tool-error"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect()),
|
||||
@@ -334,12 +347,14 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
|
||||
interface ContentAssembly {
|
||||
readonly contentIndex: number
|
||||
readonly text: string
|
||||
readonly itemId?: ResponseItemID
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
|
||||
interface ToolInputAssembly {
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
readonly itemId?: ResponseItemID
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
|
||||
@@ -385,11 +400,27 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
}
|
||||
}
|
||||
|
||||
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
|
||||
const textContent = (
|
||||
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 =>
|
||||
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
|
||||
const reasoningContent = (
|
||||
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 => ({
|
||||
...state,
|
||||
@@ -404,26 +435,32 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
|
||||
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
|
||||
return {
|
||||
...appendContent(state, textContent("", providerMetadata)),
|
||||
...appendContent(state, textContent("", itemId, providerMetadata)),
|
||||
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 started = ensureText(state, event.id, event.providerMetadata)
|
||||
const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
|
||||
const current = started.textParts[event.id]
|
||||
if (!current) return started
|
||||
const text = current.text + event.text
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
|
||||
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
...replaceContent(started, current.contentIndex, textContent(text, itemId, 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]
|
||||
if (!current) return state
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
|
||||
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, textContent(current.text, itemId, 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
|
||||
return {
|
||||
...appendContent(state, reasoningContent("", providerMetadata)),
|
||||
...appendContent(state, reasoningContent("", itemId, providerMetadata)),
|
||||
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 started = ensureReasoning(state, event.id, event.providerMetadata)
|
||||
const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
|
||||
const current = started.reasoningParts[event.id]
|
||||
if (!current) return started
|
||||
const text = current.text + event.text
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
|
||||
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, 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]
|
||||
if (!current) return state
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,7 +519,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
|
||||
...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]: {
|
||||
...current,
|
||||
name: event.name,
|
||||
itemId: event.itemId ?? current.itemId,
|
||||
providerMetadata: event.providerMetadata ?? current.providerMetadata,
|
||||
},
|
||||
},
|
||||
@@ -504,6 +550,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
||||
const toolCallContent = (event: ToolCall): ContentPart =>
|
||||
ToolCallPart.make({
|
||||
id: event.id,
|
||||
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
|
||||
name: event.name,
|
||||
input: event.input,
|
||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||
@@ -513,6 +560,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
|
||||
const toolResultContent = (event: ToolResult): ContentPart =>
|
||||
ToolResultPart.make({
|
||||
id: event.id,
|
||||
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
|
||||
name: event.name,
|
||||
result: event.result,
|
||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||
@@ -528,13 +576,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
||||
const next = appendEvent(state, event)
|
||||
switch (event.type) {
|
||||
case "text-start":
|
||||
return ensureText(next, event.id, event.providerMetadata)
|
||||
return ensureText(next, event.id, event.itemId, event.providerMetadata)
|
||||
case "text-delta":
|
||||
return reduceTextDelta(next, event)
|
||||
case "text-end":
|
||||
return reduceTextEnd(next, event)
|
||||
case "reasoning-start":
|
||||
return ensureReasoning(next, event.id, event.providerMetadata)
|
||||
return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
|
||||
case "reasoning-delta":
|
||||
return reduceReasoningDelta(next, event)
|
||||
case "reasoning-end":
|
||||
|
||||
@@ -21,6 +21,9 @@ export type ProviderID = typeof ProviderID.Type
|
||||
export const ResponseID = Schema.String
|
||||
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 type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
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 { isRecord } from "../utils/record"
|
||||
|
||||
@@ -25,6 +25,7 @@ export const SystemPart = Object.assign(systemPartSchema, {
|
||||
export const TextPart = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -121,6 +122,7 @@ export const ToolCallPart = Object.assign(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tool-call"),
|
||||
id: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -138,6 +140,7 @@ export const ToolResultPart = Object.assign(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tool-result"),
|
||||
id: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -154,6 +157,7 @@ export const ToolResultPart = Object.assign(
|
||||
): ToolResultPart => ({
|
||||
type: "tool-result",
|
||||
id: input.id,
|
||||
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
|
||||
name: input.name,
|
||||
result: ToolResultValue.make(input.result, input.resultType),
|
||||
providerExecuted: input.providerExecuted,
|
||||
@@ -168,6 +172,7 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
|
||||
export const ReasoningPart = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
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 class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
id: Schema.optional(Schema.String),
|
||||
id: Schema.optional(ResponseItemID),
|
||||
role: MessageRole,
|
||||
content: Schema.Array(ContentPart),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
|
||||
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
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,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
providerMetadata: call.providerMetadata,
|
||||
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
+11
-6
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
-54
File diff suppressed because one or more lines are too long
-28
File diff suppressed because one or more lines are too long
-28
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+10
-4
File diff suppressed because one or more lines are too long
+38
File diff suppressed because one or more lines are too long
Vendored
+39
File diff suppressed because one or more lines are too long
Vendored
+57
File diff suppressed because one or more lines are too long
@@ -8,7 +8,6 @@ import {
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
ToolResultPart,
|
||||
type ToolResultValue,
|
||||
type Usage,
|
||||
} from "../../src/schema"
|
||||
import { type Tools, toDefinitions } from "../../src/tool"
|
||||
@@ -61,9 +60,10 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
|
||||
...dispatched.map(([call, dispatched]) =>
|
||||
Message.tool({
|
||||
id: call.id,
|
||||
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
|
||||
name: call.name,
|
||||
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) {
|
||||
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") {
|
||||
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") {
|
||||
assistantContent.push(event)
|
||||
if (!event.providerExecuted) toolCalls.push(event)
|
||||
@@ -99,6 +105,7 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
assistantContent.push(
|
||||
ToolResultPart.make({
|
||||
id: event.id,
|
||||
itemId: event.itemId,
|
||||
name: event.name,
|
||||
result: event.result,
|
||||
providerExecuted: true,
|
||||
@@ -118,6 +125,7 @@ const appendText = (
|
||||
content: ContentPart[],
|
||||
type: "text" | "reasoning",
|
||||
text: string,
|
||||
itemId?: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
) => {
|
||||
const last = content.at(-1)
|
||||
@@ -125,11 +133,12 @@ const appendText = (
|
||||
content[content.length - 1] = {
|
||||
...last,
|
||||
text: `${last.text}${text}`,
|
||||
itemId: itemId ?? last.itemId,
|
||||
providerMetadata: providerMetadata ?? last.providerMetadata,
|
||||
}
|
||||
return
|
||||
}
|
||||
content.push({ type, text, providerMetadata })
|
||||
content.push({ type, text, itemId, providerMetadata })
|
||||
}
|
||||
|
||||
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",
|
||||
input: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
@@ -53,6 +53,8 @@ describe("Open Responses-compatible route", () => {
|
||||
],
|
||||
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,
|
||||
max_output_tokens: 20,
|
||||
temperature: 0,
|
||||
tool_choice: undefined,
|
||||
tools: undefined,
|
||||
top_p: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -329,7 +332,7 @@ describe("OpenAI Responses route", () => {
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/",
|
||||
apiKey: "azure-key",
|
||||
headers: { authorization: "Bearer stale" },
|
||||
}).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",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
|
||||
@@ -425,6 +428,65 @@ describe("OpenAI Responses route", () => {
|
||||
tools: 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.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{
|
||||
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: "step-finish",
|
||||
@@ -923,17 +997,20 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
itemId: "msg_commentary",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
itemId: "msg_final",
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
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([
|
||||
{
|
||||
role: "assistant",
|
||||
id: "msg_commentary",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
id: "msg_final",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
id: "msg_null",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
@@ -1043,12 +1123,24 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_2" },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||
{ type: "text-end", id: "msg_2" },
|
||||
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{
|
||||
type: "text-delta",
|
||||
id: "msg_1",
|
||||
itemId: "msg_1",
|
||||
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.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "rs_1" },
|
||||
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_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: "reasoning-end", id: "rs_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.message.content).toEqual([
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1111,6 +1209,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect.objectContaining({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
}),
|
||||
)
|
||||
@@ -1151,19 +1250,34 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
itemId: "rs_1",
|
||||
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",
|
||||
id: "rs_1:1",
|
||||
itemId: "rs_1",
|
||||
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",
|
||||
id: "rs_1:1",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ 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([
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", 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", 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." }] },
|
||||
],
|
||||
})
|
||||
expect(body.input[1]).not.toHaveProperty("id")
|
||||
expect(body.input[1]).toHaveProperty("id", "rs_1")
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ 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." }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
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* () {
|
||||
const prepared = yield* compileRequest(
|
||||
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([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
@@ -1511,6 +1634,7 @@ describe("OpenAI Responses route", () => {
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: undefined,
|
||||
cacheWriteInputTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
||||
@@ -1521,30 +1645,35 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
@@ -1564,6 +1693,17 @@ describe("OpenAI Responses route", () => {
|
||||
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({
|
||||
type: "tool-input-error",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
@@ -1652,6 +1793,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "ws_1",
|
||||
itemId: "ws_1",
|
||||
name: "web_search",
|
||||
input: { type: "search", query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
@@ -1660,11 +1802,35 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
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 } },
|
||||
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" } },
|
||||
},
|
||||
{
|
||||
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({
|
||||
type: "tool-call",
|
||||
id: "ci_1",
|
||||
itemId: "ci_1",
|
||||
name: "code_interpreter",
|
||||
input: { code: "print(1+1)", container_id: "cnt_xyz" },
|
||||
providerExecuted: true,
|
||||
@@ -1751,10 +1918,12 @@ describe("OpenAI Responses route", () => {
|
||||
expect(toolResult).toEqual({
|
||||
type: "tool-result",
|
||||
id: "ci_1",
|
||||
itemId: "ci_1",
|
||||
name: "code_interpreter",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ci_1" } },
|
||||
providerMetadata: { openai: { itemId: "ci_1", item } },
|
||||
output: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -49,6 +49,43 @@ describe("LLMResponse reducer", () => {
|
||||
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", () => {
|
||||
const state = reduce([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
|
||||
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { 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.events).toEqual([
|
||||
expect(dispatched.events).toMatchObject([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_projected",
|
||||
name: "projected",
|
||||
@@ -180,6 +180,7 @@ describe("LLMClient tools", () => {
|
||||
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 }),
|
||||
)
|
||||
|
||||
expect(dispatched.events).toEqual([
|
||||
expect(dispatched.events).toMatchObject([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "tool",
|
||||
@@ -206,12 +207,13 @@ describe("LLMClient tools", () => {
|
||||
providerMetadata,
|
||||
}),
|
||||
])
|
||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
||||
|
||||
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({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
@@ -225,6 +227,27 @@ describe("LLMClient tools", () => {
|
||||
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.events).toEqual([
|
||||
expect(dispatched.events).toMatchObject([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "eventful",
|
||||
@@ -445,6 +468,7 @@ describe("LLMClient tools", () => {
|
||||
output: { structured: { ok: true }, content: [] },
|
||||
}),
|
||||
])
|
||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user