fix(llm): split OpenAI reasoning summary blocks (#29000)
This commit is contained in:
@@ -57,6 +57,11 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
|
|||||||
encrypted_content: optionalNull(Schema.String),
|
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
|
// `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.
|
// array of content items so tools can return images in addition to text.
|
||||||
// https://platform.openai.com/docs/api-reference/responses/object
|
// 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("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
||||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
||||||
OpenAIResponsesReasoningItem,
|
OpenAIResponsesReasoningItem,
|
||||||
|
OpenAIResponsesItemReference,
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.tag("function_call"),
|
type: Schema.tag("function_call"),
|
||||||
call_id: Schema.String,
|
call_id: Schema.String,
|
||||||
@@ -86,6 +92,15 @@ const OpenAIResponsesInputItem = Schema.Union([
|
|||||||
])
|
])
|
||||||
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
|
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({
|
const OpenAIResponsesTool = Schema.Struct({
|
||||||
type: Schema.tag("function"),
|
type: Schema.tag("function"),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
@@ -112,7 +127,7 @@ const OpenAIResponsesCoreFields = {
|
|||||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||||
store: Schema.optional(Schema.Boolean),
|
store: Schema.optional(Schema.Boolean),
|
||||||
prompt_cache_key: Schema.optional(Schema.String),
|
prompt_cache_key: Schema.optional(Schema.String),
|
||||||
include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
|
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
|
||||||
reasoning: Schema.optional(
|
reasoning: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||||
@@ -193,6 +208,7 @@ const OpenAIResponsesEvent = Schema.Struct({
|
|||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
delta: Schema.optional(Schema.String),
|
delta: Schema.optional(Schema.String),
|
||||||
item_id: Schema.optional(Schema.String),
|
item_id: Schema.optional(Schema.String),
|
||||||
|
summary_index: Schema.optional(Schema.Number),
|
||||||
item: Schema.optional(OpenAIResponsesStreamItem),
|
item: Schema.optional(OpenAIResponsesStreamItem),
|
||||||
response: Schema.optional(
|
response: Schema.optional(
|
||||||
Schema.StructWithRest(
|
Schema.StructWithRest(
|
||||||
@@ -216,6 +232,18 @@ interface ParserState {
|
|||||||
readonly tools: ToolStream.State<string>
|
readonly tools: ToolStream.State<string>
|
||||||
readonly hasFunctionCall: boolean
|
readonly hasFunctionCall: boolean
|
||||||
readonly lifecycle: Lifecycle.State
|
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
|
const invalid = ProviderShared.invalidRequest
|
||||||
@@ -245,22 +273,21 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
|||||||
arguments: ProviderShared.encodeJson(part.input),
|
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
|
const openai = part.providerMetadata?.openai
|
||||||
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
|
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
|
||||||
// With store:false, OpenAI only accepts previous reasoning items when the
|
return undefined
|
||||||
// encrypted state is present. Bare rs_* ids point to non-persisted items.
|
const encryptedContent =
|
||||||
if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
|
typeof openai.reasoningEncryptedContent === "string"
|
||||||
|
? openai.reasoningEncryptedContent
|
||||||
|
: openai.reasoningEncryptedContent === null
|
||||||
|
? null
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
id: openai.itemId,
|
id: openai.itemId,
|
||||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||||
encrypted_content:
|
encrypted_content: encryptedContent,
|
||||||
typeof openai.reasoningEncryptedContent === "string"
|
|
||||||
? openai.reasoningEncryptedContent
|
|
||||||
: openai.reasoningEncryptedContent === null
|
|
||||||
? null
|
|
||||||
: undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +337,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
|||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
|
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
|
||||||
|
const reasoningReferences = new Set<string>()
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
if (content.length === 0) return
|
if (content.length === 0) return
|
||||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
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") {
|
if (part.type === "reasoning") {
|
||||||
flushText()
|
flushText()
|
||||||
const reasoning = lowerReasoning(part, store)
|
const reasoning = lowerReasoning(part)
|
||||||
if (reasoning) input.push(reasoning)
|
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
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
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) {
|
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))
|
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
||||||
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
||||||
const summary = OpenAIOptions.reasoningSummary(request)
|
const summary = OpenAIOptions.reasoningSummary(request)
|
||||||
const encryptedState = OpenAIOptions.encryptedReasoning(request)
|
const include = OpenAIOptions.include(request)
|
||||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
const verbosity = OpenAIOptions.textVerbosity(request)
|
||||||
const instructions = OpenAIOptions.instructions(request)
|
const instructions = OpenAIOptions.instructions(request)
|
||||||
return {
|
return {
|
||||||
...(instructions ? { instructions } : {}),
|
...(instructions ? { instructions } : {}),
|
||||||
...(store !== undefined ? { store } : {}),
|
...(store !== undefined ? { store } : {}),
|
||||||
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
||||||
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
|
...(include ? { include } : {}),
|
||||||
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
||||||
...(verbosity ? { text: { verbosity } } : {}),
|
...(verbosity ? { text: { verbosity } } : {}),
|
||||||
}
|
}
|
||||||
@@ -517,24 +566,53 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
|||||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
const events: LLMEvent[] = []
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
|
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
||||||
},
|
},
|
||||||
events,
|
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 onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
|
||||||
|
|
||||||
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
||||||
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
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 onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
const item = event.item
|
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]
|
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
|
||||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||||
const events: LLMEvent[] = []
|
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* (
|
const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
|
||||||
state: ParserState,
|
state: ParserState,
|
||||||
event: OpenAIResponsesEvent,
|
event: OpenAIResponsesEvent,
|
||||||
@@ -615,6 +790,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
|||||||
if (isReasoningItem(item)) {
|
if (isReasoningItem(item)) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const providerMetadata = reasoningMetadata(item)
|
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)) {
|
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
|
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"
|
event.type === "response.reasoning_summary_text.done"
|
||||||
)
|
)
|
||||||
return Effect.succeed(onReasoningDone(state, event))
|
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.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.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||||
@@ -709,7 +899,13 @@ export const protocol = Protocol.make({
|
|||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(OpenAIResponsesEvent),
|
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,
|
step,
|
||||||
terminal: (event) => TERMINAL_TYPES.has(event.type),
|
terminal: (event) => TERMINAL_TYPES.has(event.type),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,16 +24,24 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st
|
|||||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
export const reasoningStart = (
|
||||||
|
state: State,
|
||||||
|
events: LLMEvent[],
|
||||||
|
id: string,
|
||||||
|
providerMetadata?: ProviderMetadata,
|
||||||
|
): State => {
|
||||||
|
if (state.reasoning.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
if (stepped.reasoning.has(id)) {
|
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
|
||||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
|
||||||
return stepped
|
|
||||||
}
|
|
||||||
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
|
|
||||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||||
|
const started = reasoningStart(state, events, id)
|
||||||
|
events.push(LLMEvent.reasoningDelta({ id, text }))
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
export const reasoningEnd = (
|
export const reasoningEnd = (
|
||||||
state: State,
|
state: State,
|
||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
|
|||||||
@@ -7,12 +7,28 @@ export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
|||||||
)
|
)
|
||||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||||
|
|
||||||
|
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
||||||
|
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||||
|
export const OpenAIResponseIncludables = [
|
||||||
|
"file_search_call.results",
|
||||||
|
"web_search_call.results",
|
||||||
|
"web_search_call.action.sources",
|
||||||
|
"message.input_image.image_url",
|
||||||
|
"computer_call_output.output.image_url",
|
||||||
|
"code_interpreter_call.outputs",
|
||||||
|
"reasoning.encrypted_content",
|
||||||
|
"message.output_text.logprobs",
|
||||||
|
] as const
|
||||||
|
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
|
||||||
|
|
||||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||||
|
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
||||||
|
|
||||||
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||||
export const OpenAITextVerbosity = TextVerbosity
|
export const OpenAITextVerbosity = TextVerbosity
|
||||||
|
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
||||||
|
|
||||||
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
||||||
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
||||||
@@ -35,12 +51,20 @@ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefine
|
|||||||
return isAnyReasoningEffort(value) ? value : undefined
|
return isAnyReasoningEffort(value) ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
|
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
||||||
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
||||||
}
|
|
||||||
|
|
||||||
export const encryptedReasoning = (request: LLMRequest) =>
|
// Resolve the OpenAI Responses `include` field. Filters out unknown
|
||||||
options(request)?.includeEncryptedReasoning === true ? true : undefined
|
// includable values defensively so a typo in upstream config drops the
|
||||||
|
// invalid entry instead of poisoning the wire body. An empty array (either
|
||||||
|
// passed directly or produced by filtering) is treated as "no include" and
|
||||||
|
// returns undefined so the request body omits the field entirely.
|
||||||
|
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
|
||||||
|
const value = options(request)?.include
|
||||||
|
if (!Array.isArray(value)) return undefined
|
||||||
|
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
|
||||||
|
return filtered.length > 0 ? filtered : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export const promptCacheKey = (request: LLMRequest) => {
|
export const promptCacheKey = (request: LLMRequest) => {
|
||||||
const value = options(request)?.promptCacheKey
|
const value = options(request)?.promptCacheKey
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||||
import { mergeProviderOptions } from "../schema"
|
import { mergeProviderOptions } from "../schema"
|
||||||
|
import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
|
||||||
|
|
||||||
|
export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
|
||||||
|
|
||||||
export interface OpenAIOptionsInput {
|
export interface OpenAIOptionsInput {
|
||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
@@ -7,7 +10,10 @@ export interface OpenAIOptionsInput {
|
|||||||
readonly promptCacheKey?: string
|
readonly promptCacheKey?: string
|
||||||
readonly reasoningEffort?: ReasoningEffort
|
readonly reasoningEffort?: ReasoningEffort
|
||||||
readonly reasoningSummary?: "auto"
|
readonly reasoningSummary?: "auto"
|
||||||
readonly includeEncryptedReasoning?: boolean
|
// OpenAI Responses `include` wire field. Mirrors the official SDK's
|
||||||
|
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
|
||||||
|
// native-SDK callers share one shape and no translation is required.
|
||||||
|
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
|
||||||
readonly textVerbosity?: TextVerbosity
|
readonly textVerbosity?: TextVerbosity
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +31,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
|||||||
promptCacheKey: options?.promptCacheKey,
|
promptCacheKey: options?.promptCacheKey,
|
||||||
reasoningEffort: options?.reasoningEffort,
|
reasoningEffort: options?.reasoningEffort,
|
||||||
reasoningSummary: options?.reasoningSummary,
|
reasoningSummary: options?.reasoningSummary,
|
||||||
includeEncryptedReasoning: options?.includeEncryptedReasoning,
|
include: options?.include,
|
||||||
textVerbosity: options?.textVerbosity,
|
textVerbosity: options?.textVerbosity,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -42,6 +48,12 @@ export const gpt5DefaultOptions = (
|
|||||||
return openAIProviderOptions({
|
return openAIProviderOptions({
|
||||||
reasoningEffort: "medium",
|
reasoningEffort: "medium",
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
|
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
||||||
|
// `openAIDefaultOptions` below, so the only way a follow-up turn can
|
||||||
|
// carry reasoning state is via the encrypted reasoning include. Without
|
||||||
|
// this, callers using the default model facade get reasoning summaries
|
||||||
|
// they cannot replay statelessly.
|
||||||
|
include: ["reasoning.encrypted_content"],
|
||||||
textVerbosity:
|
textVerbosity:
|
||||||
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
||||||
? "low"
|
? "low"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat"
|
|||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
|
||||||
export type { OpenAIOptionsInput } from "./openai-options"
|
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||||
|
|
||||||
export const id = ProviderID.make("openai")
|
export const id = ProviderID.make("openai")
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
)
|
)
|
||||||
return events.pipe(
|
return events.pipe(
|
||||||
Stream.mapAccumEffect(
|
Stream.mapAccumEffect(
|
||||||
protocol.stream.initial,
|
() => protocol.stream.initial(request),
|
||||||
protocol.stream.step,
|
protocol.stream.step,
|
||||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export interface ProtocolBody<Body> {
|
|||||||
export interface ProtocolStream<Frame, Event, State> {
|
export interface ProtocolStream<Frame, Event, State> {
|
||||||
/** Schema for one decoded streaming event, decoded from a transport frame. */
|
/** Schema for one decoded streaming event, decoded from a transport frame. */
|
||||||
readonly event: Schema.Codec<Event, Frame>
|
readonly event: Schema.Codec<Event, Frame>
|
||||||
/** Initial parser state. Called once per response. */
|
/** Initial parser state. Called once per response with the resolved request. */
|
||||||
readonly initial: () => State
|
readonly initial: (request: LLMRequest) => State
|
||||||
/** Translate one event into emitted `LLMEvent`s plus the next state. */
|
/** Translate one event into emitted `LLMEvent`s plus the next state. */
|
||||||
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
|
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
|
||||||
/** Optional request-completion signal for transports that do not end naturally. */
|
/** Optional request-completion signal for transports that do not end naturally. */
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function continuationRequest(input: {
|
|||||||
tools: features.has("tool-call") ? [continuationTool] : [],
|
tools: features.has("tool-call") ? [continuationTool] : [],
|
||||||
cache: "none",
|
cache: "none",
|
||||||
providerOptions: features.has("encrypted-reasoning")
|
providerOptions: features.has("encrypted-reasoning")
|
||||||
? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
|
? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
|
||||||
: undefined,
|
: undefined,
|
||||||
generation: { maxTokens: 80, temperature: 0 },
|
generation: { maxTokens: 80, temperature: 0 },
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-3
File diff suppressed because one or more lines are too long
+4
-4
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
@@ -393,7 +393,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
promptCacheKey: "session_123",
|
promptCacheKey: "session_123",
|
||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
includeEncryptedReasoning: true,
|
include: ["reasoning.encrypted_content"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -407,6 +407,108 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("accepts the full ResponseIncludable union", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "hi",
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.include).toEqual([
|
||||||
|
"reasoning.encrypted_content",
|
||||||
|
"code_interpreter_call.outputs",
|
||||||
|
"web_search_call.results",
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("filters unknown includable values out of the include array", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "hi",
|
||||||
|
// The user passed one invalid entry alongside a valid one. Keep the
|
||||||
|
// valid one so the request still succeeds rather than failing on a
|
||||||
|
// typo from upstream config.
|
||||||
|
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("treats an explicit empty include as no include at all", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.include).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("treats an all-invalid include as no include at all", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.include).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("omits include when no include is set", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.include).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// The native OpenAI facade configures GPT-5 stateless (store: false) with
|
||||||
|
// reasoningSummary: "auto" by default. Without `include`, a follow-up
|
||||||
|
// turn cannot replay reasoning state, so the facade also opts into
|
||||||
|
// `reasoning.encrypted_content` automatically.
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||||
|
prompt: "hi",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.store).toBe(false)
|
||||||
|
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||||
|
expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lets callers opt out of the GPT-5 default include", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||||
|
prompt: "hi",
|
||||||
|
providerOptions: { openai: { include: [] } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.include).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("request OpenAI provider options override route defaults", () =>
|
it.effect("request OpenAI provider options override route defaults", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
@@ -547,6 +649,94 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||||
|
},
|
||||||
|
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||||
|
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||||
|
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||||
|
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||||
|
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||||
|
{ 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: "encrypted-state" },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("FirstSecond")
|
||||||
|
expect(response.events).toMatchObject([
|
||||||
|
{ type: "step-start", index: 0 },
|
||||||
|
{
|
||||||
|
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-start",
|
||||||
|
id: "rs_1:1",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||||
|
},
|
||||||
|
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||||
|
{
|
||||||
|
type: "reasoning-end",
|
||||||
|
id: "rs_1:1",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
|
},
|
||||||
|
{ type: "step-finish", index: 0, reason: "stop" },
|
||||||
|
{ type: "finish", reason: "stop" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||||
|
},
|
||||||
|
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||||
|
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||||
|
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||||
|
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||||
|
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||||
|
{ 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 },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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" } } },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("continues a stateless reasoning conversation", () =>
|
it.effect("continues a stateless reasoning conversation", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
@@ -570,6 +760,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
]),
|
]),
|
||||||
Message.user("Summarize it."),
|
Message.user("Summarize it."),
|
||||||
],
|
],
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
}),
|
}),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
@@ -627,6 +818,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ type: "text", text: "After." },
|
{ type: "text", text: "After." },
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -643,6 +835,66 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("references stored reasoning items by id", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Checked the previous diff.",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providerOptions: { openai: { store: true } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
id: "req_multi_summary_continuation",
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "First",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Second",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
|
encrypted_content: "encrypted-state",
|
||||||
|
summary: [
|
||||||
|
{ type: "summary_text", text: "First" },
|
||||||
|
{ type: "summary_text", text: "Second" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare(
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ const normalizeImageText = (value: string) =>
|
|||||||
const encryptedReasoningOptions = {
|
const encryptedReasoningOptions = {
|
||||||
openai: {
|
openai: {
|
||||||
store: false,
|
store: false,
|
||||||
includeEncryptedReasoning: true,
|
include: ["reasoning.encrypted_content"],
|
||||||
reasoningEffort: "low",
|
reasoningEffort: "low",
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice }
|
|||||||
import { Auth, LLMClient } from "../src/route"
|
import { Auth, LLMClient } from "../src/route"
|
||||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
|
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
|
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
||||||
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
|
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
|
||||||
import { ToolRuntime } from "../src/tool-runtime"
|
import { ToolRuntime } from "../src/tool-runtime"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
@@ -309,6 +310,71 @@ describe("LLMClient tools", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bodies: unknown[] = []
|
||||||
|
const layer = dynamicResponse((input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
bodies.push(decodeJson(input.text))
|
||||||
|
return input.respond(
|
||||||
|
bodies.length === 1
|
||||||
|
? sseEvents(
|
||||||
|
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
|
||||||
|
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||||
|
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "get_weather", arguments: "" },
|
||||||
|
},
|
||||||
|
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
name: "get_weather",
|
||||||
|
arguments: '{"city":"Paris"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: {} },
|
||||||
|
)
|
||||||
|
: sseEvents(
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
|
||||||
|
{ type: "response.completed", response: {} },
|
||||||
|
),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* TestToolRuntime.runTools({
|
||||||
|
request: LLM.request({
|
||||||
|
model: OpenAIResponses.route
|
||||||
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
|
.model({ id: "gpt-5.5" }),
|
||||||
|
prompt: "Use the tool.",
|
||||||
|
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||||
|
}),
|
||||||
|
tools: { get_weather },
|
||||||
|
}).pipe(Stream.runCollect, Effect.provide(layer))
|
||||||
|
|
||||||
|
expect(bodies[1]).toMatchObject({
|
||||||
|
include: ["reasoning.encrypted_content"],
|
||||||
|
input: [
|
||||||
|
{ role: "user" },
|
||||||
|
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
|
||||||
|
{ type: "function_call", call_id: "call_1", name: "get_weather" },
|
||||||
|
{ type: "function_call_output", call_id: "call_1" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
|
it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layer = scriptedResponses([
|
const layer = scriptedResponses([
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ function mimeToModality(mime: string): Modality | undefined {
|
|||||||
|
|
||||||
export const OUTPUT_TOKEN_MAX = 32_000
|
export const OUTPUT_TOKEN_MAX = 32_000
|
||||||
|
|
||||||
|
// OpenAI Responses `include` value that returns the encrypted reasoning state
|
||||||
|
// needed for stateless multi-turn reasoning (store: false). Hoisted so every
|
||||||
|
// branch that requests it stays in lockstep.
|
||||||
|
const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
|
||||||
|
|
||||||
export function sanitizeSurrogates(content: string) {
|
export function sanitizeSurrogates(content: string) {
|
||||||
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
|
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
|
||||||
}
|
}
|
||||||
@@ -756,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
|||||||
{
|
{
|
||||||
reasoningEffort: effort,
|
reasoningEffort: effort,
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
include: ["reasoning.encrypted_content"],
|
include: INCLUDE_ENCRYPTED_REASONING,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -790,7 +795,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
|||||||
{
|
{
|
||||||
reasoningEffort: effort,
|
reasoningEffort: effort,
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
include: ["reasoning.encrypted_content"],
|
include: INCLUDE_ENCRYPTED_REASONING,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -803,7 +808,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
|||||||
{
|
{
|
||||||
reasoningEffort: effort,
|
reasoningEffort: effort,
|
||||||
reasoningSummary: "auto",
|
reasoningSummary: "auto",
|
||||||
include: ["reasoning.encrypted_content"],
|
include: INCLUDE_ENCRYPTED_REASONING,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -1134,6 +1139,9 @@ export function options(input: {
|
|||||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||||
result["reasoningEffort"] = "medium"
|
result["reasoningEffort"] = "medium"
|
||||||
result["reasoningSummary"] = "auto"
|
result["reasoningSummary"] = "auto"
|
||||||
|
if (input.model.api.npm === "@ai-sdk/openai") {
|
||||||
|
result["include"] = INCLUDE_ENCRYPTED_REASONING
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only set textVerbosity for non-chat gpt-5.x models
|
// Only set textVerbosity for non-chat gpt-5.x models
|
||||||
@@ -1149,7 +1157,7 @@ export function options(input: {
|
|||||||
|
|
||||||
if (input.model.providerID.startsWith("opencode")) {
|
if (input.model.providerID.startsWith("opencode")) {
|
||||||
result["promptCacheKey"] = input.sessionID
|
result["promptCacheKey"] = input.sessionID
|
||||||
result["include"] = ["reasoning.encrypted_content"]
|
result["include"] = INCLUDE_ENCRYPTED_REASONING
|
||||||
result["reasoningSummary"] = "auto"
|
result["reasoningSummary"] = "auto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ export function stream(input: StreamInput): StreamResult {
|
|||||||
|
|
||||||
// Integration point with @opencode-ai/llm: native-request lowers session data
|
// Integration point with @opencode-ai/llm: native-request lowers session data
|
||||||
// into an LLMRequest, then LLMClient handles route selection and transport.
|
// into an LLMRequest, then LLMClient handles route selection and transport.
|
||||||
|
//
|
||||||
|
// ProviderTransform.providerOptions builds AI-SDK-shaped options for the
|
||||||
|
// selected SDK key (e.g. "openai") and the native LLM SDK reads the same
|
||||||
|
// keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
|
||||||
|
// include, textVerbosity, promptCacheKey). Both sides intentionally use
|
||||||
|
// OpenAI's official wire field names, so this is identity, not translation
|
||||||
|
// — if a field ever needs to differ between the two surfaces, the
|
||||||
|
// translation belongs here, not split across both packages.
|
||||||
const stream = input.llmClient.stream({
|
const stream = input.llmClient.stream({
|
||||||
request: LLMNative.request({
|
request: LLMNative.request({
|
||||||
model: input.model,
|
model: input.model,
|
||||||
|
|||||||
+11
-6
File diff suppressed because one or more lines are too long
+11
-6
File diff suppressed because one or more lines are too long
@@ -271,6 +271,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
|
|||||||
const model = createGpt5Model("gpt-5.2")
|
const model = createGpt5Model("gpt-5.2")
|
||||||
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
|
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
|
||||||
expect(result.textVerbosity).toBe("low")
|
expect(result.textVerbosity).toBe("low")
|
||||||
|
expect(result.include).toEqual(["reasoning.encrypted_content"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("gpt-5.1 should have textVerbosity set to low", () => {
|
test("gpt-5.1 should have textVerbosity set to low", () => {
|
||||||
|
|||||||
@@ -336,10 +336,25 @@ const weatherTool = tool({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const toolRoundtrip = (
|
const toolRoundtrip = (
|
||||||
|
events: ReadonlyArray<LLMEvent>,
|
||||||
call: { readonly id: string; readonly name: string; readonly input: unknown },
|
call: { readonly id: string; readonly name: string; readonly input: unknown },
|
||||||
result: JSONValue,
|
result: JSONValue,
|
||||||
): ModelMessage[] => [
|
): ModelMessage[] => [
|
||||||
{ role: "assistant", content: [{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input }] },
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
|
||||||
|
type: "reasoning" as const,
|
||||||
|
text: events
|
||||||
|
.filter(LLMEvent.is.reasoningDelta)
|
||||||
|
.filter((event) => event.id === part.id)
|
||||||
|
.map((event) => event.text)
|
||||||
|
.join(""),
|
||||||
|
providerMetadata: part.providerMetadata,
|
||||||
|
})),
|
||||||
|
{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: [
|
content: [
|
||||||
@@ -395,7 +410,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
|
|||||||
|
|
||||||
const turn2 = yield* collect({
|
const turn2 = yield* collect({
|
||||||
...base,
|
...base,
|
||||||
messages: [userMessage, ...toolRoundtrip(toolCall!, WEATHER_RESULT)],
|
messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
|
expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
|
||||||
|
|||||||
@@ -591,7 +591,7 @@ describe("session.llm-native.request", () => {
|
|||||||
]),
|
]),
|
||||||
storedSession.user("Summarize it."),
|
storedSession.user("Summarize it."),
|
||||||
],
|
],
|
||||||
providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
|
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||||
expectedBody: {
|
expectedBody: {
|
||||||
input: [
|
input: [
|
||||||
openAIResponses.user("What changed?"),
|
openAIResponses.user("What changed?"),
|
||||||
@@ -608,6 +608,45 @@ describe("session.llm-native.request", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves empty encrypted OpenAI reasoning items before tool output", () =>
|
||||||
|
expectOpenAIResponsesRequest({
|
||||||
|
history: [
|
||||||
|
storedSession.assistant([
|
||||||
|
storedSession.openaiReasoning("", {
|
||||||
|
storedAs: "providerMetadata",
|
||||||
|
itemId: "rs_1",
|
||||||
|
encryptedContent: "encrypted-state",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||||
|
expectedBody: {
|
||||||
|
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
|
||||||
|
include: ["reasoning.encrypted_content"],
|
||||||
|
store: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("references stored OpenAI reasoning items by id", () =>
|
||||||
|
expectOpenAIResponsesRequest({
|
||||||
|
history: [
|
||||||
|
storedSession.assistant([
|
||||||
|
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||||
|
storedAs: "providerMetadata",
|
||||||
|
itemId: "rs_1",
|
||||||
|
encryptedContent: null,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providerOptions: { openai: { store: true } },
|
||||||
|
expectedBody: {
|
||||||
|
input: [{ type: "item_reference", id: "rs_1" }],
|
||||||
|
store: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
|
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const captures: Array<{ url: string; body: unknown }> = []
|
const captures: Array<{ url: string; body: unknown }> = []
|
||||||
|
|||||||
@@ -1166,6 +1166,7 @@ describe("session.llm.stream", () => {
|
|||||||
expect(capture.body.model).toBe(model.id)
|
expect(capture.body.model).toBe(model.id)
|
||||||
expect(capture.body.stream).toBe(true)
|
expect(capture.body.stream).toBe(true)
|
||||||
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
||||||
|
expect(capture.body.include).toEqual(["reasoning.encrypted_content"])
|
||||||
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
|
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
|
||||||
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
|
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user