Skip to content

Commit 1f677e9

Browse files
committed
fix(ai-openrouter): prevent duplicate TEXT_MESSAGE_END and RUN_FINISHED events
OpenAI-compatible APIs often send two chunks with finishReason — one for the finish signal and a separate trailing chunk carrying usage data. The adapter had no guard against this, causing TEXT_MESSAGE_END and RUN_FINISHED to be emitted twice per run. Root cause: processChoice emitted finish events on every finishReason occurrence without tracking whether they had already been sent. Fix: - Add hasEmittedRunFinished / hasEmittedTextMessageEnd guards to AGUIState - Accumulate usage from any finishReason chunk into deferredUsage - Move RUN_FINISHED emission to after the stream loop so it always carries the latest usage data (even when it arrives on a later chunk) Adds tests for duplicate-finish-chunk scenarios, usage preservation, and event ordering.
1 parent 95ef893 commit 1f677e9

2 files changed

Lines changed: 326 additions & 67 deletions

File tree

packages/typescript/ai-openrouter/src/adapters/text.ts

Lines changed: 93 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -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

8288
export 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

Comments
 (0)