|
|
@@ -57,6 +57,11 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
|
|
|
encrypted_content: optionalNull(Schema.String),
|
|
|
})
|
|
|
|
|
|
+const OpenAIResponsesItemReference = Schema.Struct({
|
|
|
+ type: Schema.tag("item_reference"),
|
|
|
+ id: Schema.String,
|
|
|
+})
|
|
|
+
|
|
|
// `function_call_output.output` accepts either a plain string or an ordered
|
|
|
// array of content items so tools can return images in addition to text.
|
|
|
// https://platform.openai.com/docs/api-reference/responses/object
|
|
|
@@ -72,6 +77,7 @@ const OpenAIResponsesInputItem = Schema.Union([
|
|
|
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
|
|
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
|
|
OpenAIResponsesReasoningItem,
|
|
|
+ OpenAIResponsesItemReference,
|
|
|
Schema.Struct({
|
|
|
type: Schema.tag("function_call"),
|
|
|
call_id: Schema.String,
|
|
|
@@ -86,6 +92,15 @@ const OpenAIResponsesInputItem = Schema.Union([
|
|
|
])
|
|
|
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
|
|
|
|
|
|
+// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
|
|
+// multiple streamed summary parts into the same item before flushing.
|
|
|
+type OpenAIResponsesReasoningInput = {
|
|
|
+ type: "reasoning"
|
|
|
+ id: string
|
|
|
+ summary: Array<{ type: "summary_text"; text: string }>
|
|
|
+ encrypted_content?: string | null
|
|
|
+}
|
|
|
+
|
|
|
const OpenAIResponsesTool = Schema.Struct({
|
|
|
type: Schema.tag("function"),
|
|
|
name: Schema.String,
|
|
|
@@ -112,7 +127,7 @@ const OpenAIResponsesCoreFields = {
|
|
|
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
|
|
store: Schema.optional(Schema.Boolean),
|
|
|
prompt_cache_key: Schema.optional(Schema.String),
|
|
|
- include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
|
|
|
+ include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
|
|
|
reasoning: Schema.optional(
|
|
|
Schema.Struct({
|
|
|
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
|
|
@@ -193,6 +208,7 @@ const OpenAIResponsesEvent = Schema.Struct({
|
|
|
type: Schema.String,
|
|
|
delta: Schema.optional(Schema.String),
|
|
|
item_id: Schema.optional(Schema.String),
|
|
|
+ summary_index: Schema.optional(Schema.Number),
|
|
|
item: Schema.optional(OpenAIResponsesStreamItem),
|
|
|
response: Schema.optional(
|
|
|
Schema.StructWithRest(
|
|
|
@@ -216,6 +232,18 @@ interface ParserState {
|
|
|
readonly tools: ToolStream.State<string>
|
|
|
readonly hasFunctionCall: boolean
|
|
|
readonly lifecycle: Lifecycle.State
|
|
|
+ readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
|
|
+ readonly store: boolean | undefined
|
|
|
+}
|
|
|
+
|
|
|
+type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
|
|
+
|
|
|
+interface ReasoningStreamItem {
|
|
|
+ readonly encryptedContent: string | null | undefined
|
|
|
+ // Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to
|
|
|
+ // strings, but typing the map as `Record<number, ...>` documents intent
|
|
|
+ // and matches the wire field.
|
|
|
+ readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
|
|
|
}
|
|
|
|
|
|
const invalid = ProviderShared.invalidRequest
|
|
|
@@ -245,22 +273,21 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
|
|
arguments: ProviderShared.encodeJson(part.input),
|
|
|
})
|
|
|
|
|
|
-const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => {
|
|
|
+const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
|
|
|
const openai = part.providerMetadata?.openai
|
|
|
- if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
|
|
|
- // With store:false, OpenAI only accepts previous reasoning items when the
|
|
|
- // encrypted state is present. Bare rs_* ids point to non-persisted items.
|
|
|
- if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
|
|
|
+ if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
|
|
|
+ return undefined
|
|
|
+ const encryptedContent =
|
|
|
+ typeof openai.reasoningEncryptedContent === "string"
|
|
|
+ ? openai.reasoningEncryptedContent
|
|
|
+ : openai.reasoningEncryptedContent === null
|
|
|
+ ? null
|
|
|
+ : undefined
|
|
|
return {
|
|
|
type: "reasoning",
|
|
|
id: openai.itemId,
|
|
|
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
|
|
- encrypted_content:
|
|
|
- typeof openai.reasoningEncryptedContent === "string"
|
|
|
- ? openai.reasoningEncryptedContent
|
|
|
- : openai.reasoningEncryptedContent === null
|
|
|
- ? null
|
|
|
- : undefined,
|
|
|
+ encrypted_content: encryptedContent,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -310,6 +337,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|
|
|
|
|
if (message.role === "assistant") {
|
|
|
const content: TextPart[] = []
|
|
|
+ const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
|
|
|
+ const reasoningReferences = new Set<string>()
|
|
|
const flushText = () => {
|
|
|
if (content.length === 0) return
|
|
|
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
|
|
@@ -322,8 +351,21 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|
|
}
|
|
|
if (part.type === "reasoning") {
|
|
|
flushText()
|
|
|
- const reasoning = lowerReasoning(part, store)
|
|
|
- if (reasoning) input.push(reasoning)
|
|
|
+ const reasoning = lowerReasoning(part)
|
|
|
+ if (!reasoning) continue
|
|
|
+ if (store !== false && reasoning.id) {
|
|
|
+ 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)
|
|
|
+ if (typeof reasoning.encrypted_content === "string") existing.encrypted_content = reasoning.encrypted_content
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ reasoningItems[reasoning.id] = reasoning
|
|
|
+ input.push(reasoning)
|
|
|
continue
|
|
|
}
|
|
|
if (part.type === "tool-call") {
|
|
|
@@ -352,7 +394,14 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- return input
|
|
|
+ // With store:false, OpenAI only accepts 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
|
|
|
})
|
|
|
|
|
|
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
|
|
|
@@ -362,14 +411,14 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
|
|
|
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
|
|
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
|
|
const summary = OpenAIOptions.reasoningSummary(request)
|
|
|
- const encryptedState = OpenAIOptions.encryptedReasoning(request)
|
|
|
+ const include = OpenAIOptions.include(request)
|
|
|
const verbosity = OpenAIOptions.textVerbosity(request)
|
|
|
const instructions = OpenAIOptions.instructions(request)
|
|
|
return {
|
|
|
...(instructions ? { instructions } : {}),
|
|
|
...(store !== undefined ? { store } : {}),
|
|
|
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
|
|
- ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
|
|
|
+ ...(include ? { include } : {}),
|
|
|
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
|
|
...(verbosity ? { text: { verbosity } } : {}),
|
|
|
}
|
|
|
@@ -517,24 +566,53 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
|
|
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
|
|
if (!event.delta) return [state, NO_EVENTS]
|
|
|
const events: LLMEvent[] = []
|
|
|
+ const itemID = event.item_id ?? "reasoning-0"
|
|
|
+ const id =
|
|
|
+ event.summary_index !== undefined || state.reasoningItems[itemID]
|
|
|
+ ? `${itemID}:${event.summary_index ?? 0}`
|
|
|
+ : itemID
|
|
|
return [
|
|
|
{
|
|
|
...state,
|
|
|
- lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
|
|
|
+ lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
|
|
},
|
|
|
events,
|
|
|
]
|
|
|
}
|
|
|
|
|
|
-// The summary done event does not carry encrypted continuation state. Finish the
|
|
|
-// common reasoning block when the full reasoning item arrives in output_item.done.
|
|
|
const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
|
|
|
|
|
|
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
|
|
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
|
|
|
|
|
+// OpenAI Responses streams reasoning items in a stable order:
|
|
|
+// `output_item.added` (reasoning) →
|
|
|
+// `reasoning_summary_part.added` (index=0) →
|
|
|
+// `reasoning_summary_text.delta` →
|
|
|
+// `reasoning_summary_part.done` (index=0) →
|
|
|
+// (repeat for index>0) →
|
|
|
+// `output_item.done` (reasoning).
|
|
|
+// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
|
|
|
+// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
|
|
|
+// short-circuits when the entry already exists, and higher-index handlers
|
|
|
+// fold against the same entry. Behaviour for out-of-order events is
|
|
|
+// best-effort, not guaranteed.
|
|
|
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
|
|
const item = event.item
|
|
|
+ if (item && isReasoningItem(item)) {
|
|
|
+ const events: LLMEvent[] = []
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ ...state,
|
|
|
+ lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
|
|
|
+ reasoningItems: {
|
|
|
+ ...state.reasoningItems,
|
|
|
+ [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ events,
|
|
|
+ ]
|
|
|
+ }
|
|
|
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
|
|
|
const providerMetadata = openaiMetadata({ itemId: item.id })
|
|
|
const events: LLMEvent[] = []
|
|
|
@@ -555,6 +633,103 @@ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
|
|
]
|
|
|
}
|
|
|
|
|
|
+const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
|
|
+ if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
|
|
+ const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
|
|
|
+ if (event.summary_index === 0) {
|
|
|
+ if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
|
|
|
+ const events: LLMEvent[] = []
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ ...state,
|
|
|
+ lifecycle: Lifecycle.reasoningStart(
|
|
|
+ state.lifecycle,
|
|
|
+ events,
|
|
|
+ `${event.item_id}:0`,
|
|
|
+ openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }),
|
|
|
+ ),
|
|
|
+ reasoningItems: {
|
|
|
+ ...state.reasoningItems,
|
|
|
+ [event.item_id]: { ...item, summaryParts: { 0: "active" } },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ events,
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ 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]}`,
|
|
|
+ openaiMetadata({ itemId: event.item_id }),
|
|
|
+ ),
|
|
|
+ state.lifecycle,
|
|
|
+ )
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ ...state,
|
|
|
+ lifecycle: Lifecycle.reasoningStart(
|
|
|
+ closed,
|
|
|
+ events,
|
|
|
+ `${event.item_id}:${event.summary_index}`,
|
|
|
+ openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
|
|
+ ),
|
|
|
+ reasoningItems: {
|
|
|
+ ...state.reasoningItems,
|
|
|
+ [event.item_id]: {
|
|
|
+ ...item,
|
|
|
+ summaryParts: {
|
|
|
+ ...Object.fromEntries(
|
|
|
+ Object.entries(item.summaryParts).map((entry) =>
|
|
|
+ entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ [event.summary_index]: "active",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ events,
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
|
|
+ if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
|
|
+ const item = state.reasoningItems[event.item_id]
|
|
|
+ if (!item) return [state, NO_EVENTS]
|
|
|
+ const events: LLMEvent[] = []
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ ...state,
|
|
|
+ lifecycle:
|
|
|
+ state.store !== false
|
|
|
+ ? Lifecycle.reasoningEnd(
|
|
|
+ state.lifecycle,
|
|
|
+ events,
|
|
|
+ `${event.item_id}:${event.summary_index}`,
|
|
|
+ openaiMetadata({ 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",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ events,
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
|
|
|
state: ParserState,
|
|
|
event: OpenAIResponsesEvent,
|
|
|
@@ -615,6 +790,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
|
|
if (isReasoningItem(item)) {
|
|
|
const events: LLMEvent[] = []
|
|
|
const providerMetadata = reasoningMetadata(item)
|
|
|
+ const reasoningItem = state.reasoningItems[item.id]
|
|
|
+ if (reasoningItem) {
|
|
|
+ const lifecycle = Object.entries(reasoningItem.summaryParts)
|
|
|
+ .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
|
|
+ .reduce(
|
|
|
+ (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata),
|
|
|
+ state.lifecycle,
|
|
|
+ )
|
|
|
+ const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
|
|
+ return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
|
|
+ }
|
|
|
if (!state.lifecycle.reasoning.has(item.id)) {
|
|
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
|
|
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
|
|
|
@@ -683,6 +869,10 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
|
|
event.type === "response.reasoning_summary_text.done"
|
|
|
)
|
|
|
return Effect.succeed(onReasoningDone(state, event))
|
|
|
+ if (event.type === "response.reasoning_summary_part.added")
|
|
|
+ return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
|
|
+ if (event.type === "response.reasoning_summary_part.done")
|
|
|
+ return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
|
|
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
|
|
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
|
|
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
|
|
@@ -709,7 +899,13 @@ export const protocol = Protocol.make({
|
|
|
},
|
|
|
stream: {
|
|
|
event: Protocol.jsonEvent(OpenAIResponsesEvent),
|
|
|
- initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
|
|
|
+ initial: (request) => ({
|
|
|
+ hasFunctionCall: false,
|
|
|
+ tools: ToolStream.empty<string>(),
|
|
|
+ lifecycle: Lifecycle.initial(),
|
|
|
+ reasoningItems: {},
|
|
|
+ store: OpenAIOptions.store(request),
|
|
|
+ }),
|
|
|
step,
|
|
|
terminal: (event) => TERMINAL_TYPES.has(event.type),
|
|
|
},
|