modelmix 5.1.1 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,622 @@
1
+ const { fetchJsonResponse } = require('../../http-client');
2
+ const { isPlainObject } = require('../object-utils');
3
+ const {
4
+ normalizeContentCache,
5
+ stripContentCacheMetadata
6
+ } = require('../content-cache');
7
+
8
+ function createOpenAIProviders({
9
+ ModelMix,
10
+ MixCustom,
11
+ MixOpenAI,
12
+ MixModeration
13
+ }) {
14
+ const WebSocket = require('ws');
15
+
16
+ class MixOpenAIResponses extends MixOpenAI {
17
+ async create({ config = {}, options = {} } = {}) {
18
+
19
+ // Keep GPT/o-model option normalization behavior
20
+ if (options.model?.startsWith('o')) {
21
+ delete options.max_tokens;
22
+ delete options.temperature;
23
+ }
24
+ if (options.model?.includes('gpt-5')) {
25
+ if (options.max_tokens) {
26
+ options.max_completion_tokens = options.max_tokens;
27
+ delete options.max_tokens;
28
+ }
29
+ delete options.temperature;
30
+ }
31
+
32
+ const responsesUrl = this.config.url.replace('/chat/completions', '/responses');
33
+ const request = MixOpenAIResponses.buildResponsesRequest(options, config);
34
+ const response = await fetchJsonResponse(responsesUrl, {
35
+ method: 'POST',
36
+ headers: this.headers,
37
+ body: JSON.stringify(request)
38
+ });
39
+
40
+ return MixOpenAIResponses.processResponsesResponse(response);
41
+ }
42
+
43
+ static buildResponsesRequest(options = {}, config = {}) {
44
+ const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
45
+ const input = MixOpenAIResponses.messagesToResponsesInput(options.messages, {
46
+ translateNeutralCache: isGPT56
47
+ });
48
+ if (config.system) {
49
+ input.unshift({ role: 'developer', content: [{ type: 'input_text', text: config.system }] });
50
+ }
51
+ MixOpenAIResponses.validatePromptCaching(options, input);
52
+ const request = {
53
+ model: options.model,
54
+ input,
55
+ stream: false
56
+ };
57
+
58
+ if (options.reasoning_effort) request.reasoning = { effort: options.reasoning_effort };
59
+ if (options.verbosity) request.text = { verbosity: options.verbosity };
60
+
61
+ if (options.response_format) {
62
+ const rf = options.response_format;
63
+ let format;
64
+ if (rf.type === 'json_schema' && rf.json_schema) {
65
+ format = {
66
+ type: 'json_schema',
67
+ name: rf.json_schema.name || 'response',
68
+ strict: true,
69
+ schema: rf.json_schema.schema
70
+ };
71
+ } else if (rf.type) {
72
+ format = { type: rf.type };
73
+ }
74
+ if (format) {
75
+ request.text = { ...request.text, format };
76
+ }
77
+ }
78
+
79
+ if (typeof options.max_completion_tokens === 'number') {
80
+ request.max_output_tokens = options.max_completion_tokens;
81
+ } else if (typeof options.max_tokens === 'number') {
82
+ request.max_output_tokens = options.max_tokens;
83
+ }
84
+
85
+ if (typeof options.temperature === 'number') request.temperature = options.temperature;
86
+ if (typeof options.top_p === 'number') request.top_p = options.top_p;
87
+ if (typeof options.presence_penalty === 'number') request.presence_penalty = options.presence_penalty;
88
+ if (typeof options.frequency_penalty === 'number') request.frequency_penalty = options.frequency_penalty;
89
+ if (options.stop !== undefined) request.stop = options.stop;
90
+ if (typeof options.n === 'number') request.n = options.n;
91
+ if (options.logit_bias !== undefined) request.logit_bias = options.logit_bias;
92
+ if (options.user !== undefined) request.user = options.user;
93
+ if (options.prompt_cache_key !== undefined) request.prompt_cache_key = options.prompt_cache_key;
94
+ if (options.prompt_cache_retention !== undefined) request.prompt_cache_retention = options.prompt_cache_retention;
95
+ if (options.prompt_cache_options !== undefined) request.prompt_cache_options = options.prompt_cache_options;
96
+
97
+ return request;
98
+ }
99
+
100
+ static validatePromptCaching(options, input) {
101
+ const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
102
+ const cacheOptions = options.prompt_cache_options;
103
+ const breakpoints = input.flatMap(message => Array.isArray(message.content)
104
+ ? message.content
105
+ .filter(block => block?.prompt_cache_breakpoint !== undefined)
106
+ .map(block => block.prompt_cache_breakpoint)
107
+ : []);
108
+
109
+ if (isGPT56 && options.prompt_cache_retention !== undefined) {
110
+ throw new Error('GPT-5.6 does not support prompt_cache_retention; use prompt_cache_options.ttl instead.');
111
+ }
112
+ if (!isGPT56 && cacheOptions !== undefined) {
113
+ throw new Error('prompt_cache_options is only supported by GPT-5.6 models.');
114
+ }
115
+ if (!isGPT56 && breakpoints.length > 0) {
116
+ throw new Error('prompt_cache_breakpoint is only supported by GPT-5.6 models.');
117
+ }
118
+ if (cacheOptions !== undefined) {
119
+ if (!isPlainObject(cacheOptions)) {
120
+ throw new TypeError('prompt_cache_options must be a plain non-null object.');
121
+ }
122
+ if (cacheOptions.mode !== undefined
123
+ && cacheOptions.mode !== 'implicit'
124
+ && cacheOptions.mode !== 'explicit') {
125
+ throw new TypeError('prompt_cache_options.mode must be "implicit" or "explicit".');
126
+ }
127
+ if (cacheOptions.ttl !== undefined && cacheOptions.ttl !== '30m') {
128
+ throw new TypeError('prompt_cache_options.ttl must be "30m".');
129
+ }
130
+ }
131
+ for (const breakpoint of breakpoints) {
132
+ if (!isPlainObject(breakpoint)) {
133
+ throw new TypeError('prompt_cache_breakpoint must be a plain non-null object.');
134
+ }
135
+ if (breakpoint.mode !== 'explicit') {
136
+ throw new TypeError('prompt_cache_breakpoint mode must be "explicit".');
137
+ }
138
+ }
139
+ }
140
+
141
+ static processResponsesResponse(response) {
142
+ const message = MixOpenAIResponses.extractResponsesMessage(response.data);
143
+ return {
144
+ message,
145
+ think: null,
146
+ toolCalls: [],
147
+ tokens: MixOpenAIResponses.extractResponsesTokens(response.data),
148
+ response: response.data
149
+ };
150
+ }
151
+
152
+ static extractResponsesTokens(data) {
153
+ if (data.usage) {
154
+ return ModelMix.normalizeTokenUsage({
155
+ input: data.usage.input_tokens || 0,
156
+ output: data.usage.output_tokens || 0,
157
+ total: data.usage.total_tokens,
158
+ cached: ModelMix.extractCacheTokens(data.usage),
159
+ cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
160
+ });
161
+ }
162
+ return ModelMix.normalizeTokenUsage();
163
+ }
164
+
165
+ static extractResponsesMessage(data) {
166
+ if (!Array.isArray(data.output)) return '';
167
+ return data.output
168
+ .filter(item => item.type === 'message')
169
+ .flatMap(item => Array.isArray(item.content) ? item.content : [])
170
+ .filter(content => content.type === 'output_text' && typeof content.text === 'string')
171
+ .map(content => content.text)
172
+ .join('\n')
173
+ .trim();
174
+ }
175
+
176
+ static messagesToResponsesInput(messages = [], { translateNeutralCache = false } = {}) {
177
+ const mapped = [];
178
+
179
+ for (const message of messages) {
180
+ if (!message || !message.role) continue;
181
+ if (message.tool_calls || message.role === 'tool') continue;
182
+
183
+ const content = [];
184
+ const isAssistant = message.role === 'assistant';
185
+ const textType = isAssistant ? 'output_text' : 'input_text';
186
+ if (typeof message.content === 'string') {
187
+ if (message.content) content.push({ type: textType, text: message.content });
188
+ } else if (Array.isArray(message.content)) {
189
+ for (const item of message.content) {
190
+ if (!item || typeof item !== 'object') continue;
191
+ const neutralCache = item.cache !== undefined
192
+ ? normalizeContentCache(item.cache)
193
+ : undefined;
194
+ const promptCacheBreakpoint = item.prompt_cache_breakpoint !== undefined
195
+ ? item.prompt_cache_breakpoint
196
+ : (translateNeutralCache && neutralCache?.breakpoint
197
+ ? { mode: 'explicit' }
198
+ : undefined);
199
+ const breakpoint = !isAssistant && promptCacheBreakpoint !== undefined
200
+ ? { prompt_cache_breakpoint: promptCacheBreakpoint }
201
+ : {};
202
+
203
+ if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text')
204
+ && typeof item.text === 'string') {
205
+ content.push({ type: textType, text: item.text, ...breakpoint });
206
+ continue;
207
+ }
208
+ if (item.type === 'image' && item.source) {
209
+ let imageUrl;
210
+ if (item.source.type === 'base64') {
211
+ if (!item.source.media_type || typeof item.source.data !== 'string') {
212
+ throw new TypeError('Responses base64 images require source.media_type and string source.data.');
213
+ }
214
+ imageUrl = `data:${item.source.media_type};base64,${item.source.data}`;
215
+ } else if (item.source.type === 'url' && typeof item.source.data === 'string') {
216
+ imageUrl = item.source.data;
217
+ } else {
218
+ throw new TypeError('Responses images must be processed to base64 or use a URL source.');
219
+ }
220
+ content.push({ type: 'input_image', image_url: imageUrl, ...breakpoint });
221
+ continue;
222
+ }
223
+ if (item.type === 'image_url' && typeof item.image_url?.url === 'string') {
224
+ content.push({ type: 'input_image', image_url: item.image_url.url, ...breakpoint });
225
+ continue;
226
+ }
227
+ if (item.type === 'input_image' || item.type === 'input_file') {
228
+ content.push({
229
+ ...stripContentCacheMetadata(item),
230
+ ...breakpoint
231
+ });
232
+ }
233
+ }
234
+ }
235
+
236
+ if (content.length === 0) continue;
237
+ mapped.push({
238
+ role: message.role,
239
+ content
240
+ });
241
+ }
242
+
243
+ return mapped;
244
+ }
245
+ }
246
+
247
+ class MixOpenAIModeration extends MixModeration {
248
+ getDefaultConfig(customConfig) {
249
+ const apiKey = customConfig.apiKey || process.env.OPENAI_API_KEY;
250
+ if (!apiKey) {
251
+ throw new Error('OpenAI API key not found. Please provide it in config or set OPENAI_API_KEY environment variable.');
252
+ }
253
+
254
+ return super.getDefaultConfig({
255
+ url: 'https://api.openai.com/v1/moderations',
256
+ apiKey,
257
+ ...customConfig
258
+ });
259
+ }
260
+
261
+ async create({ config = {}, options = {} } = {}) {
262
+ if (options.stream) {
263
+ throw new Error('Stream is not supported for OpenAI moderation');
264
+ }
265
+
266
+ const input = MixOpenAIModeration.messagesToModerationInput(options.messages);
267
+ const response = await fetchJsonResponse(this.config.url, {
268
+ method: 'POST',
269
+ headers: this.headers,
270
+ body: JSON.stringify({ model: options.model, input })
271
+ });
272
+
273
+ return {
274
+ moderation: response.data.results,
275
+ tokens: ModelMix.normalizeTokenUsage(),
276
+ response: response.data
277
+ };
278
+ }
279
+
280
+ static messagesToModerationInput(messages = []) {
281
+ const input = [];
282
+
283
+ for (const message of messages) {
284
+ if (typeof message.content === 'string') {
285
+ input.push({ type: 'text', text: message.content });
286
+ continue;
287
+ }
288
+ if (!Array.isArray(message.content)) continue;
289
+
290
+ for (const content of message.content) {
291
+ if (content?.type === 'text') {
292
+ input.push({ type: 'text', text: content.text });
293
+ } else if (content?.type === 'image') {
294
+ const { media_type: mediaType, data } = content.source || {};
295
+ if (!mediaType || !data) {
296
+ throw new Error('OpenAI moderation images must be prepared as base64 data URLs');
297
+ }
298
+ input.push({
299
+ type: 'image_url',
300
+ image_url: { url: `data:${mediaType};base64,${data}` }
301
+ });
302
+ }
303
+ }
304
+ }
305
+
306
+ return input;
307
+ }
308
+ }
309
+
310
+ class ModerationMix extends ModelMix {
311
+ static new(setup = {}) {
312
+ return new ModerationMix(setup);
313
+ }
314
+
315
+ new({ options = {}, config = {} } = {}) {
316
+ return new ModerationMix({
317
+ options: { ...this.options, ...options },
318
+ config: { ...this.config, ...config }
319
+ });
320
+ }
321
+
322
+ attach(key, provider) {
323
+ if (!(provider instanceof MixModeration)) {
324
+ throw new Error('ModerationMix only accepts moderation providers.');
325
+ }
326
+ return super.attach(key, provider);
327
+ }
328
+
329
+ openai({ options = {}, config = {} } = {}) {
330
+ return this.attach('omni-moderation-latest', new MixOpenAIModeration({ options, config }));
331
+ }
332
+
333
+ async message() {
334
+ throw new Error('ModerationMix does not generate messages. Use raw() and read result.moderation.');
335
+ }
336
+
337
+ async json() {
338
+ throw new Error('ModerationMix does not generate JSON. Use raw() and read result.moderation.');
339
+ }
340
+
341
+ async block() {
342
+ throw new Error('ModerationMix does not generate blocks. Use raw() and read result.moderation.');
343
+ }
344
+
345
+ async stream() {
346
+ throw new Error('ModerationMix does not support streaming. Use raw().');
347
+ }
348
+ }
349
+
350
+ class MixOpenAIWebSocket extends MixOpenAIResponses {
351
+ getDefaultConfig(customConfig) {
352
+ return super.getDefaultConfig({
353
+ realtimeUrl: 'wss://api.openai.com/v1/realtime',
354
+ websocketTimeoutMs: 120000,
355
+ ...customConfig
356
+ });
357
+ }
358
+
359
+ async create({ config = {}, options = {} } = {}) {
360
+ if (options.model?.startsWith('o')) {
361
+ delete options.max_tokens;
362
+ delete options.temperature;
363
+ }
364
+ if (options.model?.includes('gpt-5')) {
365
+ if (options.max_tokens) {
366
+ options.max_completion_tokens = options.max_tokens;
367
+ delete options.max_tokens;
368
+ }
369
+ delete options.temperature;
370
+ }
371
+
372
+ const mergedConfig = { ...this.config, ...config };
373
+ const realtimeUrl = `${mergedConfig.realtimeUrl}?model=${encodeURIComponent(options.model)}`;
374
+ const timeoutMs = mergedConfig.websocketTimeoutMs || 120000;
375
+
376
+ return await new Promise((resolve, reject) => {
377
+ const ws = new WebSocket(realtimeUrl, {
378
+ headers: {
379
+ authorization: `Bearer ${mergedConfig.apiKey}`
380
+ }
381
+ });
382
+
383
+ const events = [];
384
+ let message = '';
385
+ let settled = false;
386
+ let finalResponse = null;
387
+
388
+ const timeout = setTimeout(() => {
389
+ if (settled) return;
390
+ settled = true;
391
+ ws.close();
392
+ reject({
393
+ message: `Realtime WebSocket timed out after ${timeoutMs}ms`,
394
+ statusCode: null,
395
+ details: null
396
+ });
397
+ }, timeoutMs);
398
+
399
+ const cleanUp = () => clearTimeout(timeout);
400
+
401
+ ws.on('open', () => {
402
+ const session = {
403
+ type: 'realtime',
404
+ output_modalities: ['text']
405
+ };
406
+
407
+ if (mergedConfig.system) session.instructions = mergedConfig.system;
408
+ if (Array.isArray(options.tools) && options.tools.length > 0) {
409
+ session.tools = options.tools;
410
+ }
411
+
412
+ ws.send(JSON.stringify({ type: 'session.update', session }));
413
+
414
+ const items = MixOpenAIWebSocket.messagesToConversationItems(options.messages);
415
+ for (const item of items) {
416
+ ws.send(JSON.stringify({
417
+ type: 'conversation.item.create',
418
+ item
419
+ }));
420
+ }
421
+
422
+ const responseConfig = { output_modalities: ['text'] };
423
+ if (typeof options.max_completion_tokens === 'number') {
424
+ responseConfig.max_output_tokens = Math.min(options.max_completion_tokens, 4096);
425
+ } else if (typeof options.max_tokens === 'number') {
426
+ responseConfig.max_output_tokens = Math.min(options.max_tokens, 4096);
427
+ }
428
+ if (Array.isArray(options.tools) && options.tools.length > 0) responseConfig.tools = options.tools;
429
+
430
+ ws.send(JSON.stringify({
431
+ type: 'response.create',
432
+ response: responseConfig
433
+ }));
434
+ });
435
+
436
+ ws.on('message', raw => {
437
+ let event;
438
+ try {
439
+ event = JSON.parse(raw.toString());
440
+ } catch {
441
+ return;
442
+ }
443
+
444
+ events.push(event);
445
+
446
+ const isTextDeltaEvent = event.type === 'response.text.delta' || event.type === 'response.output_text.delta';
447
+ if (isTextDeltaEvent) {
448
+ const delta = MixOpenAIWebSocket.extractDelta(event);
449
+ if (delta) {
450
+ message += delta;
451
+ if (this.streamCallback) {
452
+ this.streamCallback({ response: event, message, delta });
453
+ }
454
+ }
455
+ return;
456
+ }
457
+
458
+ if (event.type === 'response.done') {
459
+ finalResponse = event.response || null;
460
+ if (!message && finalResponse) {
461
+ message = MixOpenAIResponses.extractResponsesMessage(finalResponse);
462
+ }
463
+
464
+ if (!settled) {
465
+ settled = true;
466
+ cleanUp();
467
+ ws.close();
468
+ resolve({
469
+ message: message.trim(),
470
+ think: null,
471
+ toolCalls: [],
472
+ tokens: MixOpenAIResponses.extractResponsesTokens(finalResponse || {}),
473
+ response: {
474
+ response: finalResponse,
475
+ events
476
+ }
477
+ });
478
+ }
479
+ return;
480
+ }
481
+
482
+ if (event.type === 'error' && !settled) {
483
+ settled = true;
484
+ cleanUp();
485
+ ws.close();
486
+ reject({
487
+ message: event.error?.message || 'Realtime WebSocket error',
488
+ statusCode: null,
489
+ details: event.error || event
490
+ });
491
+ }
492
+ });
493
+
494
+ ws.on('error', error => {
495
+ if (settled) return;
496
+ settled = true;
497
+ cleanUp();
498
+ reject({
499
+ message: error.message || 'Realtime WebSocket connection error',
500
+ statusCode: null,
501
+ details: null,
502
+ stack: error.stack
503
+ });
504
+ });
505
+
506
+ ws.on('close', () => {
507
+ if (settled) return;
508
+ settled = true;
509
+ cleanUp();
510
+ reject({
511
+ message: 'Realtime WebSocket closed before response.done',
512
+ statusCode: null,
513
+ details: null
514
+ });
515
+ });
516
+ });
517
+ }
518
+
519
+ static messagesToConversationItems(messages = []) {
520
+ const items = [];
521
+
522
+ for (const message of messages) {
523
+ if (!message || !message.role) continue;
524
+ if (message.role === 'tool' || message.tool_calls) continue;
525
+
526
+ const role = message.role === 'assistant' ? 'assistant' : (message.role === 'system' ? 'system' : 'user');
527
+ const content = [];
528
+
529
+ if (typeof message.content === 'string') {
530
+ content.push({
531
+ type: role === 'assistant' ? 'text' : 'input_text',
532
+ text: message.content
533
+ });
534
+ } else if (Array.isArray(message.content)) {
535
+ for (const item of message.content) {
536
+ if (!item || item.type !== 'text' || typeof item.text !== 'string') continue;
537
+ content.push({
538
+ type: role === 'assistant' ? 'text' : 'input_text',
539
+ text: item.text
540
+ });
541
+ }
542
+ }
543
+
544
+ if (content.length === 0) continue;
545
+ items.push({ type: 'message', role, content });
546
+ }
547
+
548
+ return items;
549
+ }
550
+
551
+ static extractDelta(event) {
552
+ if (typeof event.delta === 'string') return event.delta;
553
+ return '';
554
+ }
555
+ }
556
+
557
+ class MixOpenRouter extends MixOpenAI {
558
+ getDefaultConfig(customConfig) {
559
+
560
+ if (!process.env.OPENROUTER_API_KEY) {
561
+ throw new Error('OpenRouter API key not found. Please provide it in config or set OPENROUTER_API_KEY environment variable.');
562
+ }
563
+
564
+ return MixCustom.prototype.getDefaultConfig.call(this, {
565
+ url: 'https://openrouter.ai/api/v1/chat/completions',
566
+ apiKey: process.env.OPENROUTER_API_KEY,
567
+ ...customConfig
568
+ });
569
+ }
570
+ }
571
+
572
+ class MixKimi extends MixOpenAI {
573
+ getDefaultConfig(customConfig) {
574
+ if (!process.env.MOONSHOT_API_KEY) {
575
+ throw new Error('Moonshot API key not found. Please provide it in config or set MOONSHOT_API_KEY environment variable.');
576
+ }
577
+
578
+ return MixCustom.prototype.getDefaultConfig.call(this, {
579
+ url: 'https://api.moonshot.ai/v1/chat/completions',
580
+ apiKey: process.env.MOONSHOT_API_KEY,
581
+ ...customConfig
582
+ });
583
+ }
584
+
585
+ async create({ config = {}, options = {} } = {}) {
586
+ if (Object.hasOwn(options, 'max_tokens')) {
587
+ options.max_completion_tokens = options.max_tokens;
588
+ delete options.max_tokens;
589
+ }
590
+
591
+ delete options.temperature;
592
+ delete options.top_p;
593
+ delete options.n;
594
+ delete options.presence_penalty;
595
+ delete options.frequency_penalty;
596
+
597
+ return super.create({ config, options });
598
+ }
599
+
600
+ extractDelta(data) {
601
+ return data?.choices?.[0]?.delta?.content || '';
602
+ }
603
+
604
+ processResponse(response) {
605
+ return {
606
+ ...super.processResponse(response),
607
+ assistantMessage: response.data?.choices?.[0]?.message
608
+ };
609
+ }
610
+ }
611
+
612
+ return {
613
+ MixOpenAIResponses,
614
+ MixOpenAIModeration,
615
+ ModerationMix,
616
+ MixOpenAIWebSocket,
617
+ MixOpenRouter,
618
+ MixKimi
619
+ };
620
+ }
621
+
622
+ module.exports = createOpenAIProviders;
@@ -0,0 +1,26 @@
1
+ const createBaseProviders = require('./providers/base');
2
+ const createOpenAIProviders = require('./providers/openai');
3
+ const createAnthropicProviders = require('./providers/anthropic');
4
+ const createCompatibleProviders = require('./providers/openai-compatible');
5
+ const createGoogleProviders = require('./providers/google');
6
+
7
+ function createProviders({ ModelMix, log }) {
8
+ const base = createBaseProviders({ ModelMix });
9
+ const openai = createOpenAIProviders({ ModelMix, ...base });
10
+ const anthropic = createAnthropicProviders({ ModelMix, MixCustom: base.MixCustom, log });
11
+ const compatible = createCompatibleProviders({
12
+ MixCustom: base.MixCustom,
13
+ MixOpenAI: base.MixOpenAI
14
+ });
15
+ const google = createGoogleProviders({ ModelMix, MixCustom: base.MixCustom });
16
+
17
+ return {
18
+ ...base,
19
+ ...openai,
20
+ ...anthropic,
21
+ ...compatible,
22
+ ...google
23
+ };
24
+ }
25
+
26
+ module.exports = createProviders;