88 * - `/messages` -> Anthropic Messages (api "anthropic-messages")
99 * - `/responses` -> OpenAI Responses (api "openai-responses")
1010 *
11- * Turns are scripted protocol-independently ({ text?, toolCalls? }); each
12- * handler renders the matching SSE. Run `node fake-model-server.mjs --self-test`.
11+ * Turns are scripted protocol-independently ({ reasoning?, text?, toolCalls? });
12+ * each handler renders the matching SSE. Run `node fake-model-server.mjs --self-test`.
1313 */
1414
1515import { createServer } from "node:http" ;
@@ -18,16 +18,17 @@ import { pathToFileURL } from "node:url";
1818const isMain = import . meta. url === pathToFileURL ( process . argv [ 1 ] || "" ) . href ;
1919
2020/**
21- * @param {{ port?: number, turns?: Array<{text?:string, chunks?:number, chunkDelayMs?:number, toolCalls?:Array<{id?:string,name:string,args:object}>}> } } opts
22- * @returns {Promise<{url:string, origin:string, port:number, requests:object[], stop:()=>Promise<void>}> }
21+ * @param {{ port?: number, turns?: Array<{reasoning?:string, text?:string, chunks?:number, chunkDelayMs?:number, toolCalls?:Array<{id?:string,name:string,args:object}>}> } } opts
22+ * @returns {Promise<{url:string, origin:string, port:number, requests:object[], streamLog:Array<{streamId:number,protocol:string,kind:string,delta:string}>, stop:()=>Promise<void>}> }
2323 *
24- * A turn's ` text` is emitted as ONE delta by default. Set `chunks` (>1) to split
25- * it into that many text deltas so streaming has an in-flight window (abort/steer
26- * QA); `chunkDelayMs` spaces those deltas apart. Both absent = byte-identical
27- * single-delta behavior .
24+ * A turn's non-empty `reasoning` and ` text` fields are each emitted as ONE delta
25+ * by default. Set `chunks` (>1) to split either field into that many deltas and
26+ * `chunkDelayMs` to space those deltas apart. One shared emitter keeps text and
27+ * reasoning on the identical chunking/delay path for abort and steering QA .
2828 */
2929export function startFakeModelServer ( { port = 0 , turns = [ { text : "OK" } ] } = { } ) {
3030 const requests = [ ] ;
31+ const streamLog = [ ] ;
3132 let callIndex = 0 ;
3233
3334 const server = createServer ( ( req , res ) => {
@@ -62,13 +63,14 @@ export function startFakeModelServer({ port = 0, turns = [{ text: "OK" }] } = {}
6263 return sendJson ( res , 200 , { object : "list" , data : [ { id : body . model || "mock" , object : "model" } ] } ) ;
6364 }
6465
66+ const streamId = callIndex ;
6567 const turn = turns [ Math . min ( callIndex , turns . length - 1 ) ] || { text : "OK" } ;
6668 callIndex ++ ;
6769 const modelId = body . model || "mock" ;
6870
69- if ( url . includes ( "/chat/completions" ) ) return writeCompletionsSse ( res , turn , modelId ) ;
70- if ( url . includes ( "/messages" ) ) return writeAnthropicSse ( res , turn , modelId ) ;
71- if ( url . includes ( "/responses" ) ) return writeResponsesSse ( res , turn , modelId ) ;
71+ if ( url . includes ( "/chat/completions" ) ) return writeCompletionsSse ( res , turn , modelId , streamLog , streamId ) ;
72+ if ( url . includes ( "/messages" ) ) return writeAnthropicSse ( res , turn , modelId , streamLog , streamId ) ;
73+ if ( url . includes ( "/responses" ) ) return writeResponsesSse ( res , turn , modelId , streamLog , streamId ) ;
7274 return sendJson ( res , 404 , { error : { message : `no route: ${ req . method } ${ url } ` } } ) ;
7375 } ) ;
7476 } ) ;
@@ -83,6 +85,7 @@ export function startFakeModelServer({ port = 0, turns = [{ text: "OK" }] } = {}
8385 origin,
8486 port : actual ,
8587 requests,
88+ streamLog,
8689 stop : ( ) => new Promise ( ( r ) => server . close ( ( ) => r ( ) ) ) ,
8790 } ) ;
8891 } ) ;
@@ -117,19 +120,18 @@ function splitIntoChunks(text, n) {
117120}
118121
119122/**
120- * Emit a turn's text via `emitDelta`, then run `done`. Without `chunks` this is a
121- * single synchronous `emitDelta(turn.text)` (byte-identical to legacy behavior);
122- * with `chunks` > 1 the text is split into that many deltas written `chunkDelayMs`
123- * apart, and `done` runs after the last one so the response closes in order.
123+ * Emit either scripted string field through the shared chunk/delay machinery,
124+ * then run `done`. Without `chunks` each non-empty field is one synchronous
125+ * delta; with `chunks` > 1 it is split and spaced by `chunkDelayMs`.
124126 */
125- function emitTextDeltas ( turn , emitDelta , done ) {
126- if ( ! turn . text ) return done ( ) ;
127+ function emitDeltas ( turn , value , emitDelta , done ) {
128+ if ( ! value ) return done ( ) ;
127129 const n = Number . isInteger ( turn . chunks ) && turn . chunks > 1 ? turn . chunks : 0 ;
128130 if ( ! n ) {
129- emitDelta ( turn . text ) ;
131+ emitDelta ( value ) ;
130132 return done ( ) ;
131133 }
132- const pieces = splitIntoChunks ( turn . text , n ) ;
134+ const pieces = splitIntoChunks ( value , n ) ;
133135 const delay = Number . isFinite ( turn . chunkDelayMs ) ? turn . chunkDelayMs : 0 ;
134136 let i = 0 ;
135137 const tick = ( ) => {
@@ -141,8 +143,12 @@ function emitTextDeltas(turn, emitDelta, done) {
141143 tick ( ) ;
142144}
143145
146+ function recordDelta ( streamLog , streamId , protocol , kind , delta ) {
147+ streamLog . push ( { streamId, protocol, kind, delta } ) ;
148+ }
149+
144150// --- OpenAI chat completions ---------------------------------------------
145- function writeCompletionsSse ( res , turn , modelId ) {
151+ function writeCompletionsSse ( res , turn , modelId , streamLog , streamId ) {
146152 sseHead ( res ) ;
147153 const base = { id : "chatcmpl-mock" , object : "chat.completion.chunk" , created : 0 , model : modelId } ;
148154 const send = ( delta , finish = null ) =>
@@ -153,18 +159,36 @@ function writeCompletionsSse(res, turn, modelId) {
153159 type : "function" ,
154160 function : { name : tc . name , arguments : JSON . stringify ( tc . args ?? { } ) } ,
155161 } ) ) ;
156- send ( { role : "assistant" , content : "" } ) ;
157- emitTextDeltas ( turn , ( t ) => send ( { content : t } ) , ( ) => {
162+ const finish = ( ) => {
158163 if ( tcs . length ) send ( { tool_calls : tcs } ) ;
159164 send ( { } , tcs . length ? "tool_calls" : "stop" ) ;
160165 res . write ( `data: ${ JSON . stringify ( { ...base , choices : [ ] , usage : { prompt_tokens : 1 , completion_tokens : 1 , total_tokens : 2 } } ) } \n\n` ) ;
161166 res . write ( "data: [DONE]\n\n" ) ;
162167 res . end ( ) ;
163- } ) ;
168+ } ;
169+ send ( { role : "assistant" , content : "" } ) ;
170+ emitDeltas (
171+ turn ,
172+ turn . reasoning ,
173+ ( delta ) => {
174+ send ( { reasoning_content : delta } ) ;
175+ recordDelta ( streamLog , streamId , "openai-completions" , "reasoning_delta" , delta ) ;
176+ } ,
177+ ( ) =>
178+ emitDeltas (
179+ turn ,
180+ turn . text ,
181+ ( delta ) => {
182+ send ( { content : delta } ) ;
183+ recordDelta ( streamLog , streamId , "openai-completions" , "text_delta" , delta ) ;
184+ } ,
185+ finish ,
186+ ) ,
187+ ) ;
164188}
165189
166190// --- Anthropic Messages ---------------------------------------------------
167- function writeAnthropicSse ( res , turn , modelId ) {
191+ function writeAnthropicSse ( res , turn , modelId , streamLog , streamId ) {
168192 sseHead ( res ) ;
169193 const ev = ( event , data ) => res . write ( `event: ${ event } \ndata: ${ JSON . stringify ( { type : event , ...data } ) } \n\n` ) ;
170194 ev ( "message_start" , {
@@ -183,20 +207,42 @@ function writeAnthropicSse(res, turn, modelId) {
183207 ev ( "message_stop" , { } ) ;
184208 res . end ( ) ;
185209 } ;
186- if ( turn . text ) {
210+ const emitText = ( ) => {
211+ if ( ! turn . text ) return afterText ( ) ;
187212 ev ( "content_block_start" , { index, content_block : { type : "text" , text : "" } } ) ;
188- emitTextDeltas ( turn , ( t ) => ev ( "content_block_delta" , { index, delta : { type : "text_delta" , text : t } } ) , ( ) => {
213+ emitDeltas (
214+ turn ,
215+ turn . text ,
216+ ( delta ) => {
217+ ev ( "content_block_delta" , { index, delta : { type : "text_delta" , text : delta } } ) ;
218+ recordDelta ( streamLog , streamId , "anthropic-messages" , "text_delta" , delta ) ;
219+ } ,
220+ ( ) => {
221+ ev ( "content_block_stop" , { index } ) ;
222+ index ++ ;
223+ afterText ( ) ;
224+ } ,
225+ ) ;
226+ } ;
227+ if ( ! turn . reasoning ) return emitText ( ) ;
228+ ev ( "content_block_start" , { index, content_block : { type : "thinking" , thinking : "" } } ) ;
229+ emitDeltas (
230+ turn ,
231+ turn . reasoning ,
232+ ( delta ) => {
233+ ev ( "content_block_delta" , { index, delta : { type : "thinking_delta" , thinking : delta } } ) ;
234+ recordDelta ( streamLog , streamId , "anthropic-messages" , "reasoning_delta" , delta ) ;
235+ } ,
236+ ( ) => {
189237 ev ( "content_block_stop" , { index } ) ;
190238 index ++ ;
191- afterText ( ) ;
192- } ) ;
193- } else {
194- afterText ( ) ;
195- }
239+ emitText ( ) ;
240+ } ,
241+ ) ;
196242}
197243
198244// --- OpenAI Responses -----------------------------------------------------
199- function writeResponsesSse ( res , turn , modelId ) {
245+ function writeResponsesSse ( res , turn , modelId , streamLog , streamId ) {
200246 sseHead ( res ) ;
201247 let seq = 0 ;
202248 const ev = ( type , data ) => res . write ( `event: ${ type } \ndata: ${ JSON . stringify ( { type, sequence_number : seq ++ , ...data } ) } \n\n` ) ;
@@ -211,20 +257,48 @@ function writeResponsesSse(res, turn, modelId) {
211257 } ) ;
212258 res . end ( ) ;
213259 } ;
214- if ( turn . text ) {
260+ const emitText = ( ) => {
261+ if ( ! turn . text ) return afterText ( ) ;
215262 const itemId = "msg_mock" ;
216263 ev ( "response.output_item.added" , { output_index : outputIndex , item : { id : itemId , type : "message" , status : "in_progress" , role : "assistant" , content : [ ] } } ) ;
217264 ev ( "response.content_part.added" , { item_id : itemId , output_index : outputIndex , content_index : 0 , part : { type : "output_text" , text : "" , annotations : [ ] } } ) ;
218- emitTextDeltas ( turn , ( t ) => ev ( "response.output_text.delta" , { item_id : itemId , output_index : outputIndex , content_index : 0 , delta : t } ) , ( ) => {
219- const item = { id : itemId , type : "message" , status : "completed" , role : "assistant" , content : [ { type : "output_text" , text : turn . text , annotations : [ ] } ] } ;
220- ev ( "response.output_item.done" , { output_index : outputIndex , item } ) ;
221- outputItems . push ( item ) ;
222- outputIndex ++ ;
223- afterText ( ) ;
224- } ) ;
225- } else {
226- afterText ( ) ;
227- }
265+ emitDeltas (
266+ turn ,
267+ turn . text ,
268+ ( delta ) => {
269+ ev ( "response.output_text.delta" , { item_id : itemId , output_index : outputIndex , content_index : 0 , delta } ) ;
270+ recordDelta ( streamLog , streamId , "openai-responses" , "text_delta" , delta ) ;
271+ } ,
272+ ( ) => {
273+ const item = { id : itemId , type : "message" , status : "completed" , role : "assistant" , content : [ { type : "output_text" , text : turn . text , annotations : [ ] } ] } ;
274+ ev ( "response.output_item.done" , { output_index : outputIndex , item } ) ;
275+ outputItems . push ( item ) ;
276+ outputIndex ++ ;
277+ afterText ( ) ;
278+ } ,
279+ ) ;
280+ } ;
281+ const emitReasoning = ( ) => {
282+ if ( ! turn . reasoning ) return emitText ( ) ;
283+ const itemId = "rsn_mock" ;
284+ ev ( "response.output_item.added" , { output_index : outputIndex , item : { id : itemId , type : "reasoning" , status : "in_progress" , summary : [ ] } } ) ;
285+ emitDeltas (
286+ turn ,
287+ turn . reasoning ,
288+ ( delta ) => {
289+ ev ( "response.reasoning_summary_text.delta" , { item_id : itemId , output_index : outputIndex , summary_index : 0 , delta } ) ;
290+ recordDelta ( streamLog , streamId , "openai-responses" , "reasoning_delta" , delta ) ;
291+ } ,
292+ ( ) => {
293+ const item = { id : itemId , type : "reasoning" , status : "completed" , summary : [ { type : "summary_text" , text : turn . reasoning } ] } ;
294+ ev ( "response.output_item.done" , { output_index : outputIndex , item } ) ;
295+ outputItems . push ( item ) ;
296+ outputIndex ++ ;
297+ emitText ( ) ;
298+ } ,
299+ ) ;
300+ } ;
301+ emitReasoning ( ) ;
228302
229303 function emitToolCalls ( ) {
230304 for ( const tc of turn . toolCalls || [ ] ) {
@@ -241,25 +315,65 @@ function writeResponsesSse(res, turn, modelId) {
241315 }
242316}
243317
318+ function byteSequenceInOrder ( bytes , parts ) {
319+ let offset = 0 ;
320+ for ( const part of parts ) {
321+ const index = bytes . indexOf ( Buffer . from ( part ) , offset ) ;
322+ if ( index < 0 ) return false ;
323+ offset = index + Buffer . byteLength ( part ) ;
324+ }
325+ return true ;
326+ }
327+
244328// --- self-test ------------------------------------------------------------
245329async function selfTest ( ) {
246- const srv = await startFakeModelServer ( { turns : [ { text : "FAKE-OK" } ] } ) ;
330+ const reasoning = "FAKE-THINK" ;
331+ const text = "FAKE-OK" ;
332+ const reasoningPieces = splitIntoChunks ( reasoning , 2 ) ;
333+ const textPieces = splitIntoChunks ( text , 2 ) ;
334+ const srv = await startFakeModelServer ( { turns : [ { reasoning, text, chunks : 2 , chunkDelayMs : 0 } ] } ) ;
247335 const checks = [ ] ;
248- const probe = async ( label , path , headers ) => {
336+ const probe = async ( label , path , headers , expectedBytes ) => {
249337 const r = await fetch ( `${ srv . origin } ${ path } ` , {
250338 method : "POST" ,
251339 headers : { "content-type" : "application/json" , ...headers } ,
252340 body : JSON . stringify ( { model : "m" , stream : true , messages : [ { role : "user" , content : "hi" } ] } ) ,
253341 } ) ;
254- const text = await r . text ( ) ;
255- const ok = text . includes ( "FAKE-OK" ) ;
256- checks . push ( ok ) ;
257- process . stdout . write ( `[${ ok ? "PASS" : "FAIL" } ] ${ label } streamed scripted text\n` ) ;
342+ const bytes = Buffer . from ( await r . arrayBuffer ( ) ) ;
343+ const scriptedText = textPieces . every ( ( piece ) => bytes . includes ( Buffer . from ( piece ) ) ) ;
344+ const exactReasoningSequence = byteSequenceInOrder ( bytes , expectedBytes ) ;
345+ checks . push ( scriptedText , exactReasoningSequence ) ;
346+ process . stdout . write ( `[${ scriptedText ? "PASS" : "FAIL" } ] ${ label } streamed scripted text\n` ) ;
347+ process . stdout . write ( `[${ exactReasoningSequence ? "PASS" : "FAIL" } ] ${ label } reasoning SSE bytes are ordered and complete\n` ) ;
258348 } ;
259349 try {
260- await probe ( "openai-completions" , "/v1/chat/completions" , { authorization : "Bearer k" } ) ;
261- await probe ( "anthropic-messages" , "/v1/messages" , { "x-api-key" : "k" } ) ;
262- await probe ( "openai-responses" , "/v1/responses" , { authorization : "Bearer k" } ) ;
350+ await probe ( "openai-completions" , "/v1/chat/completions" , { authorization : "Bearer k" } , [
351+ `"reasoning_content":"${ reasoningPieces [ 0 ] } "` ,
352+ `"reasoning_content":"${ reasoningPieces [ 1 ] } "` ,
353+ `"content":"${ textPieces [ 0 ] } "` ,
354+ `"content":"${ textPieces [ 1 ] } "` ,
355+ "data: [DONE]\n\n" ,
356+ ] ) ;
357+ await probe ( "anthropic-messages" , "/v1/messages" , { "x-api-key" : "k" } , [
358+ "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n" ,
359+ `event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"${ reasoningPieces [ 0 ] } "}}\n\n` ,
360+ `event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"${ reasoningPieces [ 1 ] } "}}\n\n` ,
361+ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" ,
362+ "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" ,
363+ `event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"${ textPieces [ 0 ] } "}}\n\n` ,
364+ ] ) ;
365+ await probe ( "openai-responses" , "/v1/responses" , { authorization : "Bearer k" } , [
366+ "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":1,\"output_index\":0,\"item\":{\"id\":\"rsn_mock\",\"type\":\"reasoning\",\"status\":\"in_progress\",\"summary\":[]}}\n\n" ,
367+ `event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","sequence_number":2,"item_id":"rsn_mock","output_index":0,"summary_index":0,"delta":"${ reasoningPieces [ 0 ] } "}\n\n` ,
368+ `event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","sequence_number":3,"item_id":"rsn_mock","output_index":0,"summary_index":0,"delta":"${ reasoningPieces [ 1 ] } "}\n\n` ,
369+ "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"sequence_number\":4,\"output_index\":0,\"item\":{\"id\":\"rsn_mock\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"FAKE-THINK\"}]}}\n\n" ,
370+ "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":5,\"output_index\":1,\"item\":{\"id\":\"msg_mock\",\"type\":\"message\"" ,
371+ "event: response.completed\n" ,
372+ ] ) ;
373+ const loggedReasoning = srv . streamLog . filter ( ( entry ) => entry . kind === "reasoning_delta" ) . map ( ( entry ) => entry . delta ) ;
374+ const logComplete = loggedReasoning . join ( "" ) === reasoning . repeat ( 3 ) ;
375+ checks . push ( logComplete ) ;
376+ process . stdout . write ( `[${ logComplete ? "PASS" : "FAIL" } ] server stream log recorded all reasoning chunks\n` ) ;
263377 } finally {
264378 await srv . stop ( ) ;
265379 }
0 commit comments