Skip to content

Commit 81492d8

Browse files
rekram1-nodeopencode-agent[bot]
authored andcommitted
fix(ai): parse compatible reasoning deltas
1 parent fafb04e commit 81492d8

8 files changed

Lines changed: 545 additions & 13 deletions

File tree

packages/ai/src/protocols/openai-chat.ts

Lines changed: 157 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
5656
})
5757
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
5858

59+
type OpenAIChatReasoningDetail = Schema.Schema.Type<typeof JsonObject>
60+
5961
const OpenAIChatUserContent = Schema.Union([
6062
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
6163
Schema.Struct({
@@ -75,6 +77,9 @@ const OpenAIChatMessage = Schema.Union([
7577
content: Schema.NullOr(Schema.String),
7678
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
7779
reasoning_content: Schema.optional(Schema.String),
80+
reasoning: Schema.optional(Schema.String),
81+
reasoning_text: Schema.optional(Schema.String),
82+
reasoning_details: optionalArray(JsonObject),
7883
}),
7984
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
8085
]).pipe(Schema.toTaggedUnion("role"))
@@ -145,6 +150,9 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
145150
const OpenAIChatDelta = Schema.Struct({
146151
content: optionalNull(Schema.String),
147152
reasoning_content: optionalNull(Schema.String),
153+
reasoning: optionalNull(Schema.String),
154+
reasoning_text: optionalNull(Schema.String),
155+
reasoning_details: optionalNull(Schema.Array(JsonObject)),
148156
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
149157
})
150158

@@ -166,6 +174,8 @@ export interface ParserState {
166174
readonly usage?: Usage
167175
readonly finishReason?: FinishReason
168176
readonly lifecycle: Lifecycle.State
177+
readonly reasoningDetails: ReadonlyArray<OpenAIChatReasoningDetail>
178+
readonly reasoningField?: NonNullable<ReturnType<typeof reasoningDelta>>["field"]
169179
}
170180

171181
// =============================================================================
@@ -208,6 +218,27 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
208218
const openAICompatibleReasoningContent = (native: unknown) =>
209219
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
210220

221+
const reasoningState = (part: ReasoningPart | ToolCallPart) => {
222+
const state = part.providerMetadata?.openai
223+
return isRecord(state) ? state : undefined
224+
}
225+
226+
const reasoningField = (part: ReasoningPart) => {
227+
const field = reasoningState(part)?.reasoningField
228+
if (
229+
field === "reasoning" ||
230+
field === "reasoning_content" ||
231+
field === "reasoning_text" ||
232+
field === "reasoning_details"
233+
)
234+
return field
235+
}
236+
237+
const reasoningDetails = (part: ReasoningPart | ToolCallPart) => {
238+
const details = reasoningState(part)?.reasoningDetails
239+
return Array.isArray(details) ? details.filter(isRecord) : []
240+
}
241+
211242
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
212243
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
213244
for (const part of message.content) {
@@ -248,14 +279,24 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
248279
continue
249280
}
250281
}
282+
const text = reasoning.map((part) => part.text).join("")
283+
const field = reasoning.map(reasoningField).find((item) => item !== undefined) ?? "reasoning_content"
284+
const details = message.content.flatMap((part) =>
285+
part.type === "reasoning" || part.type === "tool-call" ? reasoningDetails(part) : [],
286+
)
251287
return {
252288
role: "assistant" as const,
253289
content: content.length === 0 ? null : ProviderShared.joinText(content),
254290
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
255291
reasoning_content:
256-
reasoning.length > 0
257-
? reasoning.map((part) => part.text).join("")
258-
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
292+
reasoning.length === 0
293+
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
294+
: field === "reasoning_content"
295+
? text
296+
: undefined,
297+
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
298+
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
299+
reasoning_details: details.length > 0 ? details : undefined,
259300
}
260301
})
261302

@@ -400,6 +441,82 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
400441
})
401442
}
402443

