modelmix 5.0.3 → 5.0.4

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 CHANGED
@@ -982,6 +982,18 @@ new ModelMix(args = { options: {}, config: {} })
982
982
  - `toolCalls`: Array of tool calls made by the model (if any)
983
983
  - `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
984
  - `response`: The raw API response
985
+ - `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.
986
+ ```javascript
987
+ const { ModerationMix } = require('modelmix');
988
+
989
+ const { moderation: [profile] } = await ModerationMix.new()
990
+ .openai()
991
+ .addText(username)
992
+ .addImageFromUrl(avatarUrl)
993
+ .raw();
994
+
995
+ if (profile.flagged) throw new Error('Profile rejected by moderation');
996
+ ```
985
997
  - `stream(callback)`: Sends the message and streams the response, invoking the callback with each streamed part.
986
998
  - `json(schemaExample, descriptions = {}, options = {})`: Forces the model to return a response in a specific JSON format.
987
999
  - `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 {
@@ -571,7 +598,14 @@ export declare class MixCustom {
571
598
  }
572
599
 
573
600
  export declare class MixOpenAI extends MixCustom {}
601
+ export declare class MixModeration extends MixCustom {}
574
602
  export declare class MixOpenAIResponses extends MixOpenAI {}
603
+ export declare class MixOpenAIModeration extends MixModeration {
604
+ static messagesToModerationInput(messages?: ChatMessage[]): Array<
605
+ | { type: 'text'; text: string }
606
+ | { type: 'image_url'; image_url: { url: string } }
607
+ >;
608
+ }
575
609
  export declare class MixOpenAIWebSocket extends MixOpenAIResponses {}
576
610
  export declare class MixOpenRouter extends MixOpenAI {}
577
611
  export declare class MixKimi extends MixOpenAI {}
@@ -590,6 +624,18 @@ export declare class MixFireworks extends MixCustom {}
590
624
  export declare class MixNVIDIA extends MixCustom {}
591
625
  export declare class MixGoogle extends MixCustom {}
592
626
 
627
+ export declare class ModerationMix extends ModelMix {
628
+ constructor(setup?: Omit<ModelMixSetup, 'mix'>);
629
+ static new(setup?: Omit<ModelMixSetup, 'mix'>): ModerationMix;
630
+ new(setup?: Omit<ModelMixSetup, 'mix'>): ModerationMix;
631
+ attach(key: string, provider: MixModeration): this;
632
+ openai(args?: ModelAttachArgs): this;
633
+ message(): Promise<never>;
634
+ json(): Promise<never>;
635
+ block(): Promise<never>;
636
+ stream(): Promise<never>;
637
+ }
638
+
593
639
  /** Normalize unified effort to integer -1 or 0..100. */
594
640
  export function normalizeEffort(value: unknown): EffortValue;
595
641
 
package/index.js CHANGED
@@ -2602,6 +2602,12 @@ class MixOpenAI extends MixCustom {
2602
2602
  }
2603
2603
  }
2604
2604
 
2605
+ class MixModeration extends MixCustom {
2606
+ getOptionsTools() {
2607
+ return {};
2608
+ }
2609
+ }
2610
+
2605
2611
  class MixOpenAIResponses extends MixOpenAI {
2606
2612
  async create({ config = {}, options = {} } = {}) {
2607
2613
 
@@ -2833,6 +2839,109 @@ class MixOpenAIResponses extends MixOpenAI {
2833
2839
  }
2834
2840
  }
2835
2841
 
2842
+ class MixOpenAIModeration extends MixModeration {
2843
+ getDefaultConfig(customConfig) {
2844
+ const apiKey = customConfig.apiKey || process.env.OPENAI_API_KEY;
2845
+ if (!apiKey) {
2846
+ throw new Error('OpenAI API key not found. Please provide it in config or set OPENAI_API_KEY environment variable.');
2847
+ }
2848
+
2849
+ return super.getDefaultConfig({
2850
+ url: 'https://api.openai.com/v1/moderations',
2851
+ apiKey,
2852
+ ...customConfig
2853
+ });
2854
+ }
2855
+
2856
+ async create({ config = {}, options = {} } = {}) {
2857
+ if (options.stream) {
2858
+ throw new Error('Stream is not supported for OpenAI moderation');
2859
+ }
2860
+
2861
+ const input = MixOpenAIModeration.messagesToModerationInput(options.messages);
2862
+ const response = await fetchJsonResponse(this.config.url, {
2863
+ method: 'POST',
2864
+ headers: this.headers,
2865
+ body: JSON.stringify({ model: options.model, input })
2866
+ });
2867
+
2868
+ return {
2869
+ moderation: response.data.results,
2870
+ tokens: ModelMix.normalizeTokenUsage(),
2871
+ response: response.data
2872
+ };
2873
+ }
2874
+
2875
+ static messagesToModerationInput(messages = []) {
2876
+ const input = [];
2877
+
2878
+ for (const message of messages) {
2879
+ if (typeof message.content === 'string') {
2880
+ input.push({ type: 'text', text: message.content });
2881
+ continue;
2882
+ }
2883
+ if (!Array.isArray(message.content)) continue;
2884
+
2885
+ for (const content of message.content) {
2886
+ if (content?.type === 'text') {
2887
+ input.push({ type: 'text', text: content.text });
2888
+ } else if (content?.type === 'image') {
2889
+ const { media_type: mediaType, data } = content.source || {};
2890
+ if (!mediaType || !data) {
2891
+ throw new Error('OpenAI moderation images must be prepared as base64 data URLs');
2892
+ }
2893
+ input.push({
2894
+ type: 'image_url',
2895
+ image_url: { url: `data:${mediaType};base64,${data}` }
2896
+ });
2897
+ }
2898
+ }
2899
+ }
2900
+
2901
+ return input;
2902
+ }
2903
+ }
2904
+
2905
+ class ModerationMix extends ModelMix {
2906
+ static new(setup = {}) {
2907
+ return new ModerationMix(setup);
2908
+ }
2909
+
2910
+ new({ options = {}, config = {} } = {}) {
2911
+ return new ModerationMix({
2912
+ options: { ...this.options, ...options },
2913
+ config: { ...this.config, ...config }
2914
+ });
2915
+ }
2916
+
2917
+ attach(key, provider) {
2918
+ if (!(provider instanceof MixModeration)) {
2919
+ throw new Error('ModerationMix only accepts moderation providers.');
2920
+ }
2921
+ return super.attach(key, provider);
2922
+ }
2923
+
2924
+ openai({ options = {}, config = {} } = {}) {
2925
+ return this.attach('omni-moderation-latest', new MixOpenAIModeration({ options, config }));
2926
+ }
2927
+
2928
+ async message() {
2929
+ throw new Error('ModerationMix does not generate messages. Use raw() and read result.moderation.');
2930
+ }
2931
+
2932
+ async json() {
2933
+ throw new Error('ModerationMix does not generate JSON. Use raw() and read result.moderation.');
2934
+ }
2935
+
2936
+ async block() {
2937
+ throw new Error('ModerationMix does not generate blocks. Use raw() and read result.moderation.');
2938
+ }
2939
+
2940
+ async stream() {
2941
+ throw new Error('ModerationMix does not support streaming. Use raw().');
2942
+ }
2943
+ }
2944
+
2836
2945
  class MixOpenAIWebSocket extends MixOpenAIResponses {
2837
2946
  getDefaultConfig(customConfig) {
2838
2947
  return super.getDefaultConfig({
@@ -4020,4 +4129,4 @@ class MixGoogle extends MixCustom {
4020
4129
  }
4021
4130
  }
4022
4131
 
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 };
4132
+ 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",
3
+ "version": "5.0.4",
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
- "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
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
+ }
@@ -137,6 +137,9 @@ ModelMix.new({ config: { effort: 80 } })
137
137
  ## Available Model Shorthands
138
138
 
139
139
  ### OpenAI
140
+
141
+ 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.
142
+
140
143
  `gpt52()` `gpt52chat()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gpt45()` `gpt41()` `gpt41mini()` `gpt41nano()` `o3()` `o4mini()`
141
144
 
142
145
  ### Anthropic
@@ -610,7 +613,7 @@ const model = ModelMix.new({
610
613
 
611
614
  ## Available Provider Classes
612
615
 
613
- `MixOpenAI` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
616
+ `ModerationMix` `MixModeration` `MixOpenAI` `MixOpenAIResponses` `MixOpenAIModeration` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
614
617
 
615
618
  ## Troubleshooting
616
619
 
@@ -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
+ });