mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 14:06:21 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e73478aa7 | ||
|
|
0f5b8afecb | ||
|
|
0573284de6 | ||
|
|
66ed593636 | ||
|
|
c5ab88feb8 |
@@ -346,6 +346,7 @@ export const Event = Schema.StructWithRest(
|
||||
item_id: Schema.optional(Schema.String),
|
||||
output_index: Schema.optional(Schema.Number),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
content_index: Schema.optional(Schema.Number),
|
||||
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
|
||||
item: optionalNull(StreamItem),
|
||||
response: Schema.optional(
|
||||
@@ -400,19 +401,11 @@ export interface ParserState {
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly open: boolean
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by the wire protocol'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>>
|
||||
// Summary indexes that received at least one streamed delta. The `:0` block
|
||||
// is started eagerly when the item opens, so block existence cannot tell
|
||||
// whether a `.done` final would duplicate streamed text.
|
||||
readonly deltaIndexes: ReadonlySet<number>
|
||||
readonly rawDeltaIndexes: ReadonlySet<number>
|
||||
readonly summaryDeltaIndexes: ReadonlySet<number>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -471,10 +464,13 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
: undefined
|
||||
// A raw-only block remains visible locally, but cannot be replayed as a native summary.
|
||||
const raw = metadata.reasoningTextIsRaw === true
|
||||
if (raw && encryptedContent === undefined) return undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
...(id === undefined ? {} : { id }),
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
summary: !raw && part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
}
|
||||
}
|
||||
@@ -860,95 +856,41 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
|
||||
const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
|
||||
|
||||
const text = event.delta ?? event.text
|
||||
if (!text || !item?.open) return [state, NO_EVENTS]
|
||||
const raw =
|
||||
event.type === "response.reasoning.delta" ||
|
||||
event.type === "response.reasoning.done" ||
|
||||
event.type === "response.reasoning_text.delta" ||
|
||||
event.type === "response.reasoning_text.done"
|
||||
const index = raw ? (event.content_index ?? 0) : (event.summary_index ?? 0)
|
||||
const indexes = raw ? item.rawDeltaIndexes : item.summaryDeltaIndexes
|
||||
if (event.type.endsWith(".done") && indexes.has(index)) return [state, NO_EVENTS]
|
||||
const separator = !raw && !indexes.has(index) && indexes.size > 0 ? "\n\n" : ""
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] !== "concluded")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
lifecycle,
|
||||
events,
|
||||
`${itemID}:${index}`,
|
||||
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
),
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:0`, separator + text),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[itemID]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
|
||||
),
|
||||
),
|
||||
[index]: "active",
|
||||
},
|
||||
},
|
||||
[itemID]: raw
|
||||
? { ...item, rawDeltaIndexes: new Set([...item.rawDeltaIndexes, index]) }
|
||||
: { ...item, summaryDeltaIndexes: new Set([...item.summaryDeltaIndexes, index]) },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item?.open) return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
|
||||
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
|
||||
const current = started.reasoningItems[itemID]
|
||||
if (!current) return [started, emitted]
|
||||
const events: LLMEvent[] = [...emitted]
|
||||
return [
|
||||
{
|
||||
...started,
|
||||
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
|
||||
reasoningItems: {
|
||||
...started.reasoningItems,
|
||||
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
// Some compatible gateways emit a reasoning final without streaming any
|
||||
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
|
||||
// as a single delta unless that summary index already streamed one.
|
||||
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
|
||||
const index = event.summary_index ?? 0
|
||||
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
}
|
||||
|
||||
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// Responses APIs normally stream reasoning items in this 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).
|
||||
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
|
||||
// also an implicit boundary for the previous part. This keeps the common event
|
||||
// lifecycle ordered when a compatible provider omits or delays a part-done event.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
@@ -991,8 +933,8 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
[item.id]: {
|
||||
open: true,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "active" },
|
||||
deltaIndexes: new Set(),
|
||||
rawDeltaIndexes: new Set(),
|
||||
summaryDeltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1021,34 +963,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
]
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item?.open) return [state, NO_EVENTS]
|
||||
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: "can-conclude",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
|
||||
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
|
||||
state: ParserState,
|
||||
event: Event,
|
||||
@@ -1162,7 +1076,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const tracked = state.reasoningItems[item.id]
|
||||
const encryptedContent = item.encrypted_content === undefined ? tracked?.encryptedContent : item.encrypted_content
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
for (const part of summaryParts) {
|
||||
@@ -1176,28 +1091,31 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
|
||||
if (decoded) content.push(decoded.text)
|
||||
}
|
||||
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
|
||||
const summaryText = joinReasoningText(summary)
|
||||
const itemText = summaryText ?? joinReasoningText(content)
|
||||
const events: LLMEvent[] = []
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
const fragments = Object.entries(reasoningItem.summaryParts)
|
||||
let lifecycle = state.lifecycle
|
||||
for (const [index, status] of fragments) {
|
||||
if (status === "concluded") continue
|
||||
// Do not repeat earlier summaries that were already emitted as separate fragments.
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
const raw = summaryText === undefined && (itemText !== undefined || tracked?.rawDeltaIndexes.size)
|
||||
const finalMetadata = providerMetadata(
|
||||
state,
|
||||
raw
|
||||
? {
|
||||
itemId: item.id,
|
||||
reasoningTextIsRaw: true,
|
||||
...(encryptedContent === undefined ? {} : { reasoningEncryptedContent: encryptedContent }),
|
||||
}
|
||||
: { itemId: item.id, reasoningEncryptedContent: encryptedContent ?? null },
|
||||
)
|
||||
if (tracked) {
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, `${item.id}:0`, finalMetadata, itemText),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: {
|
||||
...reasoningItem,
|
||||
...tracked,
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
|
||||
encryptedContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1206,11 +1124,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: finalMetadata }))
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
providerMetadata: finalMetadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
@@ -1223,8 +1141,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
[item.id]: {
|
||||
open: false,
|
||||
encryptedContent: item.encrypted_content,
|
||||
summaryParts: { 0: "concluded" },
|
||||
deltaIndexes: new Set(),
|
||||
rawDeltaIndexes: new Set(),
|
||||
summaryDeltaIndexes: new Set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1232,7 +1150,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, finalMetadata) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
@@ -1253,6 +1171,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
events.push(...emitted)
|
||||
}
|
||||
}
|
||||
for (const [itemID, item] of Object.entries(current.reasoningItems)) {
|
||||
if (!item.open || item.rawDeltaIndexes.size === 0) continue
|
||||
current = {
|
||||
...current,
|
||||
lifecycle: Lifecycle.reasoningEnd(
|
||||
current.lifecycle,
|
||||
events,
|
||||
`${itemID}:0`,
|
||||
providerMetadata(current, {
|
||||
itemId: itemID,
|
||||
reasoningTextIsRaw: true,
|
||||
...(item.encryptedContent === undefined ? {} : { reasoningEncryptedContent: item.encryptedContent }),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
@@ -1337,25 +1271,20 @@ export const step = (state: ParserState, input: Event) => {
|
||||
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (
|
||||
event.type === "response.reasoning.delta" ||
|
||||
event.type === "response.reasoning.done" ||
|
||||
event.type === "response.reasoning_text.delta" ||
|
||||
event.type === "response.reasoning_text.done" ||
|
||||
event.type === "response.reasoning_summary_text.delta" ||
|
||||
event.type === "response.reasoning_summary_text.done"
|
||||
) {
|
||||
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (
|
||||
event.type === "response.reasoning.done" ||
|
||||
event.type === "response.reasoning_summary_text.done" ||
|
||||
event.type === "response.reasoning_text.done"
|
||||
) {
|
||||
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
if (event.type === "response.reasoning_summary_part.added" || event.type === "response.reasoning_summary_part.done")
|
||||
return event.item_id !== undefined
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return event.item_id !== undefined
|
||||
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
? Effect.succeed([state, NO_EVENTS] satisfies StepResult)
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
|
||||
@@ -185,12 +185,6 @@ const HOSTED_TOOLS = {
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta")
|
||||
return event.item_id !== undefined
|
||||
? Effect.succeed(
|
||||
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
|
||||
)
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -138,13 +138,17 @@ describe("Open Responses completed item reasoning", () => {
|
||||
expect(response.reasoning).toBe(fixture.text)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
|
||||
"openai-compatible": {
|
||||
itemId: "rs_1",
|
||||
...(fixture.name === "raw text" ? { reasoningTextIsRaw: true } : {}),
|
||||
reasoningEncryptedContent: "encrypted",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("replaces only the still-open summary without repeating earlier text", () =>
|
||||
it.effect("replaces all streamed summary parts with completed text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
@@ -164,8 +168,8 @@ describe("Open Responses completed item reasoning", () => {
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe("First final")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
|
||||
expect(response.reasoning).toBe("First \n\nfinal")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual(["First \n\nfinal"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
|
||||
}
|
||||
|
||||
describe("Open Responses basic-item lifecycles", () => {
|
||||
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
|
||||
it.effect("keeps summary parts in one block and ignores late events for completed reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const events = yield* collect(
|
||||
@@ -106,23 +106,11 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "\n\nSecond" },
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "\n\nThird" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:2",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -386,7 +386,15 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "reasoning",
|
||||
text: "Preserved",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
|
||||
"openai-compatible": {
|
||||
itemId: routing.id,
|
||||
...(event.type === "response.reasoning.delta" ||
|
||||
event.type === "response.reasoning.done" ||
|
||||
event.type.startsWith("response.reasoning_text")
|
||||
? { reasoningTextIsRaw: true }
|
||||
: {}),
|
||||
reasoningEncryptedContent: "encrypted-state",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -437,25 +445,14 @@ describe("Open Responses-compatible route", () => {
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "First.",
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
text: "First.\n\nSecond.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: `${routing.id}:0`,
|
||||
text: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
},
|
||||
{ type: "reasoning-end", id: `${routing.id}:1` },
|
||||
{ type: "reasoning-end", id: `${routing.id}:0` },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "reasoning-model" })
|
||||
const request = LLM.request({ model, prompt: "Think it through." })
|
||||
const completed = { type: "response.completed", response: { id: "resp_1" } }
|
||||
const generate = (...events: OpenAIResponses.Event[]) =>
|
||||
LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...events))))
|
||||
|
||||
describe("OpenAI Responses reasoning items", () => {
|
||||
it.effect("streams raw and summary events into one reasoning block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", output_index: 2, item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.reasoning_text.delta",
|
||||
output_index: 2,
|
||||
item_id: "wrong",
|
||||
content_index: 0,
|
||||
delta: "Raw delta. ",
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 2,
|
||||
item_id: "wrong",
|
||||
summary_index: 0,
|
||||
delta: "Summary delta.",
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{
|
||||
type: "response.reasoning_summary_text.done",
|
||||
item_id: "rs_1",
|
||||
summary_index: 1,
|
||||
text: "Second summary.",
|
||||
},
|
||||
{ type: "response.reasoning.delta", item_id: "rs_1", content_index: 1, delta: " Raw tail." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Corrected summary." },
|
||||
{ type: "summary_text", text: "Second summary." },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Completed raw." }],
|
||||
encrypted_content: "state",
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual([
|
||||
"Raw delta. ",
|
||||
"Summary delta.",
|
||||
"\n\nSecond summary.",
|
||||
" Raw tail.",
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([
|
||||
"Corrected summary.\n\nSecond summary.",
|
||||
])
|
||||
expect(response.reasoning).toBe("Corrected summary.\n\nSecond summary.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tracks raw and summary final indexes independently", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_text.delta", item_id: "rs_1", content_index: 0, delta: "Raw" },
|
||||
{ type: "response.reasoning_text.done", item_id: "rs_1", content_index: 0, text: "Raw duplicate" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Summary" },
|
||||
{ type: "response.reasoning.done", item_id: "rs_1", content_index: 1, text: " raw final" },
|
||||
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual([
|
||||
"Raw",
|
||||
"Summary",
|
||||
" raw final",
|
||||
])
|
||||
expect(response.reasoning).toBe("RawSummary raw final")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays raw-only reasoning without serializing it as a summary", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const done of [
|
||||
undefined,
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", content: [{ type: "reasoning_text", text: "Internal detail." }] },
|
||||
} satisfies OpenAIResponses.Event,
|
||||
]) {
|
||||
const response = yield* generate(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
|
||||
},
|
||||
{ type: "response.reasoning_text.delta", item_id: "rs_1", delta: "Internal detail." },
|
||||
...(done ? [done] : []),
|
||||
completed,
|
||||
)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "state" },
|
||||
])
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2264,7 +2264,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Raw",
|
||||
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "state" } },
|
||||
providerMetadata: { openai: { itemId: "", reasoningTextIsRaw: true, reasoningEncryptedContent: "state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -2680,7 +2680,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
it.effect("streams reasoning summary parts in one block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2708,7 +2708,7 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("FirstSecond")
|
||||
expect(response.reasoning).toBe("First\n\nSecond")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
@@ -2717,16 +2717,10 @@ describe("OpenAI Responses route", () => {
|
||||
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-delta", id: "rs_1:0", text: "\n\nSecond" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
@@ -2735,73 +2729,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concludes reasoning at implicit summary boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { 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" },
|
||||
// The next part is enough to conclude the previous one even when
|
||||
// its done event is delayed.
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ 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 },
|
||||
// Some compatible providers begin the next part with its first delta.
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
|
||||
{
|
||||
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("FirstSecondThird")
|
||||
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
|
||||
{ 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", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:2",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
@@ -3083,10 +3010,9 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user