modelmix 4.7.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { expect } from 'chai';
2
- import { ModelMix, MixAnthropic, MixCustom, MixGoogle, MixMiMo, MixOpenAIResponses, MixOpenRouter } from '../index.js';
2
+ import { ModelMix, MixAnthropic, MixCustom, MixGoogle, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenRouter } from '../index.js';
3
3
  import { createRequire } from 'module';
4
4
 
5
5
  const require = createRequire(import.meta.url);
@@ -18,14 +18,15 @@ describe('Token Usage Tracking', () => {
18
18
  nock.activate();
19
19
  });
20
20
 
21
- it('should extract cached tokens from supported provider usage formats', function () {
21
+ it('should normalize prompt cache usage from supported provider formats', function () {
22
22
  const openAIChatTokens = MixCustom.extractTokens({
23
23
  usage: {
24
24
  prompt_tokens: 120,
25
25
  completion_tokens: 30,
26
26
  total_tokens: 150,
27
27
  prompt_tokens_details: {
28
- cached_tokens: 80
28
+ cached_tokens: 80,
29
+ cache_write_tokens: 10
29
30
  }
30
31
  }
31
32
  });
@@ -35,7 +36,8 @@ describe('Token Usage Tracking', () => {
35
36
  output_tokens: 20,
36
37
  total_tokens: 110,
37
38
  input_tokens_details: {
38
- cached_tokens: 45
39
+ cached_tokens: 45,
40
+ cache_write_tokens: 15
39
41
  }
40
42
  }
41
43
  });
@@ -43,7 +45,8 @@ describe('Token Usage Tracking', () => {
43
45
  usage: {
44
46
  input_tokens: 60,
45
47
  output_tokens: 15,
46
- cache_read_input_tokens: 25
48
+ cache_read_input_tokens: 25,
49
+ cache_creation_input_tokens: 10
47
50
  }
48
51
  });
49
52
  const googleTokens = MixGoogle.extractTokens({
@@ -55,10 +58,69 @@ describe('Token Usage Tracking', () => {
55
58
  }
56
59
  });
57
60
 
58
- expect(openAIChatTokens.cached).to.equal(80);
59
- expect(openAIResponsesTokens.cached).to.equal(45);
60
- expect(anthropicTokens.cached).to.equal(25);
61
- expect(googleTokens.cached).to.equal(35);
61
+ expect(openAIChatTokens).to.include({
62
+ input: 120,
63
+ output: 30,
64
+ total: 150,
65
+ cached: 80,
66
+ cacheWrite: 10,
67
+ uncachedInput: 30,
68
+ cacheHitRate: 0.6667
69
+ });
70
+ expect(openAIResponsesTokens).to.include({
71
+ input: 90,
72
+ output: 20,
73
+ total: 110,
74
+ cached: 45,
75
+ cacheWrite: 15,
76
+ uncachedInput: 30,
77
+ cacheHitRate: 0.5
78
+ });
79
+ expect(anthropicTokens).to.include({
80
+ input: 95,
81
+ output: 15,
82
+ total: 110,
83
+ cached: 25,
84
+ cacheWrite: 10,
85
+ uncachedInput: 60,
86
+ cacheHitRate: 0.2632
87
+ });
88
+ expect(googleTokens).to.include({
89
+ input: 70,
90
+ output: 10,
91
+ total: 80,
92
+ cached: 35,
93
+ cacheWrite: 0,
94
+ uncachedInput: 35,
95
+ cacheHitRate: 0.5
96
+ });
97
+ });
98
+
99
+ it('should return zero for prompt cache categories omitted by the provider', function () {
100
+ const tokens = MixCustom.extractTokens({
101
+ usage: {
102
+ prompt_tokens: 20,
103
+ completion_tokens: 5,
104
+ total_tokens: 25
105
+ }
106
+ });
107
+
108
+ expect(tokens).to.include({
109
+ cached: 0,
110
+ cacheWrite: 0,
111
+ uncachedInput: 20,
112
+ cacheHitRate: 0,
113
+ cost: 0
114
+ });
115
+ expect(tokens.costBreakdown).to.deep.equal({
116
+ uncachedInput: 0,
117
+ cachedInput: 0,
118
+ cacheWrite: 0,
119
+ cacheWrite5m: 0,
120
+ cacheWrite1h: 0,
121
+ output: 0,
122
+ total: 0
123
+ });
62
124
  });
63
125
 
64
126
  it('should pass OpenAI Responses prompt cache options through the request body', function () {
@@ -76,6 +138,186 @@ describe('Token Usage Tracking', () => {
76
138
  expect(request.prompt_cache_retention).to.equal('24h');
77
139
  });
78
140
 
141
+ it('should pass GPT-5.6 explicit cache controls and preserve block breakpoints', function () {
142
+ const breakpoint = { mode: 'explicit' };
143
+ const request = MixOpenAIResponses.buildResponsesRequest({
144
+ model: 'gpt-5.6-luna',
145
+ messages: [{
146
+ role: 'developer',
147
+ content: [
148
+ { type: 'text', text: 'Stable instructions', prompt_cache_breakpoint: breakpoint },
149
+ {
150
+ type: 'image',
151
+ source: { type: 'base64', media_type: 'image/png', data: 'AAAA' },
152
+ prompt_cache_breakpoint: breakpoint
153
+ },
154
+ {
155
+ type: 'input_file',
156
+ file_id: 'file_123',
157
+ prompt_cache_breakpoint: breakpoint
158
+ }
159
+ ]
160
+ }],
161
+ prompt_cache_key: 'explicit-cache',
162
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
163
+ });
164
+
165
+ expect(request.prompt_cache_options).to.deep.equal({ mode: 'explicit', ttl: '30m' });
166
+ expect(request.input[0].content).to.deep.equal([
167
+ { type: 'input_text', text: 'Stable instructions', prompt_cache_breakpoint: breakpoint },
168
+ {
169
+ type: 'input_image',
170
+ image_url: 'data:image/png;base64,AAAA',
171
+ prompt_cache_breakpoint: breakpoint
172
+ },
173
+ {
174
+ type: 'input_file',
175
+ file_id: 'file_123',
176
+ prompt_cache_breakpoint: breakpoint
177
+ }
178
+ ]);
179
+ });
180
+
181
+ it('should translate neutral cache breakpoints for GPT-5.6 and filter them for older models', async function () {
182
+ const breakpoint = { mode: 'explicit' };
183
+ const model = ModelMix.new()
184
+ .addText('Stable text', { role: 'developer', cache: { breakpoint: true } })
185
+ .addImageFromUrl('data:image/png;base64,AAAA', { cache: { breakpoint: true } });
186
+ const messages = await model.prepareMessages();
187
+ const gpt56Request = MixOpenAIResponses.buildResponsesRequest({
188
+ model: 'gpt-5.6-luna',
189
+ messages,
190
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
191
+ });
192
+ const olderRequest = MixOpenAIResponses.buildResponsesRequest({
193
+ model: 'gpt-5.4',
194
+ messages
195
+ });
196
+
197
+ expect(messages[0].content[0]).to.deep.include({ cache: { breakpoint: true } });
198
+ expect(messages[0].content[0]).to.not.have.property('prompt_cache_breakpoint');
199
+ expect(gpt56Request.input[0].content[0].prompt_cache_breakpoint).to.deep.equal(breakpoint);
200
+ expect(gpt56Request.input[1].content[0]).to.deep.equal({
201
+ type: 'input_image',
202
+ image_url: 'data:image/png;base64,AAAA',
203
+ prompt_cache_breakpoint: breakpoint
204
+ });
205
+ expect(olderRequest.input[0].content[0]).to.not.have.property('cache');
206
+ expect(olderRequest.input[0].content[0]).to.not.have.property('prompt_cache_breakpoint');
207
+ expect(olderRequest.input[1].content[0]).to.not.have.property('prompt_cache_breakpoint');
208
+ });
209
+
210
+ it('should map assistant history to Responses output_text content', function () {
211
+ const request = MixOpenAIResponses.buildResponsesRequest({
212
+ model: 'gpt-5.6-luna',
213
+ messages: [
214
+ { role: 'user', content: [{ type: 'text', text: 'My name is Alice' }] },
215
+ { role: 'assistant', content: [{ type: 'text', text: 'Nice to meet you' }] },
216
+ { role: 'user', content: [{ type: 'text', text: 'What is my name?' }] }
217
+ ]
218
+ });
219
+
220
+ expect(request.input[0].content[0]).to.deep.equal({
221
+ type: 'input_text',
222
+ text: 'My name is Alice'
223
+ });
224
+ expect(request.input[1].content[0]).to.deep.equal({
225
+ type: 'output_text',
226
+ text: 'Nice to meet you'
227
+ });
228
+ expect(request.input[2].content[0]).to.deep.equal({
229
+ type: 'input_text',
230
+ text: 'What is my name?'
231
+ });
232
+ });
233
+
234
+ it('should not treat the native OpenAI breakpoint as a fluent API alias', async function () {
235
+ const nativeBreakpoint = { prompt_cache_breakpoint: { mode: 'explicit' } };
236
+ const model = ModelMix.new()
237
+ .addText('Stable text', nativeBreakpoint)
238
+ .addImageFromUrl('data:image/png;base64,AAAA', nativeBreakpoint);
239
+ const messages = await model.prepareMessages();
240
+ const request = MixOpenAIResponses.buildResponsesRequest({
241
+ model: 'gpt-5.6-luna',
242
+ messages,
243
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
244
+ });
245
+
246
+ expect(messages[0].content).to.have.length(2);
247
+ expect(messages[0].content.every(item => item.cache === undefined)).to.equal(true);
248
+ const content = request.input.flatMap(item => item.content ?? []);
249
+ expect(content).to.have.length(2);
250
+ expect(content.every(item => item.prompt_cache_breakpoint === undefined)).to.equal(true);
251
+ });
252
+
253
+ it('should strip neutral and Anthropic cache metadata from OpenAI-compatible chat requests', function () {
254
+ const messages = [{
255
+ role: 'user',
256
+ content: [{
257
+ type: 'text',
258
+ text: 'Stable text',
259
+ cache: { breakpoint: true },
260
+ cache_control: { type: 'ephemeral' }
261
+ }]
262
+ }];
263
+ const converted = MixOpenAI.convertMessages(messages, { system: 'System' });
264
+
265
+ expect(converted[1].content[0]).to.deep.equal({ type: 'text', text: 'Stable text' });
266
+ expect(messages[0].content[0]).to.deep.include({ cache: { breakpoint: true } });
267
+ });
268
+
269
+ it('should reject prompt cache controls unsupported by the selected OpenAI model', function () {
270
+ expect(() => MixOpenAIResponses.buildResponsesRequest({
271
+ model: 'gpt-5.6-luna',
272
+ messages: [{ role: 'user', content: 'Hi' }],
273
+ prompt_cache_retention: '24h'
274
+ })).to.throw('prompt_cache_options.ttl');
275
+
276
+ expect(() => MixOpenAIResponses.buildResponsesRequest({
277
+ model: 'gpt-5.4',
278
+ messages: [{ role: 'user', content: 'Hi' }],
279
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
280
+ })).to.throw('only supported by GPT-5.6');
281
+
282
+ expect(() => MixOpenAIResponses.buildResponsesRequest({
283
+ model: 'gpt-5.4',
284
+ messages: [{
285
+ role: 'user',
286
+ content: [{
287
+ type: 'text',
288
+ text: 'Hi',
289
+ prompt_cache_breakpoint: { mode: 'explicit' }
290
+ }]
291
+ }]
292
+ })).to.throw('prompt_cache_breakpoint is only supported by GPT-5.6');
293
+ });
294
+
295
+ it('should validate GPT-5.6 prompt cache option values', function () {
296
+ expect(() => MixOpenAIResponses.buildResponsesRequest({
297
+ model: 'gpt-5.6-luna',
298
+ messages: [{ role: 'user', content: 'Hi' }],
299
+ prompt_cache_options: { mode: 'automatic', ttl: '30m' }
300
+ })).to.throw('mode must be "implicit" or "explicit"');
301
+
302
+ expect(() => MixOpenAIResponses.buildResponsesRequest({
303
+ model: 'gpt-5.6-luna',
304
+ messages: [{ role: 'user', content: 'Hi' }],
305
+ prompt_cache_options: { mode: 'explicit', ttl: '1h' }
306
+ })).to.throw('ttl must be "30m"');
307
+
308
+ expect(() => MixOpenAIResponses.buildResponsesRequest({
309
+ model: 'gpt-5.6-luna',
310
+ messages: [{
311
+ role: 'user',
312
+ content: [{
313
+ type: 'text',
314
+ text: 'Hi',
315
+ prompt_cache_breakpoint: { mode: 'implicit' }
316
+ }]
317
+ }]
318
+ })).to.throw('breakpoint mode must be "explicit"');
319
+ });
320
+
79
321
  it('should register GPT-5.5 shortcuts with OpenAI Responses provider', function () {
80
322
  const model = ModelMix.new()
81
323
  .gpt55()
@@ -100,23 +342,177 @@ describe('Token Usage Tracking', () => {
100
342
  'gpt-5.6-luna'
101
343
  ]);
102
344
  expect(model.models.every(({ provider }) => provider instanceof MixOpenAIResponses)).to.equal(true);
103
- expect(ModelMix.calculateCost('gpt-5.6-sol', { input: 1_000_000, output: 1_000_000 })).to.equal(35);
104
- expect(ModelMix.calculateCost('gpt-5.6-terra', { input: 1_000_000, output: 1_000_000 })).to.equal(17.5);
105
- expect(ModelMix.calculateCost('gpt-5.6-luna', { input: 1_000_000, output: 1_000_000 })).to.equal(7);
345
+ expect(ModelMix.calculateCost('gpt-5.6-sol', { input: 1_000_000, output: 1_000_000 })).to.equal(55);
346
+ expect(ModelMix.calculateCost('gpt-5.6-terra', { input: 1_000_000, output: 1_000_000 })).to.equal(22);
347
+ expect(ModelMix.calculateCost('gpt-5.6-luna', { input: 1_000_000, output: 1_000_000 })).to.equal(2.2);
348
+ });
349
+
350
+ it('should calculate GPT-5.6 cache reads and writes at their actual rates', function () {
351
+ const tokens = ModelMix.normalizeTokenUsage({
352
+ input: 1200,
353
+ output: 50,
354
+ cached: 800,
355
+ cacheWrite: 200
356
+ });
357
+
358
+ expect(ModelMix.calculateCostBreakdown('gpt-5.6-terra', tokens)).to.deep.equal({
359
+ uncachedInput: 0.0004,
360
+ cachedInput: 0.00016,
361
+ cacheWrite: 0.0005,
362
+ cacheWrite5m: 0,
363
+ cacheWrite1h: 0,
364
+ output: 0.0006,
365
+ total: 0.00166
366
+ });
367
+ expect(ModelMix.calculateCost('gpt-5.6-terra', tokens)).to.equal(0.00166);
368
+ });
369
+
370
+ it('should apply GPT-5.6 long-context multipliers to the entire request', function () {
371
+ const tokens = ModelMix.normalizeTokenUsage({
372
+ input: 300_000,
373
+ output: 1_000,
374
+ cached: 100_000,
375
+ cacheWrite: 100_000
376
+ });
377
+
378
+ expect(ModelMix.calculateCostBreakdown('gpt-5.6-luna', tokens)).to.deep.equal({
379
+ uncachedInput: 0.04,
380
+ cachedInput: 0.004,
381
+ cacheWrite: 0.05,
382
+ cacheWrite5m: 0,
383
+ cacheWrite1h: 0,
384
+ output: 0.0018,
385
+ total: 0.0958
386
+ });
387
+ expect(ModelMix.calculateCacheMetrics('gpt-5.6-luna', tokens)).to.deep.equal({
388
+ cacheSavings: 0.036,
389
+ cacheWritePremium: 0.01,
390
+ breakEvenHits: 0.2778
391
+ });
392
+ });
393
+
394
+ it('should not apply GPT-5.6 long-context multipliers at exactly 272K input tokens', function () {
395
+ const tokens = ModelMix.normalizeTokenUsage({
396
+ input: 272_000,
397
+ output: 1_000,
398
+ cached: 100_000,
399
+ cacheWrite: 100_000
400
+ });
401
+
402
+ expect(ModelMix.calculateCostBreakdown('gpt-5.6-luna', tokens)).to.deep.equal({
403
+ uncachedInput: 0.0144,
404
+ cachedInput: 0.002,
405
+ cacheWrite: 0.025,
406
+ cacheWrite5m: 0,
407
+ cacheWrite1h: 0,
408
+ output: 0.0012,
409
+ total: 0.0426
410
+ });
411
+ });
412
+
413
+ it('should price Anthropic 5-minute and 1-hour cache writes separately', function () {
414
+ const tokens = MixAnthropic.extractTokens({
415
+ usage: {
416
+ input_tokens: 10,
417
+ output_tokens: 5,
418
+ cache_read_input_tokens: 400,
419
+ cache_creation_input_tokens: 300,
420
+ cache_creation: {
421
+ ephemeral_5m_input_tokens: 100,
422
+ ephemeral_1h_input_tokens: 200
423
+ }
424
+ }
425
+ });
426
+
427
+ expect(tokens).to.include({
428
+ input: 710,
429
+ cached: 400,
430
+ cacheWrite: 300,
431
+ cacheWrite5m: 100,
432
+ cacheWrite1h: 200,
433
+ uncachedInput: 10
434
+ });
435
+ expect(ModelMix.calculateCostBreakdown('claude-haiku-4-5-20251001', tokens)).to.deep.equal({
436
+ uncachedInput: 0.00001,
437
+ cachedInput: 0.00004,
438
+ cacheWrite: 0.000525,
439
+ cacheWrite5m: 0.000125,
440
+ cacheWrite1h: 0.0004,
441
+ output: 0.000025,
442
+ total: 0.0006
443
+ });
444
+ expect(ModelMix.calculateCacheMetrics('claude-haiku-4-5-20251001', tokens)).to.deep.equal({
445
+ cacheSavings: 0.00036,
446
+ cacheWritePremium: 0.000225,
447
+ breakEvenHits: 0.8333
448
+ });
449
+ });
450
+
451
+ it('should expose normalized cache costs through raw() and lastRaw', async function () {
452
+ const provider = new MixCustom();
453
+ provider.create = async () => ({
454
+ message: 'ok',
455
+ think: null,
456
+ toolCalls: [],
457
+ tokens: MixOpenAIResponses.extractResponsesTokens({
458
+ usage: {
459
+ input_tokens: 1200,
460
+ output_tokens: 50,
461
+ total_tokens: 1250,
462
+ input_tokens_details: {
463
+ cached_tokens: 1024,
464
+ cache_write_tokens: 0
465
+ }
466
+ }
467
+ }),
468
+ response: {}
469
+ });
470
+
471
+ const model = ModelMix.new()
472
+ .attach('gpt-5.6-luna', provider)
473
+ .addText('test');
474
+ const result = await model.raw();
475
+
476
+ expect(result.tokens).to.include({
477
+ input: 1200,
478
+ output: 50,
479
+ total: 1250,
480
+ cached: 1024,
481
+ cacheWrite: 0,
482
+ uncachedInput: 176,
483
+ cacheHitRate: 0.8533,
484
+ cacheSavings: 0.00018432,
485
+ cacheWritePremium: 0,
486
+ breakEvenHits: 0,
487
+ cost: 0.00011568
488
+ });
489
+ expect(result.tokens.costBreakdown).to.deep.equal({
490
+ uncachedInput: 0.0000352,
491
+ cachedInput: 0.00002048,
492
+ cacheWrite: 0,
493
+ cacheWrite5m: 0,
494
+ cacheWrite1h: 0,
495
+ output: 0.00006,
496
+ total: 0.00011568
497
+ });
498
+ expect(model.lastRaw.tokens).to.deep.equal(result.tokens);
106
499
  });
107
500
 
108
501
  it('should register Gemini Flash shortcuts with Google provider', function () {
109
502
  const model = ModelMix.new()
110
503
  .gemini36flash()
111
- .gemini35flash();
504
+ .gemini35flash()
505
+ .gemini35flashLite();
112
506
 
113
507
  expect(model.models.map(({ key }) => key)).to.deep.equal([
114
508
  'gemini-3.6-flash',
115
- 'gemini-3.5-flash'
509
+ 'gemini-3.5-flash',
510
+ 'gemini-3.5-flash-lite'
116
511
  ]);
117
512
  expect(model.models.every(({ provider }) => provider instanceof MixGoogle)).to.equal(true);
118
513
  expect(ModelMix.calculateCost('gemini-3.6-flash', { input: 1_000_000, output: 1_000_000 })).to.equal(9);
119
514
  expect(ModelMix.calculateCost('gemini-3.5-flash', { input: 1_000_000, output: 1_000_000 })).to.equal(5.25);
515
+ expect(ModelMix.calculateCost('gemini-3.5-flash-lite', { input: 1_000_000, output: 1_000_000 })).to.equal(2.8);
120
516
  });
121
517
 
122
518
  it('should register MiMo shortcuts with native and OpenRouter providers', function () {
@@ -179,7 +575,7 @@ describe('Token Usage Tracking', () => {
179
575
  this.timeout(30000);
180
576
 
181
577
  const model = ModelMix.new()
182
- .gpt5nano()
578
+ .gpt56luna()
183
579
  .addText('Say hi');
184
580
 
185
581
  const result = await model.raw();
@@ -244,7 +640,7 @@ describe('Token Usage Tracking', () => {
244
640
  this.timeout(60000);
245
641
 
246
642
  const conversation = ModelMix.new({ config: { max_history: 10 } })
247
- .gpt5nano();
643
+ .gpt56luna();
248
644
 
249
645
  // First turn
250
646
  conversation.addText('My name is Alice');
@@ -269,7 +665,7 @@ describe('Token Usage Tracking', () => {
269
665
  this.timeout(30000);
270
666
 
271
667
  const model = ModelMix.new()
272
- .gpt5nano()
668
+ .gpt56luna()
273
669
  .addText('Return a simple greeting');
274
670
 
275
671
  // Using raw() to get token info
@@ -285,7 +681,7 @@ describe('Token Usage Tracking', () => {
285
681
  this.timeout(90000);
286
682
 
287
683
  const providers = [
288
- { name: 'OpenAI', create: (m) => m.gpt5nano() },
684
+ { name: 'OpenAI', create: (m) => m.gpt56luna() },
289
685
  { name: 'Anthropic', create: (m) => m.haiku45() },
290
686
  { name: 'Google', create: (m) => m.gemini3flash() }
291
687
  ];