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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "4.7.2",
3
+ "version": "5.0.0",
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,12 +122,12 @@ 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
- `gemini3pro()` `gemini3flash()` `gemini36flash()` `gemini35flash()` `gemini25pro()` `gemini25flash()`
130
+ `gemini3pro()` `gemini3flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
131
131
 
132
132
  ### Grok
133
133
  `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
@@ -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,37 @@ 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
368
  model.replace({
329
- '{role}': 'data analyst',
330
- '{language}': 'Spanish'
369
+ role: 'data analyst',
370
+ language: 'Spanish'
331
371
  });
332
- model.replaceKeyFromFile('{code}', './src/utils.js');
372
+ model.replaceKeyFromFile('code', './src/utils.js');
333
373
  console.log(await model.message());
334
374
  ```
335
375
 
376
+ 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 `replace()` data. Relative includes work in templates loaded from files.
377
+
378
+ Use ModelMix choice directives for random prompt variants:
379
+
380
+ ```ejs
381
+ <% choice %>
382
+ <% option 20 %>
383
+ Use emojis.
384
+ <% option 40 %>
385
+ Use few emojis.
386
+ <% option 40 %>
387
+ Do not use emojis.
388
+ <% /choice %>
389
+ ```
390
+
391
+ 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.
392
+
336
393
  ### Round-robin load balancing
337
394
 
338
395
  ```javascript
