mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5483cd6e2b | |||
| 50f76827bf | |||
| a619814c79 |
@@ -70,10 +70,14 @@ const OpenResponsesReasoningItem = Schema.Struct({
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenResponsesItemReference = Schema.Struct({
|
||||
type: Schema.tag("item_reference"),
|
||||
id: Schema.String,
|
||||
})
|
||||
export const ProviderItem = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
id: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type OpenResponsesProviderItem = Schema.Schema.Type<typeof ProviderItem>
|
||||
|
||||
// `function_call_output.output` accepts either a plain string or an ordered
|
||||
// array of content items so tools can return images and files in addition to text.
|
||||
@@ -91,29 +95,42 @@ 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({
|
||||
type: Schema.optionalKey(Schema.tag("message")),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("user"),
|
||||
content: Schema.Array(OpenResponsesInputContent),
|
||||
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.optionalKey(Schema.tag("message")),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call_output"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
|
||||
}),
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| OpenResponsesProviderItem
|
||||
| {
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
@@ -128,7 +145,7 @@ type OpenResponsesReasoningInput = {
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
|
||||
type OpenResponsesReasoningReplay = OpenResponsesReasoningInput
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
@@ -269,10 +286,9 @@ export interface ParserState {
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly encryptedContent: string | null | undefined
|
||||
@@ -310,34 +326,82 @@ 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 responseItemID = (prefix: string, id: string) => {
|
||||
const value = id.startsWith("call_") ? id.slice(5) : id
|
||||
const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "item"
|
||||
const direct = `${prefix}_${sanitized}`
|
||||
if (value === sanitized && direct.length <= 64) return direct
|
||||
const hash = Array.from(id).reduce(
|
||||
(hash, character) => BigInt.asUintN(64, (hash ^ BigInt(character.codePointAt(0) ?? 0)) * 1099511628211n),
|
||||
14695981039346656037n,
|
||||
)
|
||||
const suffix = hash.toString(36)
|
||||
return `${prefix}_${sanitized.slice(0, 62 - prefix.length - suffix.length)}_${suffix}`
|
||||
}
|
||||
|
||||
const validResponseItemID = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length <= 64 && /^[a-zA-Z0-9]+_.+$/.test(value)
|
||||
|
||||
const responseItemMetadata = (part: { readonly providerMetadata?: ProviderMetadata }, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) ? metadata : undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const metadata = responseItemMetadata(part, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
id: validResponseItemID(metadata?.itemId) ? metadata.itemId : responseItemID("fc", part.id),
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
status: "completed",
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
|
||||
const encryptedContent =
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
: undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id: metadata.itemId,
|
||||
id: validResponseItemID(metadata.itemId) ? metadata.itemId : responseItemID("rs", 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]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
const hostedToolItem = (part: ToolResultPart, providerMetadataKey: string): OpenResponsesProviderItem | undefined => {
|
||||
const metadata = responseItemMetadata(part, providerMetadataKey)
|
||||
if (
|
||||
ProviderShared.isRecord(metadata?.responseItem) &&
|
||||
typeof metadata.responseItem.id === "string" &&
|
||||
typeof metadata.responseItem.type === "string"
|
||||
)
|
||||
return {
|
||||
...metadata.responseItem,
|
||||
type: metadata.responseItem.type,
|
||||
id: validResponseItemID(metadata.responseItem.id)
|
||||
? metadata.responseItem.id
|
||||
: responseItemID("item", metadata.responseItem.id),
|
||||
}
|
||||
if (
|
||||
part.result.type === "json" &&
|
||||
ProviderShared.isRecord(part.result.value) &&
|
||||
typeof part.result.value.id === "string" &&
|
||||
typeof part.result.value.type === "string"
|
||||
)
|
||||
return {
|
||||
...part.result.value,
|
||||
type: part.result.value.type,
|
||||
id: validResponseItemID(part.result.value.id)
|
||||
? part.result.value.id
|
||||
: responseItemID("item", part.result.value.id),
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
@@ -400,14 +464,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
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 providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
// `store` controls server persistence, not client-managed history. Replay the
|
||||
// same stable item identities for stored and stateless requests.
|
||||
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,8 +492,8 @@ 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>()
|
||||
let textItemIndex = 0
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
@@ -443,11 +508,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
[],
|
||||
)
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
role: "assistant" as const,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
...groups.map((group) => {
|
||||
const index = textItemIndex++
|
||||
const first = group.parts[0]
|
||||
const metadata = first ? responseItemMetadata(first, providerMetadataKey) : undefined
|
||||
const id = validResponseItemID(metadata?.itemId)
|
||||
? metadata.itemId
|
||||
: message.id === undefined && typeof metadata?.itemId !== "string"
|
||||
? undefined
|
||||
: index === 0 && validResponseItemID(message.id)
|
||||
? message.id
|
||||
: responseItemID("msg", `${message.id ?? metadata?.itemId}_${index}`)
|
||||
return {
|
||||
type: "message" as const,
|
||||
...(id === undefined ? {} : { id }),
|
||||
role: "assistant" as const,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
status: "completed" as const,
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
}
|
||||
}),
|
||||
)
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
@@ -460,11 +540,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)
|
||||
@@ -472,11 +547,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
const replay = {
|
||||
type: reasoning.type,
|
||||
summary: reasoning.summary,
|
||||
encrypted_content: reasoning.encrypted_content,
|
||||
}
|
||||
const replay = { ...reasoning }
|
||||
reasoningItems[reasoning.id] = replay
|
||||
input.push(replay)
|
||||
continue
|
||||
@@ -484,22 +555,21 @@ 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))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const item = hostedToolItem(part, providerMetadataKey)
|
||||
if (item && !hostedToolItems.has(item.id)) input.push(item)
|
||||
if (!item && 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 (item) hostedToolItems.add(item.id)
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -518,20 +588,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
id: responseItemID("fco", part.id),
|
||||
call_id: part.id,
|
||||
output: yield* lowerToolResultOutput(part, request, extension),
|
||||
status: "completed",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With store:false, Responses APIs only accept previous reasoning items when the
|
||||
// complete item has encrypted state. Summary blocks for one item may carry
|
||||
// that state only on the last block, so filter after they have been joined.
|
||||
return store === false
|
||||
? input.filter(
|
||||
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
|
||||
)
|
||||
: input
|
||||
return input
|
||||
})
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
@@ -641,7 +706,7 @@ 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 metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
||||
}
|
||||
@@ -652,7 +717,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 })),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
@@ -761,23 +832,11 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
}
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
closed,
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
@@ -787,11 +846,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
||||
),
|
||||
),
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: "active",
|
||||
},
|
||||
},
|
||||
@@ -809,22 +864,13 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle:
|
||||
state.store !== false
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
)
|
||||
: state.lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
|
||||
[event.summary_index]: "can-conclude",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -870,7 +916,10 @@ 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 }),
|
||||
}),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
@@ -1037,7 +1086,6 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
|
||||
@@ -241,8 +241,6 @@ export interface ParserState {
|
||||
readonly reasoningEmitted: boolean
|
||||
readonly latestToolIndex?: number
|
||||
readonly nextToolIndex: number
|
||||
readonly outputStarted: boolean
|
||||
readonly requireFinishReason: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -709,7 +707,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Boolean(delta?.content) ||
|
||||
reasoning !== undefined ||
|
||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||
toolDeltas.some(
|
||||
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
|
||||
)
|
||||
if (state.finishReason !== undefined) {
|
||||
if (hasLateContent)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
||||
@@ -749,7 +749,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
||||
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
||||
const index =
|
||||
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||
tool.index ?? matched ??
|
||||
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||
const current = tools[index]
|
||||
const pending = pendingTools[index]
|
||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||
@@ -805,8 +806,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
reasoningEmitted,
|
||||
latestToolIndex,
|
||||
nextToolIndex,
|
||||
outputStarted: state.outputStarted || hasLateContent,
|
||||
requireFinishReason: state.requireFinishReason,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
@@ -837,23 +836,6 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
return events
|
||||
}
|
||||
|
||||
const onHalt = (state: ParserState) =>
|
||||
Effect.gen(function* () {
|
||||
if (state.finishReason !== undefined || state.requireFinishReason) return finishEvents(state)
|
||||
if (!state.outputStarted) return []
|
||||
if (Object.keys(state.pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
|
||||
// Chat has no per-call stop event, so an accepted EOF must finalize every
|
||||
// accumulated tool input before publishing the synthetic terminal reason.
|
||||
const finished = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
return finishEvents({
|
||||
...state,
|
||||
tools: finished.tools,
|
||||
toolCallEvents: finished.events,
|
||||
finishReason: { normalized: "unknown" },
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Protocol And OpenAI Route
|
||||
// =============================================================================
|
||||
@@ -881,11 +863,9 @@ export const protocol = Protocol.make({
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
nextToolIndex: 0,
|
||||
outputStarted: false,
|
||||
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
|
||||
}),
|
||||
step,
|
||||
onHalt,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export const route = Route.make({
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { openresponses: { store: false } } },
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||
|
||||
@@ -37,10 +37,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.optionalKey(Schema.tag("message")),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.ProviderItem,
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
@@ -195,7 +199,8 @@ 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, { itemId: item.id, responseItem: item })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
@@ -204,14 +209,14 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
providerMetadata: callMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: yield* hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
providerMetadata: resultMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
|
||||
@@ -2,11 +2,11 @@ import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage }
|
||||
|
||||
export interface State {
|
||||
readonly stepStarted: boolean
|
||||
readonly text: ReadonlySet<string>
|
||||
readonly reasoning: ReadonlySet<string>
|
||||
readonly text: ReadonlyMap<string, ProviderMetadata | undefined>
|
||||
readonly reasoning: ReadonlyMap<string, ProviderMetadata | undefined>
|
||||
}
|
||||
|
||||
export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
|
||||
export const initial = (): State => ({ stepStarted: false, text: new Map(), reasoning: new Map() })
|
||||
|
||||
export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
if (state.stepStarted) return state
|
||||
@@ -18,7 +18,7 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
|
||||
if (state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
return { ...stepped, text: new Map([...stepped.text, [id, providerMetadata]]) }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
@@ -36,7 +36,7 @@ export const reasoningStart = (
|
||||
if (state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
|
||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||
return { ...stepped, reasoning: new Map([...stepped.reasoning, [id, providerMetadata]]) }
|
||||
}
|
||||
|
||||
export const reasoningDelta = (
|
||||
@@ -59,8 +59,10 @@ export const reasoningEnd = (
|
||||
): State => {
|
||||
if (!state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
||||
const reasoning = new Set(stepped.reasoning)
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({ id, providerMetadata: mergeMetadata(stepped.reasoning.get(id), providerMetadata) }),
|
||||
)
|
||||
const reasoning = new Map(stepped.reasoning)
|
||||
reasoning.delete(id)
|
||||
return { ...stepped, reasoning }
|
||||
}
|
||||
@@ -68,16 +70,24 @@ export const reasoningEnd = (
|
||||
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (!state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||
const text = new Set(stepped.text)
|
||||
events.push(LLMEvent.textEnd({ id, providerMetadata: mergeMetadata(stepped.text.get(id), providerMetadata) }))
|
||||
const text = new Map(stepped.text)
|
||||
text.delete(id)
|
||||
return { ...stepped, text }
|
||||
}
|
||||
|
||||
const mergeMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => {
|
||||
if (left === undefined) return right
|
||||
if (right === undefined) return left
|
||||
return Object.fromEntries(
|
||||
Array.from(new Set([...Object.keys(left), ...Object.keys(right)]), (key) => [key, { ...left[key], ...right[key] }]),
|
||||
)
|
||||
}
|
||||
|
||||
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
|
||||
for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
|
||||
for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
|
||||
return { ...state, text: new Set(), reasoning: new Set() }
|
||||
for (const [id, providerMetadata] of state.reasoning) events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
||||
for (const [id, providerMetadata] of state.text) events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||
return { ...state, text: new Map(), reasoning: new Map() }
|
||||
}
|
||||
|
||||
export const finish = (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol, ProtocolStream } from "./protocol"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
import * as ProviderShared from "../protocols/shared"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema"
|
||||
@@ -243,56 +243,28 @@ const incompleteStreamError = (route: string) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const ensureTerminalEvent = (route: string, required: boolean) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
||||
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
||||
Stream.suspend(() => {
|
||||
let terminal = false
|
||||
let output = false
|
||||
const fallback = Stream.suspend(() => {
|
||||
if (terminal) return Stream.empty
|
||||
if (required || !output) return Stream.fail(incompleteStreamError(route))
|
||||
// The compatibility override trusts a clean stream end, but it cannot
|
||||
// recover the provider's omitted reason.
|
||||
const reason = { normalized: "unknown" as const }
|
||||
return Stream.make(LLMEvent.stepFinish({ index: 0, reason }), LLMEvent.finish({ reason }))
|
||||
})
|
||||
return events.pipe(
|
||||
Stream.mapEffect((event) => {
|
||||
if (terminal)
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal event`),
|
||||
)
|
||||
output = true
|
||||
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
||||
return Effect.succeed(event)
|
||||
}),
|
||||
Stream.concat(fallback),
|
||||
Stream.onEnd(
|
||||
Effect.suspend(() =>
|
||||
terminal
|
||||
? Effect.void
|
||||
: Effect.fail(incompleteStreamError(route)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
type ProtocolEvent<Event> = { readonly type: "event"; readonly event: Event } | { readonly type: "halt" }
|
||||
|
||||
const parseProtocolEvents = <Event, State>(
|
||||
events: Stream.Stream<Event, AIError>,
|
||||
request: LLMRequest,
|
||||
protocol: { readonly stream: ProtocolStream<unknown, Event, State> },
|
||||
) =>
|
||||
events.pipe(
|
||||
Stream.map((event): ProtocolEvent<Event> => ({ type: "event", event })),
|
||||
// A normal halt becomes an in-band parser input so finalization may fail.
|
||||
Stream.concat(Stream.succeed({ type: "halt" } as const)),
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
(state, event) => {
|
||||
if (event.type === "event") return protocol.stream.step(state, event.event)
|
||||
if (!protocol.stream.onHalt) return Effect.succeed([state, []] as const)
|
||||
const events = protocol.stream.onHalt(state)
|
||||
return Effect.isEffect(events)
|
||||
? events.pipe(Effect.map((events) => [state, events] as const))
|
||||
: Effect.succeed([state, events] as const)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared> {
|
||||
@@ -357,9 +329,14 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
return parseProtocolEvents(events, request, protocol).pipe(
|
||||
return events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
ensureTerminalEvent(route, request.model.compatibility?.requireFinishReason ?? true),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
},
|
||||
} satisfies Route<Body, Prepared>
|
||||
|
||||
@@ -59,8 +59,8 @@ export interface ProtocolStream<Frame, Event, State> {
|
||||
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
|
||||
/** Optional request-completion signal for transports that do not end naturally. */
|
||||
readonly terminal?: (event: Event) => boolean
|
||||
/** Optional flush emitted when the framed stream ends successfully. */
|
||||
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent> | Effect.Effect<ReadonlyArray<LLMEvent>, AIError>
|
||||
/** Optional flush emitted when the framed stream ends. */
|
||||
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,29 +138,6 @@ describe("llm route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
unterminated.effect("synthesizes an unknown finish when a terminal event is not required", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* (yield* LLMClient.Service).generate(
|
||||
LLMRequest.update(request, {
|
||||
model: updateModel(request.model, { compatibility: { requireFinishReason: false } }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("partial")
|
||||
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||
expect(response.events.slice(-2)).toEqual([
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "unknown" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "unknown" }, usage: undefined },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects routes by model route value", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -44,7 +44,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"type\":\"message\",\"status\":\"completed\",\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"phase\":\"final_answer\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\",\"id\":\"fc_pdf_1\",\"status\":\"completed\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}],\"id\":\"fco_pdf_1\",\"status\":\"completed\"}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\",\"id\":\"fc_pdf_1\",\"status\":\"completed\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}],\"id\":\"fco_pdf_1\",\"status\":\"completed\"}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -40,10 +40,6 @@ const request = LLM.request({
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
|
||||
const optionalFinishRequest = LLMRequest.update(request, {
|
||||
model: LanguageModel.update(model, { compatibility: { requireFinishReason: false } }),
|
||||
})
|
||||
|
||||
describe("OpenAI Chat route", () => {
|
||||
it.effect("prepares OpenAI Chat payload", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -600,20 +596,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts text and usage without a finish reason when configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({ role: "assistant", content: "Hello" }),
|
||||
usageChunk({ prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }),
|
||||
)
|
||||
const response = yield* LLMClient.generate(optionalFinishRequest).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 1, totalTokens: 6 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||
@@ -1163,89 +1145,21 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes a streamed tool call without a finish reason when configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(optionalFinishRequest, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
|
||||
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles malformed tool input without a finish reason when configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
|
||||
}),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(optionalFinishRequest, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolInputError)).toMatchObject([
|
||||
{ id: "call_1", name: "lookup", raw: '{"query"' },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects incomplete tool identity without a finish reason when configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }] }))
|
||||
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
|
||||
Effect.provide(fixedResponse(body)),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an empty stream when a finish reason is not required", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents())),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails on malformed stream events when a finish reason is not required", () =>
|
||||
it.effect("fails on malformed stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(deltaChunk({ content: 123 }))
|
||||
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
|
||||
Effect.provide(fixedResponse(body)),
|
||||
Effect.flip,
|
||||
)
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Invalid openai/openai-chat stream event")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces transport errors when a finish reason is not required", () =>
|
||||
it.effect("surfaces transport errors that occur mid-stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = truncatedStream([
|
||||
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
||||
])
|
||||
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(Effect.provide(layer), Effect.flip)
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||
}),
|
||||
|
||||
@@ -51,7 +51,13 @@ describe("Open Responses-compatible route", () => {
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
max_output_tokens: undefined,
|
||||
temperature: undefined,
|
||||
tool_choice: undefined,
|
||||
tools: undefined,
|
||||
top_p: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -112,6 +118,40 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps response item replay independent of store", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const messages = [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "Checked the previous diff.",
|
||||
providerMetadata: {
|
||||
openresponses: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
},
|
||||
}),
|
||||
]
|
||||
const stored = yield* compileRequest(
|
||||
LLM.request({ model, messages, providerOptions: { openresponses: { store: true } } }),
|
||||
)
|
||||
const stateless = yield* compileRequest(
|
||||
LLM.request({ model, messages, providerOptions: { openresponses: { store: false } } }),
|
||||
)
|
||||
|
||||
expect(stored.body.input).toEqual(stateless.body.input)
|
||||
expect(stored.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not interpret OpenAI hosted-tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -211,7 +211,12 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "input_text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "After." }],
|
||||
status: "completed",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -329,7 +334,7 @@ describe("OpenAI Responses route", () => {
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
resourceName: "opencode-test",
|
||||
apiKey: "azure-key",
|
||||
headers: { authorization: "Bearer stale" },
|
||||
}).responses("gpt-4.1-mini"),
|
||||
@@ -414,8 +419,21 @@ describe("OpenAI Responses route", () => {
|
||||
model: "gpt-4.1-mini",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
|
||||
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
id: "fco_1",
|
||||
call_id: "call_1",
|
||||
output: '{"forecast":"sunny"}',
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
@@ -864,10 +882,10 @@ 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-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
@@ -923,35 +941,44 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openai: { phase: null } },
|
||||
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_commentary",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
status: "completed",
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_final",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
status: "completed",
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_null",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
status: "completed",
|
||||
phase: null,
|
||||
},
|
||||
])
|
||||
@@ -1043,12 +1070,12 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_2" },
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||
{ type: "text-end", id: "msg_2" },
|
||||
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1070,7 +1097,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ 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: "text-start", id: "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" },
|
||||
@@ -1080,7 +1107,7 @@ 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: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1146,33 +1173,34 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("FirstSecond")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
it.effect("preserves complete reasoning metadata when storage is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
|
||||
@@ -1192,7 +1220,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
@@ -1201,8 +1229,16 @@ 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",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1250,7 +1286,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." },
|
||||
@@ -1267,6 +1303,98 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps OpenAI and Azure response item replay independent of store", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = [
|
||||
model,
|
||||
Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("gpt-4.1-mini"),
|
||||
]
|
||||
const messages = [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning" as const,
|
||||
text: "Checked the previous diff.",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
},
|
||||
},
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { openai: { itemId: "fc_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||
]
|
||||
|
||||
for (const current of models) {
|
||||
const stored = yield* compileRequest(
|
||||
LLM.request({ model: current, messages, providerOptions: { openai: { store: true } } }),
|
||||
)
|
||||
const stateless = yield* compileRequest(
|
||||
LLM.request({ model: current, messages, providerOptions: { openai: { store: false } } }),
|
||||
)
|
||||
|
||||
expect(stored.body.input).toEqual(stateless.body.input)
|
||||
expect(stored.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
id: "fco_1",
|
||||
call_id: "call_1",
|
||||
output: '{"forecast":"sunny"}',
|
||||
status: "completed",
|
||||
},
|
||||
])
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces invalid item ids without creating collisions", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.make({
|
||||
id: "msg_text",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Ready.", providerMetadata: { openai: { itemId: "" } } },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think.",
|
||||
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "encrypted" } },
|
||||
},
|
||||
ToolCallPart.make({ id: "call_a/b", name: "one", input: {} }),
|
||||
ToolCallPart.make({ id: "call_a?b", name: "two", input: {} }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const ids = prepared.body.input.flatMap((item) => ("id" in item && typeof item.id === "string" ? [item.id] : []))
|
||||
|
||||
expect(ids).toHaveLength(4)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(ids.every((id) => /^[a-zA-Z0-9]+_.+$/.test(id) && id.length <= 64)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves assistant content order around reasoning items", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1274,38 +1402,55 @@ describe("OpenAI Responses route", () => {
|
||||
id: "req_reasoning_order",
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Before." },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked order.",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "rs_1",
|
||||
reasoningEncryptedContent: "encrypted-state",
|
||||
Message.make({
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Before." },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked order.",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "rs_1",
|
||||
reasoningEncryptedContent: "encrypted-state",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "After." },
|
||||
]),
|
||||
{ type: "text", text: "After." },
|
||||
],
|
||||
}),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Before." }],
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_msg_assistant_1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "After." }],
|
||||
status: "completed",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored reasoning items by id", () =>
|
||||
it.effect("replays stored reasoning items with their id", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1323,11 +1468,18 @@ 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,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored provider-executed hosted tool results by id", () =>
|
||||
it.effect("replays stored provider-executed hosted tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1357,7 +1509,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "item_reference", id: "ws_1" },
|
||||
{ type: "web_search_call", id: "ws_1", status: "completed" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
@@ -1432,6 +1584,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" },
|
||||
@@ -1442,7 +1595,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||
it.effect("replays reasoning ids without encrypted state", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1472,6 +1625,12 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
encrypted_content: null,
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
@@ -1663,7 +1822,8 @@ describe("OpenAI Responses route", () => {
|
||||
name: "web_search",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
output: undefined,
|
||||
providerMetadata: { openai: { itemId: "ws_1", responseItem: item } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1754,7 +1914,8 @@ describe("OpenAI Responses route", () => {
|
||||
name: "code_interpreter",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ci_1" } },
|
||||
output: undefined,
|
||||
providerMetadata: { openai: { itemId: "ci_1", responseItem: item } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -261,7 +261,7 @@ const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep
|
||||
content.push({ type: "reasoning", text: response.reasoning, providerMetadata: reasoning.providerMetadata })
|
||||
}
|
||||
|
||||
if (response.text.length > 0) content.push({ type: "text", text: response.text })
|
||||
content.push(...response.message.content.filter((part) => part.type === "text"))
|
||||
content.push(...response.toolCalls)
|
||||
return Message.assistant(content)
|
||||
}
|
||||
|
||||
@@ -2440,30 +2440,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues after an unknown finish containing a local tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Echo this")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "unknown" } },
|
||||
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
|
||||
),
|
||||
TestLLM.text("Done", "text-final"),
|
||||
)
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual(["hello"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Echo this" },
|
||||
{ type: "assistant", finish: "unknown", content: [{ type: "tool", state: { status: "completed" } }] },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloads a model switch before a tool-driven continuation step", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
Reference in New Issue
Block a user