@@ -76,7 +76,13 @@ interface AGUIState {
7676 hasClosedReasoning : boolean
7777 hasEmittedRunStarted : boolean
7878 hasEmittedTextMessageStart : boolean
79+ hasEmittedTextMessageEnd : boolean
80+ hasEmittedRunFinished : boolean
7981 hasEmittedStepStarted : boolean
82+ deferredUsage :
83+ | { promptTokens : number ; completionTokens : number ; totalTokens : number }
84+ | undefined
85+ computedFinishReason : string | undefined
8086}
8187
8288export class OpenRouterTextAdapter <
@@ -116,7 +122,11 @@ export class OpenRouterTextAdapter<
116122 hasClosedReasoning : false ,
117123 hasEmittedRunStarted : false ,
118124 hasEmittedTextMessageStart : false ,
125+ hasEmittedTextMessageEnd : false ,
126+ hasEmittedRunFinished : false ,
119127 hasEmittedStepStarted : false ,
128+ deferredUsage : undefined ,
129+ computedFinishReason : undefined ,
120130 }
121131
122132 try {
@@ -178,6 +188,20 @@ export class OpenRouterTextAdapter<
178188 )
179189 }
180190 }
191+
192+ // Emit RUN_FINISHED after the stream ends so we capture usage from
193+ // any chunk (some SDKs send usage on a separate trailing chunk).
194+ if ( aguiState . hasEmittedRunFinished && aguiState . computedFinishReason ) {
195+ yield asChunk ( {
196+ type : 'RUN_FINISHED' ,
197+ runId : aguiState . runId ,
198+ threadId : aguiState . threadId ,
199+ model : currentModel || options . model ,
200+ timestamp,
201+ usage : aguiState . deferredUsage ,
202+ finishReason : aguiState . computedFinishReason ,
203+ } )
204+ }
181205 } catch ( error ) {
182206 // Emit RUN_STARTED if not yet emitted (error on first call)
183207 if ( ! aguiState . hasEmittedRunStarted ) {
@@ -535,82 +559,84 @@ export class OpenRouterTextAdapter<
535559 }
536560
537561 if ( finishReason ) {
538- // Emit all completed tool calls when finish reason indicates tool usage
539- if ( finishReason === 'tool_calls' || toolCallBuffers . size > 0 ) {
540- for ( const [ , tc ] of toolCallBuffers . entries ( ) ) {
541- // Parse arguments for TOOL_CALL_END
542- let parsedInput : unknown = { }
543- try {
544- parsedInput = tc . arguments ? JSON . parse ( tc . arguments ) : { }
545- } catch {
546- parsedInput = { }
562+ // Capture usage from whichever chunk provides it (may arrive on a
563+ // later duplicate finishReason chunk from the SDK).
564+ if ( usage ) {
565+ aguiState . deferredUsage = {
566+ promptTokens : usage . promptTokens || 0 ,
567+ completionTokens : usage . completionTokens || 0 ,
568+ totalTokens : usage . totalTokens || 0 ,
569+ }
570+ }
571+
572+ // Guard: only emit finish events once. OpenAI-compatible APIs often
573+ // send two chunks with finishReason (one for the finish, one carrying
574+ // usage data). Without this guard TEXT_MESSAGE_END and RUN_FINISHED
575+ // would be emitted twice.
576+ if ( ! aguiState . hasEmittedRunFinished ) {
577+ aguiState . hasEmittedRunFinished = true
578+
579+ // Emit all completed tool calls when finish reason indicates tool usage
580+ if ( finishReason === 'tool_calls' || toolCallBuffers . size > 0 ) {
581+ for ( const [ , tc ] of toolCallBuffers . entries ( ) ) {
582+ // Parse arguments for TOOL_CALL_END
583+ let parsedInput : unknown = { }
584+ try {
585+ parsedInput = tc . arguments ? JSON . parse ( tc . arguments ) : { }
586+ } catch {
587+ parsedInput = { }
588+ }
589+
590+ // Emit AG-UI TOOL_CALL_END
591+ yield asChunk ( {
592+ type : 'TOOL_CALL_END' ,
593+ toolCallId : tc . id ,
594+ toolCallName : tc . name ,
595+ toolName : tc . name ,
596+ model : meta . model ,
597+ timestamp : meta . timestamp ,
598+ input : parsedInput ,
599+ } )
547600 }
548601
549- // Emit AG-UI TOOL_CALL_END
602+ toolCallBuffers . clear ( )
603+ }
604+
605+ aguiState . computedFinishReason =
606+ finishReason === 'tool_calls'
607+ ? 'tool_calls'
608+ : finishReason === 'length'
609+ ? 'length'
610+ : 'stop'
611+
612+ // Close reasoning events if still open
613+ if ( aguiState . reasoningMessageId && ! aguiState . hasClosedReasoning ) {
614+ aguiState . hasClosedReasoning = true
615+ yield asChunk ( {
616+ type : 'REASONING_MESSAGE_END' ,
617+ messageId : aguiState . reasoningMessageId ,
618+ model : meta . model ,
619+ timestamp : meta . timestamp ,
620+ } )
550621 yield asChunk ( {
551- type : 'TOOL_CALL_END' ,
552- toolCallId : tc . id ,
553- toolCallName : tc . name ,
554- toolName : tc . name ,
622+ type : 'REASONING_END' ,
623+ messageId : aguiState . reasoningMessageId ,
555624 model : meta . model ,
556625 timestamp : meta . timestamp ,
557- input : parsedInput ,
558626 } )
559627 }
560628
561- toolCallBuffers . clear ( )
562- }
563-
564- const computedFinishReason =
565- finishReason === 'tool_calls'
566- ? 'tool_calls'
567- : finishReason === 'length'
568- ? 'length'
569- : 'stop'
570-
571- // Close reasoning events if still open
572- if ( aguiState . reasoningMessageId && ! aguiState . hasClosedReasoning ) {
573- aguiState . hasClosedReasoning = true
574- yield asChunk ( {
575- type : 'REASONING_MESSAGE_END' ,
576- messageId : aguiState . reasoningMessageId ,
577- model : meta . model ,
578- timestamp : meta . timestamp ,
579- } )
580- yield asChunk ( {
581- type : 'REASONING_END' ,
582- messageId : aguiState . reasoningMessageId ,
583- model : meta . model ,
584- timestamp : meta . timestamp ,
585- } )
586- }
587-
588- // Emit TEXT_MESSAGE_END if we had text content
589- if ( aguiState . hasEmittedTextMessageStart ) {
590- yield asChunk ( {
591- type : 'TEXT_MESSAGE_END' ,
592- messageId : aguiState . messageId ,
593- model : meta . model ,
594- timestamp : meta . timestamp ,
595- } )
629+ // Emit TEXT_MESSAGE_END if we had text content
630+ if ( aguiState . hasEmittedTextMessageStart ) {
631+ aguiState . hasEmittedTextMessageEnd = true
632+ yield asChunk ( {
633+ type : 'TEXT_MESSAGE_END' ,
634+ messageId : aguiState . messageId ,
635+ model : meta . model ,
636+ timestamp : meta . timestamp ,
637+ } )
638+ }
596639 }
597-
598- // Emit AG-UI RUN_FINISHED
599- yield asChunk ( {
600- type : 'RUN_FINISHED' ,
601- runId : aguiState . runId ,
602- threadId : aguiState . threadId ,
603- model : meta . model ,
604- timestamp : meta . timestamp ,
605- usage : usage
606- ? {
607- promptTokens : usage . promptTokens || 0 ,
608- completionTokens : usage . completionTokens || 0 ,
609- totalTokens : usage . totalTokens || 0 ,
610- }
611- : undefined ,
612- finishReason : computedFinishReason ,
613- } )
614640 }
615641 }
616642
0 commit comments