diff --git a/src/app/core/setting/config.spec.mjs b/src/app/core/setting/config.spec.mjs new file mode 100644 index 00000000..2a300734 --- /dev/null +++ b/src/app/core/setting/config.spec.mjs @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const configSource = readFileSync(resolve(__dirname, 'config.tsx'), 'utf-8') + +test('baseAiConfig includes MiniMax provider entry', () => { + assert.ok(configSource.includes("key: 'minimax'"), 'config should have minimax key') + assert.ok(configSource.includes("title: 'MiniMax'"), 'config should have MiniMax title') +}) + +test('MiniMax provider uses correct base URL', () => { + assert.ok( + configSource.includes("baseURL: 'https://api.minimax.io/v1'"), + 'MiniMax baseURL should be https://api.minimax.io/v1' + ) +}) + +test('MiniMax provider has apiKeyUrl pointing to platform', () => { + assert.ok( + configSource.includes("apiKeyUrl: 'https://platform.minimaxi.com/'"), + 'MiniMax apiKeyUrl should point to platform.minimaxi.com' + ) +}) + +test('MiniMax provider has an icon URL', () => { + // Extract the MiniMax block from the config + const minimaxIdx = configSource.indexOf("key: 'minimax'") + assert.ok(minimaxIdx > -1, 'MiniMax entry should exist in config') + const blockEnd = configSource.indexOf('},', minimaxIdx) + const minimaxBlock = configSource.substring(minimaxIdx, blockEnd) + assert.ok(minimaxBlock.includes('icon:'), 'MiniMax entry should have an icon') +}) + +test('MiniMax appears after existing providers (Gitee AI)', () => { + const giteeIdx = configSource.indexOf("key: 'gitee'") + const minimaxIdx = configSource.indexOf("key: 'minimax'") + assert.ok(giteeIdx > -1, 'Gitee AI entry should exist') + assert.ok(minimaxIdx > -1, 'MiniMax entry should exist') + assert.ok(minimaxIdx > giteeIdx, 'MiniMax should appear after Gitee AI in the config array') +}) + +test('all provider entries in baseAiConfig have required fields', () => { + // Extract all key entries + const keyPattern = /key:\s*'([^']+)'/g + const keys = [] + let match + while ((match = keyPattern.exec(configSource)) !== null) { + keys.push(match[1]) + } + assert.ok(keys.includes('minimax'), 'MiniMax should be in the provider keys') + assert.ok(keys.includes('chatgpt'), 'ChatGPT should be in the provider keys') + assert.ok(keys.includes('deepseek'), 'DeepSeek should be in the provider keys') + + // Verify MiniMax block has all required fields + const minimaxIdx = configSource.indexOf("key: 'minimax'") + const blockStart = configSource.lastIndexOf('{', minimaxIdx) + const blockEnd = configSource.indexOf('},', minimaxIdx) + const minimaxBlock = configSource.substring(blockStart, blockEnd + 1) + + assert.ok(minimaxBlock.includes('key:'), 'MiniMax should have key field') + assert.ok(minimaxBlock.includes('title:'), 'MiniMax should have title field') + assert.ok(minimaxBlock.includes('baseURL:'), 'MiniMax should have baseURL field') + assert.ok(minimaxBlock.includes('icon:'), 'MiniMax should have icon field') + assert.ok(minimaxBlock.includes('apiKeyUrl:'), 'MiniMax should have apiKeyUrl field') +}) diff --git a/src/app/core/setting/config.tsx b/src/app/core/setting/config.tsx index feabffd5..9903dd47 100644 --- a/src/app/core/setting/config.tsx +++ b/src/app/core/setting/config.tsx @@ -227,6 +227,13 @@ const baseAiConfig: AiConfig[] = [ icon: 'https://s2.loli.net/2025/09/15/ih7aTnGPvELFsVc.png', apiKeyUrl: 'https://ai.gitee.com/' }, + { + key: 'minimax', + title: 'MiniMax', + baseURL: 'https://api.minimax.io/v1', + icon: 'https://filecdn.minimax.chat/public/c5b4442f-ab8b-4d97-9119-8504670b0097.png', + apiKeyUrl: 'https://platform.minimaxi.com/' + }, ] export { baseAiConfig } \ No newline at end of file diff --git a/src/lib/ai/minimax-integration.spec.mjs b/src/lib/ai/minimax-integration.spec.mjs new file mode 100644 index 00000000..25149bb0 --- /dev/null +++ b/src/lib/ai/minimax-integration.spec.mjs @@ -0,0 +1,130 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const API_KEY = process.env.MINIMAX_API_KEY +const BASE_URL = 'https://api.minimax.io/v1' + +// Skip all integration tests if no API key is set +const skipReason = API_KEY ? undefined : 'MINIMAX_API_KEY not set' + +test('MiniMax M2.7 chat completion (non-streaming)', { skip: skipReason, timeout: 30000 }, async () => { + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Say "test passed" and nothing else.' }], + max_tokens: 20, + temperature: 0.7, + }), + }) + + assert.equal(response.ok, true, `HTTP ${response.status}: ${response.statusText}`) + const data = await response.json() + assert.ok(data.choices, 'response should have choices') + assert.ok(data.choices.length > 0, 'choices should not be empty') + assert.ok(data.choices[0].message.content, 'message content should not be empty') +}) + +test('MiniMax M2.7 chat completion (streaming)', { skip: skipReason, timeout: 30000 }, async () => { + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Count 1 to 3.' }], + max_tokens: 50, + stream: true, + temperature: 0.7, + }), + }) + + assert.equal(response.ok, true, `HTTP ${response.status}: ${response.statusText}`) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let chunks = 0 + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + for (const line of lines) { + if (line.startsWith('data:') && !line.includes('[DONE]')) { + chunks++ + } + } + } + + assert.ok(chunks > 1, `expected multiple SSE chunks, got ${chunks}`) +}) + +test('MiniMax M2.7-highspeed model works', { skip: skipReason, timeout: 30000 }, async () => { + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: 'MiniMax-M2.7-highspeed', + messages: [{ role: 'user', content: 'Say "highspeed ok"' }], + max_tokens: 20, + temperature: 0.7, + }), + }) + + assert.equal(response.ok, true, `HTTP ${response.status}: ${response.statusText}`) + const data = await response.json() + assert.ok(data.choices[0].message.content, 'M2.7-highspeed model should return content') +}) + +test('MiniMax M2.5 model still works (backward compatibility)', { skip: skipReason, timeout: 30000 }, async () => { + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: 'MiniMax-M2.5', + messages: [{ role: 'user', content: 'Say "ok"' }], + max_tokens: 10, + temperature: 0.7, + }), + }) + + assert.equal(response.ok, true, 'M2.5 model should still be accessible') + const data = await response.json() + assert.ok(data.choices[0].message.content, 'M2.5 should return content') +}) + +test('MiniMax handles temperature edge cases', { skip: skipReason, timeout: 30000 }, async () => { + // Temperature=0 should still produce a valid response + const response = await fetch(`${BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: 'MiniMax-M2.7', + messages: [{ role: 'user', content: 'Say "ok"' }], + max_tokens: 10, + temperature: 0, + }), + }) + + assert.equal(response.ok, true, 'temperature=0 should be accepted') + const data = await response.json() + assert.ok(data.choices[0].message.content, 'should return content with temperature=0') +})