@@ -10,7 +10,16 @@ export interface RecordedRequest {
1010 bodyJson : unknown ;
1111}
1212
13- export type OpenAiMockStreamEvent = Record < string , unknown > | '[DONE]' ;
13+ export type OpenAiMockStreamData = Record < string , unknown > | string ;
14+
15+ export interface OpenAiMockSseFrame {
16+ data : OpenAiMockStreamData ;
17+ event ?: string ;
18+ delayMs ?: number ;
19+ disconnectAfterWrite ?: boolean ;
20+ }
21+
22+ export type OpenAiMockStreamEvent = OpenAiMockStreamData | OpenAiMockSseFrame ;
1423
1524export interface OpenAiMockUpstreamOptions {
1625 model ?: string ;
@@ -48,6 +57,25 @@ const readBody = async (req: NodeJS.ReadableStream) => {
4857 return Buffer . concat ( chunks ) . toString ( 'utf8' ) ;
4958} ;
5059
60+ const isSseFrame = (
61+ event : OpenAiMockStreamEvent ,
62+ ) : event is OpenAiMockSseFrame =>
63+ typeof event === 'object' && event !== null && 'data' in event ;
64+
65+ const renderSseFrame = ( frame : OpenAiMockSseFrame ) => {
66+ const lines : string [ ] = [ ] ;
67+
68+ if ( frame . event ) {
69+ lines . push ( `event: ${ frame . event } ` ) ;
70+ }
71+
72+ const payload =
73+ typeof frame . data === 'string' ? frame . data : JSON . stringify ( frame . data ) ;
74+ lines . push ( `data: ${ payload } ` ) ;
75+
76+ return `${ lines . join ( '\n' ) } \n\n` ;
77+ } ;
78+
5179const defaultNonStreamBody = ( model : string ) => ( {
5280 id : 'chatcmpl-e2e-mock' ,
5381 object : 'chat.completion' ,
@@ -125,6 +153,63 @@ const defaultStreamEvents = (model: string) => [
125153 '[DONE]' as const ,
126154] ;
127155
156+ export const buildOpenAiTrailingContentAfterFinishReasonStreamEvents = (
157+ model : string ,
158+ ) : OpenAiMockStreamEvent [ ] => [
159+ {
160+ id : 'chatcmpl-late-delta-e2e-mock' ,
161+ object : 'chat.completion.chunk' ,
162+ created : 1 ,
163+ model,
164+ choices : [
165+ {
166+ index : 0 ,
167+ delta : { role : 'assistant' , content : 'hello ' } ,
168+ finish_reason : null ,
169+ } ,
170+ ] ,
171+ } ,
172+ {
173+ id : 'chatcmpl-late-delta-e2e-mock' ,
174+ object : 'chat.completion.chunk' ,
175+ created : 1 ,
176+ model,
177+ choices : [
178+ {
179+ index : 0 ,
180+ delta : { } ,
181+ finish_reason : 'stop' ,
182+ } ,
183+ ] ,
184+ } ,
185+ {
186+ id : 'chatcmpl-late-delta-e2e-mock' ,
187+ object : 'chat.completion.chunk' ,
188+ created : 1 ,
189+ model,
190+ choices : [
191+ {
192+ index : 0 ,
193+ delta : { content : 'from trailing delta' } ,
194+ finish_reason : null ,
195+ } ,
196+ ] ,
197+ } ,
198+ {
199+ id : 'chatcmpl-late-delta-e2e-mock' ,
200+ object : 'chat.completion.chunk' ,
201+ created : 1 ,
202+ model,
203+ choices : [ ] ,
204+ usage : {
205+ prompt_tokens : 10 ,
206+ completion_tokens : 8 ,
207+ total_tokens : 18 ,
208+ } ,
209+ } ,
210+ '[DONE]' as const ,
211+ ] ;
212+
128213export const buildOpenAiToolCallStreamEvents = (
129214 model : string ,
130215) : OpenAiMockStreamEvent [ ] => [
@@ -474,14 +559,20 @@ export const startOpenAiMockUpstream = async (
474559
475560 let sentEvents = 0 ;
476561 for ( const event of current . streamEvents ?? defaultStreamEvents ( model ) ) {
477- if ( typeof event === 'string' ) {
478- res . write ( `data: ${ event } \n\n` ) ;
479- } else {
480- res . write ( `data: ${ JSON . stringify ( event ) } \n\n` ) ;
481- }
562+ const frame : OpenAiMockSseFrame = isSseFrame ( event )
563+ ? event
564+ : { data : event } ;
565+
566+ res . write ( renderSseFrame ( frame ) ) ;
482567
483568 sentEvents += 1 ;
484569
570+ if ( frame . disconnectAfterWrite ) {
571+ await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
572+ res . socket ?. destroy ( ) ;
573+ return ;
574+ }
575+
485576 if (
486577 current . disconnectAfterEvents !== undefined &&
487578 sentEvents >= current . disconnectAfterEvents
@@ -491,8 +582,9 @@ export const startOpenAiMockUpstream = async (
491582 return ;
492583 }
493584
494- if ( current . eventDelayMs ) {
495- await sleep ( current . eventDelayMs ) ;
585+ const delayMs = frame . delayMs ?? current . eventDelayMs ;
586+ if ( delayMs ) {
587+ await sleep ( delayMs ) ;
496588 }
497589 }
498590
0 commit comments