@@ -79,8 +79,12 @@ const GEMMA_SPEC = exported({
7979 ...method ( 'get_eos_ids' , [ ] , [ { kind : 'Int' } ] ) ,
8080} ) ;
8181
82- function registerModelWithSchema ( schema : any , options : { maxContextLen ?: number } = { } ) {
82+ function registerModelWithSchema (
83+ schema : any ,
84+ options : { maxContextLen ?: number ; tokenIds ?: readonly number [ ] } = { }
85+ ) {
8386 const maxContextLen = options . maxContextLen ?? 512 ;
87+ let step = 0 ;
8488 fakeJsi . registerModel ( MODEL_PATH , {
8589 schema,
8690 execute : ( methodName , _inputs , outputs ) => {
@@ -96,11 +100,12 @@ function registerModelWithSchema(schema: any, options: { maxContextLen?: number
96100 return [ true ] ;
97101 case 'get_eos_ids' :
98102 return [ 1 ] ;
103+ case 'forward' :
99104 case 'text_decoder' : {
100105 const out = outputs [ 0 ] ;
101- if ( out ) {
102- // Write a greedy choice to index 2
103- out . setElement ( 2 , 10 ) ;
106+ if ( out && ( options . tokenIds || methodName === 'text_decoder' ) ) {
107+ for ( let i = 0 ; i < out . numel ; i ++ ) out . setElement ( i , 0 ) ;
108+ out . setElement ( options . tokenIds ? ( options . tokenIds [ step ++ ] ?? 1 ) : 2 , 10 ) ;
104109 }
105110 return undefined ;
106111 }
@@ -115,7 +120,6 @@ function registerModelWithSchema(schema: any, options: { maxContextLen?: number
115120 }
116121 case 'token_embedding' :
117122 case 'vision_encoder' :
118- case 'forward' :
119123 default :
120124 return undefined ;
121125 }
@@ -130,6 +134,87 @@ beforeEach(() => {
130134 } ) ;
131135} ) ;
132136
137+ describe . each ( [
138+ [ 'text' , TEXT_SPEC , [ ] ] ,
139+ [ 'multimodal' , MULTIMODAL_SPEC , [ 'image' ] ] ,
140+ [ 'gemma' , GEMMA_SPEC , [ 'image' , 'audio' ] ] ,
141+ ] as const ) ( '%s runner — streaming UTF-8' , ( _name , schema , modalities ) => {
142+ function registerBytes ( pieces : readonly ( readonly number [ ] ) [ ] ) {
143+ fakeJsi . registerTokenizer ( TOKENIZER_PATH , {
144+ tokens : [ 'prompt' , '<eos>' , ...pieces . map ( ( _ , i ) => `piece${ i } ` ) ] ,
145+ tokenBytes : [ [ 112 ] , [ ...Buffer . from ( '<eos>' ) ] , ...pieces ] ,
146+ specialIds : [ 1 ] ,
147+ } ) ;
148+ registerModelWithSchema ( schema , { tokenIds : pieces . map ( ( _ , i ) => i + 2 ) } ) ;
149+ }
150+
151+ it . each ( [ 'हिन्दी' , 'اردو' , 'ไทย' , '한국어' , '😀' , 'żółć' , '中文' ] ) (
152+ 'preserves split bytes in %s' ,
153+ async ( reply ) => {
154+ const bytes = [ ...Buffer . from ( reply , 'utf8' ) ] ;
155+ registerBytes ( bytes . map ( ( byte ) => [ byte ] ) ) ;
156+ const runner = tracked ( await createLLMRunner ( MODEL_PATH , TOKENIZER_PATH , modalities ) ) ;
157+ const chunks : string [ ] = [ ] ;
158+
159+ const stats = runner . generate ( 'prompt' , { temperature : 0 } , ( text ) => chunks . push ( text ) ) ;
160+
161+ expect ( stats . numTokens ) . toBe ( bytes . length ) ;
162+ expect ( chunks . join ( '' ) ) . toBe ( reply ) ;
163+ expect ( chunks ) . not . toContain ( '' ) ;
164+ expect ( chunks . join ( '' ) ) . not . toContain ( '\ufffd' ) ;
165+ }
166+ ) ;
167+
168+ it ( 'buffers a complete prefix together with an incomplete tail' , async ( ) => {
169+ registerBytes ( [ [ 65 , 0xe0 ] , [ 0xa4 ] , [ 0x95 , 66 ] ] ) ;
170+ const runner = tracked ( await createLLMRunner ( MODEL_PATH , TOKENIZER_PATH , modalities ) ) ;
171+ const chunks : string [ ] = [ ] ;
172+
173+ runner . generate ( 'prompt' , { temperature : 0 } , ( text ) => chunks . push ( text ) ) ;
174+
175+ expect ( chunks ) . toEqual ( [ 'AकB' ] ) ;
176+ } ) ;
177+
178+ it ( 'drops an incomplete tail at EOS' , async ( ) => {
179+ registerBytes ( [ [ 65 ] , [ 0xf0 ] , [ 0x9f ] ] ) ;
180+ const runner = tracked ( await createLLMRunner ( MODEL_PATH , TOKENIZER_PATH , modalities ) ) ;
181+ const chunks : string [ ] = [ ] ;
182+
183+ const stats = runner . generate ( 'prompt' , { temperature : 0 } , ( text ) => chunks . push ( text ) ) ;
184+
185+ expect ( stats . numTokens ) . toBe ( 3 ) ;
186+ expect ( chunks ) . toEqual ( [ 'A' ] ) ;
187+ } ) ;
188+
189+ it ( 'preserves the runner special-token setting' , async ( ) => {
190+ registerBytes ( [ [ 65 ] ] ) ;
191+ registerModelWithSchema ( schema , { tokenIds : [ 1 , 2 ] } ) ;
192+ const runner = tracked ( await createLLMRunner ( MODEL_PATH , TOKENIZER_PATH , modalities ) ) ;
193+ const chunks : string [ ] = [ ] ;
194+
195+ runner . generate ( 'prompt' , { temperature : 0 , ignoreEos : true , maxNewTokens : 2 } , ( text ) =>
196+ chunks . push ( text )
197+ ) ;
198+
199+ expect ( chunks ) . toEqual ( _name === 'multimodal' ? [ '' , 'A' ] : [ '<eos>' , 'A' ] ) ;
200+ } ) ;
201+
202+ it ( 'does not carry a partial character into the next generation' , async ( ) => {
203+ // The first prefill samples the lead byte, then its decode step samples
204+ // EOS. The next prefill samples ASCII and must start with an empty buffer.
205+ registerBytes ( [ [ 0xc3 ] , [ 65 ] ] ) ;
206+ registerModelWithSchema ( schema , { tokenIds : [ 2 , 1 , 3 , 1 ] } ) ;
207+ const runner = tracked ( await createLLMRunner ( MODEL_PATH , TOKENIZER_PATH , modalities ) ) ;
208+ const chunks : string [ ] = [ ] ;
209+
210+ runner . generate ( 'prompt' , { temperature : 0 , maxNewTokens : 1 } , ( text ) => chunks . push ( text ) ) ;
211+ expect ( chunks ) . toEqual ( [ ] ) ;
212+ runner . generate ( 'prompt' , { temperature : 0 } , ( text ) => chunks . push ( text ) ) ;
213+
214+ expect ( chunks ) . toEqual ( [ 'A' ] ) ;
215+ } ) ;
216+ } ) ;
217+
133218describe ( 'createLLMRunner — variant dispatch & modalities validation' , ( ) => {
134219 it ( 'dispatches to text runner when schema matches LLM_TEXT_SPEC' , async ( ) => {
135220 registerModelWithSchema ( TEXT_SPEC ) ;
0 commit comments