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.
@@ -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
+ });
@@ -16,8 +16,8 @@ overrides:
16
16
  brace-expansion: 5.0.9
17
17
  diff: 8.0.4
18
18
  fast-uri: 3.1.6
19
- hono: 4.12.34
19
+ hono: 4.13.5
20
20
  ip-address: 10.3.1
21
- js-yaml: 4.3.1
21
+ js-yaml: 4.3.2
22
22
  qs: 6.16.0
23
23
  serialize-javascript: 7.0.5
@@ -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
- `museGlimmer30b()` `museSpark12()` `museSpark12c()` `museSpark13()` `museSpark13c()` `gptOss()` `qwen35397b()` `qwen36plus()` `qwen37plus()` `qwen3827b()` `qwen38flash()` `hermes470b()` `hermes4405b()` `qwen38max()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM45()` `GLM52()` `GLM53()` `GLM53Flash()`
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
 
@@ -1,7 +1,279 @@
1
1
  const { expect } = require('chai');
2
- const { ModelMix } = require('../index.js');
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 } });
@@ -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) {