@@ -56,6 +56,8 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
5656} )
5757type OpenAIChatAssistantToolCall = Schema . Schema . Type < typeof OpenAIChatAssistantToolCall >
5858
59+ type OpenAIChatReasoningDetail = Schema . Schema . Type < typeof JsonObject >
60+
5961const 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
145150const 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
208218const 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+
211242const 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+
403520const 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