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
6 changes: 5 additions & 1 deletion server/ai/drivers/http/chatCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from './toolLoop'
import type { SseFrame } from './sse'
import { parseToolArguments } from './toolArgs'
import type { AiStreamRequest } from '../types'
import { nanoid } from 'nanoid'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -329,8 +330,9 @@ export function makeChatCompletionsAdapter(opts: {
baseUrl: string
apiKey: string | null
label: string
requestBodyExtras?: (req: AiStreamRequest) => Record<string, unknown>
}): ProviderAdapter<ChatTurn> {
const { baseUrl, apiKey, label } = opts
const { baseUrl, apiKey, label, requestBodyExtras } = opts
return {
label,
endpoint: `${normalizeOpenAiBaseUrl(baseUrl)}/v1/chat/completions`,
Expand All @@ -355,6 +357,8 @@ export function makeChatCompletionsAdapter(opts: {
function: { name: t.name, description: t.description, parameters: t.inputSchema },
}))
}
const extra = requestBodyExtras?.(req)
if (extra) Object.assign(body, extra)
return body
},
buildToolResultMessage(results: TurnToolResult[]): ChatTurn {
Expand Down
3 changes: 2 additions & 1 deletion server/ai/drivers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import type { AiProvider } from './types'
import type { AiProviderId } from '../runtime/types'
import { anthropicDriver } from './anthropic'
import { openaiDriver } from './openai'
import { minimaxDriver } from './minimax'
import { ollamaDriver } from './ollama'
import { openrouterDriver } from './openrouter'
import { openaiCompatibleDriver } from './openaiCompatible'

const DRIVERS: Record<AiProviderId, AiProvider> = {
anthropic: anthropicDriver,
openai: openaiDriver,
minimax: minimaxDriver,
ollama: ollamaDriver,
openrouter: openrouterDriver,
'openai-compatible': openaiCompatibleDriver,
Expand All @@ -30,4 +32,3 @@ export function resolveDriver(providerId: AiProviderId): AiProvider {
}
return driver
}

53 changes: 53 additions & 0 deletions server/ai/drivers/minimax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { minimaxDriver } from './minimax'

const originalFetch = globalThis.fetch

afterEach(() => {
globalThis.fetch = originalFetch
})

function creds(baseUrl: string | null) {
return { id: 'c1', providerId: 'minimax', authMode: 'baseUrl', apiKey: 'sk-test', baseUrl }
}

describe('minimax driver', () => {
it('reports baseUrl as its only auth mode', () => {
expect(minimaxDriver.supportedAuthModes).toEqual(['baseUrl'])
})

it('returns the MiniMax model catalogue when the live endpoint is reachable', async () => {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
expect(url).toBe('https://api.minimax.io/v1/models')
return new Response(JSON.stringify({
data: [{ id: 'MiniMax-M3' }, { id: 'MiniMax-M2.7' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

const models = await minimaxDriver.listModels(creds('https://api.minimax.io/v1'))
expect(models.map((model) => model.id)).toEqual(['MiniMax-M3', 'MiniMax-M2.7'])
expect(models[0]).toMatchObject({
label: 'MiniMax M3',
capabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true },
contextWindow: 1000000,
})
})

it('returns [] when no base URL is configured', async () => {
expect(await minimaxDriver.listModels(creds(null))).toEqual([])
})

it('reports the MiniMax M3 vision capability without enabling prompt cache', () => {
expect(minimaxDriver.capabilities('MiniMax-M3')).toMatchObject({
toolCalling: true,
visionInput: true,
toolResultImages: false,
promptCache: false,
streaming: true,
})
})
})
115 changes: 115 additions & 0 deletions server/ai/drivers/minimax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* MiniMax driver — direct HTTP against the documented MiniMax API.
*
* The runtime reuses the shared OpenAI-compatible chat/completions transport
* for MiniMax's text models, then overlays the provider-specific model
* catalogue and request fields.
*/

import type {
AiAuthMode,
AiProviderId,
AiStreamEvent,
} from '../runtime/types'
import type {
AiProvider,
AiProviderCapabilities,
AiProviderModel,
AiResolvedCredential,
AiStreamRequest,
} from './types'
import { runToolLoop } from './http/toolLoop'
import { makeChatCompletionsAdapter } from './http/chatCompletions'
import { openaiCompatibleDriver } from './openaiCompatible'

const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['baseUrl']

const MINIMAX_MODELS: AiProviderModel[] = [
{
id: 'MiniMax-M3',
label: 'MiniMax M3',
capabilities: {
toolCalling: true,
visionInput: true,
toolResultImages: false,
promptCache: false,
streaming: true,
},
pricing: { inputPerMTok: 0.6, outputPerMTok: 2.4 },
contextWindow: 1_000_000,
catalogueSource: 'live',
},
{
id: 'MiniMax-M2.7',
label: 'MiniMax M2.7',
capabilities: {
toolCalling: true,
visionInput: false,
toolResultImages: false,
promptCache: false,
streaming: true,
},
pricing: { inputPerMTok: 0.3, outputPerMTok: 1.2 },
contextWindow: 204_800,
catalogueSource: 'live',
},
]

const DEFAULT_CAPABILITIES: AiProviderCapabilities = {
toolCalling: true,
visionInput: false,
toolResultImages: false,
promptCache: false,
streaming: true,
}

function staticCapabilities(modelId: string): AiProviderCapabilities {
if (modelId === 'MiniMax-M3') {
return { ...DEFAULT_CAPABILITIES, visionInput: true }
}
return { ...DEFAULT_CAPABILITIES }
}

function minimaxAdapter(baseUrl: string, apiKey: string | null) {
return makeChatCompletionsAdapter({
baseUrl,
apiKey,
label: 'MiniMax',
requestBodyExtras() {
return {
reasoning_split: true,
thinking: { type: 'adaptive' },
}
},
})
}

export const minimaxDriver: AiProvider = {
id: 'minimax' as AiProviderId,
label: 'MiniMax',
supportedAuthModes: SUPPORTED_AUTH_MODES,

capabilities(modelId: string) {
return staticCapabilities(modelId)
},

async listModels(creds: AiResolvedCredential, signal?: AbortSignal) {
if (creds.authMode !== 'baseUrl' || !creds.baseUrl) return []
const liveModels = await openaiCompatibleDriver.listModels(creds, signal)
const liveIds = new Set(liveModels.map((model) => model.id))
const models = MINIMAX_MODELS.filter((model) => liveIds.has(model.id))
return models.length > 0 ? models : []
},

async *stream(req: AiStreamRequest): AsyncIterable<AiStreamEvent> {
if (req.credentials.authMode !== 'baseUrl' || !req.credentials.baseUrl) {
yield {
type: 'error',
message:
'MiniMax requires a base URL. Add a base-URL credential in /admin/ai/providers and pick it for the site default.',
}
return
}
yield* runToolLoop(minimaxAdapter(req.credentials.baseUrl, req.credentials.apiKey), req)
},
}
1 change: 1 addition & 0 deletions server/ai/handlers/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const ALL_SCOPES: ToolScope[] = ['site', 'content', 'data', 'plugin']
const ProviderId = Type.Union([
Type.Literal('anthropic'),
Type.Literal('openai'),
Type.Literal('minimax'),
Type.Literal('ollama'),
Type.Literal('openrouter'),
Type.Literal('openai-compatible'),
Expand Down
4 changes: 2 additions & 2 deletions server/ai/handlers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { getModelCatalogue, pricingKey } from '../pricing'
import type { AiProviderModel } from '../drivers/types'
import type { AiProviderId } from '../runtime/types'

const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter', 'openai-compatible']
const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'minimax', 'ollama', 'openrouter', 'openai-compatible']

export function tryHandleAiModels(
req: Request,
Expand Down Expand Up @@ -77,7 +77,7 @@ async function handleModels(
id: '',
providerId,
authMode:
providerId === 'ollama' || providerId === 'openai-compatible'
providerId === 'ollama' || providerId === 'minimax' || providerId === 'openai-compatible'
? ('baseUrl' as const)
: ('apiKey' as const),
apiKey: null,
Expand Down
3 changes: 1 addition & 2 deletions server/ai/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type { AiContentBlock, AiToolImage, AiToolOutput } from '@core/ai'
// Provider identity + auth modes
// ---------------------------------------------------------------------------

export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'openai-compatible'
export type AiProviderId = 'anthropic' | 'openai' | 'minimax' | 'ollama' | 'openrouter' | 'openai-compatible'
/**
* Credential auth modes.
*
Expand Down Expand Up @@ -207,4 +207,3 @@ export interface AiBrowserBridge {
// Aggregated usage — drivers report token counts so the handler can persist
// per-message + per-conversation totals and compute cost from pricing.ts.
// ---------------------------------------------------------------------------

17 changes: 16 additions & 1 deletion src/__tests__/ai/providersTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('ProvidersTab', () => {

expect(screen.queryByRole('combobox', { name: 'Provider' })).toBeNull()
expect(screen.queryByLabelText('Authentication')).toBeNull()
expect(screen.getByLabelText('API key')).toBeDefined()
expect(screen.getByLabelText(/API key/)).toBeDefined()
expect(screen.queryByRole('button', { name: 'Add' })).toBeNull()
expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull()
expect(screen.queryByText('Secrets are encrypted at rest and never returned to the browser.')).toBeNull()
Expand All @@ -54,6 +54,21 @@ describe('ProvidersTab', () => {
expect(screen.queryByLabelText('API key')).toBeNull()
})

it('shows MiniMax as a base-url provider with the documented endpoint placeholder', async () => {
mockEmptyCredentials()

render(<ProvidersTab onNavigateToDefaults={() => {}} />)
await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined())

fireEvent.click(screen.getByRole('button', { name: 'MiniMax M3 / M2.7' }))

expect(screen.getByRole('heading', { name: 'Connect MiniMax' })).toBeDefined()
expect(screen.getByLabelText('Base URL')).toBeDefined()
expect(screen.getByLabelText('Base URL').getAttribute('placeholder')).toBe('https://api.minimax.io/v1')
expect(screen.getByLabelText(/API key/)).toBeDefined()
expect(screen.queryByLabelText('Authentication')).toBeNull()
})

it('opens configured credentials in the detail inspector', async () => {
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
Expand Down
7 changes: 4 additions & 3 deletions src/admin/ai/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
const ProviderId = Type.Union([
Type.Literal('anthropic'),
Type.Literal('openai'),
Type.Literal('minimax'),
Type.Literal('ollama'),
Type.Literal('openrouter'),
Type.Literal('openai-compatible'),
Expand Down Expand Up @@ -182,13 +183,13 @@ export async function listCredentials(signal?: AbortSignal): Promise<CredentialV

export type CreateCredentialBody =
| {
providerId: 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'openai-compatible'
providerId: 'anthropic' | 'openai' | 'minimax' | 'ollama' | 'openrouter' | 'openai-compatible'
authMode: 'apiKey'
displayLabel: string
apiKey: string
}
| {
providerId: 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'openai-compatible'
providerId: 'anthropic' | 'openai' | 'minimax' | 'ollama' | 'openrouter' | 'openai-compatible'
authMode: 'baseUrl'
displayLabel: string
baseUrl: string
Expand Down Expand Up @@ -253,7 +254,7 @@ export function clearModelListCache(credentialId?: string): void {
}

export async function listModels(
providerId: 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'openai-compatible',
providerId: 'anthropic' | 'openai' | 'minimax' | 'ollama' | 'openrouter' | 'openai-compatible',
credentialId?: string,
): Promise<AiModel[]> {
const key = `${providerId}\0${credentialId ?? ''}`
Expand Down
10 changes: 9 additions & 1 deletion src/admin/pages/ai/providerCatalog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ProviderId = 'anthropic' | 'openai' | 'openrouter' | 'ollama' | 'openai-compatible'
export type ProviderId = 'anthropic' | 'openai' | 'minimax' | 'openrouter' | 'ollama' | 'openai-compatible'
export type ProviderAuthMode = 'apiKey' | 'baseUrl'

export interface ProviderSpec {
Expand Down Expand Up @@ -27,6 +27,14 @@ export const PROVIDER_SPECS: ProviderSpec[] = [
authMode: 'apiKey',
endpointLabel: 'api.openai.com',
},
{
id: 'minimax',
label: 'MiniMax',
shortLabel: 'M3 / M2.7',
description: 'MiniMax text models with the documented API endpoint.',
authMode: 'baseUrl',
endpointLabel: 'api.minimax.io/v1',
},
{
id: 'openrouter',
label: 'OpenRouter',
Expand Down
1 change: 1 addition & 0 deletions src/admin/pages/ai/tabs/AuditTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export function AuditTab() {
const PROVIDER_LABEL: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
minimax: 'MiniMax',
ollama: 'Ollama',
unknown: 'Unknown (deleted credential)',
}
Expand Down
3 changes: 3 additions & 0 deletions src/admin/pages/ai/tabs/ProvidersTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Selection =
const API_KEY_PLACEHOLDER: Partial<Record<ProviderId, string>> = {
anthropic: 'sk-ant-...',
openai: 'sk-...',
minimax: 'sk-... (optional)',
openrouter: 'sk-or-...',
'openai-compatible': 'sk-... (optional)',
}
Expand Down Expand Up @@ -519,6 +520,8 @@ function AddCredentialForm({
const [busy, setBusy] = useState(false)
const baseUrlPlaceholder = provider.id === 'ollama'
? 'http://localhost:11434'
: provider.id === 'minimax'
? 'https://api.minimax.io/v1'
: 'https://api.example.com/v1'

async function handleSubmit(event: React.FormEvent) {
Expand Down