444+
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
445+
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
446+
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
447+
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
448+
const text = delta?.reasoning_details
449+
?.flatMap((detail) => {
450+
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
451+
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
452+
return [detail.summary]
453+
return []
454+
})
455+
.join("")
456+
return text ? ({ field: "reasoning_details", text } as const) : undefined
457+
}
458+
459+
const reasoningMetadata = (
460+
field: NonNullable<ReturnType<typeof reasoningDelta>>["field"],
461+
details: ReadonlyArray<OpenAIChatReasoningDetail>,
462+
) => ({
463+
openai: {
464+
reasoningField: field,
465+
...(details.length > 0 ? { reasoningDetails: details } : {}),
466+
},
467+
})
468+
469+
const withEncryptedReasoningDetails = (
470+
events: ReadonlyArray<LLMEvent>,
471+
details: ReadonlyArray<OpenAIChatReasoningDetail>,
472+
) =>
473+
events.map((event) => {
474+
if (event.type !== "tool-call") return event
475+
const matches = details.filter(
476+
(detail) => detail.type === "reasoning.encrypted" && detail.id === event.id && Boolean(detail.data),
477+
)
478+
if (matches.length === 0) return event
479+
const current = event.providerMetadata?.openai
480+
return LLMEvent.toolCall({
481+
...event,
482+
providerMetadata: {
483+
...event.providerMetadata,
484+
openai: { ...(isRecord(current) ? current : {}), reasoningDetails: matches },
485+
},
486+
})
487+
})
488+
489+
const mergeReasoningDetails = (
490+
current: ReadonlyArray<OpenAIChatReasoningDetail>,
491+
incoming: ReadonlyArray<OpenAIChatReasoningDetail>,
492+
) => {
493+
const result = [...current]
494+
for (const detail of incoming) {
495+
const index = result.findIndex(
496+
(item) =>
497+
item.type === detail.type &&
498+
((typeof detail.index === "number" && item.index === detail.index) ||
499+
(typeof detail.id === "string" && item.id === detail.id)),
500+
)
501+
if (index === -1) {
502+
result.push(detail)
503+
continue
504+
}
505+
const previous = result[index]!
506+
result[index] = {
507+
...previous,
508+
...detail,
509+
...(typeof detail.text === "string"
510+
? { text: `${typeof previous.text === "string" ? previous.text : ""}${detail.text}` }
511+
: {}),
512+
...(typeof detail.summary === "string"
513+
? { summary: `${typeof previous.summary === "string" ? previous.summary : ""}${detail.summary}` }
514+
: {}),
515+
}
516+
}
517+
return result
518+
}
519+
403520
const step = (state: ParserState, event: OpenAIChatEvent) =>
404521
Effect.gen(function* () {
405522
const events: LLMEvent[] = []
@@ -408,19 +525,40 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
408525
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
409526
const delta = choice?.delta
410527
const toolDeltas = delta?.tool_calls ?? []
528+
const reasoningDetails = mergeReasoningDetails(state.reasoningDetails, delta?.reasoning_details ?? [])
411529
let tools = state.tools
412530

413531
let lifecycle = state.lifecycle
414532

415-
if (delta?.reasoning_content)
416-
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
533+
const reasoning = reasoningDelta(delta)
534+
const reasoningField = state.reasoningField ?? reasoning?.field
535+
const currentReasoningMetadata = reasoningField
536+
? reasoningMetadata(
537+
reasoningField,
538+
reasoningDetails.filter((detail) => detail.type !== "reasoning.encrypted"),
539+
)
540+
: undefined
541+
if (reasoning) {
542+
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", currentReasoningMetadata)
543+
events.push(
544+
LLMEvent.reasoningDelta({
545+
id: "reasoning-0",
546+
text: reasoning.text,
547+
providerMetadata: currentReasoningMetadata,
548+
}),
549+
)
550+
}
417551

418552
if (delta?.content) {
419-
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
553+
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", currentReasoningMetadata)
420554
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
421555
}
422556

423-
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
557+
if (toolDeltas.length)
558+
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", currentReasoningMetadata)
559+
560+
if (finishReason !== undefined)
561+
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", currentReasoningMetadata)
424562

425563
for (const tool of toolDeltas) {
426564
const result = ToolStream.appendOrStart(
@@ -446,10 +584,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
446584
return [
447585
{
448586
tools: finished?.tools ?? tools,
449-
toolCallEvents: finished?.events ?? state.toolCallEvents,
587+
toolCallEvents: finished
588+
? withEncryptedReasoningDetails(finished.events, reasoningDetails)
589+
: state.toolCallEvents,
450590
usage,
451591
finishReason,
452592
lifecycle,
593+
reasoningDetails,
594+
reasoningField,
453595
},
454596
events,
455597
] as const
@@ -482,7 +624,13 @@ export const protocol = Protocol.make({
482624
},
483625
stream: {
484626
event: Protocol.jsonEvent(OpenAIChatEvent),
485-
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
627+
initial: () => ({
628+
tools: ToolStream.empty<number>(),
629+
toolCallEvents: [],
630+
lifecycle: Lifecycle.initial(),
631+
reasoningDetails: [],
632+
reasoningField: undefined,
633+
}),
486634
step,
487635
onHalt: finishEvents,
488636
},

0 commit comments

Comments
 (0)