modelmix 5.1.10 → 5.1.15
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 +42 -10
- package/demo/free.js +1 -3
- package/demo/short.js +15 -13
- package/effort.js +13 -7
- package/http-client.js +6 -6
- package/index.d.ts +13 -6
- package/index.js +204 -144
- package/lib/abort-signal.js +57 -0
- package/lib/model-chain.js +2 -2
- package/lib/provider-api-key.js +9 -0
- package/lib/providers/anthropic.js +24 -22
- package/lib/providers/base.js +21 -28
- package/lib/providers/google.js +12 -8
- package/lib/providers/openai-compatible.js +19 -48
- package/lib/providers/openai-options.js +16 -0
- package/lib/providers/openai.js +48 -47
- package/lib/providers.js +5 -2
- package/lib/token-usage.js +19 -4
- package/mcp-tools.js +5 -2
- package/package.json +3 -2
- package/plugins/rlm/index.d.ts +1 -0
- package/plugins/rlm/lib/budget.js +2 -7
- package/plugins/rlm/lib/isolated-vm-sandbox.js +8 -1
- package/plugins/rlm/lib/planner-prompt.js +1 -7
- package/plugins/rlm/lib/plugin.js +24 -11
- package/plugins/rlm/lib/validation.js +14 -0
- package/plugins/rlm/lib/variable-descriptors.js +1 -5
- package/plugins/rlm/lib/worker-catalog.js +1 -5
- package/plugins/rlm/test/isolated-vm-sandbox.test.js +23 -0
- package/pnpm-workspace.yaml +10 -5
- package/schema.js +0 -28
- package/skills/modelmix/SKILL.md +34 -12
- package/test/abort.test.js +517 -0
- package/test/anthropic.test.js +77 -1
- package/test/bottleneck.test.js +2 -2
- package/test/effort.test.js +24 -1
- package/test/fallback.test.js +101 -1
- package/test/glm.test.js +2 -6
- package/test/kimi.test.js +2 -6
- package/test/live.test.js +10 -5
- package/test/muse.test.js +24 -7
- package/test/provider-expansion.test.js +17 -0
- package/test/public-api.test.js +2 -2
- package/test/qwen.test.js +2 -3
- package/test/tokens.test.js +23 -8
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -105,7 +105,7 @@ const model = ModelMix.new()
|
|
|
105
105
|
|
|
106
106
|
### Instance plugins
|
|
107
107
|
|
|
108
|
-
Register middleware with `.use({ name, execute })`. Plugins are scoped to the instance, run in registration order, and receive the rendered provider-neutral request. They may edit `context.request`, call `next()`, or return a complete ModelMix result.
|
|
108
|
+
Register middleware with `.use({ name, execute })`. Plugins are scoped to the instance, run in registration order, and receive the rendered provider-neutral request. They may edit `context.request`, call `next()`, or return a complete ModelMix result. `context.signal` is the current execution's `AbortSignal` and is inherited by `context.invoke()` children.
|
|
109
109
|
|
|
110
110
|
```javascript
|
|
111
111
|
model.use({
|
|
@@ -151,13 +151,17 @@ ModelMix.new({ config: { effort: 80 } })
|
|
|
151
151
|
|
|
152
152
|
Use `ModerationMix.new().openai()` with `.raw()` to classify text and images through OpenAI's Moderations endpoint. Read the results from `raw.moderation`. `ModerationMix` accepts moderation providers as ordered fallbacks, rejects generative providers, and does not generate text or support streaming.
|
|
153
153
|
|
|
154
|
-
`
|
|
154
|
+
`gpt56sol()` `gpt56terra()` `gpt56luna()` `gpt55()` `gpt55pro()` `gpt54()` `gpt54mini()` `gpt54nano()` `gpt54pro()` `gpt53codex()` `gpt53chat()` `gpt52()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gptRealtime()` `gptRealtimeMini()` `gptOss()`
|
|
155
|
+
|
|
156
|
+
Every textual GPT-5 shortcut registers only the official OpenAI model by default. Pass `mix: { openrouter: true }` to `ModelMix.new()` or to an individual shortcut to append its `openai/*` OpenRouter route as a fallback. `gpt53chat()` uses `gpt-5.3-chat-latest` officially and `openai/gpt-5.3-chat` through OpenRouter. Both API keys are required when that fallback is enabled. Realtime shortcuts remain official-only.
|
|
155
157
|
|
|
156
158
|
### Anthropic
|
|
157
|
-
`fable50()` `opus50()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
|
|
159
|
+
`fable51()` `fable50()` `opus50()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
|
|
158
160
|
|
|
159
161
|
Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.effort(100).opus50()`. `fable5()` and `opus5()` remain available as compatibility aliases.
|
|
160
162
|
|
|
163
|
+
`fable51()` registers `claude-fable-5-1` through Anthropic by default. Pass `mix: { openrouter: true }` to append `anthropic/claude-fable-5.1` as its fallback.
|
|
164
|
+
|
|
161
165
|
### Google
|
|
162
166
|
`gemini31pro()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()`
|
|
163
167
|
|
|
@@ -186,7 +190,7 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
|
|
|
186
190
|
`GLM46()`
|
|
187
191
|
|
|
188
192
|
### OpenRouter
|
|
189
|
-
`museGlimmer30b()` `gptOss()` `qwen35397b()` `qwen36plus()` `qwen37plus()` `qwen3827b()` `qwen38flash()` `hermes470b()` `hermes4405b()` `qwen38max()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM45()` `GLM52()` `GLM53()` `GLM53Flash()`
|
|
193
|
+
`museGlimmer30b()` `museSpark12Contributor()` `gptOss()` `qwen35397b()` `qwen36plus()` `qwen37plus()` `qwen3827b()` `qwen38flash()` `hermes470b()` `hermes4405b()` `qwen38max()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM45()` `GLM52()` `GLM53()` `GLM53Flash()`
|
|
190
194
|
|
|
191
195
|
### Multi-provider (auto-fallback across free/paid tiers)
|
|
192
196
|
`hermes3()` `kimiK25()`
|
|
@@ -328,6 +332,24 @@ const raw = await ModelMix.new()
|
|
|
328
332
|
// raw.message, raw.think, raw.tokens, raw.toolCalls, raw.response
|
|
329
333
|
```
|
|
330
334
|
|
|
335
|
+
### Cancel one execution
|
|
336
|
+
|
|
337
|
+
Pass an `AbortSignal` directly to the terminal method:
|
|
338
|
+
|
|
339
|
+
```javascript
|
|
340
|
+
const controller = new AbortController();
|
|
341
|
+
const pending = model.message(controller.signal);
|
|
342
|
+
controller.abort(new Error('Request cancelled'));
|
|
343
|
+
await pending;
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The other forms are `raw(signal)`, `stream(callback, signal)`,
|
|
347
|
+
`block(options, signal)`, and `json(example, description, jsonOptions, signal)`.
|
|
348
|
+
For `execute()`, use the top-level `{ signal }` field. Never place `signal` in
|
|
349
|
+
`config` or `options`; ModelMix rejects those reusable/payload-bearing locations.
|
|
350
|
+
Plugins read `context.signal`, child invocations inherit it, and local tool
|
|
351
|
+
callbacks receive it as their second argument.
|
|
352
|
+
|
|
331
353
|
### Access full response with lastRaw
|
|
332
354
|
|
|
333
355
|
After calling `message()`, `json()`, `block()`, or `stream()`, use `lastRaw` to access the complete response:
|
|
@@ -551,10 +573,9 @@ For full debug output, also set: `DEBUG=ModelMix* node script.js`
|
|
|
551
573
|
### Free-tier models
|
|
552
574
|
|
|
553
575
|
```javascript
|
|
554
|
-
const model = ModelMix.new(
|
|
576
|
+
const model = ModelMix.new()
|
|
555
577
|
.gptOss()
|
|
556
578
|
.kimiK25()
|
|
557
|
-
.hermes3()
|
|
558
579
|
.addText("What is the capital of France?");
|
|
559
580
|
console.log(await model.message());
|
|
560
581
|
```
|
|
@@ -568,7 +589,8 @@ Some model shorthands register the same model across multiple providers for maxi
|
|
|
568
589
|
```javascript
|
|
569
590
|
const model = ModelMix.new({
|
|
570
591
|
mix: {
|
|
571
|
-
|
|
592
|
+
anthropic: true, // fable51() default: true
|
|
593
|
+
openrouter: false, // fallback default: false
|
|
572
594
|
cerebras: true, // default: true
|
|
573
595
|
groq: true, // default: true
|
|
574
596
|
together: false, // default: false
|
|
@@ -610,12 +632,12 @@ const model = ModelMix.new({
|
|
|
610
632
|
| `.assign({})` | `this` | Assign EJS template data |
|
|
611
633
|
| `.assignKey(key, value)` | `this` | Assign one EJS template-data value |
|
|
612
634
|
| `.assignKeyFromFile(key, path)` | `this` | Assign the rendered output of an EJS file to one key |
|
|
613
|
-
| `.message()` | `Promise<string>` | Get text response |
|
|
614
|
-
| `.json(example, desc?, opts?)` | `Promise<object\|array>` | Get structured JSON |
|
|
615
|
-
| `.raw()` | `Promise<{message, think, toolCalls, tokens, response}>` | Full response |
|
|
635
|
+
| `.message(signal?)` | `Promise<string>` | Get text response |
|
|
636
|
+
| `.json(example, desc?, opts?, signal?)` | `Promise<object\|array>` | Get structured JSON |
|
|
637
|
+
| `.raw(signal?)` | `Promise<{message, think, toolCalls, tokens, response}>` | Full response |
|
|
616
638
|
| `.lastRaw` | `object \| null` | Full response from last call |
|
|
617
|
-
| `.stream(callback)` | `Promise` | Stream response |
|
|
618
|
-
| `.block({addSystemExtra?})` | `Promise<string>` | Extract code block from response |
|
|
639
|
+
| `.stream(callback, signal?)` | `Promise` | Stream response |
|
|
640
|
+
| `.block({addSystemExtra?}, signal?)` | `Promise<string>` | Extract code block from response |
|
|
619
641
|
| `.addMCP(package)` | `Promise` | Add MCP server tools |
|
|
620
642
|
| `.addTool(def, callback)` | `this` | Register custom local tool |
|
|
621
643
|
| `.addTools([{tool, callback}])` | `this` | Register multiple tools |
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const { EventEmitter } = require('events');
|
|
3
|
+
const sinon = require('sinon');
|
|
4
|
+
const {
|
|
5
|
+
MixCustom,
|
|
6
|
+
MixModeration,
|
|
7
|
+
MixOpenAI,
|
|
8
|
+
ModelMix
|
|
9
|
+
} = require('../index.js');
|
|
10
|
+
const createOpenAIProviders = require('../lib/providers/openai');
|
|
11
|
+
const { rejectsAnthropicSamplingParams } = require('../lib/providers/anthropic');
|
|
12
|
+
const {
|
|
13
|
+
fetchBinaryResponse,
|
|
14
|
+
fetchJsonResponse,
|
|
15
|
+
fetchStreamResponse
|
|
16
|
+
} = require('../http-client');
|
|
17
|
+
|
|
18
|
+
function deferred() {
|
|
19
|
+
let resolve;
|
|
20
|
+
let reject;
|
|
21
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
22
|
+
resolve = resolvePromise;
|
|
23
|
+
reject = rejectPromise;
|
|
24
|
+
});
|
|
25
|
+
return { promise, reject, resolve };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function rejection(promise) {
|
|
29
|
+
try {
|
|
30
|
+
await promise;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Expected promise to reject.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createProvider(handler) {
|
|
38
|
+
const provider = new MixCustom();
|
|
39
|
+
provider.create = handler;
|
|
40
|
+
return provider;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createModel(handler, config = {}) {
|
|
44
|
+
return ModelMix.new({
|
|
45
|
+
config: {
|
|
46
|
+
bottleneck: { maxConcurrent: 8, minTime: 0 },
|
|
47
|
+
...config
|
|
48
|
+
}
|
|
49
|
+
}).attach('custom', createProvider(handler)).addText('test');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('AbortSignal execution contract', () => {
|
|
53
|
+
if (global.setupTestHooks) global.setupTestHooks();
|
|
54
|
+
|
|
55
|
+
it('passes the direct signal through every terminal method without storing it', async () => {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const cases = [
|
|
58
|
+
['message', model => model.message(controller.signal), 'plain'],
|
|
59
|
+
['raw', model => model.raw(controller.signal), 'plain'],
|
|
60
|
+
['stream', model => model.stream(() => {}, controller.signal), 'plain'],
|
|
61
|
+
['block', model => model.block({}, controller.signal), '```plain```'],
|
|
62
|
+
['json', model => model.json(null, {}, {}, controller.signal), '{"ok":true}'],
|
|
63
|
+
['execute', model => model.execute({ signal: controller.signal }), 'plain']
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const [, invoke, message] of cases) {
|
|
67
|
+
let providerRequest;
|
|
68
|
+
const model = createModel(async request => {
|
|
69
|
+
providerRequest = request;
|
|
70
|
+
return { message, toolCalls: [] };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await invoke(model);
|
|
74
|
+
|
|
75
|
+
expect(providerRequest.signal).to.equal(controller.signal);
|
|
76
|
+
expect(providerRequest.config).to.not.have.property('signal');
|
|
77
|
+
expect(providerRequest.options).to.not.have.property('signal');
|
|
78
|
+
expect(model).to.not.have.property('signal');
|
|
79
|
+
expect(model.config).to.not.have.property('signal');
|
|
80
|
+
expect(model.options).to.not.have.property('signal');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects invalid and misplaced signals before provider work starts', async () => {
|
|
85
|
+
const signal = new AbortController().signal;
|
|
86
|
+
expect(() => ModelMix.new({ config: { signal } })).to.throw(TypeError, 'config.signal');
|
|
87
|
+
expect(() => ModelMix.new({ options: { signal } })).to.throw(TypeError, 'options.signal');
|
|
88
|
+
expect(() => new MixCustom({ config: { signal } })).to.throw(TypeError, 'config.signal');
|
|
89
|
+
expect(() => new MixCustom({ options: { signal } })).to.throw(TypeError, 'options.signal');
|
|
90
|
+
|
|
91
|
+
let providerCalls = 0;
|
|
92
|
+
const model = createModel(async () => {
|
|
93
|
+
providerCalls += 1;
|
|
94
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(await rejection(model.message({}))).to.be.instanceOf(TypeError);
|
|
98
|
+
expect(await rejection(model.execute({ config: { signal } }))).to.be.instanceOf(TypeError);
|
|
99
|
+
expect(await rejection(model.execute({ options: { signal } }))).to.be.instanceOf(TypeError);
|
|
100
|
+
expect(providerCalls).to.equal(0);
|
|
101
|
+
|
|
102
|
+
const pluginModel = createModel(async () => {
|
|
103
|
+
providerCalls += 1;
|
|
104
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
105
|
+
}).use({
|
|
106
|
+
name: 'misplaced-signal',
|
|
107
|
+
execute(context, next) {
|
|
108
|
+
context.request.options.signal = signal;
|
|
109
|
+
return next();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const pluginError = await rejection(pluginModel.message());
|
|
113
|
+
expect(pluginError).to.be.instanceOf(TypeError);
|
|
114
|
+
expect(pluginError.message).to.include('options.signal');
|
|
115
|
+
expect(providerCalls).to.equal(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('preserves a pre-aborted custom reason and leaves execution state untouched', async () => {
|
|
119
|
+
const reason = new Error('cancel before start');
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
controller.abort(reason);
|
|
122
|
+
let providerCalls = 0;
|
|
123
|
+
const model = createModel(async () => {
|
|
124
|
+
providerCalls += 1;
|
|
125
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
126
|
+
});
|
|
127
|
+
const originalMessages = model.messages.map(message => ({ ...message }));
|
|
128
|
+
|
|
129
|
+
expect(await rejection(model.message(controller.signal))).to.equal(reason);
|
|
130
|
+
expect(providerCalls).to.equal(0);
|
|
131
|
+
expect(model.lastRaw).to.equal(null);
|
|
132
|
+
expect(model.messages).to.deep.equal(originalMessages);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('aborts remote image preparation without mutating messages or invoking a provider', async () => {
|
|
136
|
+
const reason = new Error('stop image download');
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const fetchStarted = deferred();
|
|
139
|
+
let fetchedSignal;
|
|
140
|
+
sinon.stub(global, 'fetch').callsFake((_url, { signal }) => {
|
|
141
|
+
fetchedSignal = signal;
|
|
142
|
+
fetchStarted.resolve();
|
|
143
|
+
return new Promise((_, rejectPromise) => {
|
|
144
|
+
signal.addEventListener('abort', () => rejectPromise(signal.reason), { once: true });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
let providerCalls = 0;
|
|
148
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 1, minTime: 0 } } })
|
|
149
|
+
.attach('custom', createProvider(async () => {
|
|
150
|
+
providerCalls += 1;
|
|
151
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
152
|
+
}))
|
|
153
|
+
.addImageFromUrl('https://example.test/image.png')
|
|
154
|
+
.addText('describe');
|
|
155
|
+
const originalMessages = JSON.parse(JSON.stringify(model.messages));
|
|
156
|
+
|
|
157
|
+
const execution = model.raw(controller.signal);
|
|
158
|
+
await fetchStarted.promise;
|
|
159
|
+
controller.abort(reason);
|
|
160
|
+
|
|
161
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
162
|
+
expect(fetchedSignal).to.equal(controller.signal);
|
|
163
|
+
expect(providerCalls).to.equal(0);
|
|
164
|
+
expect(model.messages).to.deep.equal(originalMessages);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('aborts an in-flight custom provider without falling back or recording its late result', async () => {
|
|
168
|
+
const pending = deferred();
|
|
169
|
+
const primaryStarted = deferred();
|
|
170
|
+
const reason = new Error('stop provider');
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
let primaryCalls = 0;
|
|
173
|
+
let fallbackCalls = 0;
|
|
174
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 2, minTime: 0 } } })
|
|
175
|
+
.attach('primary', createProvider(async () => {
|
|
176
|
+
primaryCalls += 1;
|
|
177
|
+
primaryStarted.resolve();
|
|
178
|
+
return pending.promise;
|
|
179
|
+
}))
|
|
180
|
+
.attach('fallback', createProvider(async () => {
|
|
181
|
+
fallbackCalls += 1;
|
|
182
|
+
return { message: 'fallback', toolCalls: [] };
|
|
183
|
+
}))
|
|
184
|
+
.addText('test');
|
|
185
|
+
|
|
186
|
+
const execution = model.raw(controller.signal);
|
|
187
|
+
await primaryStarted.promise;
|
|
188
|
+
controller.abort(reason);
|
|
189
|
+
|
|
190
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
191
|
+
pending.resolve({ message: 'late', toolCalls: [] });
|
|
192
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
193
|
+
expect(primaryCalls).to.equal(1);
|
|
194
|
+
expect(fallbackCalls).to.equal(0);
|
|
195
|
+
expect(model.lastRaw).to.equal(null);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('rejects while queued and prevents the queued provider invocation', async () => {
|
|
199
|
+
const first = deferred();
|
|
200
|
+
const firstStarted = deferred();
|
|
201
|
+
const reason = new Error('leave queue');
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
let providerCalls = 0;
|
|
204
|
+
const model = createModel(async () => {
|
|
205
|
+
providerCalls += 1;
|
|
206
|
+
if (providerCalls === 1) {
|
|
207
|
+
firstStarted.resolve();
|
|
208
|
+
return first.promise;
|
|
209
|
+
}
|
|
210
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
211
|
+
}, {
|
|
212
|
+
max_history: -1,
|
|
213
|
+
bottleneck: { maxConcurrent: 1, minTime: 0 }
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const running = model.raw();
|
|
217
|
+
await firstStarted.promise;
|
|
218
|
+
const queued = model.raw(controller.signal);
|
|
219
|
+
controller.abort(reason);
|
|
220
|
+
|
|
221
|
+
expect(await rejection(queued)).to.equal(reason);
|
|
222
|
+
first.resolve({ message: 'first', toolCalls: [] });
|
|
223
|
+
await running;
|
|
224
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
225
|
+
expect(providerCalls).to.equal(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('aborts retry backoff without retrying or falling back', async () => {
|
|
229
|
+
const reason = new Error('stop retry');
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
let primaryCalls = 0;
|
|
232
|
+
let fallbackCalls = 0;
|
|
233
|
+
const model = ModelMix.new({
|
|
234
|
+
config: {
|
|
235
|
+
bottleneck: { maxConcurrent: 1, minTime: 0 },
|
|
236
|
+
retry: {
|
|
237
|
+
enabled: true,
|
|
238
|
+
retries: 2,
|
|
239
|
+
baseDelayMs: 200,
|
|
240
|
+
maxDelayMs: 200,
|
|
241
|
+
retryableStatusCodes: [429]
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.attach('primary', createProvider(async () => {
|
|
246
|
+
primaryCalls += 1;
|
|
247
|
+
throw { message: 'rate limited', statusCode: 429 };
|
|
248
|
+
}))
|
|
249
|
+
.attach('fallback', createProvider(async () => {
|
|
250
|
+
fallbackCalls += 1;
|
|
251
|
+
return { message: 'fallback', toolCalls: [] };
|
|
252
|
+
}))
|
|
253
|
+
.addText('test');
|
|
254
|
+
|
|
255
|
+
const execution = model.raw(controller.signal);
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
257
|
+
controller.abort(reason);
|
|
258
|
+
|
|
259
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
260
|
+
expect(primaryCalls).to.equal(1);
|
|
261
|
+
expect(fallbackCalls).to.equal(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('isolates signals across concurrent executions on one instance', async () => {
|
|
265
|
+
const first = deferred();
|
|
266
|
+
const second = deferred();
|
|
267
|
+
const started = deferred();
|
|
268
|
+
const controllerA = new AbortController();
|
|
269
|
+
const controllerB = new AbortController();
|
|
270
|
+
const reason = new Error('only A');
|
|
271
|
+
const requests = [];
|
|
272
|
+
const model = createModel(async request => {
|
|
273
|
+
requests.push(request);
|
|
274
|
+
if (requests.length === 2) started.resolve();
|
|
275
|
+
return requests.length === 1 ? first.promise : second.promise;
|
|
276
|
+
}, {
|
|
277
|
+
max_history: -1,
|
|
278
|
+
bottleneck: { maxConcurrent: 2, minTime: 0 }
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const executionA = model.raw(controllerA.signal);
|
|
282
|
+
const executionB = model.raw(controllerB.signal);
|
|
283
|
+
await started.promise;
|
|
284
|
+
controllerA.abort(reason);
|
|
285
|
+
second.resolve({ message: 'B', toolCalls: [] });
|
|
286
|
+
|
|
287
|
+
expect(await rejection(executionA)).to.equal(reason);
|
|
288
|
+
expect((await executionB).message).to.equal('B');
|
|
289
|
+
first.resolve({ message: 'late A', toolCalls: [] });
|
|
290
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
291
|
+
expect(requests.map(request => request.signal)).to.deep.equal([
|
|
292
|
+
controllerA.signal,
|
|
293
|
+
controllerB.signal
|
|
294
|
+
]);
|
|
295
|
+
expect(model.lastRaw.message).to.equal('B');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('exposes the signal to plugins and inherits it in child invocations', async () => {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
const contexts = [];
|
|
301
|
+
let providerSignal;
|
|
302
|
+
const provider = createProvider(async request => {
|
|
303
|
+
providerSignal = request.signal;
|
|
304
|
+
return { message: 'child', toolCalls: [] };
|
|
305
|
+
});
|
|
306
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 2, minTime: 0 } } })
|
|
307
|
+
.attach('custom', provider)
|
|
308
|
+
.use({
|
|
309
|
+
name: 'child',
|
|
310
|
+
execute(context, next) {
|
|
311
|
+
contexts.push(context);
|
|
312
|
+
if (context.execution.depth > 0) return next();
|
|
313
|
+
return context.invoke({
|
|
314
|
+
messages: [{ role: 'user', content: 'child prompt' }],
|
|
315
|
+
history: false
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
.addText('parent prompt');
|
|
320
|
+
|
|
321
|
+
expect((await model.raw(controller.signal)).message).to.equal('child');
|
|
322
|
+
expect(contexts).to.have.length(2);
|
|
323
|
+
expect(contexts.every(context => context.signal === controller.signal)).to.equal(true);
|
|
324
|
+
expect(contexts.every(context => !Object.hasOwn(context.request.config, 'signal'))).to.equal(true);
|
|
325
|
+
expect(contexts.every(context => !Object.hasOwn(context.request.options, 'signal'))).to.equal(true);
|
|
326
|
+
expect(providerSignal).to.equal(controller.signal);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('passes the signal to local and MCP tools and preserves abort through tool handling', async () => {
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
let localSignal;
|
|
332
|
+
const model = ModelMix.new().addTool({
|
|
333
|
+
name: 'local',
|
|
334
|
+
description: 'local tool',
|
|
335
|
+
inputSchema: { type: 'object' }
|
|
336
|
+
}, async (_args, signal) => {
|
|
337
|
+
localSignal = signal;
|
|
338
|
+
return 'local result';
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const localResult = await model.processToolCalls([
|
|
342
|
+
{ id: 'local-1', name: 'local', input: {} }
|
|
343
|
+
], controller.signal);
|
|
344
|
+
expect(localSignal).to.equal(controller.signal);
|
|
345
|
+
expect(localResult[0].content).to.equal('local result');
|
|
346
|
+
|
|
347
|
+
let mcpArguments;
|
|
348
|
+
model.toolClient.remote = {
|
|
349
|
+
async callTool(...args) {
|
|
350
|
+
mcpArguments = args;
|
|
351
|
+
return { content: [{ type: 'text', text: 'remote result' }] };
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const remoteResult = await model.processToolCalls([
|
|
355
|
+
{ id: 'remote-1', name: 'remote', input: { value: 1 } }
|
|
356
|
+
], controller.signal);
|
|
357
|
+
expect(remoteResult[0].content).to.equal('remote result');
|
|
358
|
+
expect(mcpArguments[2]).to.deep.equal({ signal: controller.signal });
|
|
359
|
+
|
|
360
|
+
const reason = new Error('stop local tool');
|
|
361
|
+
const abortController = new AbortController();
|
|
362
|
+
const toolStarted = deferred();
|
|
363
|
+
let providerCalls = 0;
|
|
364
|
+
const toolModel = createModel(async () => {
|
|
365
|
+
providerCalls += 1;
|
|
366
|
+
return {
|
|
367
|
+
message: '',
|
|
368
|
+
toolCalls: [{ id: 'slow-1', name: 'slow', input: {} }]
|
|
369
|
+
};
|
|
370
|
+
}, { max_history: -1 }).addTool({
|
|
371
|
+
name: 'slow',
|
|
372
|
+
description: 'slow tool',
|
|
373
|
+
inputSchema: { type: 'object' }
|
|
374
|
+
}, async (_args, signal) => {
|
|
375
|
+
toolStarted.resolve(signal);
|
|
376
|
+
return new Promise((_, rejectPromise) => {
|
|
377
|
+
signal.addEventListener('abort', () => rejectPromise(signal.reason), { once: true });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
const originalMessages = JSON.parse(JSON.stringify(toolModel.messages));
|
|
381
|
+
const execution = toolModel.raw(abortController.signal);
|
|
382
|
+
expect(await toolStarted.promise).to.equal(abortController.signal);
|
|
383
|
+
abortController.abort(reason);
|
|
384
|
+
|
|
385
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
386
|
+
expect(providerCalls).to.equal(1);
|
|
387
|
+
expect(toolModel.messages).to.deep.equal(originalMessages);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('forwards the signal through every fetch helper without serializing it', async () => {
|
|
391
|
+
const controller = new AbortController();
|
|
392
|
+
const calls = [];
|
|
393
|
+
sinon.stub(global, 'fetch').callsFake(async (_url, init) => {
|
|
394
|
+
calls.push(init);
|
|
395
|
+
if (calls.length === 1) {
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
status: 200,
|
|
399
|
+
headers: new Headers(),
|
|
400
|
+
text: async () => '{"ok":true}'
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (calls.length === 2) {
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
status: 200,
|
|
407
|
+
headers: new Headers(),
|
|
408
|
+
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
status: 200,
|
|
414
|
+
headers: new Headers(),
|
|
415
|
+
body: new ReadableStream({
|
|
416
|
+
start(streamController) {
|
|
417
|
+
streamController.close();
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await fetchJsonResponse('https://example.test/json', { body: '{}', signal: controller.signal });
|
|
424
|
+
await fetchBinaryResponse('https://example.test/binary', { signal: controller.signal });
|
|
425
|
+
const stream = await fetchStreamResponse('https://example.test/stream', { body: '{}', signal: controller.signal });
|
|
426
|
+
stream.data.resume();
|
|
427
|
+
|
|
428
|
+
expect(calls).to.have.length(3);
|
|
429
|
+
expect(calls.every(call => call.signal === controller.signal)).to.equal(true);
|
|
430
|
+
expect(JSON.parse(calls[0].body)).to.not.have.property('signal');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('keeps the signal outside a built-in provider payload', async () => {
|
|
434
|
+
const controller = new AbortController();
|
|
435
|
+
const reason = new Error('stop OpenAI request');
|
|
436
|
+
const fetchStarted = deferred();
|
|
437
|
+
let request;
|
|
438
|
+
sinon.stub(global, 'fetch').callsFake((_url, init) => {
|
|
439
|
+
request = init;
|
|
440
|
+
fetchStarted.resolve();
|
|
441
|
+
return new Promise((_, rejectPromise) => {
|
|
442
|
+
init.signal.addEventListener('abort', () => rejectPromise(init.signal.reason), { once: true });
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 1, minTime: 0 } } })
|
|
446
|
+
.gpt51()
|
|
447
|
+
.addText('test');
|
|
448
|
+
|
|
449
|
+
const execution = model.raw(controller.signal);
|
|
450
|
+
await fetchStarted.promise;
|
|
451
|
+
expect(request.signal).to.equal(controller.signal);
|
|
452
|
+
expect(JSON.parse(request.body)).to.not.have.property('signal');
|
|
453
|
+
controller.abort(reason);
|
|
454
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('aborts the realtime WebSocket and preserves the custom reason', async () => {
|
|
458
|
+
const created = deferred();
|
|
459
|
+
class FakeWebSocket extends EventEmitter {
|
|
460
|
+
constructor() {
|
|
461
|
+
super();
|
|
462
|
+
this.readyState = 0;
|
|
463
|
+
this.terminated = false;
|
|
464
|
+
created.resolve(this);
|
|
465
|
+
setImmediate(() => {
|
|
466
|
+
if (this.terminated) return;
|
|
467
|
+
this.readyState = 1;
|
|
468
|
+
this.emit('open');
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
send() {}
|
|
473
|
+
|
|
474
|
+
close() {
|
|
475
|
+
this.terminate();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
terminate() {
|
|
479
|
+
if (this.terminated) return;
|
|
480
|
+
this.terminated = true;
|
|
481
|
+
this.readyState = 3;
|
|
482
|
+
this.emit('close');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const wsPath = require.resolve('ws');
|
|
486
|
+
const originalWebSocket = require.cache[wsPath].exports;
|
|
487
|
+
require.cache[wsPath].exports = FakeWebSocket;
|
|
488
|
+
try {
|
|
489
|
+
const { MixOpenAIWebSocket } = createOpenAIProviders({
|
|
490
|
+
ModelMix,
|
|
491
|
+
MixCustom,
|
|
492
|
+
MixOpenAI,
|
|
493
|
+
MixModeration,
|
|
494
|
+
rejectsAnthropicSamplingParams
|
|
495
|
+
});
|
|
496
|
+
const provider = new MixOpenAIWebSocket({
|
|
497
|
+
config: {
|
|
498
|
+
apiKey: 'test-key',
|
|
499
|
+
realtimeUrl: 'ws://example.test',
|
|
500
|
+
websocketTimeoutMs: 2000
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 1, minTime: 0 } } })
|
|
504
|
+
.attach('realtime-test', provider)
|
|
505
|
+
.addText('test');
|
|
506
|
+
const controller = new AbortController();
|
|
507
|
+
const reason = new Error('close realtime');
|
|
508
|
+
const execution = model.raw(controller.signal);
|
|
509
|
+
const socket = await created.promise;
|
|
510
|
+
controller.abort(reason);
|
|
511
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
512
|
+
expect(socket.terminated).to.equal(true);
|
|
513
|
+
} finally {
|
|
514
|
+
require.cache[wsPath].exports = originalWebSocket;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|