fix(llm): preserve response message phases (#38452)
This commit is contained in:
parent
553b42fcc7
commit
4b19ea2a71
7 changed files with 880 additions and 25 deletions
|
|
@ -46,8 +46,12 @@ type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInpu
|
||||||
const OpenAIResponsesOutputText = Schema.Struct({
|
const OpenAIResponsesOutputText = Schema.Struct({
|
||||||
type: Schema.tag("output_text"),
|
type: Schema.tag("output_text"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
annotations: Schema.Array(Schema.Unknown),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const OpenAIResponsesMessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||||
|
type OpenAIResponsesMessagePhase = Schema.Schema.Type<typeof OpenAIResponsesMessagePhase>
|
||||||
|
|
||||||
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
||||||
type: Schema.tag("summary_text"),
|
type: Schema.tag("summary_text"),
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
|
@ -78,7 +82,19 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([
|
||||||
const OpenAIResponsesInputItem = Schema.Union([
|
const OpenAIResponsesInputItem = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||||
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.String,
|
||||||
|
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||||
|
}),
|
||||||
|
Schema.Struct({
|
||||||
|
type: Schema.tag("message"),
|
||||||
|
id: Schema.String,
|
||||||
|
status: Schema.Literals(["in_progress", "completed", "incomplete"]),
|
||||||
|
role: Schema.tag("assistant"),
|
||||||
|
content: Schema.Array(OpenAIResponsesOutputText),
|
||||||
|
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||||
|
}),
|
||||||
OpenAIResponsesReasoningItem,
|
OpenAIResponsesReasoningItem,
|
||||||
OpenAIResponsesItemReference,
|
OpenAIResponsesItemReference,
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
|
|
@ -194,7 +210,9 @@ const OpenAIResponsesStreamItem = Schema.Struct({
|
||||||
server_label: Schema.optional(Schema.String),
|
server_label: Schema.optional(Schema.String),
|
||||||
output: Schema.optional(Schema.Unknown),
|
output: Schema.optional(Schema.Unknown),
|
||||||
error: Schema.optional(Schema.Unknown),
|
error: Schema.optional(Schema.Unknown),
|
||||||
|
content: Schema.optional(Schema.Array(Schema.Unknown)),
|
||||||
encrypted_content: optionalNull(Schema.String),
|
encrypted_content: optionalNull(Schema.String),
|
||||||
|
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||||
})
|
})
|
||||||
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
|
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
|
||||||
|
|
||||||
|
|
@ -212,7 +230,9 @@ const OpenAIResponsesErrorPayload = Schema.Struct({
|
||||||
const OpenAIResponsesEvent = Schema.Struct({
|
const OpenAIResponsesEvent = Schema.Struct({
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
delta: Schema.optional(Schema.String),
|
delta: Schema.optional(Schema.String),
|
||||||
|
text: Schema.optional(Schema.String),
|
||||||
item_id: Schema.optional(Schema.String),
|
item_id: Schema.optional(Schema.String),
|
||||||
|
content_index: Schema.optional(Schema.Number),
|
||||||
summary_index: Schema.optional(Schema.Number),
|
summary_index: Schema.optional(Schema.Number),
|
||||||
item: Schema.optional(OpenAIResponsesStreamItem),
|
item: Schema.optional(OpenAIResponsesStreamItem),
|
||||||
response: Schema.optional(
|
response: Schema.optional(
|
||||||
|
|
@ -237,10 +257,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 messageItems: Readonly<Record<string, MessageStreamItem>>
|
||||||
|
readonly messageContentIDs: ReadonlySet<string>
|
||||||
|
readonly nextMessageContentID: number
|
||||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||||
readonly store: boolean | undefined
|
readonly store: boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessageStreamItem {
|
||||||
|
readonly providerMetadata?: ProviderMetadata
|
||||||
|
readonly content: Readonly<Record<number, { readonly id: string; readonly text: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||||
|
|
||||||
interface ReasoningStreamItem {
|
interface ReasoningStreamItem {
|
||||||
|
|
@ -298,6 +326,26 @@ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | un
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messagePhase = (part: TextPart): OpenAIResponsesMessagePhase | null | undefined => {
|
||||||
|
const phase = part.providerMetadata?.openai?.phase
|
||||||
|
return phase === "commentary" || phase === "final_answer" || phase === null ? phase : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageItemID = (part: TextPart) => {
|
||||||
|
const itemID = part.providerMetadata?.openai?.itemId
|
||||||
|
return typeof itemID === "string" && itemID.length > 0 ? itemID : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageStatus = (part: TextPart) => {
|
||||||
|
const status = part.providerMetadata?.openai?.status
|
||||||
|
return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageAnnotations = (part: TextPart) => {
|
||||||
|
const annotations = part.providerMetadata?.openai?.annotations
|
||||||
|
return Array.isArray(annotations) ? annotations : []
|
||||||
|
}
|
||||||
|
|
||||||
const hostedToolItemID = (part: ToolResultPart) => {
|
const hostedToolItemID = (part: ToolResultPart) => {
|
||||||
const openai = part.providerMetadata?.openai
|
const openai = part.providerMetadata?.openai
|
||||||
return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0
|
return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0
|
||||||
|
|
@ -368,17 +416,49 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
|
const inputStart = input.length
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
|
let phase: OpenAIResponsesMessagePhase | null | undefined
|
||||||
|
let itemID: string | undefined
|
||||||
|
let status: "in_progress" | "completed" | "incomplete" | undefined
|
||||||
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
||||||
const reasoningReferences = new Set<string>()
|
const reasoningReferences = new Set<string>()
|
||||||
const hostedToolReferences = new Set<string>()
|
const hostedToolReferences = 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(
|
||||||
|
itemID
|
||||||
|
? {
|
||||||
|
type: "message",
|
||||||
|
id: itemID,
|
||||||
|
status: status ?? "completed",
|
||||||
|
role: "assistant",
|
||||||
|
content: content.map((part) => ({
|
||||||
|
type: "output_text",
|
||||||
|
text: part.text,
|
||||||
|
annotations: messageAnnotations(part),
|
||||||
|
})),
|
||||||
|
...(phase !== undefined ? { phase } : {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
role: "assistant",
|
||||||
|
content: ProviderShared.joinText(content),
|
||||||
|
...(phase !== undefined ? { phase } : {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
content.splice(0, content.length)
|
content.splice(0, content.length)
|
||||||
|
phase = undefined
|
||||||
|
itemID = undefined
|
||||||
|
status = undefined
|
||||||
}
|
}
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
|
const nextPhase = messagePhase(part)
|
||||||
|
const nextItemID = messageItemID(part)
|
||||||
|
if (content.length > 0 && (phase !== nextPhase || itemID !== nextItemID)) flushText()
|
||||||
|
phase = nextPhase
|
||||||
|
itemID = nextItemID
|
||||||
|
status = messageStatus(part) ?? status
|
||||||
content.push(part)
|
content.push(part)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -429,6 +509,20 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
flushText()
|
flushText()
|
||||||
|
if (store === false && Object.values(reasoningItems).some((item) => typeof item.encrypted_content !== "string"))
|
||||||
|
input.splice(
|
||||||
|
inputStart,
|
||||||
|
input.length - inputStart,
|
||||||
|
...input.slice(inputStart).map((item) =>
|
||||||
|
"type" in item && item.type === "message"
|
||||||
|
? {
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: ProviderShared.joinText(item.content),
|
||||||
|
...(item.phase !== undefined ? { phase: item.phase } : {}),
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -612,15 +706,134 @@ const NO_EVENTS: StepResult["1"] = []
|
||||||
// the protocol's `terminal` predicate stay in sync.
|
// the protocol's `terminal` predicate stay in sync.
|
||||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const messageMetadata = (item: OpenAIResponsesStreamItem, id: string, previous?: ProviderMetadata) => {
|
||||||
if (!event.delta) return [state, NO_EVENTS]
|
const openai = previous?.openai
|
||||||
|
const phase = item.phase !== undefined ? item.phase : openai?.phase
|
||||||
|
const status =
|
||||||
|
item.status === "in_progress" || item.status === "completed" || item.status === "incomplete"
|
||||||
|
? item.status
|
||||||
|
: openai?.status
|
||||||
|
return openaiMetadata({
|
||||||
|
itemId: id,
|
||||||
|
...(phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : {}),
|
||||||
|
...(status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageContentMetadata = (
|
||||||
|
providerMetadata: ProviderMetadata,
|
||||||
|
item: OpenAIResponsesStreamItem,
|
||||||
|
index: number,
|
||||||
|
): ProviderMetadata => {
|
||||||
|
const content = item.content?.[index]
|
||||||
|
if (!ProviderShared.isRecord(content) || content.type !== "output_text" || !Array.isArray(content.annotations))
|
||||||
|
return providerMetadata
|
||||||
|
return openaiMetadata({ ...providerMetadata.openai, annotations: content.annotations })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureMessageContent = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||||
|
const itemID = event.item_id ?? "text-0"
|
||||||
|
const index = event.content_index ?? 0
|
||||||
|
const item = state.messageItems[itemID] ?? { content: {} }
|
||||||
|
const existing = item.content[index]
|
||||||
|
if (existing) return { state, itemID, index, item, content: existing }
|
||||||
|
const findID = (next: number): readonly [string, number] => {
|
||||||
|
const id = `openai-text-${next}`
|
||||||
|
return state.messageContentIDs.has(id) ? findID(next + 1) : [id, next + 1]
|
||||||
|
}
|
||||||
|
const [id, nextMessageContentID] =
|
||||||
|
index === 0 && !state.messageContentIDs.has(itemID)
|
||||||
|
? ([itemID, state.nextMessageContentID] as const)
|
||||||
|
: findID(state.nextMessageContentID)
|
||||||
|
const content = { id, text: "" }
|
||||||
|
const nextItem = { ...item, content: { ...item.content, [index]: content } }
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
messageItems: { ...state.messageItems, [itemID]: nextItem },
|
||||||
|
messageContentIDs: new Set([...state.messageContentIDs, id]),
|
||||||
|
nextMessageContentID,
|
||||||
|
},
|
||||||
|
itemID,
|
||||||
|
index,
|
||||||
|
item: nextItem,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMessageContent = (
|
||||||
|
state: ParserState,
|
||||||
|
itemID: string,
|
||||||
|
index: number,
|
||||||
|
content: { readonly id: string; readonly text: string },
|
||||||
|
): ParserState => ({
|
||||||
|
...state,
|
||||||
|
messageItems: {
|
||||||
|
...state.messageItems,
|
||||||
|
[itemID]: {
|
||||||
|
...state.messageItems[itemID],
|
||||||
|
content: { ...state.messageItems[itemID]?.content, [index]: content },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeOtherMessageContent = (state: ParserState, events: LLMEvent[], item: MessageStreamItem, index: number) =>
|
||||||
|
Object.entries(item.content).reduce(
|
||||||
|
(lifecycle, entry) =>
|
||||||
|
Number(entry[0]) === index ? lifecycle : Lifecycle.textEnd(lifecycle, events, entry[1].id, item.providerMetadata),
|
||||||
|
state.lifecycle,
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendOutputText = (state: ParserState, event: OpenAIResponsesEvent, text: string): StepResult => {
|
||||||
|
const ensured = ensureMessageContent(state, event)
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
const lifecycle = Lifecycle.textStart(
|
||||||
|
closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index),
|
||||||
|
events,
|
||||||
|
ensured.content.id,
|
||||||
|
ensured.item.providerMetadata,
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
{
|
||||||
|
...updateMessageContent(ensured.state, ensured.itemID, ensured.index, {
|
||||||
|
...ensured.content,
|
||||||
|
text: ensured.content.text + text,
|
||||||
|
}),
|
||||||
|
lifecycle: Lifecycle.textDelta(lifecycle, events, ensured.content.id, text),
|
||||||
|
},
|
||||||
events,
|
events,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
|
return appendOutputText(state, event, event.delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
|
if (event.text === undefined) return [state, NO_EVENTS]
|
||||||
|
const ensured = ensureMessageContent(state, event)
|
||||||
|
if (event.text === ensured.content.text) {
|
||||||
|
if (ensured.state.lifecycle.text.has(ensured.content.id)) return [ensured.state, NO_EVENTS]
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...ensured.state,
|
||||||
|
lifecycle: Lifecycle.textStart(
|
||||||
|
closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index),
|
||||||
|
events,
|
||||||
|
ensured.content.id,
|
||||||
|
ensured.item.providerMetadata,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (event.text.startsWith(ensured.content.text))
|
||||||
|
return appendOutputText(ensured.state, event, event.text.slice(ensured.content.text.length))
|
||||||
|
return [ensured.state, NO_EVENTS]
|
||||||
|
}
|
||||||
|
|
||||||
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[] = []
|
||||||
|
|
@ -655,6 +868,23 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
||||||
// best-effort, not guaranteed.
|
// 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?.type === "message" && item.id) {
|
||||||
|
const existing = state.messageItems[item.id]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
messageItems: {
|
||||||
|
...state.messageItems,
|
||||||
|
[item.id]: {
|
||||||
|
...existing,
|
||||||
|
providerMetadata: messageMetadata(item, item.id, existing?.providerMetadata),
|
||||||
|
content: existing?.content ?? {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NO_EVENTS,
|
||||||
|
]
|
||||||
|
}
|
||||||
if (item && isReasoningItem(item)) {
|
if (item && isReasoningItem(item)) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
return [
|
return [
|
||||||
|
|
@ -812,6 +1042,32 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||||
const item = event.item
|
const item = event.item
|
||||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||||
|
|
||||||
|
if (item.type === "message" && item.id) {
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
const itemID = item.id
|
||||||
|
const messageItem = state.messageItems[itemID]
|
||||||
|
const { [itemID]: _finished, ...messageItems } = state.messageItems
|
||||||
|
const providerMetadata = messageMetadata(item, itemID, messageItem?.providerMetadata)
|
||||||
|
const lifecycle = Object.entries(messageItem?.content ?? {}).reduce(
|
||||||
|
(lifecycle, entry) =>
|
||||||
|
Lifecycle.textEnd(
|
||||||
|
lifecycle,
|
||||||
|
events,
|
||||||
|
entry[1].id,
|
||||||
|
messageContentMetadata(providerMetadata, item, Number(entry[0])),
|
||||||
|
),
|
||||||
|
state.lifecycle,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
lifecycle,
|
||||||
|
messageItems,
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
] satisfies StepResult
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === "function_call") {
|
if (item.type === "function_call") {
|
||||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||||
const tools = state.tools[item.id]
|
const tools = state.tools[item.id]
|
||||||
|
|
@ -939,6 +1195,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||||
if (event.type === "response.reasoning_summary_part.done")
|
if (event.type === "response.reasoning_summary_part.done")
|
||||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
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.output_text.done") return Effect.succeed(onOutputTextDone(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)
|
||||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||||
|
|
@ -968,6 +1225,9 @@ export const protocol = Protocol.make({
|
||||||
hasFunctionCall: false,
|
hasFunctionCall: false,
|
||||||
tools: ToolStream.empty<string>(),
|
tools: ToolStream.empty<string>(),
|
||||||
lifecycle: Lifecycle.initial(),
|
lifecycle: Lifecycle.initial(),
|
||||||
|
messageItems: {},
|
||||||
|
messageContentIDs: new Set<string>(),
|
||||||
|
nextMessageContentID: 0,
|
||||||
reasoningItems: {},
|
reasoningItems: {},
|
||||||
store: OpenAIOptions.store(request),
|
store: OpenAIOptions.store(request),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||||
return { ...state, stepStarted: true }
|
return { ...state, stepStarted: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||||
|
if (state.text.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
if (stepped.text.has(id)) {
|
events.push(LLMEvent.textStart({ id, ...(providerMetadata ? { providerMetadata } : {}) }))
|
||||||
events.push(LLMEvent.textDelta({ id, text }))
|
|
||||||
return stepped
|
|
||||||
}
|
|
||||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
|
||||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||||
|
const started = textStart(state, events, id)
|
||||||
|
events.push(LLMEvent.textDelta({ id, text }))
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
export const reasoningStart = (
|
export const reasoningStart = (
|
||||||
state: State,
|
state: State,
|
||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
|
|
@ -65,7 +68,7 @@ export const reasoningEnd = (
|
||||||
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||||
if (!state.text.has(id)) return state
|
if (!state.text.has(id)) return state
|
||||||
const stepped = stepStart(state, events)
|
const stepped = stepStart(state, events)
|
||||||
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
events.push(LLMEvent.textEnd({ id, ...(providerMetadata ? { providerMetadata } : {}) }))
|
||||||
const text = new Set(stepped.text)
|
const text = new Set(stepped.text)
|
||||||
text.delete(id)
|
text.delete(id)
|
||||||
return { ...stepped, text }
|
return { ...stepped, text }
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLM, Message } from "../../src"
|
||||||
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
|
import { OpenAIResponses } from "../../src/protocols/openai-responses"
|
||||||
|
import { LLMClient } from "../../src/route"
|
||||||
|
import { weatherTool } from "../recorded-scenarios"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = OpenAI.configure({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||||
|
}).responses("gpt-5.6-sol")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "openai-responses-phase",
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
requires: ["OPENAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("OpenAI Responses phase recorded", () => {
|
||||||
|
recorded.effect.with("round-trips commentary into a final answer", { tags: ["phase", "tool"] }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const user = Message.user("What is the weather in Paris?")
|
||||||
|
const first = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
system:
|
||||||
|
"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. Do not provide the final answer until its result is available.",
|
||||||
|
messages: [user],
|
||||||
|
tools: [weatherTool],
|
||||||
|
generation: { maxTokens: 100 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const call = first.toolCalls[0]
|
||||||
|
if (!call) throw new Error("OpenAI Responses did not return the expected weather tool call")
|
||||||
|
|
||||||
|
expect(call).toMatchObject({ name: "get_weather", input: { city: "Paris" } })
|
||||||
|
const commentary = first.message.content.find(
|
||||||
|
(part) => part.type === "text" && part.providerMetadata?.openai?.phase === "commentary",
|
||||||
|
)
|
||||||
|
if (!commentary || commentary.type !== "text") throw new Error("OpenAI Responses did not return commentary text")
|
||||||
|
const itemID = commentary.providerMetadata?.openai?.itemId
|
||||||
|
if (typeof itemID !== "string") throw new Error("OpenAI Responses commentary did not include an item ID")
|
||||||
|
expect(commentary).toEqual({
|
||||||
|
type: "text",
|
||||||
|
text: "I’ll check the current weather in Paris.",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: { itemId: itemID, phase: "commentary", status: "completed", annotations: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const continuation = LLM.request({
|
||||||
|
model,
|
||||||
|
system:
|
||||||
|
"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. After its result, answer exactly: Paris is sunny.",
|
||||||
|
messages: [
|
||||||
|
user,
|
||||||
|
first.message,
|
||||||
|
Message.tool({
|
||||||
|
id: call.id,
|
||||||
|
name: call.name,
|
||||||
|
result: { temperature: 22, condition: "sunny" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tools: [weatherTool],
|
||||||
|
generation: { maxTokens: 100 },
|
||||||
|
})
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(continuation)
|
||||||
|
expect(prepared.body.input).toContainEqual({
|
||||||
|
type: "message",
|
||||||
|
id: itemID,
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: commentary.text, annotations: [] }],
|
||||||
|
phase: "commentary",
|
||||||
|
})
|
||||||
|
|
||||||
|
const second = yield* LLMClient.generate(continuation)
|
||||||
|
|
||||||
|
expect(second.text.trim()).toBe("Paris is sunny.")
|
||||||
|
expect(
|
||||||
|
second.message.content.some(
|
||||||
|
(part) => part.type === "text" && part.providerMetadata?.openai?.phase === "final_answer",
|
||||||
|
),
|
||||||
|
).toBeTrue()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -153,7 +153,7 @@ describe("OpenAI Responses route", () => {
|
||||||
{ type: "input_text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
{ type: "input_text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
{ role: "assistant", content: "After." },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -529,11 +529,11 @@ describe("OpenAI Responses route", () => {
|
||||||
encrypted_content: "encrypted-continuation-state",
|
encrypted_content: "encrypted-continuation-state",
|
||||||
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
||||||
},
|
},
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
|
{ role: "assistant", content: "It shows a small test image." },
|
||||||
{ role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
|
||||||
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
|
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
|
||||||
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
|
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
|
{ role: "assistant", content: "Paris is 22 degrees." },
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
|
content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
|
||||||
|
|
@ -754,6 +754,395 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves streamed assistant message phases", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking first." },
|
||||||
|
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_final", delta: "Finished." },
|
||||||
|
{ type: "response.output_text.done", item_id: "msg_final" },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "msg_commentary",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "msg_commentary", text: "Checking first." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "msg_commentary",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "msg_final",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "msg_final", text: "Finished." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "msg_final",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Checking first.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Finished.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves phased message and content boundaries", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.delta",
|
||||||
|
item_id: "msg_commentary",
|
||||||
|
content_index: 0,
|
||||||
|
delta: "First.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_commentary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.done",
|
||||||
|
item_id: "msg_commentary",
|
||||||
|
content_index: 0,
|
||||||
|
text: "First.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.done",
|
||||||
|
item_id: "msg_commentary",
|
||||||
|
content_index: 1,
|
||||||
|
text: "Second.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_commentary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_commentary_2", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.delta",
|
||||||
|
item_id: "msg_commentary_2",
|
||||||
|
content_index: 0,
|
||||||
|
delta: "Thi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.done",
|
||||||
|
item_id: "msg_commentary_2",
|
||||||
|
content_index: 0,
|
||||||
|
text: "Third.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_commentary_2", phase: "commentary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "openai-text-0" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.done",
|
||||||
|
item_id: "openai-text-0",
|
||||||
|
content_index: 0,
|
||||||
|
text: "Final.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
id: "openai-text-0",
|
||||||
|
phase: "final_answer",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "output_text",
|
||||||
|
text: "Final.",
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
type: "url_citation",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "Example",
|
||||||
|
start_index: 0,
|
||||||
|
end_index: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_null", phase: null },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.done",
|
||||||
|
item_id: "msg_null",
|
||||||
|
content_index: 0,
|
||||||
|
text: "Nullable.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_null", phase: null },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_unphased" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.done",
|
||||||
|
item_id: "msg_unphased",
|
||||||
|
content_index: 0,
|
||||||
|
text: "Unphased.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "message", id: "msg_unphased" },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "First.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Second.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Third.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Final.",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
itemId: "openai-text-0",
|
||||||
|
phase: "final_answer",
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
type: "url_citation",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "Example",
|
||||||
|
start_index: 0,
|
||||||
|
end_index: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Nullable.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Unphased.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_unphased" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "msg_commentary",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "msg_commentary", text: "First." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "msg_commentary",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "openai-text-0",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "openai-text-0", text: "Second." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "openai-text-0",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "msg_commentary_2",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "msg_commentary_2", text: "Thi" },
|
||||||
|
{ type: "text-delta", id: "msg_commentary_2", text: "rd." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "msg_commentary_2",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "openai-text-1",
|
||||||
|
providerMetadata: { openai: { itemId: "openai-text-0" } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "openai-text-1", text: "Final." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "openai-text-1",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
itemId: "openai-text-0",
|
||||||
|
phase: "final_answer",
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
type: "url_citation",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "Example",
|
||||||
|
start_index: 0,
|
||||||
|
end_index: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "msg_null",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "msg_null", text: "Nullable." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "msg_null",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-start",
|
||||||
|
id: "msg_unphased",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_unphased" } },
|
||||||
|
},
|
||||||
|
{ type: "text-delta", id: "msg_unphased", text: "Unphased." },
|
||||||
|
{
|
||||||
|
type: "text-end",
|
||||||
|
id: "msg_unphased",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_unphased" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_commentary",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
phase: "commentary",
|
||||||
|
content: [
|
||||||
|
{ type: "output_text", text: "First.", annotations: [] },
|
||||||
|
{ type: "output_text", text: "Second.", annotations: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_commentary_2",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
phase: "commentary",
|
||||||
|
content: [{ type: "output_text", text: "Third.", annotations: [] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "openai-text-0",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
phase: "final_answer",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "output_text",
|
||||||
|
text: "Final.",
|
||||||
|
annotations: [
|
||||||
|
{
|
||||||
|
type: "url_citation",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "Example",
|
||||||
|
start_index: 0,
|
||||||
|
end_index: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_null",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
phase: null,
|
||||||
|
content: [{ type: "output_text", text: "Nullable.", annotations: [] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_unphased",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "Unphased.", annotations: [] }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses reasoning summary stream fixtures", () =>
|
it.effect("parses reasoning summary stream fixtures", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|
@ -947,7 +1336,7 @@ describe("OpenAI Responses route", () => {
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||||
},
|
},
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
{ role: "assistant", content: "The parser changed." },
|
||||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
@ -995,13 +1384,69 @@ describe("OpenAI Responses route", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body.input).toEqual([
|
expect(prepared.body.input).toEqual([
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
{ role: "assistant", content: "Before." },
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
encrypted_content: "encrypted-state",
|
encrypted_content: "encrypted-state",
|
||||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||||
},
|
},
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
{ role: "assistant", content: "After." },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("round-trips assistant message phases", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Checking first.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Still checking.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Finished.",
|
||||||
|
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_commentary",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
phase: "commentary",
|
||||||
|
content: [{ type: "output_text", text: "Checking first.", annotations: [] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_commentary_2",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
phase: "commentary",
|
||||||
|
content: [{ type: "output_text", text: "Still checking.", annotations: [] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
id: "msg_final",
|
||||||
|
status: "completed",
|
||||||
|
role: "assistant",
|
||||||
|
phase: "final_answer",
|
||||||
|
content: [{ type: "output_text", text: "Finished.", annotations: [] }],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -1120,7 +1565,13 @@ describe("OpenAI Responses route", () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "text", text: "The parser changed." },
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "The parser changed.",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: { itemId: "msg_1", phase: "final_answer", status: "completed" },
|
||||||
|
},
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
Message.user("Summarize it."),
|
Message.user("Summarize it."),
|
||||||
],
|
],
|
||||||
|
|
@ -1131,7 +1582,7 @@ describe("OpenAI Responses route", () => {
|
||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toMatchObject({
|
||||||
input: [
|
input: [
|
||||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||||
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
{ role: "assistant", content: "The parser changed.", phase: "final_answer" },
|
||||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||||
],
|
],
|
||||||
store: false,
|
store: false,
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ const storedSession = {
|
||||||
|
|
||||||
const openAIResponses = {
|
const openAIResponses = {
|
||||||
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||||
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
assistant: (text: string) => ({ role: "assistant", content: text }),
|
||||||
openaiReasoning: (text: string, encryptedContent: string) => ({
|
openaiReasoning: (text: string, encryptedContent: string) => ({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
encrypted_content: encryptedContent,
|
encrypted_content: encryptedContent,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue