modelmix 5.0.3 → 5.0.5
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 +230 -153
- package/demo/moderation.js +17 -0
- package/index.d.ts +48 -0
- package/index.js +166 -1
- package/package.json +4 -5
- package/skills/modelmix/SKILL.md +15 -1
- package/test/fallback.test.js +34 -0
- package/test/moderation.test.js +135 -0
package/README.md
CHANGED
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
Ever found yourself wanting to integrate AI models into your projects but worried about reliability? ModelMix helps you build resilient AI applications by chaining multiple models together. If one model fails, it automatically switches to the next one, ensuring your application keeps running smoothly.
|
|
6
6
|
|
|
7
|
+
## 📑 Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Features](#-features)
|
|
10
|
+
- [Usage](#-usage)
|
|
11
|
+
- [Shorthand Methods](#-shorthand-methods)
|
|
12
|
+
- [Unified Effort Scale](#-unified-effort-scale)
|
|
13
|
+
- [Templates](#-templates)
|
|
14
|
+
- [JSON Structured Output](#-json-structured-output)
|
|
15
|
+
- [Token Usage Tracking](#-token-usage-tracking)
|
|
16
|
+
- [Prompt Caching](#-prompt-caching)
|
|
17
|
+
- [Model Context Protocol (MCP) Integration](#-model-context-protocol-mcp-integration)
|
|
18
|
+
- [Enabling Debug Mode](#-enabling-debug-mode)
|
|
19
|
+
- [Bottleneck Integration](#-bottleneck-integration)
|
|
20
|
+
- [Retry (Opt-In)](#-retry-optin)
|
|
21
|
+
- [Instance Plugins](#-instance-plugins)
|
|
22
|
+
- [ModelMix Class Overview](#-modelmix-class-overview)
|
|
23
|
+
- [Contributing](#-contributing)
|
|
24
|
+
- [License](#-license)
|
|
25
|
+
|
|
7
26
|
## ✨ Features
|
|
8
27
|
|
|
9
28
|
- **Unified Interface**: Interact with multiple AI models through a single, coherent API.
|
|
@@ -76,6 +95,19 @@ const model = await ModelMix.new(setup)
|
|
|
76
95
|
console.log(await model.message());
|
|
77
96
|
```
|
|
78
97
|
|
|
98
|
+
The same ordered chain can be attached by passing model shortcuts directly to
|
|
99
|
+
`chain()`. Add `@effort` to override unified effort for one model; entries
|
|
100
|
+
without it inherit the chain effort, or use the provider default when the chain
|
|
101
|
+
has no configured effort:
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
const model = ModelMix.new(setup)
|
|
105
|
+
.chain('sonnet5', 'gpt56luna@20', 'gemini37flash@-1')
|
|
106
|
+
.addText("What's your name?");
|
|
107
|
+
|
|
108
|
+
console.log(await model.message());
|
|
109
|
+
```
|
|
110
|
+
|
|
79
111
|
**Use Perplexity to get the price of ETH**
|
|
80
112
|
```javascript
|
|
81
113
|
const ETH = ModelMix.new()
|
|
@@ -101,159 +133,6 @@ This pattern allows you to:
|
|
|
101
133
|
- Track token usage across all providers
|
|
102
134
|
- Keep your code clean and maintainable
|
|
103
135
|
|
|
104
|
-
## 🔌 Instance Plugins
|
|
105
|
-
|
|
106
|
-
Plugins wrap one ModelMix instance without changing global behavior. They run in registration order after templates are rendered and before provider-specific request conversion:
|
|
107
|
-
|
|
108
|
-
```javascript
|
|
109
|
-
const metrics = {
|
|
110
|
-
name: 'metrics',
|
|
111
|
-
async execute(context, next) {
|
|
112
|
-
const startedAt = Date.now();
|
|
113
|
-
const result = await next();
|
|
114
|
-
return { ...result, elapsedMs: Date.now() - startedAt };
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const model = ModelMix.new()
|
|
119
|
-
.gpt56luna()
|
|
120
|
-
.use(metrics)
|
|
121
|
-
.addText('Summarize this request.');
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
A plugin may edit `context.request`, call `next()`, or return a complete ModelMix result itself. It can also create history-free child executions with `context.invoke()` and choose plugin inheritance:
|
|
125
|
-
|
|
126
|
-
```javascript
|
|
127
|
-
const child = await context.invoke({
|
|
128
|
-
systemFile: './prompts/extract-entities.md',
|
|
129
|
-
assign: { outputLanguage: 'Spanish' },
|
|
130
|
-
messages: [{ role: 'user', content: section }],
|
|
131
|
-
plugins: { exclude: ['recursive-plugin'] },
|
|
132
|
-
history: false
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Supported policies are `'inherit'`, `'none'`, `{ include: [...] }`, and `{ exclude: [...] }`. Child metadata exposes `executionId`, `parentExecutionId`, and `depth` to middleware. `.new()` inherits registered plugins but not message history.
|
|
137
|
-
|
|
138
|
-
Child `systemFile` templates use the same EJS engine, `assign()` data contract, and relative Markdown includes as ordinary ModelMix templates. Use either `system` or `systemFile`, not both.
|
|
139
|
-
|
|
140
|
-
### Recursive Language Model plugin
|
|
141
|
-
|
|
142
|
-
The separately publishable `@modelmix/rlm` workspace package keeps document parsing, planner prompts, and `isolated-vm` out of the core `modelmix` dependency tree. It requires Node.js 22 or newer.
|
|
143
|
-
|
|
144
|
-
```javascript
|
|
145
|
-
const { ModelMix } = require('modelmix');
|
|
146
|
-
const { rlm } = require('@modelmix/rlm');
|
|
147
|
-
|
|
148
|
-
const fast = ModelMix.new().gpt41mini();
|
|
149
|
-
|
|
150
|
-
const result = await ModelMix.new()
|
|
151
|
-
.gpt56luna()
|
|
152
|
-
.use(rlm({
|
|
153
|
-
maxDepth: 2,
|
|
154
|
-
documents: {
|
|
155
|
-
book: {
|
|
156
|
-
format: 'markdown',
|
|
157
|
-
content: markdownBook
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
workers: {
|
|
161
|
-
fast: {
|
|
162
|
-
model: fast,
|
|
163
|
-
intelligence: 2,
|
|
164
|
-
cost: 1,
|
|
165
|
-
speed: 4,
|
|
166
|
-
description: 'Translation, extraction, and simple transformations'
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
limits: {
|
|
170
|
-
maxQueryBytes: 64 * 1024,
|
|
171
|
-
sandboxMemoryBytes: 64 * 1024 * 1024,
|
|
172
|
-
maxConcurrentQueries: 4,
|
|
173
|
-
maxCalls: 100,
|
|
174
|
-
maxOutputBytes: 8 * 1024 * 1024,
|
|
175
|
-
maxGeneratedTokens: 100000,
|
|
176
|
-
maxWallTimeMs: 120000
|
|
177
|
-
}
|
|
178
|
-
}))
|
|
179
|
-
.addText('Translate this book to neutral Latin American Spanish.')
|
|
180
|
-
.message();
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
Markdown headings become stable nested sections, lists expose item arrays, and the original source order remains reconstructable. The planner receives only a content-free variable manifest: paths, types, array item counts, serialized UTF-8 byte estimates, string lengths, line and paragraph counts, structural summaries, and partition hints. The document values enter only the isolated sandbox, where generated JavaScript can inspect `variables` and call registered workers through `query()`.
|
|
184
|
-
|
|
185
|
-
A worker normally supplies `model: anotherModelMixInstance`. To offer the current parent chain under a name, register it with `useParent: true` instead; defining both is rejected.
|
|
186
|
-
|
|
187
|
-
Planner instructions live in Markdown templates under `plugins/rlm/prompts/`. The plugin supplies manifests and limits through ModelMix `assign()` and loads the system prompt through `systemFile`, so relative includes work and runtime values are rendered exactly once.
|
|
188
|
-
|
|
189
|
-
## 🎛️ Unified Effort Scale
|
|
190
|
-
|
|
191
|
-
Control reasoning depth with one ModelMix policy value (`-1` adaptive, or `0`–`100`). It lives **outside** native `options` and is mapped to each provider’s effort API at request time.
|
|
192
|
-
|
|
193
|
-
```javascript
|
|
194
|
-
// In config (ModelMix.new or per-model shorthand)
|
|
195
|
-
ModelMix.new({ config: { effort: 50 } }).opus5().addText('...').message();
|
|
196
|
-
ModelMix.new().deepseekV4Flash({ config: { effort: 100 } }).addText('...').message();
|
|
197
|
-
|
|
198
|
-
// Fluent
|
|
199
|
-
ModelMix.new().effort(-1).minimaxM3().addText('...').message();
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
**Native wins:** if you already set a provider-native field (`reasoning_effort`, `output_config.effort`, `thinkingConfig`, etc.), unified `effort` is ignored for that request.
|
|
203
|
-
|
|
204
|
-
| | 0–19 | 20–39 | 40–59 | 60–79 | 80–100 | `-1` |
|
|
205
|
-
|--|------|-------|-------|-------|--------|------|
|
|
206
|
-
| OpenAI | `none` | `low` | `medium` | `high` | `xhigh` | — |
|
|
207
|
-
| Anthropic | `low` | `medium` | `high` | `xhigh` | `max` | adaptive |
|
|
208
|
-
| Gemini 3+ | `minimal` | `low` | `medium` | `high` | — | dynamic |
|
|
209
|
-
| DeepSeek V4 | off | `low`↑ | `high`↑ | `high`↑ | `max`↑ | — |
|
|
210
|
-
| MiniMax M3 | off | adaptive | adaptive | adaptive | adaptive | adaptive |
|
|
211
|
-
|
|
212
|
-
### Provider-specific behavior
|
|
213
|
-
|
|
214
|
-
- **Gemini:** Gemini 3+ uses bands 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.7 Flash clamps these bands to `low` / `low` / `medium` / `high`; `-1` leaves its native `medium` default unchanged. Gemini 2.5 maps 0–100 to `thinkingBudget`.
|
|
215
|
-
- **DeepSeek:** `↑` means thinking is enabled; `off` means it is disabled.
|
|
216
|
-
- **MiniMax:** `off` maps to `thinking.disabled`; `adaptive` maps to `thinking.type=adaptive`.
|
|
217
|
-
- **Anthropic:** Claude 5, Fable, Opus 4.6+, and Sonnet 4.6+ use adaptive thinking with `output_config.effort`. Sonnet 4.5 and Haiku 4.5 use `thinking.type=enabled` with `budget_tokens`.
|
|
218
|
-
- **Grok 4.6:** 0–39 / 40–59 / 60–79 / 80–100 map to `low` / `medium` / `high` / `xhigh`. Without effort, Grok uses its native `high` default.
|
|
219
|
-
|
|
220
|
-
`-1` uses the provider's adaptive or dynamic mode when available; otherwise it is a no-op. Effort levels are clamped to each model's supported range.
|
|
221
|
-
|
|
222
|
-
### Migrating from thinking shorthands
|
|
223
|
-
|
|
224
|
-
The former `*think()` methods were removed. Use `.effort(n).<model>()` with `0`–`100` or `-1` instead.
|
|
225
|
-
|
|
226
|
-
- **Kimi:** use `kimiK25()` or `kimiK26()`.
|
|
227
|
-
- **Grok 4.20:** `.grok420()` selects the non-reasoning model. Use `.effort(20+).grok420()` or `.effort(-1).grok420()` to select the reasoning model.
|
|
228
|
-
|
|
229
|
-
## 🔧 Model Context Protocol (MCP) Integration
|
|
230
|
-
|
|
231
|
-
ModelMix makes it incredibly easy to enhance your AI models with powerful capabilities through the Model Context Protocol. With just a few lines of code, you can add features like web search, code execution, or any custom functionality to your models.
|
|
232
|
-
|
|
233
|
-
### Example: Adding Web Search Capability
|
|
234
|
-
|
|
235
|
-
Include the API key for Brave Search in your .env file.
|
|
236
|
-
```
|
|
237
|
-
BRAVE_API_KEY="BSA0..._fm"
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
```javascript
|
|
241
|
-
const mmix = ModelMix.new({ config: { max_history: 10 } }).gpt56sol();
|
|
242
|
-
mmix.setSystem('You are an assistant and today is ' + new Date().toISOString());
|
|
243
|
-
|
|
244
|
-
// Add web search capability through MCP
|
|
245
|
-
await mmix.addMCP('@modelcontextprotocol/server-brave-search');
|
|
246
|
-
mmix.addText('Use Internet: When did the last Christian pope die?');
|
|
247
|
-
console.log(await mmix.message());
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
This simple integration allows your model to:
|
|
251
|
-
- Search the web in real-time
|
|
252
|
-
- Access up-to-date information
|
|
253
|
-
- Combine AI reasoning with external data
|
|
254
|
-
|
|
255
|
-
The Model Context Protocol makes it easy to add any capability to your models, from web search to code execution, database queries, or custom functions. All with just a few lines of code!
|
|
256
|
-
|
|
257
136
|
## ⚡️ Shorthand Methods
|
|
258
137
|
|
|
259
138
|
ModelMix provides convenient shorthand methods for quickly accessing different AI models.
|
|
@@ -339,6 +218,46 @@ const result = await ModelMix.new({
|
|
|
339
218
|
.message();
|
|
340
219
|
```
|
|
341
220
|
|
|
221
|
+
## 🎛️ Unified Effort Scale
|
|
222
|
+
|
|
223
|
+
Control reasoning depth with one ModelMix policy value (`-1` adaptive, or `0`–`100`). It lives **outside** native `options` and is mapped to each provider’s effort API at request time.
|
|
224
|
+
|
|
225
|
+
```javascript
|
|
226
|
+
// In config (ModelMix.new or per-model shorthand)
|
|
227
|
+
ModelMix.new({ config: { effort: 50 } }).opus5().addText('...').message();
|
|
228
|
+
ModelMix.new().deepseekV4Flash({ config: { effort: 100 } }).addText('...').message();
|
|
229
|
+
|
|
230
|
+
// Fluent
|
|
231
|
+
ModelMix.new().effort(-1).minimaxM3().addText('...').message();
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Native wins:** if you already set a provider-native field (`reasoning_effort`, `output_config.effort`, `thinkingConfig`, etc.), unified `effort` is ignored for that request.
|
|
235
|
+
|
|
236
|
+
| | 0–19 | 20–39 | 40–59 | 60–79 | 80–100 | `-1` |
|
|
237
|
+
|--|------|-------|-------|-------|--------|------|
|
|
238
|
+
| OpenAI | `none` | `low` | `medium` | `high` | `xhigh` | — |
|
|
239
|
+
| Anthropic | `low` | `medium` | `high` | `xhigh` | `max` | adaptive |
|
|
240
|
+
| Gemini 3+ | `minimal` | `low` | `medium` | `high` | — | dynamic |
|
|
241
|
+
| DeepSeek V4 | off | `low`↑ | `high`↑ | `high`↑ | `max`↑ | — |
|
|
242
|
+
| MiniMax M3 | off | adaptive | adaptive | adaptive | adaptive | adaptive |
|
|
243
|
+
|
|
244
|
+
### Provider-specific behavior
|
|
245
|
+
|
|
246
|
+
- **Gemini:** Gemini 3+ uses bands 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.7 Flash clamps these bands to `low` / `low` / `medium` / `high`; `-1` leaves its native `medium` default unchanged. Gemini 2.5 maps 0–100 to `thinkingBudget`.
|
|
247
|
+
- **DeepSeek:** `↑` means thinking is enabled; `off` means it is disabled.
|
|
248
|
+
- **MiniMax:** `off` maps to `thinking.disabled`; `adaptive` maps to `thinking.type=adaptive`.
|
|
249
|
+
- **Anthropic:** Claude 5, Fable, Opus 4.6+, and Sonnet 4.6+ use adaptive thinking with `output_config.effort`. Sonnet 4.5 and Haiku 4.5 use `thinking.type=enabled` with `budget_tokens`.
|
|
250
|
+
- **Grok 4.6:** 0–39 / 40–59 / 60–79 / 80–100 map to `low` / `medium` / `high` / `xhigh`. Without effort, Grok uses its native `high` default.
|
|
251
|
+
|
|
252
|
+
`-1` uses the provider's adaptive or dynamic mode when available; otherwise it is a no-op. Effort levels are clamped to each model's supported range.
|
|
253
|
+
|
|
254
|
+
### Migrating from thinking shorthands
|
|
255
|
+
|
|
256
|
+
The former `*think()` methods were removed. Use `.effort(n).<model>()` with `0`–`100` or `-1` instead.
|
|
257
|
+
|
|
258
|
+
- **Kimi:** use `kimiK25()` or `kimiK26()`.
|
|
259
|
+
- **Grok 4.20:** `.grok420()` selects the non-reasoning model. Use `.effort(20+).grok420()` or `.effort(-1).grok420()` to select the reasoning model.
|
|
260
|
+
|
|
342
261
|
## 🔄 Templates
|
|
343
262
|
|
|
344
263
|
ModelMix renders system prompts and user messages with [EJS](https://ejs.co/). Templates can be inline or stored in external files, and support variables, conditionals, loops, and relative includes.
|
|
@@ -800,6 +719,39 @@ console.log(model.lastRaw.tokens);
|
|
|
800
719
|
|
|
801
720
|
`thinking` contains internal reasoning tokens when a provider reports them separately; cost calculation bills them at the output rate. `cached` aggregates cache reads reported by the provider, while `cacheWrite` aggregates cache writes. Anthropic additionally exposes `cacheWrite5m` and `cacheWrite1h` because those writes cost 1.25× and 2× the normal input rate, respectively. `cacheSavings` compares cache reads with the normal input rate, `cacheWritePremium` compares writes with that rate, and `breakEvenHits` estimates how many complete future hits recover the current write premium. For Anthropic, `input` is normalized to include uncached input, cache reads, and cache writes. Missing usage or pricing categories return `0`. The `speed` field is the generation speed measured in output tokens per second (integer).
|
|
802
721
|
|
|
722
|
+
## 🧠 Prompt Caching
|
|
723
|
+
|
|
724
|
+
Prompt caching reuses the stable beginning of a prompt at the provider level. It does not cache the answer: every call still generates a new response.
|
|
725
|
+
|
|
726
|
+
For GPT-5.6, keep the long, reusable instructions first, mark the end of that stable prefix, and add the changing request afterward:
|
|
727
|
+
|
|
728
|
+
```javascript
|
|
729
|
+
async function ask(question) {
|
|
730
|
+
const model = ModelMix.new()
|
|
731
|
+
.gpt56luna({
|
|
732
|
+
options: {
|
|
733
|
+
prompt_cache_key: 'support-rules-v1',
|
|
734
|
+
prompt_cache_options: { mode: 'explicit', ttl: '30m' }
|
|
735
|
+
}
|
|
736
|
+
})
|
|
737
|
+
.addTextFromFile('./prompts/support.md', {
|
|
738
|
+
role: 'developer',
|
|
739
|
+
cache: { breakpoint: true }
|
|
740
|
+
})
|
|
741
|
+
.addText(question);
|
|
742
|
+
|
|
743
|
+
const answer = await model.message();
|
|
744
|
+
const { cached, cacheWrite, cacheHitRate } = model.lastRaw.tokens;
|
|
745
|
+
console.log({ cached, cacheWrite, cacheHitRate });
|
|
746
|
+
return answer;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
await ask('Summarize support ticket 123.');
|
|
750
|
+
await ask('Summarize support ticket 456.');
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
The contents of `support.md` and the cache key stay the same between calls; only the final question changes. The first request may report `cacheWrite > 0`, while later requests confirm reuse with `cached > 0`. For GPT-5.6, the stable prefix must contain at least 1,024 tokens. Keep all variable content after the breakpoint, and change `prompt_cache_key` when the stable instructions change.
|
|
754
|
+
|
|
803
755
|
### GPT-5.6 prompt caching
|
|
804
756
|
|
|
805
757
|
GPT-5.6 supports implicit or explicit caching through `prompt_cache_options`. Put the explicit breakpoint at the end of the stable prefix; the provider only caches prompts with at least 1,024 tokens.
|
|
@@ -856,6 +808,34 @@ const model = ModelMix.new()
|
|
|
856
808
|
|
|
857
809
|
GPT-5.6 receives `prompt_cache_breakpoint`; Anthropic receives `cache_control`; older OpenAI models and providers without an equivalent omit the marker. When a neutral explicit breakpoint is present for Anthropic, its model-scoped `cache_control` becomes that block's policy instead of adding an automatic breakpoint after the variable suffix.
|
|
858
810
|
|
|
811
|
+
## 🔧 Model Context Protocol (MCP) Integration
|
|
812
|
+
|
|
813
|
+
ModelMix makes it incredibly easy to enhance your AI models with powerful capabilities through the Model Context Protocol. With just a few lines of code, you can add features like web search, code execution, or any custom functionality to your models.
|
|
814
|
+
|
|
815
|
+
### Example: Adding Web Search Capability
|
|
816
|
+
|
|
817
|
+
Include the API key for Brave Search in your .env file.
|
|
818
|
+
```
|
|
819
|
+
BRAVE_API_KEY="BSA0..._fm"
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
```javascript
|
|
823
|
+
const mmix = ModelMix.new({ config: { max_history: 10 } }).gpt56sol();
|
|
824
|
+
mmix.setSystem('You are an assistant and today is ' + new Date().toISOString());
|
|
825
|
+
|
|
826
|
+
// Add web search capability through MCP
|
|
827
|
+
await mmix.addMCP('@modelcontextprotocol/server-brave-search');
|
|
828
|
+
mmix.addText('Use Internet: When did the last Christian pope die?');
|
|
829
|
+
console.log(await mmix.message());
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
This simple integration allows your model to:
|
|
833
|
+
- Search the web in real-time
|
|
834
|
+
- Access up-to-date information
|
|
835
|
+
- Combine AI reasoning with external data
|
|
836
|
+
|
|
837
|
+
The Model Context Protocol makes it easy to add any capability to your models, from web search to code execution, database queries, or custom functions. All with just a few lines of code!
|
|
838
|
+
|
|
859
839
|
## 🐛 Enabling Debug Mode
|
|
860
840
|
|
|
861
841
|
To activate debug mode in ModelMix and view detailed request information, follow these two steps:
|
|
@@ -929,6 +909,91 @@ Behavior summary:
|
|
|
929
909
|
- If retry is enabled, ModelMix retries the same model only for configured transient status codes.
|
|
930
910
|
- After retries are exhausted (or for non-retryable errors), ModelMix continues with normal fallback chain.
|
|
931
911
|
|
|
912
|
+
## 🔌 Instance Plugins
|
|
913
|
+
|
|
914
|
+
Plugins wrap one ModelMix instance without changing global behavior. They run in registration order after templates are rendered and before provider-specific request conversion:
|
|
915
|
+
|
|
916
|
+
```javascript
|
|
917
|
+
const metrics = {
|
|
918
|
+
name: 'metrics',
|
|
919
|
+
async execute(context, next) {
|
|
920
|
+
const startedAt = Date.now();
|
|
921
|
+
const result = await next();
|
|
922
|
+
return { ...result, elapsedMs: Date.now() - startedAt };
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const model = ModelMix.new()
|
|
927
|
+
.gpt56luna()
|
|
928
|
+
.use(metrics)
|
|
929
|
+
.addText('Summarize this request.');
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
A plugin may edit `context.request`, call `next()`, or return a complete ModelMix result itself. It can also create history-free child executions with `context.invoke()` and choose plugin inheritance:
|
|
933
|
+
|
|
934
|
+
```javascript
|
|
935
|
+
const child = await context.invoke({
|
|
936
|
+
systemFile: './prompts/extract-entities.md',
|
|
937
|
+
assign: { outputLanguage: 'Spanish' },
|
|
938
|
+
messages: [{ role: 'user', content: section }],
|
|
939
|
+
plugins: { exclude: ['recursive-plugin'] },
|
|
940
|
+
history: false
|
|
941
|
+
});
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
Supported policies are `'inherit'`, `'none'`, `{ include: [...] }`, and `{ exclude: [...] }`. Child metadata exposes `executionId`, `parentExecutionId`, and `depth` to middleware. `.new()` inherits registered plugins but not message history.
|
|
945
|
+
|
|
946
|
+
Child `systemFile` templates use the same EJS engine, `assign()` data contract, and relative Markdown includes as ordinary ModelMix templates. Use either `system` or `systemFile`, not both.
|
|
947
|
+
|
|
948
|
+
### Recursive Language Model plugin
|
|
949
|
+
|
|
950
|
+
The separately publishable `@modelmix/rlm` workspace package keeps document parsing, planner prompts, and `isolated-vm` out of the core `modelmix` dependency tree. It requires Node.js 22 or newer.
|
|
951
|
+
|
|
952
|
+
```javascript
|
|
953
|
+
const { ModelMix } = require('modelmix');
|
|
954
|
+
const { rlm } = require('@modelmix/rlm');
|
|
955
|
+
|
|
956
|
+
const fast = ModelMix.new().gpt41mini();
|
|
957
|
+
|
|
958
|
+
const result = await ModelMix.new()
|
|
959
|
+
.gpt56luna()
|
|
960
|
+
.use(rlm({
|
|
961
|
+
maxDepth: 2,
|
|
962
|
+
documents: {
|
|
963
|
+
book: {
|
|
964
|
+
format: 'markdown',
|
|
965
|
+
content: markdownBook
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
workers: {
|
|
969
|
+
fast: {
|
|
970
|
+
model: fast,
|
|
971
|
+
intelligence: 2,
|
|
972
|
+
cost: 1,
|
|
973
|
+
speed: 4,
|
|
974
|
+
description: 'Translation, extraction, and simple transformations'
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
limits: {
|
|
978
|
+
maxQueryBytes: 64 * 1024,
|
|
979
|
+
sandboxMemoryBytes: 64 * 1024 * 1024,
|
|
980
|
+
maxConcurrentQueries: 4,
|
|
981
|
+
maxCalls: 100,
|
|
982
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
983
|
+
maxGeneratedTokens: 100000,
|
|
984
|
+
maxWallTimeMs: 120000
|
|
985
|
+
}
|
|
986
|
+
}))
|
|
987
|
+
.addText('Translate this book to neutral Latin American Spanish.')
|
|
988
|
+
.message();
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
Markdown headings become stable nested sections, lists expose item arrays, and the original source order remains reconstructable. The planner receives only a content-free variable manifest: paths, types, array item counts, serialized UTF-8 byte estimates, string lengths, line and paragraph counts, structural summaries, and partition hints. The document values enter only the isolated sandbox, where generated JavaScript can inspect `variables` and call registered workers through `query()`.
|
|
992
|
+
|
|
993
|
+
A worker normally supplies `model: anotherModelMixInstance`. To offer the current parent chain under a name, register it with `useParent: true` instead; defining both is rejected.
|
|
994
|
+
|
|
995
|
+
Planner instructions live in Markdown templates under `plugins/rlm/prompts/`. The plugin supplies manifests and limits through ModelMix `assign()` and loads the system prompt through `systemFile`, so relative includes work and runtime values are rendered exactly once.
|
|
996
|
+
|
|
932
997
|
## 📚 ModelMix Class Overview
|
|
933
998
|
|
|
934
999
|
```javascript
|
|
@@ -982,6 +1047,18 @@ new ModelMix(args = { options: {}, config: {} })
|
|
|
982
1047
|
- `toolCalls`: Array of tool calls made by the model (if any)
|
|
983
1048
|
- `tokens`: Normalized token counts (`input`, `output`, `thinking`, `total`, `cached`, `cacheWrite`, `cacheWrite5m`, `cacheWrite1h`, `uncachedInput`, `cacheHitRate`), cache economics (`cacheSavings`, `cacheWritePremium`, `breakEvenHits`), plus `cost`, `costBreakdown` (USD), and `speed` (output tokens/sec)
|
|
984
1049
|
- `response`: The raw API response
|
|
1050
|
+
- `ModerationMix` owns moderation-only provider chains. Use `openai()` to attach OpenAI's current `omni-moderation-latest`; `raw()` exposes the results under `moderation` (`flagged`, `categories`, `category_scores`, and `category_applied_input_types`). It uses `/v1/moderations`, rejects generative providers, does not generate text, and does not support streaming. Future moderation providers can be appended as fallbacks.
|
|
1051
|
+
```javascript
|
|
1052
|
+
const { ModerationMix } = require('modelmix');
|
|
1053
|
+
|
|
1054
|
+
const { moderation: [profile] } = await ModerationMix.new()
|
|
1055
|
+
.openai()
|
|
1056
|
+
.addText(username)
|
|
1057
|
+
.addImageFromUrl(avatarUrl)
|
|
1058
|
+
.raw();
|
|
1059
|
+
|
|
1060
|
+
if (profile.flagged) throw new Error('Profile rejected by moderation');
|
|
1061
|
+
```
|
|
985
1062
|
- `stream(callback)`: Sends the message and streams the response, invoking the callback with each streamed part.
|
|
986
1063
|
- `json(schemaExample, descriptions = {}, options = {})`: Forces the model to return a response in a specific JSON format.
|
|
987
1064
|
- `schemaExample`: Example of the JSON structure to be returned. Top-level arrays are auto-wrapped for better LLM compatibility.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ModerationMix } from '../index.js';
|
|
2
|
+
try { process.loadEnvFile(); } catch {}
|
|
3
|
+
|
|
4
|
+
const username = 'player_name';
|
|
5
|
+
const avatarUrl = 'https://example.com/avatar.png';
|
|
6
|
+
|
|
7
|
+
const { moderation: [profileModeration] } = await ModerationMix.new()
|
|
8
|
+
.openai()
|
|
9
|
+
.addText(username)
|
|
10
|
+
.addImageFromUrl(avatarUrl)
|
|
11
|
+
.raw();
|
|
12
|
+
|
|
13
|
+
console.log({
|
|
14
|
+
allowed: !profileModeration.flagged,
|
|
15
|
+
categories: profileModeration.categories,
|
|
16
|
+
scores: profileModeration.category_scores
|
|
17
|
+
});
|
package/index.d.ts
CHANGED
|
@@ -190,9 +190,36 @@ export interface ModelMixResult {
|
|
|
190
190
|
response?: unknown;
|
|
191
191
|
assistantMessage?: ChatMessage;
|
|
192
192
|
execution?: PluginExecutionMetadata;
|
|
193
|
+
moderation?: ModerationResult[];
|
|
193
194
|
[key: string]: unknown;
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
export interface ModerationCategories {
|
|
198
|
+
harassment: boolean;
|
|
199
|
+
'harassment/threatening': boolean;
|
|
200
|
+
hate: boolean;
|
|
201
|
+
'hate/threatening': boolean;
|
|
202
|
+
illicit: boolean | null;
|
|
203
|
+
'illicit/violent': boolean | null;
|
|
204
|
+
'self-harm': boolean;
|
|
205
|
+
'self-harm/instructions': boolean;
|
|
206
|
+
'self-harm/intent': boolean;
|
|
207
|
+
sexual: boolean;
|
|
208
|
+
'sexual/minors': boolean;
|
|
209
|
+
violence: boolean;
|
|
210
|
+
'violence/graphic': boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type ModerationCategoryScores = Record<keyof ModerationCategories, number>;
|
|
214
|
+
export type ModerationAppliedInputTypes = Record<keyof ModerationCategories, Array<'text' | 'image'>>;
|
|
215
|
+
|
|
216
|
+
export interface ModerationResult {
|
|
217
|
+
flagged: boolean;
|
|
218
|
+
categories: ModerationCategories;
|
|
219
|
+
category_scores: ModerationCategoryScores;
|
|
220
|
+
category_applied_input_types: ModerationAppliedInputTypes;
|
|
221
|
+
}
|
|
222
|
+
|
|
196
223
|
export type ModelMixOutputMode = 'message' | 'json' | 'block' | 'raw' | 'stream';
|
|
197
224
|
|
|
198
225
|
export interface PluginExecutionMetadata {
|
|
@@ -409,6 +436,8 @@ export declare class ModelMix {
|
|
|
409
436
|
assign(keyValues: Record<string, unknown>): this;
|
|
410
437
|
assignKey(key: string, value: unknown): this;
|
|
411
438
|
effort(value: EffortValue): this;
|
|
439
|
+
/** Attach an ordered model chain. Use `shortcut@effort` for a per-model override. */
|
|
440
|
+
chain(...modelSpecs: string[]): this;
|
|
412
441
|
attach(key: string, provider: MixCustom): this;
|
|
413
442
|
|
|
414
443
|
// OpenAI
|
|
@@ -571,7 +600,14 @@ export declare class MixCustom {
|
|
|
571
600
|
}
|
|
572
601
|
|
|
573
602
|
export declare class MixOpenAI extends MixCustom {}
|
|
603
|
+
export declare class MixModeration extends MixCustom {}
|
|
574
604
|
export declare class MixOpenAIResponses extends MixOpenAI {}
|
|
605
|
+
export declare class MixOpenAIModeration extends MixModeration {
|
|
606
|
+
static messagesToModerationInput(messages?: ChatMessage[]): Array<
|
|
607
|
+
| { type: 'text'; text: string }
|
|
608
|
+
| { type: 'image_url'; image_url: { url: string } }
|
|
609
|
+
>;
|
|
610
|
+
}
|
|
575
611
|
export declare class MixOpenAIWebSocket extends MixOpenAIResponses {}
|
|
576
612
|
export declare class MixOpenRouter extends MixOpenAI {}
|
|
577
613
|
export declare class MixKimi extends MixOpenAI {}
|
|
@@ -590,6 +626,18 @@ export declare class MixFireworks extends MixCustom {}
|
|
|
590
626
|
export declare class MixNVIDIA extends MixCustom {}
|
|
591
627
|
export declare class MixGoogle extends MixCustom {}
|
|
592
628
|
|
|
629
|
+
export declare class ModerationMix extends ModelMix {
|
|
630
|
+
constructor(setup?: Omit<ModelMixSetup, 'mix'>);
|
|
631
|
+
static new(setup?: Omit<ModelMixSetup, 'mix'>): ModerationMix;
|
|
632
|
+
new(setup?: Omit<ModelMixSetup, 'mix'>): ModerationMix;
|
|
633
|
+
attach(key: string, provider: MixModeration): this;
|
|
634
|
+
openai(args?: ModelAttachArgs): this;
|
|
635
|
+
message(): Promise<never>;
|
|
636
|
+
json(): Promise<never>;
|
|
637
|
+
block(): Promise<never>;
|
|
638
|
+
stream(): Promise<never>;
|
|
639
|
+
}
|
|
640
|
+
|
|
593
641
|
/** Normalize unified effort to integer -1 or 0..100. */
|
|
594
642
|
export function normalizeEffort(value: unknown): EffortValue;
|
|
595
643
|
|
package/index.js
CHANGED
|
@@ -401,6 +401,50 @@ const MODEL_PRICING = {
|
|
|
401
401
|
'zai-glm-4.7': { input: 0.55, output: 2.19 },
|
|
402
402
|
};
|
|
403
403
|
|
|
404
|
+
const CHAIN_MODEL_SHORTCUTS = new Set([
|
|
405
|
+
'gpt41', 'gpt41mini', 'gpt41nano', 'gpt5', 'gpt5mini', 'gpt5nano',
|
|
406
|
+
'gpt51', 'gpt52', 'gpt54', 'gpt54mini', 'gpt54nano', 'gpt54pro',
|
|
407
|
+
'gpt55', 'gpt55pro', 'gpt56sol', 'gpt56terra', 'gpt56luna',
|
|
408
|
+
'gptRealtime', 'gptRealtimeMini', 'gpt53codex', 'gpt53chat', 'gptOss',
|
|
409
|
+
'fable50', 'fable5', 'opus50', 'opus5', 'opus48', 'opus47', 'opus46',
|
|
410
|
+
'sonnet50', 'sonnet5', 'sonnet46', 'sonnet45', 'haiku45',
|
|
411
|
+
'gemini25flash', 'gemini31pro', 'gemini3pro', 'gemini3flash',
|
|
412
|
+
'gemini37flash', 'gemini36flash', 'gemini35flash', 'gemini35flashLite',
|
|
413
|
+
'gemini31flashLite', 'gemini25pro', 'sonarPro', 'sonar',
|
|
414
|
+
'grok46', 'grok45', 'grok43', 'grok420multiAgent', 'grok420',
|
|
415
|
+
'qwen3', 'qwen36plus', 'qwen37plus', 'qwen38max', 'hermes3',
|
|
416
|
+
'kimiK26', 'kimiK27Code', 'kimiK3', 'kimiK25',
|
|
417
|
+
'minimaxM25', 'minimaxM27', 'minimaxM3', 'mimo25', 'mimo25pro',
|
|
418
|
+
'deepseekV4Pro', 'deepseekV4Flash', 'GLM51', 'GLM52'
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
function parseChainModels(modelSpecs) {
|
|
422
|
+
if (modelSpecs.length === 0) {
|
|
423
|
+
throw new TypeError('chain() requires at least one model shortcut string.');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return modelSpecs.map((modelSpec, index) => {
|
|
427
|
+
if (typeof modelSpec !== 'string') {
|
|
428
|
+
throw new TypeError(`Invalid chain model at index ${index}: expected a model shortcut string.`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(?:@(-?\d+))?$/.exec(modelSpec);
|
|
432
|
+
if (!match) {
|
|
433
|
+
throw new TypeError(`Invalid chain model "${modelSpec}": expected "shortcut" or "shortcut@effort".`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const shortcut = match[1];
|
|
437
|
+
if (!CHAIN_MODEL_SHORTCUTS.has(shortcut)) {
|
|
438
|
+
throw new Error(`Unknown model shortcut "${shortcut}" in chain().`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
shortcut,
|
|
443
|
+
effort: match[2] === undefined ? undefined : normalizeEffort(Number(match[2]))
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
404
448
|
class ModelMix {
|
|
405
449
|
|
|
406
450
|
constructor({ options = {}, config = {}, mix = {} } = {}) {
|
|
@@ -483,6 +527,18 @@ class ModelMix {
|
|
|
483
527
|
return this;
|
|
484
528
|
}
|
|
485
529
|
|
|
530
|
+
chain(...modelSpecs) {
|
|
531
|
+
const models = parseChainModels(modelSpecs);
|
|
532
|
+
for (const { shortcut, effort } of models) {
|
|
533
|
+
if (effort === undefined) {
|
|
534
|
+
this[shortcut]();
|
|
535
|
+
} else {
|
|
536
|
+
this[shortcut]({ config: { effort } });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return this;
|
|
540
|
+
}
|
|
541
|
+
|
|
486
542
|
use(plugin) {
|
|
487
543
|
if (!isPlainObject(plugin)) {
|
|
488
544
|
throw new TypeError('plugin must be a plain object.');
|
|
@@ -2602,6 +2658,12 @@ class MixOpenAI extends MixCustom {
|
|
|
2602
2658
|
}
|
|
2603
2659
|
}
|
|
2604
2660
|
|
|
2661
|
+
class MixModeration extends MixCustom {
|
|
2662
|
+
getOptionsTools() {
|
|
2663
|
+
return {};
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2605
2667
|
class MixOpenAIResponses extends MixOpenAI {
|
|
2606
2668
|
async create({ config = {}, options = {} } = {}) {
|
|
2607
2669
|
|
|
@@ -2833,6 +2895,109 @@ class MixOpenAIResponses extends MixOpenAI {
|
|
|
2833
2895
|
}
|
|
2834
2896
|
}
|
|
2835
2897
|
|
|
2898
|
+
class MixOpenAIModeration extends MixModeration {
|
|
2899
|
+
getDefaultConfig(customConfig) {
|
|
2900
|
+
const apiKey = customConfig.apiKey || process.env.OPENAI_API_KEY;
|
|
2901
|
+
if (!apiKey) {
|
|
2902
|
+
throw new Error('OpenAI API key not found. Please provide it in config or set OPENAI_API_KEY environment variable.');
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
return super.getDefaultConfig({
|
|
2906
|
+
url: 'https://api.openai.com/v1/moderations',
|
|
2907
|
+
apiKey,
|
|
2908
|
+
...customConfig
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
async create({ config = {}, options = {} } = {}) {
|
|
2913
|
+
if (options.stream) {
|
|
2914
|
+
throw new Error('Stream is not supported for OpenAI moderation');
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
const input = MixOpenAIModeration.messagesToModerationInput(options.messages);
|
|
2918
|
+
const response = await fetchJsonResponse(this.config.url, {
|
|
2919
|
+
method: 'POST',
|
|
2920
|
+
headers: this.headers,
|
|
2921
|
+
body: JSON.stringify({ model: options.model, input })
|
|
2922
|
+
});
|
|
2923
|
+
|
|
2924
|
+
return {
|
|
2925
|
+
moderation: response.data.results,
|
|
2926
|
+
tokens: ModelMix.normalizeTokenUsage(),
|
|
2927
|
+
response: response.data
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
static messagesToModerationInput(messages = []) {
|
|
2932
|
+
const input = [];
|
|
2933
|
+
|
|
2934
|
+
for (const message of messages) {
|
|
2935
|
+
if (typeof message.content === 'string') {
|
|
2936
|
+
input.push({ type: 'text', text: message.content });
|
|
2937
|
+
continue;
|
|
2938
|
+
}
|
|
2939
|
+
if (!Array.isArray(message.content)) continue;
|
|
2940
|
+
|
|
2941
|
+
for (const content of message.content) {
|
|
2942
|
+
if (content?.type === 'text') {
|
|
2943
|
+
input.push({ type: 'text', text: content.text });
|
|
2944
|
+
} else if (content?.type === 'image') {
|
|
2945
|
+
const { media_type: mediaType, data } = content.source || {};
|
|
2946
|
+
if (!mediaType || !data) {
|
|
2947
|
+
throw new Error('OpenAI moderation images must be prepared as base64 data URLs');
|
|
2948
|
+
}
|
|
2949
|
+
input.push({
|
|
2950
|
+
type: 'image_url',
|
|
2951
|
+
image_url: { url: `data:${mediaType};base64,${data}` }
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
return input;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
class ModerationMix extends ModelMix {
|
|
2962
|
+
static new(setup = {}) {
|
|
2963
|
+
return new ModerationMix(setup);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
new({ options = {}, config = {} } = {}) {
|
|
2967
|
+
return new ModerationMix({
|
|
2968
|
+
options: { ...this.options, ...options },
|
|
2969
|
+
config: { ...this.config, ...config }
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
attach(key, provider) {
|
|
2974
|
+
if (!(provider instanceof MixModeration)) {
|
|
2975
|
+
throw new Error('ModerationMix only accepts moderation providers.');
|
|
2976
|
+
}
|
|
2977
|
+
return super.attach(key, provider);
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
openai({ options = {}, config = {} } = {}) {
|
|
2981
|
+
return this.attach('omni-moderation-latest', new MixOpenAIModeration({ options, config }));
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
async message() {
|
|
2985
|
+
throw new Error('ModerationMix does not generate messages. Use raw() and read result.moderation.');
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
async json() {
|
|
2989
|
+
throw new Error('ModerationMix does not generate JSON. Use raw() and read result.moderation.');
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
async block() {
|
|
2993
|
+
throw new Error('ModerationMix does not generate blocks. Use raw() and read result.moderation.');
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
async stream() {
|
|
2997
|
+
throw new Error('ModerationMix does not support streaming. Use raw().');
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
|
|
2836
3001
|
class MixOpenAIWebSocket extends MixOpenAIResponses {
|
|
2837
3002
|
getDefaultConfig(customConfig) {
|
|
2838
3003
|
return super.getDefaultConfig({
|
|
@@ -4020,4 +4185,4 @@ class MixGoogle extends MixCustom {
|
|
|
4020
4185
|
}
|
|
4021
4186
|
}
|
|
4022
4187
|
|
|
4023
|
-
module.exports = { MixCustom, ModelMix, MixAnthropic, MixKimi, MixMiniMax, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenAIWebSocket, MixOpenRouter, MixPerplexity, MixOllama, MixLMStudio, MixGroq, MixTogether, MixGrok, MixCerebras, MixGoogle, MixFireworks, MixNVIDIA, normalizeEffort, applyUnifiedEffort, resolveProviderFamily };
|
|
4188
|
+
module.exports = { MixCustom, ModelMix, ModerationMix, MixModeration, MixAnthropic, MixKimi, MixMiniMax, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenAIModeration, MixOpenAIWebSocket, MixOpenRouter, MixPerplexity, MixOllama, MixLMStudio, MixGroq, MixTogether, MixGrok, MixCerebras, MixGoogle, MixFireworks, MixNVIDIA, normalizeEffort, applyUnifiedEffort, resolveProviderFamily };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelmix",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.5",
|
|
4
4
|
"description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -77,7 +77,6 @@
|
|
|
77
77
|
"test:tokens": "mocha test/tokens.test.js --timeout 10000 --require test/setup.js",
|
|
78
78
|
"test:plugins": "mocha test/plugins.test.js --timeout 10000 --require test/setup.js",
|
|
79
79
|
"test:rlm": "mocha plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js",
|
|
80
|
-
"test:offline": "mocha test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/plugins.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
}
|
|
80
|
+
"test:offline": "mocha test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/moderation.test.js test/plugins.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
|
|
81
|
+
}
|
|
82
|
+
}
|
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -92,6 +92,17 @@ const model = ModelMix.new()
|
|
|
92
92
|
|
|
93
93
|
If `sonnet46` fails, it automatically tries `gpt52`, then `gemini3flash`.
|
|
94
94
|
|
|
95
|
+
The equivalent `chain()` form accepts public shortcut names directly in the
|
|
96
|
+
same order. Append `@effort` for a per-model unified effort override (`-1` or
|
|
97
|
+
`0`–`100`). Without the suffix, the entry inherits `config.effort`, or keeps the
|
|
98
|
+
provider default when no chain effort is configured:
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
const model = ModelMix.new()
|
|
102
|
+
.chain('sonnet46', 'gpt52@20', 'gemini3flash@-1')
|
|
103
|
+
.addText('Hello!');
|
|
104
|
+
```
|
|
105
|
+
|
|
95
106
|
### Instance plugins
|
|
96
107
|
|
|
97
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.
|
|
@@ -137,6 +148,9 @@ ModelMix.new({ config: { effort: 80 } })
|
|
|
137
148
|
## Available Model Shorthands
|
|
138
149
|
|
|
139
150
|
### OpenAI
|
|
151
|
+
|
|
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
|
+
|
|
140
154
|
`gpt52()` `gpt52chat()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gpt45()` `gpt41()` `gpt41mini()` `gpt41nano()` `o3()` `o4mini()`
|
|
141
155
|
|
|
142
156
|
### Anthropic
|
|
@@ -610,7 +624,7 @@ const model = ModelMix.new({
|
|
|
610
624
|
|
|
611
625
|
## Available Provider Classes
|
|
612
626
|
|
|
613
|
-
`MixOpenAI` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
|
|
627
|
+
`ModerationMix` `MixModeration` `MixOpenAI` `MixOpenAIResponses` `MixOpenAIModeration` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
|
|
614
628
|
|
|
615
629
|
## Troubleshooting
|
|
616
630
|
|
package/test/fallback.test.js
CHANGED
|
@@ -26,6 +26,40 @@ describe('Provider Fallback Chain Tests', () => {
|
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('should attach chain shortcuts from arguments with optional per-model effort', () => {
|
|
30
|
+
model.chain('sonnet5', 'gpt56luna@20', 'gemini37flash@-1');
|
|
31
|
+
|
|
32
|
+
expect(model.models.map(({ key }) => key)).to.deep.equal([
|
|
33
|
+
'claude-sonnet-5',
|
|
34
|
+
'gpt-5.6-luna',
|
|
35
|
+
'gemini-3.7-flash'
|
|
36
|
+
]);
|
|
37
|
+
expect(model.models[0].provider.config).to.not.have.property('effort');
|
|
38
|
+
expect(model.models[1].provider.config.effort).to.equal(20);
|
|
39
|
+
expect(model.models[2].provider.config.effort).to.equal(-1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should keep the chain default effort when an entry omits it', () => {
|
|
43
|
+
model.effort(60).chain('gpt56luna', 'sonnet5@20');
|
|
44
|
+
|
|
45
|
+
expect(model.config.effort).to.equal(60);
|
|
46
|
+
expect(model.models[0].provider.config).to.not.have.property('effort');
|
|
47
|
+
expect(model.models[1].provider.config.effort).to.equal(20);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should reject invalid chains before attaching any model', () => {
|
|
51
|
+
expect(() => model.chain('gpt56luna', 'unknownModel'))
|
|
52
|
+
.to.throw('Unknown model shortcut "unknownModel" in chain().');
|
|
53
|
+
expect(model.models).to.have.length(0);
|
|
54
|
+
|
|
55
|
+
expect(() => model.chain('gpt56luna@101'))
|
|
56
|
+
.to.throw(/Invalid effort/);
|
|
57
|
+
expect(() => model.chain())
|
|
58
|
+
.to.throw('chain() requires at least one model shortcut string.');
|
|
59
|
+
expect(() => model.chain(['gpt56luna']))
|
|
60
|
+
.to.throw('Invalid chain model at index 0: expected a model shortcut string.');
|
|
61
|
+
});
|
|
62
|
+
|
|
29
63
|
it('should use primary provider when available', async () => {
|
|
30
64
|
model.gpt5mini().sonnet46().addText('Hello');
|
|
31
65
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const nock = require('nock');
|
|
3
|
+
const { ModerationMix, MixModeration, MixOpenAIModeration } = require('../index.js');
|
|
4
|
+
|
|
5
|
+
describe('OpenAI moderation', () => {
|
|
6
|
+
const moderationResult = {
|
|
7
|
+
flagged: true,
|
|
8
|
+
categories: { violence: true },
|
|
9
|
+
category_scores: { violence: 0.98 },
|
|
10
|
+
category_applied_input_types: { violence: ['text', 'image'] }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it('registers omni-moderation-latest with openai()', () => {
|
|
14
|
+
const model = ModerationMix.new().openai({ config: { apiKey: 'test-key' } });
|
|
15
|
+
|
|
16
|
+
expect(model.models).to.have.length(1);
|
|
17
|
+
expect(model.models[0].key).to.equal('omni-moderation-latest');
|
|
18
|
+
expect(model.models[0].provider).to.be.instanceOf(MixOpenAIModeration);
|
|
19
|
+
expect(model.models[0].provider.config.url).to.equal('https://api.openai.com/v1/moderations');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts an explicit API key without requiring the environment variable', () => {
|
|
23
|
+
const originalApiKey = process.env.OPENAI_API_KEY;
|
|
24
|
+
delete process.env.OPENAI_API_KEY;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const model = ModerationMix.new().openai({ config: { apiKey: 'explicit-key' } });
|
|
28
|
+
expect(model.models[0].provider.config.apiKey).to.equal('explicit-key');
|
|
29
|
+
} finally {
|
|
30
|
+
if (originalApiKey === undefined) delete process.env.OPENAI_API_KEY;
|
|
31
|
+
else process.env.OPENAI_API_KEY = originalApiKey;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('sends text and image input to the Moderations endpoint', async () => {
|
|
36
|
+
const api = nock('https://api.openai.com')
|
|
37
|
+
.post('/v1/moderations', body => {
|
|
38
|
+
expect(body).to.deep.equal({
|
|
39
|
+
model: 'omni-moderation-latest',
|
|
40
|
+
input: [
|
|
41
|
+
{ type: 'text', text: 'Check this' },
|
|
42
|
+
{
|
|
43
|
+
type: 'image_url',
|
|
44
|
+
image_url: { url: 'data:image/png;base64,aW1hZ2U=' }
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
return true;
|
|
49
|
+
})
|
|
50
|
+
.reply(200, {
|
|
51
|
+
id: 'modr-test',
|
|
52
|
+
model: 'omni-moderation-latest',
|
|
53
|
+
results: [moderationResult]
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await ModerationMix.new()
|
|
57
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
58
|
+
.addText('Check this')
|
|
59
|
+
.addImageFromUrl('data:image/png;base64,aW1hZ2U=')
|
|
60
|
+
.raw();
|
|
61
|
+
|
|
62
|
+
expect(result.moderation).to.deep.equal([moderationResult]);
|
|
63
|
+
api.done();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('exposes the complete API response through raw()', async () => {
|
|
67
|
+
const response = {
|
|
68
|
+
id: 'modr-test',
|
|
69
|
+
model: 'omni-moderation-latest',
|
|
70
|
+
results: [moderationResult]
|
|
71
|
+
};
|
|
72
|
+
const api = nock('https://api.openai.com')
|
|
73
|
+
.post('/v1/moderations')
|
|
74
|
+
.reply(200, response);
|
|
75
|
+
|
|
76
|
+
const raw = await ModerationMix.new()
|
|
77
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
78
|
+
.addText('Check this')
|
|
79
|
+
.raw();
|
|
80
|
+
|
|
81
|
+
expect(raw.moderation).to.deep.equal(response.results);
|
|
82
|
+
expect(raw.response).to.deep.equal(response);
|
|
83
|
+
expect(raw.tokens).to.include({ input: 0, output: 0, total: 0, cost: 0 });
|
|
84
|
+
api.done();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects streaming because the Moderations endpoint does not support it', async () => {
|
|
88
|
+
const model = ModerationMix.new()
|
|
89
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
90
|
+
.addText('Check this');
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await model.stream(() => {});
|
|
94
|
+
throw new Error('Expected stream() to reject');
|
|
95
|
+
} catch (error) {
|
|
96
|
+
expect(error.message).to.equal('ModerationMix does not support streaming. Use raw().');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects generative providers from the moderation chain', () => {
|
|
101
|
+
const model = ModerationMix.new();
|
|
102
|
+
|
|
103
|
+
expect(() => model.gpt41nano()).to.throw(
|
|
104
|
+
'ModerationMix only accepts moderation providers.'
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('accepts additional moderation providers as fallbacks', () => {
|
|
109
|
+
class TestModeration extends MixModeration {}
|
|
110
|
+
const model = ModerationMix.new()
|
|
111
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
112
|
+
.attach('test-moderation', new TestModeration({ config: { apiKey: 'test-key' } }));
|
|
113
|
+
|
|
114
|
+
expect(model.models.map(({ key }) => key)).to.deep.equal([
|
|
115
|
+
'omni-moderation-latest',
|
|
116
|
+
'test-moderation'
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
for (const method of ['message', 'json', 'block']) {
|
|
121
|
+
it(`rejects ${method}() because moderation is not generative`, async () => {
|
|
122
|
+
const model = ModerationMix.new()
|
|
123
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
124
|
+
.addText('Check this');
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await model[method]();
|
|
128
|
+
throw new Error(`Expected ${method}() to reject`);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
expect(error.message).to.include('ModerationMix does not generate');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
});
|