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/README.md +210 -88
- package/demo/demo.js +4 -4
- package/demo/prompt.md +2 -2
- package/http-client.js +15 -2
- package/index.d.ts +101 -11
- package/index.js +899 -272
- package/package.json +4 -1
- package/skills/modelmix/SKILL.md +76 -19
- package/test/README.md +10 -9
- package/test/anthropic.test.js +146 -9
- package/test/effort.test.js +2 -2
- package/test/fallback.test.js +192 -2
- package/test/fixtures/account-details.txt +4 -0
- package/test/fixtures/choice-options.txt +6 -0
- package/test/fixtures/choice-template.txt +2 -0
- package/test/fixtures/system-rules.txt +1 -0
- package/test/fixtures/system-template.txt +2 -0
- package/test/fixtures/template.txt +4 -11
- package/test/grok.test.js +28 -1
- package/test/history.test.js +2 -2
- package/test/live.mcp.js +20 -20
- package/test/live.test.js +11 -11
- package/test/templates.test.js +471 -281
- package/test/tokens.test.js +414 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelmix",
|
|
3
|
-
"version": "
|
|
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"
|
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -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
|
-
- [
|
|
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
|
-
`
|
|
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
|
-
// {
|
|
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
|
-
###
|
|
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
|
-
|
|
330
|
-
|
|
369
|
+
role: 'data analyst',
|
|
370
|
+
language: 'Spanish'
|
|
331
371
|
});
|
|
332
|
-
model.replaceKeyFromFile('
|
|
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).
|
|
468
|
-
-
|
|
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()`
|
|
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` |
|
|
487
|
-
| `.replaceKeyFromFile(key, path)` | `this` |
|
|
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
|
-
-
|
|
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
|
-
- ✅
|
|
50
|
-
- ✅
|
|
51
|
-
- ✅
|
|
52
|
-
- ✅
|
|
53
|
-
- ✅
|
|
54
|
-
- ✅
|
|
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
|
package/test/anthropic.test.js
CHANGED
|
@@ -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.
|
|
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
|
|
16
|
-
const model = ModelMix.new()
|
|
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.
|
|
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
|
|
36
|
-
const model = ModelMix.new()
|
|
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.
|
|
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).
|
|
165
|
-
const model = ModelMix.new().effort(100).
|
|
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' };
|
package/test/effort.test.js
CHANGED
|
@@ -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().
|
|
340
|
-
const model = ModelMix.new().effort(100).
|
|
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 = {
|
package/test/fallback.test.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
const { expect } = require('chai');
|
|
2
2
|
const sinon = require('sinon');
|
|
3
3
|
const nock = require('nock');
|
|
4
|
-
const {
|
|
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 @@
|
|
|
1
|
+
Always respond in <%- language %>.
|