@@ -19,6 +19,10 @@ import { logRequest } from "~/lib/request-log"
1919import { disableBunRequestTimeout } from "~/lib/request-timeout"
2020import { writeUpstreamSSE } from "~/lib/sse"
2121import { state } from "~/lib/state"
22+ import {
23+ createStreamClientDisconnectProgress ,
24+ logStreamClientDisconnect ,
25+ } from "~/lib/stream-client-disconnect"
2226import {
2327 recordClientAbortedExchange ,
2428 recordCompletedExchange ,
@@ -247,6 +251,7 @@ function streamChatCompletion({
247251 let streamCompleted = false
248252 let streamFailed = false
249253 let exchangeCaptured = false
254+ const disconnectProgress = createStreamClientDisconnectProgress ( )
250255
251256 const captureOnce = async ( options : {
252257 outcome : "completed" | "client_aborted" | "failed"
@@ -320,7 +325,8 @@ function streamChatCompletion({
320325 "abort" ,
321326 ( ) => {
322327 if ( ! streamCompleted && ! streamFailed ) {
323- consola . warn ( "Chat completions stream client disconnected" , {
328+ logStreamClientDisconnect ( {
329+ progress : disconnectProgress ,
324330 requestId : trace . requestId ,
325331 route : c . req . path ,
326332 upstreamPath : "/chat/completions" ,
@@ -336,12 +342,23 @@ function streamChatCompletion({
336342 async ( stream ) => {
337343 for await ( const chunk of response ) {
338344 consola . debug ( "Streaming chunk:" , JSON . stringify ( chunk ) )
339- if ( chunk . data && chunk . data !== "[DONE]" ) {
345+ const isDoneSentinel = chunk . data === "[DONE]"
346+ let sawTerminalEvent = false
347+
348+ if ( isDoneSentinel ) {
349+ disconnectProgress . sawDoneSentinel = true
350+ }
351+
352+ if ( chunk . data && ! isDoneSentinel ) {
340353 try {
341354 const parsed = JSON . parse ( chunk . data ) as {
342355 usage ?: { prompt_tokens ?: number ; completion_tokens ?: number }
343356 } & Parameters < typeof collectChatCompletionChunk > [ 1 ]
344357 collectChatCompletionChunk ( accumulator , parsed )
358+ sawTerminalEvent = isTerminalChatCompletionChunk ( parsed )
359+ if ( sawTerminalEvent ) {
360+ disconnectProgress . sawTerminalEvent = true
361+ }
345362 if ( parsed . usage ) {
346363 usageIn = parsed . usage . prompt_tokens
347364 usageOut = parsed . usage . completion_tokens
@@ -350,9 +367,21 @@ function streamChatCompletion({
350367 // ignore parse errors for usage extraction
351368 }
352369 }
353- await writeUpstreamSSE ( stream , chunk )
370+
371+ if ( await writeUpstreamSSE ( stream , chunk ) ) {
372+ disconnectProgress . forwardedChunkCount += 1
373+
374+ if ( sawTerminalEvent ) {
375+ disconnectProgress . wroteTerminalEvent = true
376+ }
377+
378+ if ( isDoneSentinel ) {
379+ disconnectProgress . wroteDoneSentinel = true
380+ }
381+ }
354382 }
355383
384+ disconnectProgress . upstreamEnded = true
356385 streamCompleted = true
357386 await captureOnce ( { outcome : "completed" } )
358387 } ,
@@ -392,6 +421,7 @@ function streamCodexCompletion({
392421 let streamCompleted = false
393422 let streamFailed = false
394423 let exchangeCaptured = false
424+ const disconnectProgress = createStreamClientDisconnectProgress ( )
395425
396426 const captureOnce = async ( options : {
397427 outcome : "completed" | "client_aborted" | "failed"
@@ -472,7 +502,8 @@ function streamCodexCompletion({
472502 "abort" ,
473503 ( ) => {
474504 if ( ! streamCompleted && ! streamFailed ) {
475- consola . warn ( "Codex responses stream client disconnected" , {
505+ logStreamClientDisconnect ( {
506+ progress : disconnectProgress ,
476507 requestId : trace . requestId ,
477508 route : c . req . path ,
478509 upstreamPath : "/v1/responses" ,
@@ -501,21 +532,41 @@ function streamCodexCompletion({
501532 ) ) {
502533 const chunkData =
503534 typeof chunk . data === "string" ? chunk . data : await chunk . data
504- if ( chunkData && chunkData !== "[DONE]" ) {
535+ const isDoneSentinel = chunkData === "[DONE]"
536+ let sawTerminalEvent = false
537+
538+ if ( isDoneSentinel ) {
539+ disconnectProgress . sawDoneSentinel = true
540+ }
541+
542+ if ( chunkData && ! isDoneSentinel ) {
505543 try {
506- collectChatCompletionChunk (
507- accumulator ,
508- JSON . parse ( chunkData ) as Parameters <
509- typeof collectChatCompletionChunk
510- > [ 1 ] ,
511- )
544+ const parsed = JSON . parse ( chunkData ) as Parameters <
545+ typeof collectChatCompletionChunk
546+ > [ 1 ]
547+ collectChatCompletionChunk ( accumulator , parsed )
548+ sawTerminalEvent = isTerminalChatCompletionChunk ( parsed )
549+ if ( sawTerminalEvent ) {
550+ disconnectProgress . sawTerminalEvent = true
551+ }
512552 } catch {
513553 // ignore parse errors
514554 }
515555 }
556+
516557 await stream . writeSSE ( chunk )
558+ disconnectProgress . forwardedChunkCount += 1
559+
560+ if ( sawTerminalEvent ) {
561+ disconnectProgress . wroteTerminalEvent = true
562+ }
563+
564+ if ( isDoneSentinel ) {
565+ disconnectProgress . wroteDoneSentinel = true
566+ }
517567 }
518568
569+ disconnectProgress . upstreamEnded = true
519570 streamCompleted = true
520571 await captureOnce ( { outcome : "completed" } )
521572 } ,
@@ -532,6 +583,12 @@ function streamCodexCompletion({
532583 )
533584}
534585
586+ function isTerminalChatCompletionChunk (
587+ chunk : Parameters < typeof collectChatCompletionChunk > [ 1 ] ,
588+ ) : boolean {
589+ return Boolean ( chunk . choices [ 0 ] ?. finish_reason )
590+ }
591+
535592async function captureChatExchange ( {
536593 c,
537594 payload,
0 commit comments