@@ -8,6 +8,13 @@ export type Usage = { input: number; output: number }
88
99type Line = Record < string , unknown >
1010
11+ type Flow =
12+ | { type : "text" ; text : string }
13+ | { type : "reason" ; text : string }
14+ | { type : "tool-start" ; id : string ; name : string }
15+ | { type : "tool-args" ; text : string }
16+ | { type : "usage" ; usage : Usage }
17+
1118type Hit = {
1219 url : URL
1320 body : Record < string , unknown >
@@ -119,6 +126,276 @@ function bytes(input: Iterable<unknown>) {
119126 return Stream . fromIterable ( [ ...input ] . map ( line ) ) . pipe ( Stream . encodeText )
120127}
121128
129+ function responseCreated ( model : string ) {
130+ return {
131+ type : "response.created" ,
132+ sequence_number : 1 ,
133+ response : {
134+ id : "resp_test" ,
135+ created_at : Math . floor ( Date . now ( ) / 1000 ) ,
136+ model,
137+ service_tier : null ,
138+ } ,
139+ }
140+ }
141+
142+ function responseCompleted ( input : { seq : number ; usage ?: Usage } ) {
143+ return {
144+ type : "response.completed" ,
145+ sequence_number : input . seq ,
146+ response : {
147+ incomplete_details : null ,
148+ service_tier : null ,
149+ usage : {
150+ input_tokens : input . usage ?. input ?? 0 ,
151+ input_tokens_details : { cached_tokens : null } ,
152+ output_tokens : input . usage ?. output ?? 0 ,
153+ output_tokens_details : { reasoning_tokens : null } ,
154+ } ,
155+ } ,
156+ }
157+ }
158+
159+ function responseMessage ( id : string , seq : number ) {
160+ return {
161+ type : "response.output_item.added" ,
162+ sequence_number : seq ,
163+ output_index : 0 ,
164+ item : { type : "message" , id } ,
165+ }
166+ }
167+
168+ function responseText ( id : string , text : string , seq : number ) {
169+ return {
170+ type : "response.output_text.delta" ,
171+ sequence_number : seq ,
172+ item_id : id ,
173+ delta : text ,
174+ logprobs : null ,
175+ }
176+ }
177+
178+ function responseMessageDone ( id : string , seq : number ) {
179+ return {
180+ type : "response.output_item.done" ,
181+ sequence_number : seq ,
182+ output_index : 0 ,
183+ item : { type : "message" , id } ,
184+ }
185+ }
186+
187+ function responseReason ( id : string , seq : number ) {
188+ return {
189+ type : "response.output_item.added" ,
190+ sequence_number : seq ,
191+ output_index : 0 ,
192+ item : { type : "reasoning" , id, encrypted_content : null } ,
193+ }
194+ }
195+
196+ function responseReasonPart ( id : string , seq : number ) {
197+ return {
198+ type : "response.reasoning_summary_part.added" ,
199+ sequence_number : seq ,
200+ item_id : id ,
201+ summary_index : 0 ,
202+ }
203+ }
204+
205+ function responseReasonText ( id : string , text : string , seq : number ) {
206+ return {
207+ type : "response.reasoning_summary_text.delta" ,
208+ sequence_number : seq ,
209+ item_id : id ,
210+ summary_index : 0 ,
211+ delta : text ,
212+ }
213+ }
214+
215+ function responseReasonDone ( id : string , seq : number ) {
216+ return {
217+ type : "response.output_item.done" ,
218+ sequence_number : seq ,
219+ output_index : 0 ,
220+ item : { type : "reasoning" , id, encrypted_content : null } ,
221+ }
222+ }
223+
224+ function responseTool ( id : string , item : string , name : string , seq : number ) {
225+ return {
226+ type : "response.output_item.added" ,
227+ sequence_number : seq ,
228+ output_index : 0 ,
229+ item : {
230+ type : "function_call" ,
231+ id : item ,
232+ call_id : id ,
233+ name,
234+ arguments : "" ,
235+ status : "in_progress" ,
236+ } ,
237+ }
238+ }
239+
240+ function responseToolArgs ( id : string , text : string , seq : number ) {
241+ return {
242+ type : "response.function_call_arguments.delta" ,
243+ sequence_number : seq ,
244+ output_index : 0 ,
245+ item_id : id ,
246+ delta : text ,
247+ }
248+ }
249+
250+ function responseToolDone ( tool : { id : string ; item : string ; name : string ; args : string } , seq : number ) {
251+ return {
252+ type : "response.output_item.done" ,
253+ sequence_number : seq ,
254+ output_index : 0 ,
255+ item : {
256+ type : "function_call" ,
257+ id : tool . item ,
258+ call_id : tool . id ,
259+ name : tool . name ,
260+ arguments : tool . args ,
261+ status : "completed" ,
262+ } ,
263+ }
264+ }
265+
266+ function choices ( part : unknown ) {
267+ if ( ! part || typeof part !== "object" ) return
268+ if ( ! ( "choices" in part ) || ! Array . isArray ( part . choices ) ) return
269+ const choice = part . choices [ 0 ]
270+ if ( ! choice || typeof choice !== "object" ) return
271+ return choice
272+ }
273+
274+ function flow ( item : Sse ) {
275+ const out : Flow [ ] = [ ]
276+ for ( const part of [ ...item . head , ...item . tail ] ) {
277+ const choice = choices ( part )
278+ const delta =
279+ choice && "delta" in choice && choice . delta && typeof choice . delta === "object" ? choice . delta : undefined
280+
281+ if ( delta && "content" in delta && typeof delta . content === "string" ) {
282+ out . push ( { type : "text" , text : delta . content } )
283+ }
284+
285+ if ( delta && "reasoning_content" in delta && typeof delta . reasoning_content === "string" ) {
286+ out . push ( { type : "reason" , text : delta . reasoning_content } )
287+ }
288+
289+ if ( delta && "tool_calls" in delta && Array . isArray ( delta . tool_calls ) ) {
290+ for ( const tool of delta . tool_calls ) {
291+ if ( ! tool || typeof tool !== "object" ) continue
292+ const fn = "function" in tool && tool . function && typeof tool . function === "object" ? tool . function : undefined
293+ if ( "id" in tool && typeof tool . id === "string" && fn && "name" in fn && typeof fn . name === "string" ) {
294+ out . push ( { type : "tool-start" , id : tool . id , name : fn . name } )
295+ }
296+ if ( fn && "arguments" in fn && typeof fn . arguments === "string" && fn . arguments ) {
297+ out . push ( { type : "tool-args" , text : fn . arguments } )
298+ }
299+ }
300+ }
301+
302+ if ( part && typeof part === "object" && "usage" in part && part . usage && typeof part . usage === "object" ) {
303+ const raw = part . usage as Record < string , unknown >
304+ if ( typeof raw . prompt_tokens === "number" && typeof raw . completion_tokens === "number" ) {
305+ out . push ( {
306+ type : "usage" ,
307+ usage : { input : raw . prompt_tokens , output : raw . completion_tokens } ,
308+ } )
309+ }
310+ }
311+ }
312+ return out
313+ }
314+
315+ function responses ( item : Sse , model : string ) {
316+ let seq = 1
317+ let msg : string | undefined
318+ let reason : string | undefined
319+ let hasMsg = false
320+ let hasReason = false
321+ let call :
322+ | {
323+ id : string
324+ item : string
325+ name : string
326+ args : string
327+ }
328+ | undefined
329+ let usage : Usage | undefined
330+ const lines : unknown [ ] = [ responseCreated ( model ) ]
331+
332+ for ( const part of flow ( item ) ) {
333+ if ( part . type === "text" ) {
334+ msg ??= "msg_1"
335+ if ( ! hasMsg ) {
336+ hasMsg = true
337+ seq += 1
338+ lines . push ( responseMessage ( msg , seq ) )
339+ }
340+ seq += 1
341+ lines . push ( responseText ( msg , part . text , seq ) )
342+ continue
343+ }
344+
345+ if ( part . type === "reason" ) {
346+ reason ||= "rs_1"
347+ if ( ! hasReason ) {
348+ hasReason = true
349+ seq += 1
350+ lines . push ( responseReason ( reason , seq ) )
351+ seq += 1
352+ lines . push ( responseReasonPart ( reason , seq ) )
353+ }
354+ seq += 1
355+ lines . push ( responseReasonText ( reason , part . text , seq ) )
356+ continue
357+ }
358+
359+ if ( part . type === "tool-start" ) {
360+ call ||= { id : part . id , item : "fc_1" , name : part . name , args : "" }
361+ seq += 1
362+ lines . push ( responseTool ( call . id , call . item , call . name , seq ) )
363+ continue
364+ }
365+
366+ if ( part . type === "tool-args" ) {
367+ if ( ! call ) continue
368+ call . args += part . text
369+ seq += 1
370+ lines . push ( responseToolArgs ( call . item , part . text , seq ) )
371+ continue
372+ }
373+
374+ usage = part . usage
375+ }
376+
377+ if ( msg ) {
378+ seq += 1
379+ lines . push ( responseMessageDone ( msg , seq ) )
380+ }
381+ if ( reason ) {
382+ seq += 1
383+ lines . push ( responseReasonDone ( reason , seq ) )
384+ }
385+ if ( call && ! item . hang && ! item . error ) {
386+ seq += 1
387+ lines . push ( responseToolDone ( call , seq ) )
388+ }
389+ if ( ! item . hang && ! item . error ) lines . push ( responseCompleted ( { seq : seq + 1 , usage } ) )
390+ return { ...item , head : lines , tail : [ ] } satisfies Sse
391+ }
392+
393+ function modelFrom ( body : unknown ) {
394+ if ( ! body || typeof body !== "object" ) return "test-model"
395+ if ( ! ( "model" in body ) || typeof body . model !== "string" ) return "test-model"
396+ return body . model
397+ }
398+
122399function send ( item : Sse ) {
123400 const head = bytes ( item . head )
124401 const tail = bytes ( [ ...item . tail , ...( item . hang || item . error ? [ ] : [ done ] ) ] )
@@ -293,6 +570,13 @@ function item(input: Item | Reply) {
293570 return input instanceof Reply ? input . item ( ) : input
294571}
295572
573+ function hit ( url : string , body : unknown ) {
574+ return {
575+ url : new URL ( url , "http://localhost" ) ,
576+ body : body && typeof body === "object" ? ( body as Record < string , unknown > ) : { } ,
577+ } satisfies Hit
578+ }
579+
296580namespace TestLLMServer {
297581 export interface Service {
298582 readonly url : string
@@ -342,30 +626,24 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
342626 return first
343627 }
344628
345- yield * router . add (
346- "POST" ,
347- "/v1/chat/completions" ,
348- Effect . gen ( function * ( ) {
349- const req = yield * HttpServerRequest . HttpServerRequest
350- const next = pull ( )
351- if ( ! next ) return HttpServerResponse . text ( "unexpected request" , { status : 500 } )
352- const body = yield * req . json . pipe ( Effect . orElseSucceed ( ( ) => ( { } ) ) )
353- hits = [
354- ...hits ,
355- {
356- url : new URL ( req . originalUrl , "http://localhost" ) ,
357- body : body && typeof body === "object" ? ( body as Record < string , unknown > ) : { } ,
358- } ,
359- ]
360- yield * notify ( )
361- if ( next . type === "sse" && next . reset ) {
362- yield * reset ( next )
363- return HttpServerResponse . empty ( )
364- }
365- if ( next . type === "sse" ) return send ( next )
366- return fail ( next )
367- } ) ,
368- )
629+ const handle = Effect . fn ( "TestLLMServer.handle" ) ( function * ( mode : "chat" | "responses" ) {
630+ const req = yield * HttpServerRequest . HttpServerRequest
631+ const next = pull ( )
632+ if ( ! next ) return HttpServerResponse . text ( "unexpected request" , { status : 500 } )
633+ const body = yield * req . json . pipe ( Effect . orElseSucceed ( ( ) => ( { } ) ) )
634+ hits = [ ...hits , hit ( req . originalUrl , body ) ]
635+ yield * notify ( )
636+ if ( next . type !== "sse" ) return fail ( next )
637+ if ( mode === "responses" ) return send ( responses ( next , modelFrom ( body ) ) )
638+ if ( next . reset ) {
639+ yield * reset ( next )
640+ return HttpServerResponse . empty ( )
641+ }
642+ return send ( next )
643+ } )
644+
645+ yield * router . add ( "POST" , "/v1/chat/completions" , handle ( "chat" ) )
646+ yield * router . add ( "POST" , "/v1/responses" , handle ( "responses" ) )
369647
370648 yield * server . serve ( router . asHttpEffect ( ) )
371649
0 commit comments