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: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ NEXT_PUBLIC_GATEWAY_PORT=18789
# Core CRUD features work; live gateway events do not.
# NEXT_PUBLIC_GATEWAY_OPTIONAL=true

# MiniMax direct dispatch (optional)
# MINIMAX_API_KEY=
# MINIMAX_REGION=global_en
# MINIMAX_API_PROTOCOL=openai
# Region values: global_en, cn_zh. Protocol values: openai, anthropic.

# ═══════════════════════════════════════════════════════════════════════════════
# OpenClaw Integration
# ═══════════════════════════════════════════════════════════════════════════════
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ export interface ModelConfig {
provider: string
description: string
/** USD per MILLION tokens (input/output) — mirrors ModelConfig in '@/lib/models' */
costPerMTok: { input: number; output: number }
costPerMTok: { input: number; output: number; cacheRead?: number; cacheWrite?: number | null }
contextWindow?: number
inputModalities?: Array<'text' | 'image' | 'video'>
thinking?: Array<'adaptive' | 'disabled' | 'always_on'>
}

// Mission Control Phase 2 Types
Expand Down
40 changes: 40 additions & 0 deletions src/lib/__tests__/minimax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getMiniMaxApiKey, MINIMAX_REGIONS, resolveMiniMaxEndpoint } from '@/lib/minimax'

describe('MiniMax endpoint configuration', () => {
it('defaults to the global OpenAI-compatible endpoint', () => {
expect(resolveMiniMaxEndpoint({})).toEqual({
region: 'global_en',
protocol: 'openai',
baseUrl: 'https://api.minimax.io/v1',
docsRoot: 'https://platform.minimax.io/docs',
})
})

it('resolves both China compatibility endpoints', () => {
expect(resolveMiniMaxEndpoint({ MINIMAX_REGION: 'cn_zh' }).baseUrl)
.toBe('https://api.minimaxi.com/v1')
expect(resolveMiniMaxEndpoint({ MINIMAX_REGION: 'cn_zh', MINIMAX_API_PROTOCOL: 'anthropic' }).baseUrl)
.toBe('https://api.minimaxi.com/anthropic')
})

it('resolves the global Anthropic-compatible endpoint', () => {
expect(resolveMiniMaxEndpoint({ MINIMAX_API_PROTOCOL: 'anthropic' }).baseUrl)
.toBe('https://api.minimax.io/anthropic')
})

it('keeps both regional documentation roots registered', () => {
expect(MINIMAX_REGIONS.global_en.docsRoot).toBe('https://platform.minimax.io/docs')
expect(MINIMAX_REGIONS.cn_zh.docsRoot).toBe('https://platform.minimaxi.com/docs')
})

it('rejects unsupported regions and protocols', () => {
expect(() => resolveMiniMaxEndpoint({ MINIMAX_REGION: 'invalid' })).toThrow('MINIMAX_REGION')
expect(() => resolveMiniMaxEndpoint({ MINIMAX_API_PROTOCOL: 'invalid' })).toThrow('MINIMAX_API_PROTOCOL')
})

it('trims the configured API key', () => {
expect(getMiniMaxApiKey({ MINIMAX_API_KEY: ' configured-key ' })).toBe('configured-key')
expect(getMiniMaxApiKey({ MINIMAX_API_KEY: ' ' })).toBeNull()
})
})
30 changes: 30 additions & 0 deletions src/lib/__tests__/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,33 @@ describe('classifyModelProvider (catalog-derived classification)', () => {
expect(classifyModelProvider('')).toBeUndefined()
})
})

describe('MiniMax model catalog', () => {
it('registers current models with pricing and capabilities', () => {
const m3 = getModelByAlias('minimax')
const m27 = getModelByAlias('minimax-m2.7')

expect(m3).toMatchObject({
name: 'minimax/MiniMax-M3',
provider: 'minimax',
contextWindow: 1_000_000,
inputModalities: ['text', 'image', 'video'],
thinking: ['adaptive', 'disabled'],
costPerMTok: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: null },
})
expect(m27).toMatchObject({
name: 'minimax/MiniMax-M2.7',
provider: 'minimax',
contextWindow: 204_800,
inputModalities: ['text'],
thinking: ['always_on'],
costPerMTok: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
})
})

it('classifies exact model IDs without changing their API casing', () => {
expect(classifyModelProvider('MiniMax-M3')).toBe('minimax')
expect(classifyModelProvider('MiniMax-M2.7')).toBe('minimax')
expect(getDispatchModelId(getModelByAlias('minimax')!)).toBe('MiniMax-M3')
})
})
9 changes: 8 additions & 1 deletion src/lib/__tests__/task-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import Database from 'better-sqlite3'
import { insertDispatchTokenUsage, resolveTaskDispatchModelOverride } from '@/lib/task-dispatch'
import { insertDispatchTokenUsage, pickProvider, resolveTaskDispatchModelOverride } from '@/lib/task-dispatch'

