modelmix 5.1.20 → 5.2.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/.gitignore +138 -0
- package/README.md +69 -2
- package/demo/benchmark.js +83 -0
- package/demo/package.json +2 -0
- package/demo/prompts/story.txt +35 -0
- package/demo/prompts/template-engine.txt +41 -0
- package/demo/short.js +2 -0
- package/effort.js +3 -1
- package/index.d.ts +9 -5
- package/index.js +75 -12
- package/lib/model-chain.js +1 -1
- package/lib/parse-json-response.js +14 -0
- package/lib/providers/anthropic.js +3 -1
- package/lib/providers/base.js +65 -17
- package/lib/providers/google.js +13 -2
- package/lib/providers/openai-compatible.js +36 -0
- package/lib/providers/openai.js +52 -8
- package/lib/token-usage.js +5 -0
- package/package.json +5 -2
- package/plugins/benchmark/index.d.ts +112 -0
- package/plugins/benchmark/index.js +575 -0
- package/plugins/benchmark/test/benchmark.test.js +518 -0
- package/plugins/skills/index.d.ts +9 -0
- package/plugins/skills/index.js +107 -0
- package/plugins/skills/test/skills.test.js +182 -0
- package/pnpm-workspace.yaml +2 -2
- package/skills/modelmix/SKILL.md +32 -2
- package/test/deepseek.test.js +273 -1
- package/test/fallback.test.js +57 -0
- package/test/google.test.js +55 -0
- package/test/history.test.js +7 -4
- package/test/json.test.js +29 -1
- package/test/plugins.test.js +150 -1
- package/test/public-api.test.js +2 -0
- package/test/tokens.test.js +75 -3
- package/RLM_PLUGIN_SPEC.md +0 -465
- package/demo/package-lock.json +0 -516
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { ModelMix, MixCustom } = require('../../..');
|
|
6
|
+
const { skills } = require('..');
|
|
7
|
+
|
|
8
|
+
describe('Skills plugin', () => {
|
|
9
|
+
let temporary;
|
|
10
|
+
let directory;
|
|
11
|
+
const source = '---\nname: writing\ndescription: >-\n Write clear prose\n for readers.\nmetadata:\n category: editorial\n---\nUse references/style.md. Preserve <%= literal %> and ${text}.\n';
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'modelmix-skills-'));
|
|
15
|
+
directory = path.join(temporary, 'writing');
|
|
16
|
+
await fs.mkdir(path.join(directory, 'references'), { recursive: true });
|
|
17
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), source);
|
|
18
|
+
await fs.writeFile(path.join(directory, 'references/style.md'), 'Use concrete verbs.');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.rm(temporary, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function prepare(plugin) {
|
|
26
|
+
const request = { system: 'Existing system', tools: [] };
|
|
27
|
+
await plugin.execute({ request }, async () => ({ message: 'ok' }));
|
|
28
|
+
return request;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it('loads only metadata into the prompt and exposes literal skill content on demand', async () => {
|
|
32
|
+
const plugin = await skills({ paths: [directory] });
|
|
33
|
+
const request = await prepare(plugin);
|
|
34
|
+
assert.ok(request.system.startsWith('Existing system\n\n'));
|
|
35
|
+
assert.ok(request.system.includes('Write clear prose for readers.'));
|
|
36
|
+
assert.ok(!request.system.includes('Preserve <%= literal %>'));
|
|
37
|
+
const result = await request.tools[0].callback({ name: 'writing' });
|
|
38
|
+
assert.deepEqual(result, { name: 'writing', path: 'SKILL.md', content: source });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('supports SKILL.md paths and reads references relative to the skill root', async () => {
|
|
42
|
+
const request = await prepare(await skills({ paths: [path.join(directory, 'SKILL.md')] }));
|
|
43
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: 'references/style.md' }), {
|
|
44
|
+
name: 'writing', path: 'references/style.md', content: 'Use concrete verbs.'
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns the loaded snapshot for every path that resolves to SKILL.md', async () => {
|
|
49
|
+
await fs.symlink(path.join(directory, 'SKILL.md'), path.join(directory, 'alias.md'));
|
|
50
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
51
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), '---\nname: writing\ndescription: Changed\n---\nChanged body.\n');
|
|
52
|
+
for (const relative of ['./SKILL.md', 'references/../SKILL.md', 'alias.md']) {
|
|
53
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: relative }), {
|
|
54
|
+
name: 'writing', path: relative, content: source
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reads supporting files from disk on every call', async () => {
|
|
60
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
61
|
+
await fs.writeFile(path.join(directory, 'references/style.md'), 'Use strong verbs.');
|
|
62
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: 'references/style.md' }), {
|
|
63
|
+
name: 'writing', path: 'references/style.md', content: 'Use strong verbs.'
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns the snapshot for SKILL.md aliases without decoding the changed file', async () => {
|
|
68
|
+
await fs.symlink(path.join(directory, 'SKILL.md'), path.join(directory, 'alias.md'));
|
|
69
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
70
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), Buffer.from([0, 255, 128]));
|
|
71
|
+
for (const relative of ['./SKILL.md', 'alias.md']) {
|
|
72
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: relative }), {
|
|
73
|
+
name: 'writing', path: relative, content: source
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('runs the complete tool loop without re-rendering skill text or changing instance configuration', async () => {
|
|
79
|
+
const requests = [];
|
|
80
|
+
const provider = new MixCustom();
|
|
81
|
+
provider.create = async request => {
|
|
82
|
+
requests.push(request);
|
|
83
|
+
if (requests.length === 1) return {
|
|
84
|
+
message: '', toolCalls: [{ id: 'skill', name: 'read_skill', input: { name: 'writing' } }]
|
|
85
|
+
};
|
|
86
|
+
if (requests.length === 2) return {
|
|
87
|
+
message: '', toolCalls: [{ id: 'reference', name: 'read_skill', input: { name: 'writing', path: 'references/style.md' } }]
|
|
88
|
+
};
|
|
89
|
+
return { message: '{"answer":"Concrete verbs"}', toolCalls: [] };
|
|
90
|
+
};
|
|
91
|
+
const model = ModelMix.new({ config: { system: 'Be concise.' } })
|
|
92
|
+
.attach('custom', provider).use(await skills({ paths: [directory] })).addText('Use writing.');
|
|
93
|
+
assert.deepEqual(await model.json(), { answer: 'Concrete verbs' });
|
|
94
|
+
assert.equal(requests.length, 3);
|
|
95
|
+
for (const request of requests) {
|
|
96
|
+
assert.equal(request.config.system.split('Available skills:').length, 2);
|
|
97
|
+
assert.equal(request.options.tools[0].function.name, 'read_skill');
|
|
98
|
+
}
|
|
99
|
+
const outputs = requests[2].options.messages.filter(message => message.role === 'tool').map(message => JSON.parse(message.content));
|
|
100
|
+
assert.equal(outputs[0].content, source);
|
|
101
|
+
assert.equal(outputs[1].content, 'Use concrete verbs.');
|
|
102
|
+
assert.equal(model.config.max_history, 0);
|
|
103
|
+
assert.equal(model.config.system, 'Be concise.');
|
|
104
|
+
assert.deepEqual(model.tools, {});
|
|
105
|
+
assert.deepEqual(model.messages, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('supports inherited plugins without leaking tools into sibling or later requests', async () => {
|
|
109
|
+
const provider = new MixCustom();
|
|
110
|
+
const requests = [];
|
|
111
|
+
provider.create = async request => {
|
|
112
|
+
requests.push(request);
|
|
113
|
+
return { message: 'ok', toolCalls: [] };
|
|
114
|
+
};
|
|
115
|
+
const model = ModelMix.new().attach('custom', provider).use(await skills({ paths: [directory] }));
|
|
116
|
+
await model.new().addText('child').message();
|
|
117
|
+
await model.addText('first').message();
|
|
118
|
+
await model.addText('second').message();
|
|
119
|
+
await ModelMix.new().attach('custom', provider).addText('sibling').message();
|
|
120
|
+
for (const request of requests.slice(0, 3)) assert.equal(request.options.tools.length, 1);
|
|
121
|
+
assert.equal(requests[3].options.tools, undefined);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects missing files, missing metadata, malformed YAML, and duplicate names', async () => {
|
|
125
|
+
await assert.rejects(skills({ paths: [path.join(temporary, 'missing')] }), /ENOENT/);
|
|
126
|
+
for (const content of ['No frontmatter', '---\nname: writing\n---\nBody', '---\nname: [broken\n---\nBody', '---\nname: writing\nname: duplicate\ndescription: text\n---\nBody']) {
|
|
127
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), content);
|
|
128
|
+
await assert.rejects(skills({ paths: [directory] }));
|
|
129
|
+
}
|
|
130
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), source);
|
|
131
|
+
await assert.rejects(skills({ paths: [directory, directory] }), /Duplicate skill name/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('validates configuration and tool arguments', async () => {
|
|
135
|
+
for (const options of [undefined, {}, { paths: [] }, { paths: [''] }, { paths: 'directory' }]) {
|
|
136
|
+
await assert.rejects(skills(options), /paths must be/);
|
|
137
|
+
}
|
|
138
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
139
|
+
const read = request.tools[0].callback;
|
|
140
|
+
await assert.rejects(read({ name: 'missing' }), /Unknown skill/);
|
|
141
|
+
await assert.rejects(read({ name: 'writing', extra: true }), /expects a name/);
|
|
142
|
+
for (const value of ['', null, 42, directory]) {
|
|
143
|
+
await assert.rejects(read({ name: 'writing', path: value }), /relative path/);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('blocks traversal, prefix siblings, and symlinks outside the skill directory', async () => {
|
|
148
|
+
await fs.writeFile(path.join(temporary, 'outside.md'), 'outside');
|
|
149
|
+
const sibling = path.join(temporary, 'writing-other');
|
|
150
|
+
await fs.mkdir(sibling);
|
|
151
|
+
await fs.writeFile(path.join(sibling, 'file.md'), 'sibling');
|
|
152
|
+
await fs.symlink(path.join(temporary, 'outside.md'), path.join(directory, 'linked.md'));
|
|
153
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
154
|
+
for (const relative of ['../outside.md', '../writing-other/file.md', 'linked.md']) {
|
|
155
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: relative }), /inside the skill directory/);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('blocks SKILL.md symlinks outside the registered directory', async () => {
|
|
160
|
+
await fs.writeFile(path.join(temporary, 'outside.md'), source);
|
|
161
|
+
await fs.unlink(path.join(directory, 'SKILL.md'));
|
|
162
|
+
await fs.symlink(path.join(temporary, 'outside.md'), path.join(directory, 'SKILL.md'));
|
|
163
|
+
await assert.rejects(skills({ paths: [directory] }), /inside the skill directory/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rejects directories and binary resources', async () => {
|
|
167
|
+
await fs.writeFile(path.join(directory, 'binary'), Buffer.from([0, 255, 128]));
|
|
168
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
169
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'references' }), /regular file/);
|
|
170
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'binary' }));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('propagates cancellation before middleware and resource reads', async () => {
|
|
174
|
+
const plugin = await skills({ paths: [directory] });
|
|
175
|
+
const request = await prepare(plugin);
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const reason = new Error('cancelled');
|
|
178
|
+
controller.abort(reason);
|
|
179
|
+
await assert.rejects(plugin.execute({ request, signal: controller.signal }, () => assert.fail('next called')), error => error === reason);
|
|
180
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'references/style.md' }, controller.signal), error => error === reason);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/pnpm-workspace.yaml
CHANGED
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -121,6 +121,27 @@ model.use({
|
|
|
121
121
|
|
|
122
122
|
The optional `@modelmix/rlm` package is a separate workspace/npm package for recursive processing of large structured inputs. Pass Markdown through `documents: { name: { format: 'markdown', content } }`, register named ModelMix worker chains, and provide every runtime limit explicitly. A worker uses either `model: anotherModelMixInstance` or `useParent: true`. Its planner sees content-free variable size/shape metadata, while document values and generated orchestration code stay inside an `isolated-vm` sandbox. RLM planner prompts are Markdown files rendered with the normal child `assign` plus `systemFile` path.
|
|
123
123
|
|
|
124
|
+
### Loading local skills
|
|
125
|
+
|
|
126
|
+
Use the included skills plugin to expose local `SKILL.md` instructions to a model with tool-call support:
|
|
127
|
+
|
|
128
|
+
```javascript
|
|
129
|
+
import { ModelMix } from 'modelmix';
|
|
130
|
+
import { skills } from 'modelmix/plugins/skills/index.js';
|
|
131
|
+
const model = ModelMix.new()
|
|
132
|
+
.gpt6astra()
|
|
133
|
+
.opus5()
|
|
134
|
+
.use(await skills({ paths: ['./skills/writing'] }))
|
|
135
|
+
.addText('Use the writing skill to revise this paragraph: ...');
|
|
136
|
+
const answer = await model.message();
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Paths identify explicit skill directories or `SKILL.md` files relative to the working directory. YAML `name` and `description` must be non-empty strings. The system prompt receives only the catalog; `read_skill({ name })` loads instructions and `read_skill({ name, path })` reads a supporting UTF-8 file inside that skill directory. Instructions are literal, never EJS-rendered. Skill metadata and instructions are snapshots; recreate the plugin to reload them. References are read on demand. The plugin supplies no script execution or automatic permissions from skill metadata.
|
|
140
|
+
|
|
141
|
+
Plugin tools use `context.request.tools.push({ tool, callback })`. They coexist with local/MCP tools, are scoped to the current execution, and survive tool continuations. Duplicate names fail before a provider call; the skills plugin reserves `read_skill`. Child executions receive the tools only when the plugin is inherited or the tools are explicitly passed to `context.invoke()`.
|
|
142
|
+
|
|
143
|
+
With plugin tools, native `options.tools` entries are combined with registered and plugin tools. OpenAI Responses supports this tool loop, including reasoning returned with function calls.
|
|
144
|
+
|
|
124
145
|
### Unified effort
|
|
125
146
|
|
|
126
147
|
Provider-agnostic reasoning intensity. **Not** an `options` field — use `config.effort` or `.effort(n)`.
|
|
@@ -183,14 +204,23 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
|
|
|
183
204
|
### MiniMax
|
|
184
205
|
`minimaxM27()` `minimaxM3()`
|
|
185
206
|
|
|
207
|
+
### DeepSeek
|
|
208
|
+
`deepseekV41Flash({ mix: { deepseek: true, openrouter: false } })` uses the native API at `https://api.deepseek.com/chat/completions` with model `deepseek-flash` (currently V4.1 Flash). Requires `DEEPSEEK_API_KEY`; `MixDeepSeek` supports explicit `.attach()` calls. Unified effort and native cache usage are supported, and assistant reasoning is preserved for tool continuations. Cost estimates use peak rates per 1M tokens: $0.30 input, $0.006 cached input, $1.20 output; off-peak charges are half. Enabling all three providers orders them DeepSeek → Fireworks → OpenRouter.
|
|
209
|
+
|
|
186
210
|
### Fireworks
|
|
187
211
|
`museGlimmer30b()` `gptOss()` `qwen36plus()` (private/on-demand only) `qwen37plus()` `qwen38max()` `deepseekV4Flash()` `deepseekV4Pro()` `kimiK26()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM52()`
|
|
188
212
|
|
|
213
|
+
`deepseekV41Flash({ mix: { fireworks: true, openrouter: false } })` selects `accounts/fireworks/models/deepseek-v4p1-flash` and requires `FIREWORKS_API_KEY`. Per 1M tokens: $0.22 input, $0.007 cached input, $0.66 output. Set both providers to `true` for Fireworks followed by OpenRouter fallback; no arguments selects OpenRouter.
|
|
214
|
+
|
|
189
215
|
### Cerebras
|
|
190
216
|
`GLM46()`
|
|
191
217
|
|
|
192
218
|
### OpenRouter
|
|
193
|
-
`
|
|
219
|
+
`deepseekPro()` uses `deepseek/deepseek-v4-pro-0813` through OpenRouter. Requires `OPENROUTER_API_KEY`; supports the DeepSeek effort mapping and `chain('deepseekPro@100')`. Base cost estimates per 1M tokens: $0.5808 input, $0.05808 cached input, $1.7424 output. Actual rates may change, including provider and time-based pricing.
|
|
220
|
+
|
|
221
|
+
`museGlimmer30b()` `museSpark12()` `museSpark12c()` `museSpark13()` `museSpark13c()` `gptOss()` `qwen35397b()` `qwen36plus()` `qwen37plus()` `qwen3827b()` `qwen38flash()` `hermes470b()` `hermes4405b()` `qwen38max()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM45()` `GLM52()` `GLM53()` `GLM53Flash()` `deepseekV41Flash()`
|
|
222
|
+
|
|
223
|
+
`deepseekV41Flash()` selects `deepseek/deepseek-v4.1-flash` (text and image input) and requires `OPENROUTER_API_KEY`. It supports the DeepSeek effort mapping above, including `chain('deepseekV41Flash@100')`. Base cost estimates per 1M tokens: $0.15 input, $0.015 cached input, $0.60 output; actual OpenRouter pricing varies by provider and time.
|
|
194
224
|
|
|
195
225
|
Muse Spark: the `c` suffix selects Contributor, where prompts and outputs may be used to improve Meta products. Without `c`, the standard tier is selected. Use `museSpark12c()` for the former `museSpark12()` Contributor behavior.
|
|
196
226
|
|
|
@@ -651,7 +681,7 @@ const model = ModelMix.new({
|
|
|
651
681
|
|
|
652
682
|
## Available Provider Classes
|
|
653
683
|
|
|
654
|
-
`ModerationMix` `MixModeration` `MixOpenAI` `MixOpenAIResponses` `MixOpenAIModeration` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
|
|
684
|
+
`ModerationMix` `MixModeration` `MixOpenAI` `MixOpenAIResponses` `MixOpenAIModeration` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixDeepSeek` `MixLambda`
|
|
655
685
|
|
|
656
686
|
## Troubleshooting
|
|
657
687
|
|
package/test/deepseek.test.js
CHANGED
|
@@ -1,7 +1,279 @@
|
|
|
1
1
|
const { expect } = require('chai');
|
|
2
|
-
const
|
|
2
|
+
const nock = require('nock');
|
|
3
|
+
const { ModelMix, MixDeepSeek, MixFireworks, MixOpenRouter } = require('../index.js');
|
|
3
4
|
|
|
4
5
|
describe('DeepSeek Model Registration Tests', () => {
|
|
6
|
+
it('registers V4 Pro 0813 through OpenRouter and preserves caller settings', () => {
|
|
7
|
+
const model = ModelMix.new();
|
|
8
|
+
expect(model.deepseekPro({
|
|
9
|
+
options: { temperature: 0.5 },
|
|
10
|
+
config: { apiKey: 'test-key', effort: 100 }
|
|
11
|
+
})).to.equal(model);
|
|
12
|
+
|
|
13
|
+
expect(model.models).to.have.length(1);
|
|
14
|
+
expect(model.models[0].key).to.equal('deepseek/deepseek-v4-pro-0813');
|
|
15
|
+
expect(model.models[0].provider).to.be.instanceOf(MixOpenRouter);
|
|
16
|
+
expect(model.models[0].provider.options.temperature).to.equal(0.5);
|
|
17
|
+
expect(model.models[0].provider.config.effort).to.equal(100);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('DeepSeek V4 Pro 0813 requests', () => {
|
|
21
|
+
afterEach(() => nock.cleanAll());
|
|
22
|
+
|
|
23
|
+
for (const [effort, level] of [[0, undefined], [20, 'low'], [60, 'high'], [100, 'max'], [-1, undefined]]) {
|
|
24
|
+
it(`sends the exact model ID through chain() at effort ${effort} and accounts for cached input`, async () => {
|
|
25
|
+
const scope = nock('https://openrouter.ai')
|
|
26
|
+
.post('/api/v1/chat/completions', body => {
|
|
27
|
+
expect(body.model).to.equal('deepseek/deepseek-v4-pro-0813');
|
|
28
|
+
expect(body.reasoning_effort).to.equal(level);
|
|
29
|
+
expect(body.thinking).to.deep.equal(effort === -1
|
|
30
|
+
? undefined
|
|
31
|
+
: { type: effort === 0 ? 'disabled' : 'enabled' });
|
|
32
|
+
return true;
|
|
33
|
+
})
|
|
34
|
+
.reply(200, {
|
|
35
|
+
model: 'deepseek/deepseek-v4-pro-0813',
|
|
36
|
+
choices: [{ message: { role: 'assistant', content: 'ok' } }],
|
|
37
|
+
usage: {
|
|
38
|
+
prompt_tokens: 1000,
|
|
39
|
+
completion_tokens: 100,
|
|
40
|
+
prompt_tokens_details: { cached_tokens: 400 }
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const model = ModelMix.new().chain(`deepseekPro@${effort}`).addText('Hello');
|
|
44
|
+
|
|
45
|
+
expect(await model.message()).to.equal('ok');
|
|
46
|
+
expect(scope.isDone()).to.equal(true);
|
|
47
|
+
expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
|
|
48
|
+
expect(model.lastRaw.tokens.cost).to.be.closeTo(0.000545952, 1e-12);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('requires native credentials and accepts DEEPSEEK_API_KEY', () => {
|
|
54
|
+
const originalApiKey = process.env.DEEPSEEK_API_KEY;
|
|
55
|
+
try {
|
|
56
|
+
delete process.env.DEEPSEEK_API_KEY;
|
|
57
|
+
expect(() => new MixDeepSeek()).to.throw(/DEEPSEEK_API_KEY/);
|
|
58
|
+
process.env.DEEPSEEK_API_KEY = 'native-test-key';
|
|
59
|
+
const model = ModelMix.new().deepseekV41Flash({ mix: { deepseek: true, openrouter: false } });
|
|
60
|
+
expect(model.models).to.have.length(1);
|
|
61
|
+
expect(model.models[0].key).to.equal('deepseek-flash');
|
|
62
|
+
expect(model.models[0].provider.config.apiKey).to.equal('native-test-key');
|
|
63
|
+
} finally {
|
|
64
|
+
if (originalApiKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
65
|
+
else process.env.DEEPSEEK_API_KEY = originalApiKey;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('registers DeepSeek V4.1 Flash through OpenRouter and preserves caller settings', () => {
|
|
70
|
+
const model = ModelMix.new();
|
|
71
|
+
expect(model.deepseekV41Flash({
|
|
72
|
+
options: { temperature: 0.5 },
|
|
73
|
+
config: { effort: 100 }
|
|
74
|
+
})).to.equal(model);
|
|
75
|
+
|
|
76
|
+
expect(model.models).to.have.length(1);
|
|
77
|
+
expect(model.models[0].key).to.equal('deepseek/deepseek-v4.1-flash');
|
|
78
|
+
expect(model.models[0].provider).to.be.instanceOf(MixOpenRouter);
|
|
79
|
+
expect(model.models[0].provider.options.temperature).to.equal(0.5);
|
|
80
|
+
expect(model.models[0].provider.config.effort).to.equal(100);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('DeepSeek V4.1 Flash requests', () => {
|
|
84
|
+
afterEach(() => nock.cleanAll());
|
|
85
|
+
|
|
86
|
+
it('falls back from native DeepSeek through Fireworks to OpenRouter', async () => {
|
|
87
|
+
const native = nock('https://api.deepseek.com')
|
|
88
|
+
.post('/chat/completions', body => body.model === 'deepseek-flash')
|
|
89
|
+
.reply(503, { error: { message: 'Unavailable' } });
|
|
90
|
+
const fireworks = nock('https://api.fireworks.ai')
|
|
91
|
+
.post('/inference/v1/chat/completions')
|
|
92
|
+
.reply(503, { error: { message: 'Unavailable' } });
|
|
93
|
+
const openrouter = nock('https://openrouter.ai')
|
|
94
|
+
.post('/api/v1/chat/completions')
|
|
95
|
+
.reply(200, { choices: [{ message: { content: 'fallback' } }] });
|
|
96
|
+
const model = ModelMix.new({ config: { retry: { retries: 0 } } })
|
|
97
|
+
.deepseekV41Flash({
|
|
98
|
+
mix: { deepseek: true, fireworks: true, openrouter: true },
|
|
99
|
+
config: { apiKey: 'test-key' }
|
|
100
|
+
}).addText('Hello');
|
|
101
|
+
|
|
102
|
+
expect(await model.message()).to.equal('fallback');
|
|
103
|
+
expect(native.isDone()).to.equal(true);
|
|
104
|
+
expect(fireworks.isDone()).to.equal(true);
|
|
105
|
+
expect(openrouter.isDone()).to.equal(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
for (const [effort, level] of [[0, undefined], [20, 'low'], [60, 'high'], [100, 'max'], [-1, undefined]]) {
|
|
109
|
+
it(`sends native DeepSeek effort ${effort} and reads native cache usage`, async () => {
|
|
110
|
+
const scope = nock('https://api.deepseek.com', {
|
|
111
|
+
reqheaders: { authorization: 'Bearer native-test-key' }
|
|
112
|
+
})
|
|
113
|
+
.post('/chat/completions', body => {
|
|
114
|
+
expect(body.model).to.equal('deepseek-flash');
|
|
115
|
+
expect(body.reasoning_effort).to.equal(level);
|
|
116
|
+
expect(body.thinking).to.deep.equal(effort === -1
|
|
117
|
+
? undefined
|
|
118
|
+
: { type: effort === 0 ? 'disabled' : 'enabled' });
|
|
119
|
+
return true;
|
|
120
|
+
})
|
|
121
|
+
.reply(200, {
|
|
122
|
+
choices: [{ message: { role: 'assistant', content: 'ok', reasoning_content: 'Reasoning' } }],
|
|
123
|
+
usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 }
|
|
124
|
+
});
|
|
125
|
+
const model = ModelMix.new().deepseekV41Flash({
|
|
126
|
+
mix: { deepseek: true, openrouter: false },
|
|
127
|
+
config: { apiKey: 'native-test-key', effort }
|
|
128
|
+
}).addText('Hello');
|
|
129
|
+
|
|
130
|
+
expect(model.models).to.have.length(1);
|
|
131
|
+
expect(model.models[0].provider).to.be.instanceOf(MixDeepSeek);
|
|
132
|
+
expect(await model.message()).to.equal('ok');
|
|
133
|
+
expect(scope.isDone()).to.equal(true);
|
|
134
|
+
expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
|
|
135
|
+
expect(model.lastRaw.tokens.cost).to.be.closeTo(0.0003024, 1e-12);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('preserves native reasoning across tool continuation and later turns', async () => {
|
|
140
|
+
const assistantMessage = {
|
|
141
|
+
role: 'assistant', content: null, reasoning_content: 'Use the lookup tool.',
|
|
142
|
+
tool_calls: [{ id: 'call_lookup', type: 'function', function: { name: 'lookup', arguments: '{}' } }]
|
|
143
|
+
};
|
|
144
|
+
const scope = nock('https://api.deepseek.com')
|
|
145
|
+
.post('/chat/completions')
|
|
146
|
+
.reply(200, { choices: [{ message: assistantMessage }] })
|
|
147
|
+
.post('/chat/completions', body => {
|
|
148
|
+
expect(body.messages.find(message => message.role === 'assistant')).to.deep.equal(assistantMessage);
|
|
149
|
+
expect(body.messages.find(message => message.role === 'tool')).to.include({ tool_call_id: 'call_lookup' });
|
|
150
|
+
return true;
|
|
151
|
+
})
|
|
152
|
+
.reply(200, { choices: [{ message: { role: 'assistant', content: 'Found', reasoning_content: 'Lookup complete.' } }] })
|
|
153
|
+
.post('/chat/completions', body => {
|
|
154
|
+
expect(body.messages.filter(message => message.role === 'assistant').map(message => message.reasoning_content))
|
|
155
|
+
.to.deep.equal(['Use the lookup tool.', 'Lookup complete.']);
|
|
156
|
+
return true;
|
|
157
|
+
})
|
|
158
|
+
.reply(200, { choices: [{ message: { role: 'assistant', content: 'Done' } }] });
|
|
159
|
+
const model = ModelMix.new().deepseekV41Flash({
|
|
160
|
+
mix: { deepseek: true, openrouter: false },
|
|
161
|
+
config: { apiKey: 'native-test-key' }
|
|
162
|
+
}).addTool({
|
|
163
|
+
name: 'lookup', description: 'Look up a value.',
|
|
164
|
+
inputSchema: { type: 'object', properties: {} }
|
|
165
|
+
}, () => 'value').addText('Look it up');
|
|
166
|
+
|
|
167
|
+
expect(await model.message()).to.equal('Found');
|
|
168
|
+
model.addText('Continue');
|
|
169
|
+
expect(await model.message()).to.equal('Done');
|
|
170
|
+
expect(scope.isDone()).to.equal(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('streams native text without including empty reasoning or usage deltas', async () => {
|
|
174
|
+
const chunks = [
|
|
175
|
+
{ choices: [{ delta: { reasoning_content: 'Reasoning' } }] },
|
|
176
|
+
{ choices: [{ delta: { content: 'Hello' } }] },
|
|
177
|
+
{ choices: [], usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 } }
|
|
178
|
+
];
|
|
179
|
+
const scope = nock('https://api.deepseek.com')
|
|
180
|
+
.post('/chat/completions', body => body.stream === true)
|
|
181
|
+
.reply(200, chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join('') + 'data: [DONE]\n\n', {
|
|
182
|
+
'content-type': 'text/event-stream'
|
|
183
|
+
});
|
|
184
|
+
const model = ModelMix.new().deepseekV41Flash({
|
|
185
|
+
mix: { deepseek: true, openrouter: false },
|
|
186
|
+
config: { apiKey: 'native-test-key' }
|
|
187
|
+
}).addText('Hello');
|
|
188
|
+
const deltas = [];
|
|
189
|
+
const result = await model.stream(({ delta }) => deltas.push(delta));
|
|
190
|
+
|
|
191
|
+
expect(result.message).to.equal('Hello');
|
|
192
|
+
expect(deltas.join('')).to.equal('Hello');
|
|
193
|
+
expect(result.tokens.cached).to.equal(400);
|
|
194
|
+
expect(scope.isDone()).to.equal(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('uses only Fireworks when selected and accounts for its cached input pricing', async () => {
|
|
198
|
+
const scope = nock('https://api.fireworks.ai')
|
|
199
|
+
.post('/inference/v1/chat/completions', body => {
|
|
200
|
+
expect(body.model).to.equal('accounts/fireworks/models/deepseek-v4p1-flash');
|
|
201
|
+
expect(body.reasoning_effort).to.equal('max');
|
|
202
|
+
expect(body.thinking).to.deep.equal({ type: 'enabled' });
|
|
203
|
+
expect(body.temperature).to.equal(0.5);
|
|
204
|
+
return true;
|
|
205
|
+
})
|
|
206
|
+
.reply(200, {
|
|
207
|
+
choices: [{ message: { content: 'ok' } }],
|
|
208
|
+
usage: {
|
|
209
|
+
prompt_tokens: 1000,
|
|
210
|
+
completion_tokens: 100,
|
|
211
|
+
prompt_tokens_details: { cached_tokens: 400 }
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const model = ModelMix.new().deepseekV41Flash({
|
|
215
|
+
mix: { fireworks: true, openrouter: false },
|
|
216
|
+
config: { effort: 100 },
|
|
217
|
+
options: { temperature: 0.5 }
|
|
218
|
+
}).addText('Hello');
|
|
219
|
+
|
|
220
|
+
expect(model.models).to.have.length(1);
|
|
221
|
+
expect(model.models[0].provider).to.be.instanceOf(MixFireworks);
|
|
222
|
+
expect(await model.message()).to.equal('ok');
|
|
223
|
+
expect(scope.isDone()).to.equal(true);
|
|
224
|
+
expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
|
|
225
|
+
expect(model.lastRaw.tokens.cost).to.be.closeTo(0.0002008, 1e-12);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('falls back from Fireworks to OpenRouter when both are enabled', async () => {
|
|
229
|
+
const fireworks = nock('https://api.fireworks.ai')
|
|
230
|
+
.post('/inference/v1/chat/completions')
|
|
231
|
+
.reply(503, { error: { message: 'Unavailable' } });
|
|
232
|
+
const openrouter = nock('https://openrouter.ai')
|
|
233
|
+
.post('/api/v1/chat/completions', body => body.model === 'deepseek/deepseek-v4.1-flash')
|
|
234
|
+
.reply(200, { choices: [{ message: { content: 'fallback' } }] });
|
|
235
|
+
const model = ModelMix.new({ config: { retry: { retries: 0 } } })
|
|
236
|
+
.deepseekV41Flash({ mix: { fireworks: true, openrouter: true } })
|
|
237
|
+
.addText('Hello');
|
|
238
|
+
|
|
239
|
+
expect(model.models.map(({ key }) => key)).to.deep.equal([
|
|
240
|
+
'accounts/fireworks/models/deepseek-v4p1-flash',
|
|
241
|
+
'deepseek/deepseek-v4.1-flash'
|
|
242
|
+
]);
|
|
243
|
+
expect(await model.message()).to.equal('fallback');
|
|
244
|
+
expect(fireworks.isDone()).to.equal(true);
|
|
245
|
+
expect(openrouter.isDone()).to.equal(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
for (const [effort, level] of [[0, null], [20, 'low'], [60, 'high'], [100, 'max'], [-1, undefined]]) {
|
|
249
|
+
it(`sends chain effort ${effort} and accounts for cached input`, async () => {
|
|
250
|
+
const scope = nock('https://openrouter.ai')
|
|
251
|
+
.post('/api/v1/chat/completions', body => {
|
|
252
|
+
expect(body.model).to.equal('deepseek/deepseek-v4.1-flash');
|
|
253
|
+
expect(body.reasoning_effort).to.equal(level || undefined);
|
|
254
|
+
expect(body.thinking).to.deep.equal(level === undefined
|
|
255
|
+
? undefined
|
|
256
|
+
: { type: level === null ? 'disabled' : 'enabled' });
|
|
257
|
+
return true;
|
|
258
|
+
})
|
|
259
|
+
.reply(200, {
|
|
260
|
+
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
|
|
261
|
+
usage: {
|
|
262
|
+
prompt_tokens: 1000,
|
|
263
|
+
completion_tokens: 100,
|
|
264
|
+
prompt_tokens_details: { cached_tokens: 400 }
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const model = ModelMix.new().chain(`deepseekV41Flash@${effort}`).addText('Hello');
|
|
269
|
+
expect(await model.message()).to.equal('ok');
|
|
270
|
+
expect(scope.isDone()).to.equal(true);
|
|
271
|
+
expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
|
|
272
|
+
expect(model.lastRaw.tokens.cost).to.be.closeTo(0.000156, 1e-12);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
5
277
|
it('should register Fireworks DeepSeek V4 Pro by default', () => {
|
|
6
278
|
const model = ModelMix.new();
|
|
7
279
|
model.deepseekV4Pro({ mix: { fireworks: true, openrouter: false } });
|
package/test/fallback.test.js
CHANGED
|
@@ -13,6 +13,63 @@ const {
|
|
|
13
13
|
} = require('../index.js');
|
|
14
14
|
|
|
15
15
|
describe('Provider Fallback Chain Tests', () => {
|
|
16
|
+
|
|
17
|
+
describe('Interrupted provider responses', () => {
|
|
18
|
+
const timeout = {
|
|
19
|
+
id: 'generation-timeout',
|
|
20
|
+
provider: 'Novita',
|
|
21
|
+
error: { code: 504, message: 'Upstream idle timeout exceeded', metadata: { error_type: 'timeout' } },
|
|
22
|
+
choices: [{ finish_reason: 'error', message: { content: '' } }]
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('rejects HTTP 200 error bodies with their status and generation details', async () => {
|
|
26
|
+
sinon.stub(global, 'fetch').resolves(new Response(JSON.stringify(timeout), { status: 200 }));
|
|
27
|
+
const model = ModelMix.new().deepseekV41Flash().addText('test');
|
|
28
|
+
let failure;
|
|
29
|
+
try { await model.raw(); } catch (error) { failure = error; }
|
|
30
|
+
expect(failure).to.include({ statusCode: 504, message: timeout.error.message });
|
|
31
|
+
expect(failure.details).to.include({ id: timeout.id, provider: 'Novita' });
|
|
32
|
+
expect(failure.details.error.metadata.error_type).to.equal('timeout');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('retries HTTP 200 provider errors only under the configured policy', async () => {
|
|
36
|
+
const fetch = sinon.stub(global, 'fetch');
|
|
37
|
+
fetch.onFirstCall().resolves(new Response(JSON.stringify(timeout)));
|
|
38
|
+
fetch.onSecondCall().resolves(new Response(JSON.stringify({ choices: [{ finish_reason: 'stop', message: { content: 'recovered' } }] })));
|
|
39
|
+
const model = ModelMix.new({ config: { retry: { enabled: true, retries: 1, baseDelayMs: 0, maxDelayMs: 0 } } }).deepseekV41Flash().addText('test');
|
|
40
|
+
expect((await model.raw()).message).to.equal('recovered');
|
|
41
|
+
expect(fetch.callCount).to.equal(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('falls back when a provider returns only reasoning without completing', async () => {
|
|
45
|
+
const fetch = sinon.stub(global, 'fetch');
|
|
46
|
+
fetch.onFirstCall().resolves(new Response(JSON.stringify({ choices: [{ finish_reason: null, message: { content: '', reasoning: 'partial' } }] })));
|
|
47
|
+
fetch.onSecondCall().resolves(new Response(JSON.stringify({ choices: [{ finish_reason: 'stop', message: { content: 'fallback' } }] })));
|
|
48
|
+
const model = ModelMix.new().deepseekV41Flash().gpt5mini().addText('test');
|
|
49
|
+
expect((await model.raw()).message).to.equal('fallback');
|
|
50
|
+
expect(fetch.callCount).to.equal(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects a streaming timeout instead of returning partial output', async () => {
|
|
54
|
+
const chunks = [
|
|
55
|
+
{ choices: [{ delta: { content: 'partial' } }] },
|
|
56
|
+
timeout
|
|
57
|
+
];
|
|
58
|
+
sinon.stub(global, 'fetch').resolves(new Response(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join(''), { headers: { 'content-type': 'text/event-stream' } }));
|
|
59
|
+
const model = ModelMix.new().deepseekV41Flash().addText('test');
|
|
60
|
+
let failure;
|
|
61
|
+
try { await model.stream(() => {}); } catch (error) { failure = error; }
|
|
62
|
+
expect(failure).to.include({ statusCode: 504, message: timeout.error.message });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects an abruptly ended stream even after some text arrived', async () => {
|
|
66
|
+
sinon.stub(global, 'fetch').resolves(new Response('data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}\n\n'));
|
|
67
|
+
let failure;
|
|
68
|
+
try { await ModelMix.new().deepseekV41Flash().addText('test').stream(() => {}); } catch (error) { failure = error; }
|
|
69
|
+
expect(failure).to.include({ statusCode: 502 });
|
|
70
|
+
expect(failure.message).to.include('without completing');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
16
73
|
|
|
17
74
|
// Setup test hooks
|
|
18
75
|
if (global.setupTestHooks) {
|