project-nomad/admin/app/services/minimax_service.ts
PR Bot ae856b268e feat: add MiniMax as optional cloud LLM provider
- Add MiniMaxService with OpenAI-compatible API integration
- Route MiniMax models (MiniMax-M2.7, MiniMax-M2.7-highspeed) through
  existing chat controller alongside Ollama
- Cloud models appear in model selector when MINIMAX_API_KEY is set
- Add MINIMAX_API_KEY env var support
- Add 8 unit tests + 3 integration tests
- Update README with MiniMax mention
2026-03-26 12:29:14 +08:00

189 lines
5.3 KiB
TypeScript

import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
const MINIMAX_BASE_URL = 'https://api.minimax.io/v1'
export const MINIMAX_MODELS = [
{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7' },
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax-M2.7-highspeed' },
] as const
/**
* Service for interacting with MiniMax cloud LLM API.
*
* MiniMax provides an OpenAI-compatible chat completions API, so this service
* uses native fetch to call it. When MINIMAX_API_KEY is set, cloud models
* appear alongside local Ollama models in the model selector, giving users
* an optional cloud-based alternative when internet is available.
*/
export class MiniMaxService {
private apiKey: string | undefined
constructor() {
this.apiKey = env.get('MINIMAX_API_KEY')
}
/**
* Whether MiniMax cloud models are available (API key is configured).
*/
isAvailable(): boolean {
return !!this.apiKey
}
/**
* Whether the given model name is a MiniMax model.
*/
isMiniMaxModel(model: string): boolean {
return model.startsWith('MiniMax-')
}
/**
* Returns MiniMax models in Ollama ModelResponse-compatible format
* so they can be mixed into the installed models list.
*/
getModels() {
if (!this.isAvailable()) return []
return MINIMAX_MODELS.map((m) => ({
name: m.id,
model: m.id,
modified_at: new Date(),
size: 0,
digest: 'cloud',
details: {
parent_model: '',
format: 'cloud',
family: 'minimax',
families: ['minimax'],
parameter_size: 'cloud',
quantization_level: '',
},
}))
}
/**
* Sends a non-streaming chat request to MiniMax API (OpenAI-compatible).
* Returns an Ollama-compatible response shape so the controller can use it
* transparently.
*/
async chat(params: { model: string; messages: Array<{ role: string; content: string }> }) {
if (!this.apiKey) {
throw new Error('MINIMAX_API_KEY is not configured')
}
logger.debug(`[MiniMaxService] Sending chat request to model: ${params.model}`)
const response = await fetch(`${MINIMAX_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: params.model,
messages: params.messages,
temperature: 1.0,
}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
logger.error(`[MiniMaxService] API error ${response.status}: ${errorText}`)
throw new Error(`MiniMax API error: ${response.status} - ${errorText}`)
}
const data = await response.json()
return {
model: params.model,
created_at: new Date().toISOString(),
message: {
role: 'assistant' as const,
content: data.choices[0].message.content,
},
done: true,
}
}
/**
* Sends a streaming chat request to MiniMax API. Returns an async generator
* that yields Ollama-compatible chunk objects so the controller SSE logic
* can forward them as-is.
*/
async *chatStream(params: {
model: string
messages: Array<{ role: string; content: string }>
}) {
if (!this.apiKey) {
throw new Error('MINIMAX_API_KEY is not configured')
}
logger.debug(`[MiniMaxService] Starting streaming chat for model: ${params.model}`)
const response = await fetch(`${MINIMAX_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: params.model,
messages: params.messages,
temperature: 1.0,
stream: true,
}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
logger.error(`[MiniMaxService] Streaming API error ${response.status}: ${errorText}`)
throw new Error(`MiniMax API error: ${response.status} - ${errorText}`)
}
if (!response.body) {
throw new Error('MiniMax API returned no response body')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
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:')) continue
const jsonStr = line.slice(5).trim()
if (!jsonStr || jsonStr === '[DONE]') continue
try {
const data = JSON.parse(jsonStr)
const content = data.choices?.[0]?.delta?.content || ''
const finishReason = data.choices?.[0]?.finish_reason
yield {
model: params.model,
created_at: new Date().toISOString(),
message: {
role: 'assistant' as const,
content,
},
done: finishReason === 'stop',
}
} catch {
// Skip malformed SSE chunks
}
}
}
} finally {
reader.releaseLock()
}
}
}