@@ -464,27 +521,27 @@ const model = ModelMix.new({
464
521
  - When using MCP tools or `addTool()`, set `max_history` to at least 3 — tool call/response pairs consume history slots.
465
522
  - Use `.json()` for structured output instead of parsing text manually. Use descriptor objects `{ description, required, enum, default, nullable }` for richer schema control.
466
523
  - 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.
524
+ - 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.
525
+ - Templates use EJS syntax in both system prompts and user messages; prefer `<%- key %>` for raw prompt data.
469
526
  - The library uses CommonJS internally but supports ESM import via `{ ModelMix }`.
470
527
  - GPT-5+ models automatically use `max_completion_tokens` instead of `max_tokens`.
471
528
  - o-series models (o3, o4mini) automatically strip `max_tokens` and `temperature` since those APIs don't support them.
472
529
  - 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"`).
530
+ - `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
531
 
475
532
  ## API Quick Reference
476
533
 
477
534
  | Method | Returns | Description |
478
535
  | --- | --- | --- |
479
- | `.addText(text, {role?})` | `this` | Add user message |
480
- | `.addTextFromFile(path, {role?})` | `this` | Add user message from file |
536
+ | `.addText(text, {role?, cache?})` | `this` | Add user message |
537
+ | `.addTextFromFile(path, {role?, cache?})` | `this` | Add user message from file |
481
538
  | `.setSystem(text)` | `this` | Set system prompt |
482
539
  | `.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 |
540
+ | `.addImage(path, {role?, cache?})` | `this` | Add image from file |
541
+ | `.addImageFromUrl(url, {role?, cache?})` | `this` | Add image from URL or data URI |
542
+ | `.addImageFromBuffer(buffer, {role?, cache?})` | `this` | Add image from Buffer |
543
+ | `.replace({})` | `this` | Add EJS template data |
544
+ | `.replaceKeyFromFile(key, path)` | `this` | Add raw file content as EJS template data |
488
545
  | `.message()` | `Promise<string>` | Get text response |
489
546
  | `.json(example, desc?, opts?)` | `Promise<object\|array>` | Get structured JSON |
490
547
  | `.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 = {
@@ -1,7 +1,9 @@
1
1
  const { expect } = require('chai');
2
2
  const sinon = require('sinon');
3
3
  const nock = require('nock');
4
- const { ModelMix } = require('../index.js');
4
+ const { EventEmitter } = require('events');
5
+ const Module = require('module');
6
+ const { MixCustom, MixGoogle, ModelMix } = require('../index.js');
5
7
 
6
8
  describe('Provider Fallback Chain Tests', () => {
7
9
 
@@ -67,6 +69,97 @@ describe('Provider Fallback Chain Tests', () => {
67
69
  expect(response).to.include('Hello from Claude Sonnet 4!');
68
70
  });
69
71
 
72
+ it('should translate neutral cache breakpoints independently across providers', async () => {
73
+ let openAIRequest;
74
+ let anthropicRequest;
75
+ model
76
+ .gpt56luna({
77
+ options: {
78
+ prompt_cache_key: 'shared-prefix',
79
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
80
+ }
81
+ })
82
+ .haiku45({
83
+ options: {
84
+ cache_control: { type: 'ephemeral', ttl: '1h' }
85
+ }
86
+ })
87
+ .addText('Stable prefix', { cache: { breakpoint: true } })
88
+ .addText('Variable request');
89
+
90
+ nock('https://api.openai.com')
91
+ .post('/v1/responses', body => {
92
+ openAIRequest = body;
93
+ return true;
94
+ })
95
+ .reply(500, { error: 'Server error' });
96
+
97
+ nock('https://api.anthropic.com')
98
+ .post('/v1/messages', body => {
99
+ anthropicRequest = body;
100
+ return true;
101
+ })
102
+ .reply(200, {
103
+ content: [{ type: 'text', text: 'Cached fallback response' }],
104
+ usage: { input_tokens: 2, output_tokens: 3 }
105
+ });
106
+
107
+ const response = await model.message();
108
+
109
+ expect(response).to.equal('Cached fallback response');
110
+ expect(openAIRequest.prompt_cache_options).to.deep.equal({ mode: 'explicit', ttl: '30m' });
111
+ expect(openAIRequest.input[1].content[0].prompt_cache_breakpoint).to.deep.equal({ mode: 'explicit' });
112
+ expect(openAIRequest.input[1].content[0]).to.not.have.property('cache');
113
+ expect(anthropicRequest).to.not.have.property('prompt_cache_options');
114
+ expect(anthropicRequest).to.not.have.property('prompt_cache_key');
115
+ expect(anthropicRequest).to.not.have.property('cache_control');
116
+ expect(anthropicRequest.messages[0].content[0]).to.deep.equal({
117
+ type: 'text',
118
+ text: 'Stable prefix',
119
+ cache_control: { type: 'ephemeral', ttl: '1h' }
120
+ });
121
+ expect(anthropicRequest.messages[0].content[1]).to.deep.equal({
122
+ type: 'text',
123
+ text: 'Variable request'
124
+ });
125
+ });
126
+
127
+ it('should preserve neutral cache metadata after an older OpenAI fallback attempt', async () => {
128
+ let openAIRequest;
129
+ let anthropicRequest;
130
+ model
131
+ .gpt5mini()
132
+ .haiku45({ options: { cache_control: { type: 'ephemeral' } } })
133
+ .addText('Stable prefix', { cache: { breakpoint: true } })
134
+ .addText('Variable request');
135
+
136
+ nock('https://api.openai.com')
137
+ .post('/v1/chat/completions', body => {
138
+ openAIRequest = body;
139
+ return true;
140
+ })
141
+ .reply(500, { error: 'Server error' });
142
+
143
+ nock('https://api.anthropic.com')
144
+ .post('/v1/messages', body => {
145
+ anthropicRequest = body;
146
+ return true;
147
+ })
148
+ .reply(200, {
149
+ content: [{ type: 'text', text: 'Fallback preserved cache' }],
150
+ usage: { input_tokens: 2, output_tokens: 3 }
151
+ });
152
+
153
+ const response = await model.message();
154
+
155
+ expect(response).to.equal('Fallback preserved cache');
156
+ expect(openAIRequest.messages[1].content[0]).to.not.have.property('cache');
157
+ expect(openAIRequest.messages[1].content[0]).to.not.have.property('cache_control');
158
+ expect(anthropicRequest.messages[0].content[0].cache_control).to.deep.equal({
159
+ type: 'ephemeral'
160
+ });
161
+ });
162
+
70
163
  it('should cascade through multiple fallbacks', async () => {
71
164
  model.gpt5mini().sonnet46().gemini3flash().addText('Hello');
72
165
 
@@ -440,6 +533,103 @@ describe('Provider Fallback Chain Tests', () => {
440
533
  });
441
534
  });
442
535
 
536
+ it('should not expose provider configuration or options in errors', () => {
537
+ const provider = new MixCustom({
538
+ config: { apiKey: 'secret-test-key' },
539
+ options: { user: 'private-user-id' }
540
+ });
541
+
542
+ const error = provider.handleError(new Error('Request failed'));
543
+
544
+ expect(error).to.not.have.property('config');
545
+ expect(error).to.not.have.property('options');
546
+ expect(JSON.stringify(error)).to.not.include('secret-test-key');
547
+ expect(JSON.stringify(error)).to.not.include('private-user-id');
548
+ });
549
+
550
+ it('should redact Gemini API keys from HTTP errors', async () => {
551
+ const apiKey = 'secret-test-key';
552
+ const provider = new MixGoogle({ config: { apiKey } });
553
+
554
+ nock('https://generativelanguage.googleapis.com')
555
+ .post('/v1beta/models/gemini-test:generateContent')
556
+ .query({ key: apiKey })
557
+ .reply(401, {
558
+ error: 'Unauthorized',
559
+ requestUrl: `https://example.test?key=${apiKey}`
560
+ });
561
+
562
+ let error;
563
+ try {
564
+ await provider.create({
565
+ config: { system: 'Test system' },
566
+ options: { model: 'gemini-test', messages: [] }
567
+ });
568
+ } catch (caught) {
569
+ error = caught;
570
+ }
571
+
572
+ expect(error).to.exist;
573
+ expect(error.message).to.not.include(apiKey);
574
+ expect(error.details.requestUrl).to.include('[REDACTED]');
575
+ expect(error.stack).to.not.include(apiKey);
576
+ expect(JSON.stringify(error)).to.not.include(apiKey);
577
+ });
578
+
579
+ it('should not expose config or options when realtime closes early', async () => {
580
+ class ClosingWebSocket extends EventEmitter {
581
+ constructor() {
582
+ super();
583
+ queueMicrotask(() => this.emit('close'));
584
+ }
585
+
586
+ close() {}
587
+ send() {}
588
+ }
589
+
590
+ const indexPath = require.resolve('../index.js');
591
+ const cachedIndex = require.cache[indexPath];
592
+ const originalLoad = Module._load;
593
+ let RealtimeProvider;
594
+ try {
595
+ delete require.cache[indexPath];
596
+ Module._load = function (request, parent, isMain) {
597
+ if (request === 'ws') return ClosingWebSocket;
598
+ return originalLoad.call(this, request, parent, isMain);
599
+ };
600
+ ({ MixOpenAIWebSocket: RealtimeProvider } = require('../index.js'));
601
+ } finally {
602
+ Module._load = originalLoad;
603
+ delete require.cache[indexPath];
604
+ if (cachedIndex) require.cache[indexPath] = cachedIndex;
605
+ }
606
+
607
+ const apiKey = 'secret-realtime-key';
608
+ const provider = new RealtimeProvider({ config: { apiKey } });
609
+ let error;
610
+ try {
611
+ await provider.create({
612
+ config: { system: 'private system prompt' },
613
+ options: {
614
+ model: 'gpt-realtime-mini',
615
+ messages: [{ role: 'user', content: 'private prompt' }]
616
+ }
617
+ });
618
+ } catch (caught) {
619
+ error = caught;
620
+ }
621
+
622
+ expect(error).to.deep.equal({
623
+ message: 'Realtime WebSocket closed before response.done',
624
+ statusCode: null,
625
+ details: null
626
+ });
627
+ const serialized = JSON.stringify(error);
628
+ expect(serialized).to.not.include(apiKey);
629
+ expect(serialized).to.not.include('private system prompt');
630
+ expect(serialized).to.not.include('private prompt');
631
+ });
632
+
443
633
  it('should provide detailed error information when all fallbacks fail', async () => {
444
634
  model.gpt5mini().sonnet46().gemini3flash().addText('Test');
445
635
 
@@ -488,4 +678,4 @@ describe('Provider Fallback Chain Tests', () => {
488
678
  expect(response).to.include('Valid fallback response from Claude');
489
679
  });
490
680
  });
491
- });
681
+ });
@@ -0,0 +1,4 @@
1
+ Your account details:
2
+ - Username: <%- username %>
3
+ - Role: <%- role %>
4
+ - Created: <%- createdDate %>
@@ -0,0 +1,6 @@
1
+ <% choice %>
2
+ <% option %>
3
+ Be detailed.
4
+ <% option %>
5
+ Be concise.
6
+ <% /choice %>
@@ -0,0 +1,2 @@
1
+ Style:
2
+ <%- include('choice-options.txt') %>
@@ -0,0 +1 @@
1
+ Always respond in <%- language %>.