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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "4.7.4",
3
+ "version": "5.0.1",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,7 +25,9 @@
25
25
  "kimi",
26
26
  "mythos",
27
27
  "gpt5",
28
+ "opus50",
28
29
  "opus5",
30
+ "fable50",
29
31
  "fable",
30
32
  "sonnet",
31
33
  "openrouter",
@@ -51,6 +53,7 @@
51
53
  "dependencies": {
52
54
  "@modelcontextprotocol/sdk": "^1.30.0",
53
55
  "bottleneck": "^2.19.5",
56
+ "ejs": "6.0.1",
54
57
  "file-type": "^21.3.4",
55
58
  "lemonlog": "^1.2.2",
56
59
  "ws": "^8.21.1"
@@ -37,7 +37,7 @@ Do NOT use for:
37
37
  - [Get raw response (tokens, thinking, tool calls)](#get-raw-response)
38
38
  - [Access full response with lastRaw](#access-full-response-with-lastraw)
39
39
  - [Add images](#add-images)
40
- - [Templates with placeholders](#templates-with-placeholders)
40
+ - [EJS templates](#ejs-templates)
41
41
  - [Round-robin load balancing](#round-robin-load-balancing)
42
42
  - [MCP integration](#mcp-integration)
43
43
  - [Custom local tools](#custom-local-tools)
@@ -122,9 +122,9 @@ ModelMix.new({ config: { effort: 80 } })
122
122
  `gpt52()` `gpt52chat()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gpt45()` `gpt41()` `gpt41mini()` `gpt41nano()` `o3()` `o4mini()`
123
123
 
124
124
  ### Anthropic
125
- `fable5()` `opus5()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
125
+ `fable50()` `opus50()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
126
126
 
127
- Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.effort(100).opus5()`.
127
+ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.effort(100).opus50()`. `fable5()` and `opus5()` remain available as compatibility aliases.
128
128
 
129
129
  ### Google
130
130
  `gemini3pro()` `gemini3flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
@@ -301,11 +301,51 @@ After calling `message()`, `json()`, `block()`, or `stream()`, use `lastRaw` to
301
301
  const model = ModelMix.new().gpt5mini().addText("Hello!");
302
302
  const text = await model.message();
303
303
  console.log(model.lastRaw.tokens);
304
- // { input: 122, output: 86, total: 541, cost: 0.000319, speed: 38 }
304
+ // {
305
+ // input: 1200, output: 50, total: 1250,
306
+ // cached: 1024, cacheWrite: 0, uncachedInput: 176,
307
+ // cacheWrite5m: 0, cacheWrite1h: 0,
308
+ // cacheHitRate: 0.8533, cacheSavings: 0.00018432,
309
+ // cacheWritePremium: 0, breakEvenHits: 0,
310
+ // cost: 0.00011568,
311
+ // costBreakdown: {
312
+ // uncachedInput: 0.0000352, cachedInput: 0.00002048,
313
+ // cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0,
314
+ // output: 0.00006, total: 0.00011568
315
+ // },
316
+ // speed: 38
317
+ // }
305
318
  console.log(model.lastRaw.think); // reasoning content (if available)
306
319
  console.log(model.lastRaw.response); // raw API response
307
320
  ```
308
321
 
322
+ ### GPT-5.6 explicit prompt caching
323
+
324
+ ```javascript
325
+ const model = ModelMix.new()
326
+ .gpt56luna({
327
+ options: {
328
+ prompt_cache_key: 'stable-prefix-v1',
329
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
330
+ }
331
+ })
332
+ .haiku45({
333
+ options: {
334
+ cache_control: { type: 'ephemeral', ttl: '1h' }
335
+ }
336
+ })
337
+ .addText(longStableInstructions, {
338
+ cache: { breakpoint: true }
339
+ })
340
+ .addText('Handle this variable request.');
341
+
342
+ const result = await model.raw();
343
+ ```
344
+
345
+ `cache: { breakpoint: true }` is provider-neutral: GPT-5.6 receives `prompt_cache_breakpoint`, Anthropic receives `cache_control`, and unsupported providers omit it. Keep native request policies inside each model shorthand so they do not leak across fallbacks. Anthropic usage separates `cacheWrite5m` and `cacheWrite1h`; `cacheWrite` stays as their compatible aggregate.
346
+
347
+ GPT-5.6 replaces `prompt_cache_retention` with `prompt_cache_options.ttl`. Explicit breakpoints also work on image methods and Responses-native `input_text`, `input_image`, and `input_file` blocks. Prompts need at least 1,024 tokens to be cached. Requests over 272K input tokens use 2× input and 1.5× output prices for the complete request; ModelMix applies these multipliers to `cost`, `costBreakdown`, and cache economics.
348
+
309
349
  ### Add images
310
350
 
311
351
  ```javascript
@@ -319,20 +359,69 @@ const description = await model.message();
319
359
 
320
360
  All image methods accept an optional second argument `{ role }` (default `"user"`).
321
361
 
322
- ### Templates with placeholders
362
+ ### EJS templates
323
363
 
324
364
  ```javascript
325
365
  const model = ModelMix.new().gpt5mini();
326
366
  model.setSystemFromFile('./prompts/system.md');
327
367
  model.addTextFromFile('./prompts/task.md');
328
- model.replace({
329
- '{role}': 'data analyst',
330
- '{language}': 'Spanish'
368
+ model.assign({
369
+ role: 'data analyst',
370
+ language: 'Spanish'
331
371
  });
332
- model.replaceKeyFromFile('{code}', './src/utils.js');
333
372
  console.log(await model.message());
334
373
  ```
335
374
 
375
+ Templates use standard EJS syntax. Use `<%- value %>` for raw prompt content and `<%= value %>` only when XML escaping is intentional. Missing variables and files throw. Templates may contain JavaScript, so the template source must be developer-controlled; untrusted content belongs only in `assign()` data.
376
+
377
+ Use `assignKey(key, value)` for one value and `assign({ ... })` for several values.
378
+
379
+ Start with a static include. Paths are resolved relative to the containing template:
380
+
381
+ ```ejs
382
+ <%- include('shared/rules.md') %>
383
+ ```
384
+
385
+ Use a variable when the included file must be selected dynamically:
386
+
387
+ ```ejs
388
+ Analyze the following source:
389
+
390
+ <%- include(sourceFile) %>
391
+ ```
392
+
393
+ ```javascript
394
+ model.assign({ sourceFile: '../src/utils.js' });
395
+ ```
396
+
397
+ Included files are EJS source, so the path and file must be developer-controlled. Untrusted runtime content belongs in ordinary `assign()` values, not include paths. To expose a rendered file as a data key, call `assignKeyFromFile(key, filePath)`; it uses EJS `include`, supports includes relative to that file, and renders once per request. For recursive data, a template may include itself with an explicit stopping condition:
398
+
399
+ ```ejs
400
+ <%- node.text %>
401
+ <% if (node.children?.length && depth < maxDepth) { %>
402
+ <% for (const child of node.children) { %>
403
+ <%- include('tree.ejs', { node: child, depth: depth + 1, maxDepth }) %>
404
+ <% } %>
405
+ <% } %>
406
+ ```
407
+
408
+ Initialize it with `assign({ node, depth: 0, maxDepth: 10 })`. Values supplied through `assign()` remain data and are never interpreted recursively as EJS.
409
+
410
+ Use ModelMix choice directives for random prompt variants:
411
+
412
+ ```ejs
413
+ <% choice %>
414
+ <% option 20 %>
415
+ Use emojis.
416
+ <% option 40 %>
417
+ Use few emojis.
418
+ <% option 40 %>
419
+ Do not use emojis.
420
+ <% /choice %>
421
+ ```
422
+
423
+ Omit all weights for equal probabilities. Otherwise every option needs a positive relative weight; weights do not need to total 100. Keep directives on their own lines. Nested choices and choices inside includes are supported. A request keeps its selections through retries, provider fallbacks, and tool continuations.
424
+
336
425
  ### Round-robin load balancing
337
426
 
338
427
  ```javascript
@@ -464,27 +553,28 @@ const model = ModelMix.new({
464
553
  - When using MCP tools or `addTool()`, set `max_history` to at least 3 — tool call/response pairs consume history slots.
465
554
  - Use `.json()` for structured output instead of parsing text manually. Use descriptor objects `{ description, required, enum, default, nullable }` for richer schema control.
466
555
  - Use `.message()` for simple text, `.raw()` when you need tokens/thinking/toolCalls.
467
- - For Anthropic thinking, use unified `effort` (`-1` or `0`–`100`) via `config.effort` or `.effort(n)` — e.g. `.effort(100).opus5()`. Never put `effort` in `options`. Native fields win if already set.
468
- - Template placeholders use `{key}` syntax in both system prompts and user messages.
556
+ - For Anthropic thinking, use unified `effort` (`-1` or `0`–`100`) via `config.effort` or `.effort(n)` — e.g. `.effort(100).opus50()`. Never put `effort` in `options`. Native fields win if already set.
557
+ - Templates use EJS syntax in both system prompts and user messages; prefer `<%- key %>` for raw prompt data.
469
558
  - The library uses CommonJS internally but supports ESM import via `{ ModelMix }`.
470
559
  - GPT-5+ models automatically use `max_completion_tokens` instead of `max_tokens`.
471
560
  - o-series models (o3, o4mini) automatically strip `max_tokens` and `temperature` since those APIs don't support them.
472
561
  - Anthropic Opus 4.7+ / Claude 5 family automatically strip `temperature`, `top_p`, and `top_k` (API rejects them).
473
- - `addText()`, `addImage()`, `addImageFromUrl()`, and `addImageFromBuffer()` all accept `{ role }` as second argument (default `"user"`).
562
+ - `addText()`, `addImage()`, `addImageFromUrl()`, and `addImageFromBuffer()` accept `{ role, cache?: { breakpoint: true } }` as the second argument (default role: `"user"`). Adapters translate or omit the neutral marker by provider.
474
563
 
475
564
  ## API Quick Reference
476
565
 
477
566
  | Method | Returns | Description |
478
567
  | --- | --- | --- |
479
- | `.addText(text, {role?})` | `this` | Add user message |
480
- | `.addTextFromFile(path, {role?})` | `this` | Add user message from file |
568
+ | `.addText(text, {role?, cache?})` | `this` | Add user message |
569
+ | `.addTextFromFile(path, {role?, cache?})` | `this` | Add user message from file |
481
570
  | `.setSystem(text)` | `this` | Set system prompt |
482
571
  | `.setSystemFromFile(path)` | `this` | Set system prompt from file |
483
- | `.addImage(path, {role?})` | `this` | Add image from file |
484
- | `.addImageFromUrl(url, {role?})` | `this` | Add image from URL or data URI |
485
- | `.addImageFromBuffer(buffer, {role?})` | `this` | Add image from Buffer |
486
- | `.replace({})` | `this` | Set placeholder replacements |
487
- | `.replaceKeyFromFile(key, path)` | `this` | Replace placeholder with file content |
572
+ | `.addImage(path, {role?, cache?})` | `this` | Add image from file |
573
+ | `.addImageFromUrl(url, {role?, cache?})` | `this` | Add image from URL or data URI |
574
+ | `.addImageFromBuffer(buffer, {role?, cache?})` | `this` | Add image from Buffer |
575
+ | `.assign({})` | `this` | Assign EJS template data |
576
+ | `.assignKey(key, value)` | `this` | Assign one EJS template-data value |
577
+ | `.assignKeyFromFile(key, path)` | `this` | Assign the rendered output of an EJS file to one key |
488
578
  | `.message()` | `Promise<string>` | Get text response |
489
579
  | `.json(example, desc?, opts?)` | `Promise<object\|array>` | Get structured JSON |
490
580
  | `.raw()` | `Promise<{message, think, toolCalls, tokens, response}>` | Full response |
package/test/README.md CHANGED
@@ -9,7 +9,7 @@ This comprehensive test suite provides complete coverage for the ModelMix librar
9
9
  These tests require actual API keys and test the complete integration:
10
10
  - Real image processing with multiple providers
11
11
  - Actual JSON structured output
12
- - Template replacement with real models
12
+ - EJS template rendering with real models
13
13
  - Multi-modal combinations
14
14
  - Performance testing with real APIs
15
15
 
@@ -45,13 +45,14 @@ npm test -- --grep "Live Integration"
45
45
  - ✅ Context preservation through fallbacks
46
46
  - ✅ Provider-specific configurations
47
47
 
48
- ### 3. File Operations and Templates (`templates.test.js`)
49
- - ✅ Template variable replacement
50
- - ✅ Template file loading
51
- - ✅ JSON file processing
52
- - ✅ Absolute and relative paths
53
- - ✅ File error handling
54
- - ✅ Complex template + file integration
48
+ ### 3. File Operations and EJS Templates (`templates.test.js`)
49
+ - ✅ Raw and XML-escaped EJS output
50
+ - ✅ Nested data, conditionals, and loops
51
+ - ✅ Uniform, weighted, nested, and included choice blocks
52
+ - ✅ File templates and relative includes
53
+ - ✅ Raw file and JSON data injection
54
+ - ✅ Missing-variable and missing-file errors
55
+ - ✅ JSON, block, system, and multi-turn integration
55
56
 
56
57
  ### 4. Image Processing and Multimodal (`images.test.js`)
57
58
  - ✅ Base64 data handling
@@ -155,4 +156,4 @@ DEBUG_TESTS=true npm test
155
156
  - All tests use API mocking to avoid real calls
156
157
  - Test environment variables are configured in `setup.js`
157
158
  - Tests are independent and can run in any order
158
- - Automatic cleanup prevents interference between tests
159
+ - Automatic cleanup prevents interference between tests
@@ -5,15 +5,29 @@ const { ModelMix, MixAnthropic } = require('../index.js');
5
5
  describe('Anthropic Model Registration Tests', () => {
6
6
  it('should register Claude Fable 5', () => {
7
7
  const model = ModelMix.new();
8
- model.fable5();
8
+ model.fable50();
9
9
 
10
10
  expect(model.models).to.have.length(1);
11
11
  expect(model.models[0].key).to.equal('claude-fable-5');
12
12
  expect(model.models[0].provider).to.be.instanceOf(MixAnthropic);
13
13
  });
14
14
 
15
- it('should apply max effort thinking via .effort(100).fable5()', () => {
16
- const model = ModelMix.new().effort(100).fable5();
15
+ it('should keep fable5() as an alias for fable50()', () => {
16
+ const model = ModelMix.new();
17
+
18
+ expect(model.fable5({
19
+ options: { max_tokens: 123 },
20
+ config: { url: 'https://anthropic.example.test' }
21
+ })).to.equal(model);
22
+ expect(model.models).to.have.length(1);
23
+ expect(model.models[0].key).to.equal('claude-fable-5');
24
+ expect(model.models[0].provider).to.be.instanceOf(MixAnthropic);
25
+ expect(model.models[0].provider.options.max_tokens).to.equal(123);
26
+ expect(model.models[0].provider.config.url).to.equal('https://anthropic.example.test');
27
+ });
28
+
29
+ it('should apply max effort thinking via .effort(100).fable50()', () => {
30
+ const model = ModelMix.new().effort(100).fable50();
17
31
  const { applyUnifiedEffort } = require('../effort.js');
18
32
 
19
33
  expect(model.config.effort).to.equal(100);
@@ -25,15 +39,29 @@ describe('Anthropic Model Registration Tests', () => {
25
39
 
26
40
  it('should register Claude Opus 5', () => {
27
41
  const model = ModelMix.new();
28
- model.opus5();
42
+ model.opus50();
29
43
 
30
44
  expect(model.models).to.have.length(1);
31
45
  expect(model.models[0].key).to.equal('claude-opus-5');
32
46
  expect(model.models[0].provider).to.be.instanceOf(MixAnthropic);
33
47
  });
34
48
 
35
- it('should apply max effort thinking via .effort(100).opus5()', () => {
36
- const model = ModelMix.new().effort(100).opus5();
49
+ it('should keep opus5() as an alias for opus50()', () => {
50
+ const model = ModelMix.new();
51
+
52
+ expect(model.opus5({
53
+ options: { max_tokens: 123 },
54
+ config: { url: 'https://anthropic.example.test' }
55
+ })).to.equal(model);
56
+ expect(model.models).to.have.length(1);
57
+ expect(model.models[0].key).to.equal('claude-opus-5');
58
+ expect(model.models[0].provider).to.be.instanceOf(MixAnthropic);
59
+ expect(model.models[0].provider.options.max_tokens).to.equal(123);
60
+ expect(model.models[0].provider.config.url).to.equal('https://anthropic.example.test');
61
+ });
62
+
63
+ it('should apply max effort thinking via .effort(100).opus50()', () => {
64
+ const model = ModelMix.new().effort(100).opus50();
37
65
  const { applyUnifiedEffort } = require('../effort.js');
38
66
 
39
67
  expect(model.config.effort).to.equal(100);
@@ -133,6 +161,101 @@ describe('Anthropic Model Registration Tests', () => {
133
161
  });
134
162
  });
135
163
 
164
+ describe('Provider-neutral prompt caching', () => {
165
+ it('should translate neutral breakpoints and remove foreign OpenAI controls', async () => {
166
+ const originalApiKey = process.env.ANTHROPIC_API_KEY;
167
+ process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
168
+
169
+ try {
170
+ const provider = new MixAnthropic();
171
+ let requestBody;
172
+ nock('https://api.anthropic.com')
173
+ .post('/v1/messages', body => {
174
+ requestBody = body;
175
+ return true;
176
+ })
177
+ .reply(200, {
178
+ content: [{ type: 'text', text: 'Done' }],
179
+ usage: { input_tokens: 1, output_tokens: 1 }
180
+ });
181
+
182
+ await provider.create({
183
+ config: { system: 'You are an assistant.' },
184
+ options: {
185
+ model: 'claude-haiku-4-5-20251001',
186
+ max_tokens: 16,
187
+ cache_control: { type: 'ephemeral', ttl: '1h' },
188
+ prompt_cache_key: 'openai-only',
189
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' },
190
+ messages: [{
191
+ role: 'user',
192
+ content: [
193
+ { type: 'text', text: 'Stable', cache: { breakpoint: true } },
194
+ {
195
+ type: 'text',
196
+ text: 'Variable',
197
+ prompt_cache_breakpoint: { mode: 'explicit' }
198
+ }
199
+ ]
200
+ }]
201
+ }
202
+ });
203
+
204
+ expect(requestBody).to.not.have.property('prompt_cache_key');
205
+ expect(requestBody).to.not.have.property('prompt_cache_options');
206
+ expect(requestBody).to.not.have.property('cache_control');
207
+ expect(requestBody.messages[0].content[0]).to.deep.equal({
208
+ type: 'text',
209
+ text: 'Stable',
210
+ cache_control: { type: 'ephemeral', ttl: '1h' }
211
+ });
212
+ expect(requestBody.messages[0].content[1]).to.deep.equal({
213
+ type: 'text',
214
+ text: 'Variable'
215
+ });
216
+ } finally {
217
+ if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY;
218
+ else process.env.ANTHROPIC_API_KEY = originalApiKey;
219
+ nock.cleanAll();
220
+ }
221
+ });
222
+
223
+ it('should preserve top-level automatic caching when there is no explicit breakpoint', async () => {
224
+ const originalApiKey = process.env.ANTHROPIC_API_KEY;
225
+ process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
226
+
227
+ try {
228
+ const provider = new MixAnthropic();
229
+ let requestBody;
230
+ nock('https://api.anthropic.com')
231
+ .post('/v1/messages', body => {
232
+ requestBody = body;
233
+ return true;
234
+ })
235
+ .reply(200, {
236
+ content: [{ type: 'text', text: 'Done' }],
237
+ usage: { input_tokens: 1, output_tokens: 1 }
238
+ });
239
+
240
+ await provider.create({
241
+ config: { system: 'You are an assistant.' },
242
+ options: {
243
+ model: 'claude-haiku-4-5-20251001',
244
+ max_tokens: 16,
245
+ cache_control: { type: 'ephemeral' },
246
+ messages: [{ role: 'user', content: 'Hello' }]
247
+ }
248
+ });
249
+
250
+ expect(requestBody.cache_control).to.deep.equal({ type: 'ephemeral' });
251
+ } finally {
252
+ if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY;
253
+ else process.env.ANTHROPIC_API_KEY = originalApiKey;
254
+ nock.cleanAll();
255
+ }
256
+ });
257
+ });
258
+
136
259
  it('should register Claude Opus 4.8', () => {
137
260
  const model = ModelMix.new();
138
261
  model.opus48();
@@ -154,15 +277,29 @@ describe('Anthropic Model Registration Tests', () => {
154
277
 
155
278
  it('should register Claude Sonnet 5', () => {
156
279
  const model = ModelMix.new();
157
- model.sonnet5();
280
+ model.sonnet50();
281
+
282
+ expect(model.models).to.have.length(1);
283
+ expect(model.models[0].key).to.equal('claude-sonnet-5');
284
+ expect(model.models[0].provider).to.be.instanceOf(MixAnthropic);
285
+ });
286
+
287
+ it('should keep sonnet5() as an alias for sonnet50()', () => {
288
+ const model = ModelMix.new();
158
289
 
290
+ expect(model.sonnet5({
291
+ options: { max_tokens: 123 },
292
+ config: { url: 'https://anthropic.example.test' }
293
+ })).to.equal(model);
159
294
  expect(model.models).to.have.length(1);
160
295
  expect(model.models[0].key).to.equal('claude-sonnet-5');
161
296
  expect(model.models[0].provider).to.be.instanceOf(MixAnthropic);
297
+ expect(model.models[0].provider.options.max_tokens).to.equal(123);
298
+ expect(model.models[0].provider.config.url).to.equal('https://anthropic.example.test');
162
299
  });
163
300
 
164
- it('should apply adaptive thinking via .effort(100).sonnet5()', () => {
165
- const model = ModelMix.new().effort(100).sonnet5();
301
+ it('should apply adaptive thinking via .effort(100).sonnet50()', () => {
302
+ const model = ModelMix.new().effort(100).sonnet50();
166
303
  const { applyUnifiedEffort } = require('../effort.js');
167
304
 
168
305
  const options = { model: 'claude-sonnet-5' };
@@ -336,8 +336,8 @@ describe('Unified effort scale', () => {
336
336
  expect(request.reasoning).to.deep.equal({ effort: 'none' });
337
337
  });
338
338
 
339
- it('Anthropic config.effort maps through .effort().opus5()', () => {
340
- const model = ModelMix.new().effort(100).opus5();
339
+ it('Anthropic config.effort maps through .effort().opus50()', () => {
340
+ const model = ModelMix.new().effort(100).opus50();
341
341
  expect(model.config.effort).to.equal(100);
342
342
 
343
343
  const options = {