describe('insertDispatchTokenUsage', () => {
it('persists dispatch usage using the current token_usage schema', () => {
Expand Down Expand Up @@ -56,3 +56,10 @@ describe('resolveTaskDispatchModelOverride', () => {
expect(resolveTaskDispatchModelOverride({ agent_config: '{not json' })).toBeNull()
})
})

describe('MiniMax direct dispatch routing', () => {
it('selects the dedicated provider for both current model IDs', () => {
expect(pickProvider('MiniMax-M3')).toBe('minimax')
expect(pickProvider('minimax/MiniMax-M2.7')).toBe('minimax')
})
})
20 changes: 20 additions & 0 deletions src/lib/__tests__/token-pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ describe('token pricing', () => {
expect(getModelPricing('minimax/minimax-m2.1')).toMatchObject({ inputPerMTok: 0.3, outputPerMTok: 1.2 })
})

it('uses current MiniMax pricing, including cache rates', () => {
expect(getModelPricing('minimax/MiniMax-M3')).toMatchObject({
inputPerMTok: 0.6,
outputPerMTok: 2.4,
cacheReadPerMTok: 0.12,
cacheWritePerMTok: null,
})
expect(getModelPricing('minimax/MiniMax-M2.7')).toMatchObject({
inputPerMTok: 0.3,
outputPerMTok: 1.2,
cacheReadPerMTok: 0.06,
cacheWritePerMTok: 0.375,
})
})

it('maps bare MiniMax model IDs to the provider', () => {
expect(getProviderFromModel('MiniMax-M3')).toBe('minimax')
expect(getProviderFromModel('MiniMax-M2.7')).toBe('minimax')
})

it('keeps local models at zero cost', () => {
const cost = calculateTokenCost('ollama/qwen2.5-coder:14b', 50_000, 50_000)
expect(cost).toBe(0)
Expand Down
50 changes: 50 additions & 0 deletions src/lib/minimax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export type MiniMaxRegion = 'global_en' | 'cn_zh'
export type MiniMaxProtocol = 'openai' | 'anthropic'

export interface MiniMaxEnvironment {
MINIMAX_API_KEY?: string
MINIMAX_REGION?: string
MINIMAX_API_PROTOCOL?: string
}

export const MINIMAX_REGIONS = {
global_en: {
openaiBaseUrl: 'https://api.minimax.io/v1',
anthropicBaseUrl: 'https://api.minimax.io/anthropic',
docsRoot: 'https://platform.minimax.io/docs',
},
cn_zh: {
openaiBaseUrl: 'https://api.minimaxi.com/v1',
anthropicBaseUrl: 'https://api.minimaxi.com/anthropic',
docsRoot: 'https://platform.minimaxi.com/docs',
},
} as const

export function getMiniMaxApiKey(env: MiniMaxEnvironment = process.env as MiniMaxEnvironment): string | null {
return (env.MINIMAX_API_KEY || '').trim() || null
}

export function resolveMiniMaxEndpoint(env: MiniMaxEnvironment = process.env as MiniMaxEnvironment): {
region: MiniMaxRegion
protocol: MiniMaxProtocol
baseUrl: string
docsRoot: string
} {
const region = (env.MINIMAX_REGION || 'global_en').trim().toLowerCase()
if (region !== 'global_en' && region !== 'cn_zh') {
throw new Error('MINIMAX_REGION must be global_en or cn_zh')
}

const protocol = (env.MINIMAX_API_PROTOCOL || 'openai').trim().toLowerCase()
if (protocol !== 'openai' && protocol !== 'anthropic') {
throw new Error('MINIMAX_API_PROTOCOL must be openai or anthropic')
}

const regionalConfig = MINIMAX_REGIONS[region]
return {
region,
protocol,
baseUrl: protocol === 'anthropic' ? regionalConfig.anthropicBaseUrl : regionalConfig.openaiBaseUrl,
docsRoot: regionalConfig.docsRoot,
}
}
38 changes: 35 additions & 3 deletions src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ export interface ModelCostPerMTok {
input: number
/** USD per 1M output tokens */
output: number
/** USD per 1M cached input tokens read */
cacheRead?: number
/** USD per 1M cached input tokens written, when supported */
cacheWrite?: number | null
}

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

export interface ModelConfig {
alias: string
name: string
provider: string
description: string
costPerMTok: ModelCostPerMTok
contextWindow?: number
inputModalities?: ModelInputModality[]
thinking?: ModelThinkingMode[]
}

export const MODEL_CATALOG: ModelConfig[] = [
Expand All @@ -42,11 +52,31 @@ export const MODEL_CATALOG: ModelConfig[] = [
// Groq (hosted inference) — https://groq.com/pricing (verified 2026-07-04)
{ alias: 'groq-fast', name: 'groq/llama-3.1-8b-instant', provider: 'groq', description: '840 tok/s, ultra fast', costPerMTok: { input: 0.05, output: 0.08 } },
{ alias: 'groq', name: 'groq/llama-3.3-70b-versatile', provider: 'groq', description: 'Fast + quality balance', costPerMTok: { input: 0.59, output: 0.79 } },
// MiniMax — https://platform.minimax.io/docs/guides/pricing-paygo (verified 2026-07-24)
{
alias: 'minimax',
name: 'minimax/MiniMax-M3',
provider: 'minimax',
description: 'Multimodal model with configurable thinking',
costPerMTok: { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: null },
contextWindow: 1_000_000,
inputModalities: ['text', 'image', 'video'],
thinking: ['adaptive', 'disabled'],
},
{
alias: 'minimax-m2.7',
name: 'minimax/MiniMax-M2.7',
provider: 'minimax',
description: 'Text model with always-on thinking',
costPerMTok: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
contextWindow: 204_800,
inputModalities: ['text'],
thinking: ['always_on'],
},
// Other providers — Moonshot list price via https://openrouter.ai/moonshotai/kimi-k2.5;
// Venice https://docs.venice.ai/overview/pricing; MiniMax https://platform.minimax.io/docs/guides/pricing-paygo
// Venice https://docs.venice.ai/overview/pricing.
{ alias: 'kimi', name: 'moonshot/kimi-k2.5', provider: 'moonshot', description: 'Alternative provider', costPerMTok: { input: 0.6, output: 3.0 } },
{ alias: 'venice-llama-3.3-70b', name: 'venice/llama-3.3-70b', provider: 'venice', description: 'Venice AI Llama 3.3 70B', costPerMTok: { input: 0.7, output: 2.8 } },
{ alias: 'minimax', name: 'minimax/minimax-m2.1', provider: 'minimax', description: 'Cost-effective, strong coding', costPerMTok: { input: 0.3, output: 1.2 } },
]

export function getModelByAlias(alias: string): ModelConfig | undefined {
Expand Down Expand Up @@ -84,7 +114,9 @@ export function classifyModelProvider(model: string): string | undefined {
const normalized = model.trim().toLowerCase()
if (!normalized) return undefined
const entry = MODEL_CATALOG.find(m =>
m.name === normalized || getDispatchModelId(m) === normalized || m.alias === normalized
m.name.toLowerCase() === normalized
|| getDispatchModelId(m).toLowerCase() === normalized
|| m.alias.toLowerCase() === normalized
)
return entry?.provider
}
5 changes: 4 additions & 1 deletion src/lib/provider-subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process'
import path from 'node:path'
import os from 'node:os'
import { config } from '@/lib/config'
import { classifyModelProvider } from '@/lib/models'

interface ProviderSubscription {
provider: string
Expand Down Expand Up @@ -213,6 +214,9 @@ export function getProviderFromModel(modelName: string): string {
const normalized = modelName.trim().toLowerCase()
if (!normalized) return 'unknown'

const catalogProvider = classifyModelProvider(normalized)
if (catalogProvider) return catalogProvider

const [prefix] = normalized.split('/')
if (prefix && !prefix.includes(':')) {
// Most models are provider-prefixed, e.g., "anthropic/claude-sonnet-4-5".
Expand All @@ -225,4 +229,3 @@ export function getProviderFromModel(modelName: string): string {
if (normalized.includes('gpt') || normalized.includes('codex') || normalized.includes('o1') || normalized.includes('o3')) return 'openai'
return 'unknown'
}

Loading