modelmix 4.7.4 → 5.0.1

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,9 +342,160 @@ 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 () {
@@ -182,7 +575,7 @@ describe('Token Usage Tracking', () => {
182
575
  this.timeout(30000);
183
576
 
184
577
  const model = ModelMix.new()
185
- .gpt5nano()
578
+ .gpt56luna()
186
579
  .addText('Say hi');
187
580
 
188
581
  const result = await model.raw();
@@ -247,7 +640,7 @@ describe('Token Usage Tracking', () => {
247
640
  this.timeout(60000);
248
641
 
249
642
  const conversation = ModelMix.new({ config: { max_history: 10 } })
250
- .gpt5nano();
643
+ .gpt56luna();
251
644
 
252
645
  // First turn
253
646
  conversation.addText('My name is Alice');
@@ -272,7 +665,7 @@ describe('Token Usage Tracking', () => {
272
665
  this.timeout(30000);
273
666
 
274
667
  const model = ModelMix.new()
275
- .gpt5nano()
668
+ .gpt56luna()
276
669
  .addText('Return a simple greeting');
277
670
 
278
671
  // Using raw() to get token info
@@ -288,7 +681,7 @@ describe('Token Usage Tracking', () => {
288
681
  this.timeout(90000);
289
682
 
290
683
  const providers = [
291
- { name: 'OpenAI', create: (m) => m.gpt5nano() },
684
+ { name: 'OpenAI', create: (m) => m.gpt56luna() },
292
685
  { name: 'Anthropic', create: (m) => m.haiku45() },
293
686
  { name: 'Google', create: (m) => m.gemini3flash() }
294
687
  ];