Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/app/core/setting/config.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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 includes global and China regional endpoints', () => {
for (const endpoint of [
"baseURL: 'https://api.minimax.io/v1'",
"baseURL: 'https://api.minimax.io/anthropic'",
"baseURL: 'https://api.minimaxi.com/v1'",
"baseURL: 'https://api.minimaxi.com/anthropic'",
]) {
assert.ok(configSource.includes(endpoint), `MiniMax endpoints should include ${endpoint}`)
}

assert.ok(configSource.includes("region: 'global_en'"), 'MiniMax should include the global region')
assert.ok(configSource.includes("region: 'cn_zh'"), 'MiniMax should include the China region')
assert.ok(configSource.includes("protocol: 'openai'"), 'MiniMax should include the OpenAI protocol')
assert.ok(configSource.includes("protocol: 'anthropic'"), 'MiniMax should include the Anthropic protocol')
})

test('MiniMax provider has apiKeyUrl pointing to platform', () => {
assert.ok(
configSource.includes("apiKeyUrl: 'https://platform.minimax.io/'"),
'MiniMax apiKeyUrl should point to platform.minimax.io'
)
})

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)
let depth = 0
let blockEnd = -1
for (let i = blockStart; i < configSource.length; i++) {
if (configSource[i] === '{') depth++
else if (configSource[i] === '}') {
depth--
if (depth === 0) { blockEnd = i; break }
}
}
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')
assert.ok(minimaxBlock.includes('models:'), 'MiniMax should have models field')
})

test('MiniMax has MiniMax-M3 set as default (first in models list)', () => {
const minimaxIdx = configSource.indexOf("key: 'minimax'")
const blockStart = configSource.lastIndexOf('{', minimaxIdx)
// Match the MiniMax block by finding the matching closing brace
let depth = 0
let blockEnd = -1
for (let i = blockStart; i < configSource.length; i++) {
if (configSource[i] === '{') depth++
else if (configSource[i] === '}') {
depth--
if (depth === 0) { blockEnd = i; break }
}
}
const minimaxBlock = configSource.substring(blockStart, blockEnd + 1)

// Verify models list contains M3, M2.7, M2.7-highspeed
assert.ok(minimaxBlock.includes("'MiniMax-M3'"), 'M3 should be in models list')
assert.ok(minimaxBlock.includes("'MiniMax-M2.7'"), 'M2.7 should be in models list')
assert.ok(minimaxBlock.includes("'MiniMax-M2.7-highspeed'"), 'M2.7-highspeed should be in models list')

// Verify M3 is the first model (default)
const m3Idx = minimaxBlock.indexOf("'MiniMax-M3'")
const m27Idx = minimaxBlock.indexOf("'MiniMax-M2.7'")
assert.ok(m3Idx > -1, 'M3 should exist in MiniMax block')
assert.ok(m27Idx > -1, 'M2.7 should exist in MiniMax block')
assert.ok(m3Idx < m27Idx, 'M3 should appear before M2.7 (M3 is default)')

// Verify M2.5 is NOT present
assert.ok(!minimaxBlock.includes("'MiniMax-M2.5'"), 'M2.5 should be removed')
assert.ok(!minimaxBlock.includes("'MiniMax-M2.1'"), 'M2.1 should be removed')
assert.ok(!minimaxBlock.includes("'MiniMax-M2'"), 'M2 should be removed')
assert.ok(!minimaxBlock.includes("'MiniMax-M1'"), 'M1 should be removed')
})

test('MiniMax target models include current capability metadata', () => {
const minimaxIdx = configSource.indexOf("key: 'minimax'")
const minimaxBlock = configSource.substring(minimaxIdx)

assert.ok(minimaxBlock.includes('contextWindow: 1_000_000'), 'M3 should have a one-million-token context window')
assert.ok(minimaxBlock.includes('pricing: { input: 0.6, output: 2.4, cacheRead: 0.12 }'), 'M3 should have current pricing')
assert.ok(minimaxBlock.includes("inputModalities: ['text', 'image', 'video']"), 'M3 should have current input modalities')
assert.ok(minimaxBlock.includes("thinking: ['adaptive', 'disabled']"), 'M3 should have current thinking modes')

assert.ok(minimaxBlock.includes('contextWindow: 204_800'), 'M2.7 should have its current context window')
assert.ok(minimaxBlock.includes('pricing: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }'), 'M2.7 should have current pricing')
assert.ok(minimaxBlock.includes("inputModalities: ['text']"), 'M2.7 should have its current input modality')
assert.ok(minimaxBlock.includes("thinking: ['always_on']"), 'M2.7 should have its current thinking mode')
})
64 changes: 63 additions & 1 deletion src/app/core/setting/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ export default baseConfig

