@@ -26,15 +26,28 @@ import { AuthError, ZhubConnectionError } from './errors.js';
2626const _globalWS = ( globalThis as unknown as { WebSocket ?: typeof WebSocket } ) . WebSocket ;
2727const WebSocketImpl : typeof WebSocket = ( _globalWS ?? ( WS as unknown as typeof WebSocket ) ) as typeof WebSocket ;
2828
29+ /** A streaming chunk emitted by a chat handler. May be a plain text delta or
30+ * a structured payload carrying a `tool_call_delta`, `finish_reason`, and/or
31+ * `done` marker — same shape Python's `_serialize_stream_chunk` understands. */
32+ export type ChatChunkLike =
33+ | string
34+ | {
35+ delta ?: string ;
36+ tool_call_delta ?: Record < string , unknown > ;
37+ done ?: boolean ;
38+ finish_reason ?: string ;
39+ [ key : string ] : unknown ;
40+ } ;
41+
2942export type ChatHandler = (
3043 messages : Array < { role : string ; content : string } > ,
3144 options : Record < string , unknown > ,
3245) =>
3346 | string
3447 | Promise < string >
3548 | { text : string ; finish_reason ?: string }
36- | AsyncIterable < string >
37- | Iterable < string > ;
49+ | AsyncIterable < ChatChunkLike >
50+ | Iterable < ChatChunkLike > ;
3851
3952export type ChatResult = Record < string , unknown > & { text : string } ;
4053
@@ -271,28 +284,25 @@ export class ZhubPublication {
271284 const messages = ( env . payload . messages as Array < { role : string ; content : string } > ) ?? [ ] ;
272285 const options = { ...env . payload } ;
273286 delete ( options as Record < string , unknown > ) . messages ;
287+ const streamingRequested = Boolean ( ( options as Record < string , unknown > ) . stream ) ;
274288 try {
275289 const result = this . chatHandler ( messages , options ) ;
276290
277- // Async iterator → stream chat-chunks
278- if ( result && typeof result === 'object' && Symbol . asyncIterator in result ) {
279- for await ( const chunk of result as AsyncIterable < string > ) {
280- ws . send ( JSON . stringify ( chatChunk ( String ( chunk ) , env . request_id ) ) ) ;
281- }
282- ws . send ( JSON . stringify ( chatChunk ( '' , env . request_id , true , 'stop' ) ) ) ;
283- return ;
284- }
285- // Sync iterator (not string/dict) → also stream
286- if (
291+ const isAsyncIter =
292+ result && typeof result === 'object' && Symbol . asyncIterator in ( result as object ) ;
293+ const isSyncIter =
294+ ! isAsyncIter &&
287295 result && typeof result === 'object' &&
288296 Symbol . iterator in ( result as object ) &&
289297 typeof ( result as { text ?: string } ) . text === 'undefined' &&
290- typeof result !== 'string'
291- ) {
292- for ( const chunk of result as Iterable < string > ) {
293- ws . send ( JSON . stringify ( chatChunk ( String ( chunk ) , env . request_id ) ) ) ;
298+ typeof result !== 'string' ;
299+
300+ if ( isAsyncIter || isSyncIter ) {
301+ if ( streamingRequested ) {
302+ await this . streamHandlerOutput ( ws , env . request_id , result as AsyncIterable < ChatChunkLike > | Iterable < ChatChunkLike > ) ;
303+ } else {
304+ await this . accumulateHandlerOutput ( ws , env . request_id , result as AsyncIterable < ChatChunkLike > | Iterable < ChatChunkLike > ) ;
294305 }
295- ws . send ( JSON . stringify ( chatChunk ( '' , env . request_id , true , 'stop' ) ) ) ;
296306 return ;
297307 }
298308
@@ -318,6 +328,122 @@ export class ZhubPublication {
318328 ) ;
319329 }
320330 }
331+
332+ private async streamHandlerOutput (
333+ ws : WebSocket ,
334+ requestId : string ,
335+ iter : AsyncIterable < ChatChunkLike > | Iterable < ChatChunkLike > ,
336+ ) : Promise < void > {
337+ let finalFinish : string | undefined ;
338+ for await ( const chunk of iter as AsyncIterable < ChatChunkLike > ) {
339+ const { envelope : e , finishReason } = serializeStreamChunk ( chunk , requestId ) ;
340+ if ( finishReason ) finalFinish = finishReason ;
341+ ws . send ( JSON . stringify ( e ) ) ;
342+ }
343+ ws . send ( JSON . stringify ( chatChunk ( '' , requestId , true , finalFinish ?? 'stop' ) ) ) ;
344+ }
345+
346+ private async accumulateHandlerOutput (
347+ ws : WebSocket ,
348+ requestId : string ,
349+ iter : AsyncIterable < ChatChunkLike > | Iterable < ChatChunkLike > ,
350+ ) : Promise < void > {
351+ const textParts : string [ ] = [ ] ;
352+ const tcSlots = new Map < number , Record < string , unknown > > ( ) ;
353+ let finalFinish : string | undefined ;
354+ for await ( const chunk of iter as AsyncIterable < ChatChunkLike > ) {
355+ const { text, toolCallDelta, finishReason } = chunkFields ( chunk ) ;
356+ if ( text ) textParts . push ( text ) ;
357+ if ( toolCallDelta ) accumulateToolCall ( tcSlots , toolCallDelta ) ;
358+ if ( finishReason ) finalFinish = finishReason ;
359+ }
360+ const payload : Record < string , unknown > = {
361+ text : textParts . join ( '' ) ,
362+ finish_reason : finalFinish ?? 'stop' ,
363+ } ;
364+ if ( tcSlots . size > 0 ) {
365+ payload . tool_calls = Array . from ( tcSlots . keys ( ) ) . sort ( ( a , b ) => a - b ) . map ( ( i ) => tcSlots . get ( i ) ! ) ;
366+ }
367+ ws . send (
368+ JSON . stringify ( { type : 'chat-response' , request_id : requestId , payload } ) ,
369+ ) ;
370+ }
371+ }
372+
373+ /** Mirror of Python's `_serialize_stream_chunk`. Builds a chat-chunk envelope
374+ * from a string text-delta or a structured chunk carrying delta /
375+ * tool_call_delta / done / finish_reason. Returns the envelope plus any
376+ * finish_reason carried (so the caller can echo it on the terminator). */
377+ function serializeStreamChunk (
378+ chunk : ChatChunkLike ,
379+ requestId : string ,
380+ ) : { envelope : Envelope ; finishReason ?: string } {
381+ if ( typeof chunk === 'string' ) {
382+ return { envelope : chatChunk ( chunk , requestId ) } ;
383+ }
384+ if ( chunk && typeof chunk === 'object' ) {
385+ const c = chunk as Record < string , unknown > ;
386+ const payload : Record < string , unknown > = { } ;
387+ const delta = ( c . delta ?? '' ) as string ;
388+ const tcd = c . tool_call_delta ;
389+ const done = Boolean ( c . done ) ;
390+ const finish = ( c . finish_reason as string | undefined ) || undefined ;
391+ if ( delta ) payload . delta = delta ;
392+ if ( tcd ) payload . tool_call_delta = tcd ;
393+ payload . done = done ;
394+ if ( finish ) payload . finish_reason = finish ;
395+ return {
396+ envelope : { type : 'chat-chunk' , request_id : requestId , payload } ,
397+ finishReason : finish ,
398+ } ;
399+ }
400+ return {
401+ envelope : { type : 'chat-chunk' , request_id : requestId , payload : { delta : String ( chunk ) , done : false } } ,
402+ } ;
403+ }
404+
405+ /** Mirror of Python's `_chunk_fields` — extract (text, tool_call_delta,
406+ * finish_reason) from a raw chat-handler chunk for non-streaming accumulation. */
407+ function chunkFields ( chunk : ChatChunkLike ) : {
408+ text : string ;
409+ toolCallDelta ?: Record < string , unknown > ;
410+ finishReason ?: string ;
411+ } {
412+ if ( typeof chunk === 'string' ) return { text : chunk } ;
413+ if ( chunk && typeof chunk === 'object' ) {
414+ const c = chunk as Record < string , unknown > ;
415+ return {
416+ text : ( c . delta as string ) || '' ,
417+ toolCallDelta : c . tool_call_delta as Record < string , unknown > | undefined ,
418+ finishReason : c . finish_reason as string | undefined ,
419+ } ;
420+ }
421+ return { text : String ( chunk ) } ;
422+ }
423+
424+ /** Mirror of Python's `_accumulate_tool_call` — fold one tool_call delta into
425+ * `slots` keyed by index; set id/type once, keep the function name, concat arg
426+ * fragments. Same accumulator the hub uses on its streaming path, so a
427+ * non-streaming HTTP caller sees a fully assembled tool_calls list. */
428+ function accumulateToolCall (
429+ slots : Map < number , Record < string , unknown > > ,
430+ tcd : Record < string , unknown > ,
431+ ) : void {
432+ const idx = ( tcd . index as number ) ?? 0 ;
433+ let slot = slots . get ( idx ) ;
434+ if ( ! slot ) {
435+ slot = { function : { } } ;
436+ slots . set ( idx , slot ) ;
437+ }
438+ if ( 'id' in tcd ) slot . id = tcd . id ;
439+ if ( 'type' in tcd ) slot . type = tcd . type ;
440+ const fnIn = ( tcd . function as Record < string , unknown > ) || { } ;
441+ const fn = ( slot . function as Record < string , unknown > ) ?? { } ;
442+ if ( 'name' in fnIn ) fn . name = fnIn . name ;
443+ if ( 'arguments' in fnIn ) {
444+ fn . arguments = ( ( fn . arguments as string ) ?? '' ) + ( fnIn . arguments as string ) ;
445+ }
446+ slot . function = fn ;
321447}
322448
323449export function publish ( opts : PublishOptions ) : ZhubPublication {
0 commit comments