diff --git a/src/data/models.json b/src/data/models.json index 6373438e0..c927aea91 100644 --- a/src/data/models.json +++ b/src/data/models.json @@ -15425,6 +15425,22 @@ "id": "openai" }, "name": "Text Embedding Ada 002" + }, + { + "id": "MiniMax-M2.7", + "object": "model", + "provider": { + "id": "minimax" + }, + "name": "MiniMax M2.7" + }, + { + "id": "MiniMax-M2.7-highspeed", + "object": "model", + "provider": { + "id": "minimax" + }, + "name": "MiniMax M2.7 Highspeed" } ] } diff --git a/src/data/providers.json b/src/data/providers.json index fd801fdc5..bb0433f94 100644 --- a/src/data/providers.json +++ b/src/data/providers.json @@ -323,6 +323,13 @@ "object": "provider", "description": "Dashscope provides intelligent analytics solutions designed to help businesses visualize and interpret complex data effectively. Their platform integrates advanced machine learning algorithms to generate real-time insights from diverse datasets, enabling organizations to make informed decisions quickly. Dashscope focuses on enhancing data accessibility and usability, empowering teams to leverage analytics for strategic planning and operational efficiency.", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1" + }, + { + "id": "minimax", + "name": "MiniMax", + "object": "provider", + "description": "MiniMax is a leading AI company that develops advanced large language models including MiniMax-M2.7 and MiniMax-M2.7-highspeed. Their models support 204,800 tokens context window and are accessible via an OpenAI-compatible API, enabling seamless integration for text generation, chat, and tool-calling applications.", + "base_url": "https://api.minimax.io/v1" } ] } diff --git a/src/globals.ts b/src/globals.ts index 4d6e327e4..c274cd90f 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -113,6 +113,7 @@ export const ORACLE: string = 'oracle'; export const IO_INTELLIGENCE: string = 'iointelligence'; export const AIBADGR: string = 'aibadgr'; export const OVHCLOUD: string = 'ovhcloud'; +export const MINIMAX: string = 'minimax'; export const VALID_PROVIDERS = [ ANTHROPIC, @@ -189,6 +190,7 @@ export const VALID_PROVIDERS = [ IO_INTELLIGENCE, AIBADGR, OVHCLOUD, + MINIMAX, ]; export const CONTENT_TYPES = { diff --git a/src/providers/index.ts b/src/providers/index.ts index 2cd5355f8..e54e93909 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -74,6 +74,7 @@ import OracleConfig from './oracle'; import IOIntelligenceConfig from './iointelligence'; import AIBadgrConfig from './aibadgr'; import OVHcloudConfig from './ovhcloud'; +import MiniMaxConfig from './minimax'; const Providers: { [key: string]: ProviderConfigs } = { openai: OpenAIConfig, @@ -148,6 +149,7 @@ const Providers: { [key: string]: ProviderConfigs } = { iointelligence: IOIntelligenceConfig, aibadgr: AIBadgrConfig, ovhcloud: OVHcloudConfig, + minimax: MiniMaxConfig, }; export default Providers; diff --git a/src/providers/minimax/api.ts b/src/providers/minimax/api.ts new file mode 100644 index 000000000..70763f782 --- /dev/null +++ b/src/providers/minimax/api.ts @@ -0,0 +1,20 @@ +import { ProviderAPIConfig } from '../types'; + +const MiniMaxAPIConfig: ProviderAPIConfig = { + getBaseURL: () => 'https://api.minimax.io/v1', + headers: ({ providerOptions }) => { + return { + Authorization: `Bearer ${providerOptions.apiKey}`, + }; + }, + getEndpoint: ({ fn }) => { + switch (fn) { + case 'chatComplete': + return '/chat/completions'; + default: + return ''; + } + }, +}; + +export default MiniMaxAPIConfig; diff --git a/src/providers/minimax/chatComplete.ts b/src/providers/minimax/chatComplete.ts new file mode 100644 index 000000000..e040e0cd7 --- /dev/null +++ b/src/providers/minimax/chatComplete.ts @@ -0,0 +1,115 @@ +import { MINIMAX } from '../../globals'; +import { ChatCompletionResponse, ErrorResponse } from '../types'; +import { + generateErrorResponse, + generateInvalidProviderResponseError, +} from '../utils'; + +export interface MiniMaxChatCompleteResponse extends ChatCompletionResponse {} + +export interface MiniMaxErrorResponse extends ErrorResponse {} + +export interface MiniMaxStreamChunk { + id: string; + object: string; + created: number; + model: string; + choices: { + delta: { + role?: string | null; + content?: string; + tool_calls?: object[]; + }; + index: number; + finish_reason: string | null; + }[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export const MiniMaxChatCompleteResponseTransform: ( + response: MiniMaxChatCompleteResponse | MiniMaxErrorResponse, + responseStatus: number +) => ChatCompletionResponse | ErrorResponse = (response, responseStatus) => { + if ('error' in response && responseStatus !== 200) { + return generateErrorResponse( + { + message: response.error.message, + type: response.error.type, + param: null, + code: response.error.code?.toString() || null, + }, + MINIMAX + ); + } + + if ('choices' in response) { + return { + id: response.id, + object: response.object, + created: response.created, + model: response.model, + provider: MINIMAX, + choices: response.choices.map((c) => ({ + index: c.index, + message: c.message, + logprobs: c.logprobs, + finish_reason: c.finish_reason, + })), + usage: { + prompt_tokens: response.usage?.prompt_tokens || 0, + completion_tokens: response.usage?.completion_tokens || 0, + total_tokens: response.usage?.total_tokens || 0, + }, + }; + } + + return generateInvalidProviderResponseError(response, MINIMAX); +}; + +export const MiniMaxChatCompleteStreamChunkTransform: ( + response: string +) => string = (responseChunk) => { + let chunk = responseChunk.trim(); + chunk = chunk.replace(/^data: /, ''); + chunk = chunk.trim(); + if (chunk === '[DONE]') { + return `data: ${chunk}\n\n`; + } + + const parsedChunk: MiniMaxStreamChunk = JSON.parse(chunk); + return ( + `data: ${JSON.stringify({ + id: parsedChunk.id, + object: parsedChunk.object, + created: parsedChunk.created, + model: parsedChunk.model, + provider: MINIMAX, + choices: + parsedChunk.choices && parsedChunk.choices.length > 0 + ? [ + { + index: parsedChunk.choices[0].index || 0, + delta: { + role: parsedChunk.choices[0].delta?.role || undefined, + content: parsedChunk.choices[0].delta?.content || '', + tool_calls: + parsedChunk.choices[0].delta?.tool_calls || undefined, + }, + finish_reason: parsedChunk.choices[0].finish_reason || null, + }, + ] + : [], + usage: parsedChunk.usage + ? { + prompt_tokens: parsedChunk.usage.prompt_tokens || 0, + completion_tokens: parsedChunk.usage.completion_tokens || 0, + total_tokens: parsedChunk.usage.total_tokens || 0, + } + : undefined, + })}` + '\n\n' + ); +}; diff --git a/src/providers/minimax/index.ts b/src/providers/minimax/index.ts new file mode 100644 index 000000000..71206399f --- /dev/null +++ b/src/providers/minimax/index.ts @@ -0,0 +1,27 @@ +import { MINIMAX } from '../../globals'; +import { chatCompleteParams, responseTransformers } from '../open-ai-base'; +import { ProviderConfigs } from '../types'; +import MiniMaxAPIConfig from './api'; +import { MiniMaxChatCompleteStreamChunkTransform } from './chatComplete'; + +const MiniMaxConfig: ProviderConfigs = { + chatComplete: chatCompleteParams( + ['logit_bias', 'logprobs', 'top_logprobs', 'parallel_tool_calls'], + { temperature: 1 }, + { + response_format: { + param: 'response_format', + default: null, + }, + } + ), + api: MiniMaxAPIConfig, + responseTransforms: { + ...responseTransformers(MINIMAX, { + chatComplete: true, + }), + 'stream-chatComplete': MiniMaxChatCompleteStreamChunkTransform, + }, +}; + +export default MiniMaxConfig; diff --git a/src/tests/minimax.integration.test.ts b/src/tests/minimax.integration.test.ts new file mode 100644 index 000000000..189663154 --- /dev/null +++ b/src/tests/minimax.integration.test.ts @@ -0,0 +1,104 @@ +/** + * Integration tests for MiniMax provider. + * These tests call the real MiniMax API and require MINIMAX_API_KEY env var. + * Run with: npx jest src/tests/minimax.integration.test.ts + */ + +const MINIMAX_API_KEY = process.env.MINIMAX_API_KEY; +const BASE_URL = 'https://api.minimax.io/v1'; + +const skipIfNoKey = MINIMAX_API_KEY ? describe : describe.skip; + +skipIfNoKey('MiniMax integration tests', () => { + test('non-streaming chat completion with MiniMax-M2.7', async () => { + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${MINIMAX_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'MiniMax-M2.7', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Say hello in one word.' }, + ], + max_tokens: 30, + temperature: 1.0, + stream: false, + }), + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('model'); + expect(data).toHaveProperty('choices'); + expect(data.choices.length).toBeGreaterThan(0); + expect(data.choices[0]).toHaveProperty('message'); + expect(data.choices[0].message).toHaveProperty('content'); + expect(data).toHaveProperty('usage'); + expect(data.usage).toHaveProperty('prompt_tokens'); + expect(data.usage).toHaveProperty('completion_tokens'); + expect(data.usage).toHaveProperty('total_tokens'); + }); + + test('streaming chat completion with MiniMax-M2.7-highspeed', async () => { + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${MINIMAX_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'MiniMax-M2.7-highspeed', + messages: [{ role: 'user', content: 'Say hi' }], + max_tokens: 20, + temperature: 1.0, + stream: true, + }), + }); + + expect(response.status).toBe(200); + const text = await response.text(); + const lines = text + .split('\n') + .filter((line: string) => line.startsWith('data: ')); + expect(lines.length).toBeGreaterThan(0); + + // Check that the last meaningful chunk or a data chunk has proper structure + const firstLine = lines[0]; + const firstData = JSON.parse(firstLine.replace('data: ', '')); + expect(firstData).toHaveProperty('id'); + expect(firstData).toHaveProperty('model'); + expect(firstData).toHaveProperty('choices'); + + // Verify at least one chunk has finish_reason + const hasFinishReason = lines.some((line: string) => { + try { + const parsed = JSON.parse(line.replace('data: ', '')); + return parsed.choices?.[0]?.finish_reason !== null; + } catch { + return false; + } + }); + expect(hasFinishReason).toBe(true); + }); + + test('error handling with invalid API key', async () => { + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + Authorization: 'Bearer invalid-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 10, + }), + }); + + expect(response.status).not.toBe(200); + }); +}); diff --git a/src/tests/minimax.test.ts b/src/tests/minimax.test.ts new file mode 100644 index 000000000..42dcf35db --- /dev/null +++ b/src/tests/minimax.test.ts @@ -0,0 +1,249 @@ +import { MINIMAX } from '../globals'; +import MiniMaxAPIConfig from '../providers/minimax/api'; +import { + MiniMaxChatCompleteResponseTransform, + MiniMaxChatCompleteStreamChunkTransform, +} from '../providers/minimax/chatComplete'; +import MiniMaxConfig from '../providers/minimax'; + +describe('MiniMax provider', () => { + test('MINIMAX constant has correct value', () => { + expect(MINIMAX).toBe('minimax'); + }); + + test('provider config has chatComplete and api', () => { + expect(MiniMaxConfig.chatComplete).toBeDefined(); + expect(MiniMaxConfig.api).toBeDefined(); + expect(MiniMaxConfig.responseTransforms).toBeDefined(); + }); + + describe('API config', () => { + test('base URL is correct', () => { + expect(MiniMaxAPIConfig.getBaseURL({} as any)).toBe( + 'https://api.minimax.io/v1' + ); + }); + + test('headers include Bearer auth', () => { + const headers = MiniMaxAPIConfig.headers({ + providerOptions: { apiKey: 'test-key' }, + } as any); + expect(headers).toEqual({ + Authorization: 'Bearer test-key', + }); + }); + + test('getEndpoint returns /chat/completions for chatComplete', () => { + expect(MiniMaxAPIConfig.getEndpoint({ fn: 'chatComplete' } as any)).toBe( + '/chat/completions' + ); + }); + + test('getEndpoint returns empty string for unknown fn', () => { + expect(MiniMaxAPIConfig.getEndpoint({ fn: 'unknown' } as any)).toBe(''); + }); + }); + + describe('chatComplete config', () => { + const chatCompleteConfig = MiniMaxConfig.chatComplete; + + test('model param is configured', () => { + expect(chatCompleteConfig.model).toBeDefined(); + expect(chatCompleteConfig.model.param).toBe('model'); + expect(chatCompleteConfig.model.required).toBe(true); + }); + + test('temperature default is 1', () => { + expect(chatCompleteConfig.temperature).toBeDefined(); + expect(chatCompleteConfig.temperature.default).toBe(1); + }); + + test('messages param is configured', () => { + expect(chatCompleteConfig.messages).toBeDefined(); + expect(chatCompleteConfig.messages.param).toBe('messages'); + }); + + test('stream param is configured', () => { + expect(chatCompleteConfig.stream).toBeDefined(); + expect(chatCompleteConfig.stream.param).toBe('stream'); + }); + + test('logit_bias is excluded', () => { + expect(chatCompleteConfig.logit_bias).toBeUndefined(); + }); + + test('logprobs is excluded', () => { + expect(chatCompleteConfig.logprobs).toBeUndefined(); + }); + + test('top_logprobs is excluded', () => { + expect(chatCompleteConfig.top_logprobs).toBeUndefined(); + }); + + test('response_format is included', () => { + expect(chatCompleteConfig.response_format).toBeDefined(); + expect(chatCompleteConfig.response_format.param).toBe('response_format'); + }); + }); + + describe('response transforms', () => { + test('successful response is transformed correctly', () => { + const mockResponse = { + id: 'chatcmpl-123', + object: 'chat.completion', + created: 1700000000, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello! How can I help you?', + }, + logprobs: null, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 8, + total_tokens: 18, + }, + }; + + const result = MiniMaxChatCompleteResponseTransform( + mockResponse as any, + 200 + ); + expect(result).toHaveProperty('provider', MINIMAX); + expect(result).toHaveProperty('id', 'chatcmpl-123'); + expect(result).toHaveProperty('model', 'MiniMax-M2.7'); + expect((result as any).choices[0].message.content).toBe( + 'Hello! How can I help you?' + ); + expect((result as any).usage.total_tokens).toBe(18); + }); + + test('error response is transformed correctly', () => { + const mockError = { + error: { + message: 'Invalid API key', + type: 'authentication_error', + code: 401, + }, + }; + + const result = MiniMaxChatCompleteResponseTransform( + mockError as any, + 401 + ); + expect(result).toHaveProperty('error'); + expect((result as any).error.message).toContain('Invalid API key'); + }); + + test('invalid response returns error', () => { + const mockInvalid = { unexpected: 'data' }; + + const result = MiniMaxChatCompleteResponseTransform( + mockInvalid as any, + 200 + ); + expect(result).toHaveProperty('error'); + }); + }); + + describe('stream chunk transform', () => { + test('transforms regular chunk correctly', () => { + const chunk = `data: ${JSON.stringify({ + id: 'chatcmpl-123', + object: 'chat.completion.chunk', + created: 1700000000, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + delta: { content: 'Hello' }, + finish_reason: null, + }, + ], + })}`; + + const result = MiniMaxChatCompleteStreamChunkTransform(chunk); + expect(result).toContain('data: '); + expect(result).toContain('"provider":"minimax"'); + expect(result).toContain('"content":"Hello"'); + }); + + test('handles [DONE] sentinel', () => { + const result = MiniMaxChatCompleteStreamChunkTransform('data: [DONE]'); + expect(result).toBe('data: [DONE]\n\n'); + }); + + test('transforms chunk with usage info', () => { + const chunk = `data: ${JSON.stringify({ + id: 'chatcmpl-123', + object: 'chat.completion.chunk', + created: 1700000000, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + })}`; + + const result = MiniMaxChatCompleteStreamChunkTransform(chunk); + expect(result).toContain('"total_tokens":15'); + }); + + test('transforms chunk with tool calls', () => { + const chunk = `data: ${JSON.stringify({ + id: 'chatcmpl-456', + object: 'chat.completion.chunk', + created: 1700000000, + model: 'MiniMax-M2.7', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'test' } }, + ], + }, + finish_reason: null, + }, + ], + })}`; + + const result = MiniMaxChatCompleteStreamChunkTransform(chunk); + expect(result).toContain('"tool_calls"'); + }); + + test('transforms chunk without data: prefix', () => { + const chunk = JSON.stringify({ + id: 'chatcmpl-789', + object: 'chat.completion.chunk', + created: 1700000000, + model: 'MiniMax-M2.7-highspeed', + choices: [ + { + index: 0, + delta: { content: 'World' }, + finish_reason: null, + }, + ], + }); + + const result = MiniMaxChatCompleteStreamChunkTransform(chunk); + expect(result).toContain('"content":"World"'); + expect(result).toContain('"model":"MiniMax-M2.7-highspeed"'); + }); + }); +}); diff --git a/src/tests/resources/testVariables.ts b/src/tests/resources/testVariables.ts index 2c5047dc7..628e2c9ec 100644 --- a/src/tests/resources/testVariables.ts +++ b/src/tests/resources/testVariables.ts @@ -142,6 +142,12 @@ const testVariables: TestVariables = { model: 'Qwen/Qwen2.5-Coder-3B-Instruct', }, }, + minimax: { + apiKey: process.env.MINIMAX_API_KEY, + chatCompletions: { + model: 'MiniMax-M2.7', + }, + }, }; export default testVariables;