export type ModelType = 'chat' | 'image' | 'video' | 'tts' | 'stt' | 'embedding' | 'rerank';

export type ModelInputModality = 'text' | 'image' | 'video'
export type ModelThinkingMode = 'adaptive' | 'disabled' | 'always_on'

export interface ModelPricing {
input: number
output: number
cacheRead: number
cacheWrite?: number
}

export interface ProviderEndpoint {
region: 'global_en' | 'cn_zh'
protocol: 'openai' | 'anthropic'
baseURL: string
docsURL: string
}

export interface ModelConfig {
id: string
model: string
Expand All @@ -115,6 +132,10 @@ export interface ModelConfig {
topP?: number
voice?: string
enableStream?: boolean
contextWindow?: number
pricing?: ModelPricing
inputModalities?: ModelInputModality[]
thinking?: ModelThinkingMode[]
}

export interface AiConfig {
Expand All @@ -125,6 +146,7 @@ export interface AiConfig {
icon?: string
apiKeyUrl?: string
customHeaders?: Record<string, string>
endpoints?: ProviderEndpoint[]
models?: ModelConfig[]
// 保持向后兼容
model?: string
Expand Down Expand Up @@ -227,6 +249,46 @@ 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.minimax.io/',
endpoints: [
{ region: 'global_en', protocol: 'openai', baseURL: 'https://api.minimax.io/v1', docsURL: 'https://platform.minimax.io/docs' },
{ region: 'global_en', protocol: 'anthropic', baseURL: 'https://api.minimax.io/anthropic', docsURL: 'https://platform.minimax.io/docs' },
{ region: 'cn_zh', protocol: 'openai', baseURL: 'https://api.minimaxi.com/v1', docsURL: 'https://platform.minimaxi.com/docs' },
{ region: 'cn_zh', protocol: 'anthropic', baseURL: 'https://api.minimaxi.com/anthropic', docsURL: 'https://platform.minimaxi.com/docs' },
],
models: [
{
id: 'minimax-MiniMax-M3',
model: 'MiniMax-M3',
modelType: 'chat',
temperature: 0.7,
topP: 1,
enableStream: true,
contextWindow: 1_000_000,
pricing: { input: 0.6, output: 2.4, cacheRead: 0.12 },
inputModalities: ['text', 'image', 'video'],
thinking: ['adaptive', 'disabled'],
},
{
id: 'minimax-MiniMax-M2.7',
model: 'MiniMax-M2.7',
modelType: 'chat',
temperature: 0.7,
topP: 1,
enableStream: true,
contextWindow: 204_800,
pricing: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
inputModalities: ['text'],
thinking: ['always_on'],
},
{ id: 'minimax-MiniMax-M2.7-highspeed', model: 'MiniMax-M2.7-highspeed', modelType: 'chat', temperature: 0.7, topP: 1, enableStream: true },
],
},
]

export { baseAiConfig }
export { baseAiConfig }
130 changes: 130 additions & 0 deletions src/lib/ai/minimax-integration.spec.mjs
Original file line number Diff line number Diff line change
@@ -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 M3 chat completion (default model, 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-M3',
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 M3 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-M3',
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 still works (retained)', { 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 "ok"' }],
max_tokens: 10,
temperature: 0.7,
}),
})

assert.equal(response.ok, true, 'M2.7 model should still be accessible')
const data = await response.json()
assert.ok(data.choices[0].message.content, 'M2.7 should return content')
})

test('MiniMax M2.7-highspeed still works (retained)', { 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, 'M2.7-highspeed model should still be accessible')
const data = await response.json()
assert.ok(data.choices[0].message.content, 'M2.7-highspeed 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-M3',
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')
})