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
46 changes: 43 additions & 3 deletions packages/ai-provider/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,50 @@ export const AI_PROVIDERS: AiProviderMeta[] = [
{
id: 'minimax',
label: 'MiniMax',
// M3 is the current agentic/tool-use model; M2.5 moved to the legacy tier
models: ['MiniMax-M3', 'MiniMax-M2.7'],
defaultModel: 'MiniMax-M3',
// Keep saved model ids intact; only the ordinary choices follow the current lineup.
models: ['MiniMax-M3.1-Flash', 'MiniMax-M3'],
defaultModel: 'MiniMax-M3.1-Flash',
keyPlaceholder: 'eyJ...',
modelMetadata: {
'MiniMax-M3.1-Flash': {
contextWindow: 1_000_000,
maxOutputTokens: 524_288,
pricingUsdPerMillionTokens: { input: 0.15, output: 0.6, cacheRead: 0.015 },
inputModalities: ['text', 'image', 'video'],
thinkingModes: ['adaptive'],
},
'MiniMax-M3': {
contextWindow: 1_000_000,
pricingUsdPerMillionTokens: { input: 0.3, output: 1.2, cacheRead: 0.06 },
pricingNotes:
'Standard up to 512k input tokens; above 512k: input $0.60, output $2.40, cache read $0.12 per million tokens. Priority costs 1.5x standard.',
inputModalities: ['text', 'image', 'video'],
thinkingModes: ['adaptive', 'disabled'],
},
'MiniMax-M2.7': {
contextWindow: 204_800,
pricingUsdPerMillionTokens: {
input: 0.3,
output: 1.2,
cacheRead: 0.06,
cacheWrite: 0.375,
},
inputModalities: ['text'],
thinkingModes: ['always_on'],
},
},
endpoints: [
{
region: 'global_en',
openaiBaseUrl: 'https://api.minimax.io/v1',
anthropicBaseUrl: 'https://api.minimax.io/anthropic',
},
{
region: 'cn_zh',
openaiBaseUrl: 'https://api.minimaxi.com/v1',
anthropicBaseUrl: 'https://api.minimaxi.com/anthropic',
},
],
},
{
id: 'xai',
Expand Down
25 changes: 25 additions & 0 deletions packages/ai-provider/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ export interface AiProviderMeta {
needsBaseUrl?: boolean
/** Fork: canonical local gateway base URL (e.g. hermes → http://127.0.0.1:8642/v1) used as the default when unset */
defaultBaseUrl?: string
/** Vendor model capabilities and pricing used by model pickers and request guards. */
modelMetadata?: Record<string, AiModelMetadata>
/** Regional endpoints published by the provider. */
endpoints?: AiProviderEndpoint[]
}

export interface AiModelMetadata {
contextWindow: number
maxOutputTokens?: number
/** Qualifications for the listed base rates, such as input-length and priority tiers. */
pricingNotes?: string
pricingUsdPerMillionTokens: {
input: number
output: number
cacheRead?: number
cacheWrite?: number
}
inputModalities: Array<'text' | 'image' | 'video' | 'audio'>
thinkingModes: string[]
}

export interface AiProviderEndpoint {
region: string
openaiBaseUrl: string
anthropicBaseUrl: string
}

export interface AiSettings {
Expand Down
180 changes: 180 additions & 0 deletions packages/ai-provider/tests/minimax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentToolCall } from '@hermesoffice/agent-core'
import { chatForProvider } from '../src/chat'
import { activeProvider, defaultAiSettings, resolveAiSettings } from '../src/providers'
import { streamForProvider } from '../src/stream'
import { jsonResponse, okResponse, sseStream } from './test-utils'

afterEach(() => {
vi.unstubAllGlobals()
})

const endpoints = ['https://api.minimax.io/v1', 'https://api.minimaxi.com/v1']

function savedSettings(model: string, baseUrl?: string) {
const stored = defaultAiSettings()
stored.provider = 'minimax'
stored.providers.minimax = { apiKey: 'test-key', model, baseUrl }
return resolveAiSettings(stored, defaultAiSettings())
}

describe('MiniMax request boundary', () => {
it('sends Flash for a fresh MiniMax configuration with API-default thinking and effort', async () => {
const settings = defaultAiSettings({ minimax: 'test-key' })
settings.provider = 'minimax'
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'ready' } }] }))
vi.stubGlobal('fetch', fetchMock)

expect(
await chatForProvider(activeProvider(settings), settings.providers.minimax, 'system', 'hi'),
).toEqual({ ok: true, content: 'ready' })
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('https://api.minimax.io/v1/chat/completions')
expect(init?.method).toBe('POST')
expect(init?.headers).toMatchObject({ Authorization: 'Bearer test-key' })
// No disabled thinking, effort=none, service_tier, or reasoning_split=false.
expect(JSON.parse(init?.body as string)).toEqual({
model: 'MiniMax-M3.1-Flash',
messages: [
{ role: 'system', content: 'system' },
{ role: 'user', content: 'hi' },
],
temperature: 0.3,
})
})

describe.each(endpoints)('%s', (baseUrl) => {
it.each(['MiniMax-M3.1-Flash', 'MiniMax-M3', 'MiniMax-M2.7', 'my-saved-model'])(
'preserves saved %s and the regional endpoint through settings resolution',
async (model) => {
const settings = savedSettings(model, baseUrl)
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'ok' } }] }))
vi.stubGlobal('fetch', fetchMock)
await chatForProvider(activeProvider(settings), settings.providers.minimax, 'system', 'hi')
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe(`${baseUrl}/chat/completions`)
expect(JSON.parse(init?.body as string)).toEqual({
model,
messages: [
{ role: 'system', content: 'system' },
{ role: 'user', content: 'hi' },
],
temperature: 0.3,
})
},
)

it.each(['MiniMax-M3.1-Flash', 'MiniMax-M3'])(
'round-trips %s reasoning and tool history without losing image input',
async (model) => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
okResponse(
sseStream([
'data: {"choices":[{"delta":{"reasoning_content":"inspect first"}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"inspect","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}',
'data: [DONE]',
]),
),
)
vi.stubGlobal('fetch', fetchMock)
const reasoning: string[] = []
const toolCalls: AgentToolCall[] = []
const config = savedSettings(model, baseUrl).providers.minimax
const tools = [
{ name: 'inspect', description: 'Inspect the document', inputSchema: { type: 'object' } },
]
const cb = {
signal: new AbortController().signal,
onDelta: vi.fn(),
onReasoningDelta: (text: string) => reasoning.push(text),
onToolCall: (call: AgentToolCall) => toolCalls.push(call),
}
await streamForProvider('minimax', config, 'system', [], tools, 8192, cb)
expect(reasoning.join('')).toBe('inspect first')
expect(toolCalls).toMatchObject([{ id: 'call-1', name: 'inspect', input: {} }])

fetchMock.mockResolvedValueOnce(
okResponse(
sseStream(['data: {"choices":[{"delta":{"content":"done"},"finish_reason":"stop"}]}']),
),
)
await streamForProvider(
'minimax',
config,
'system',
[
{ role: 'user', text: 'Inspect', images: [{ mime: 'image/png', base64: 'aW1hZ2U=' }] },
{ role: 'assistant', text: '', reasoning: reasoning.join(''), toolCalls },
{
role: 'tool',
results: [{ id: 'call-1', name: 'inspect', output: 'document contents' }],
},
],
tools,
8192,
cb,
)
const [url, init] = fetchMock.mock.calls[1]!
expect(url).toBe(`${baseUrl}/chat/completions`)
expect(JSON.parse(init?.body as string)).toEqual({
model,
max_tokens: 8192,
messages: [
{ role: 'system', content: 'system' },
{
role: 'user',
content: [
{ type: 'text', text: 'Inspect' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,aW1hZ2U=' } },
],
},
{
role: 'assistant',
content: null,
reasoning_content: 'inspect first',
tool_calls: [
{ id: 'call-1', type: 'function', function: { name: 'inspect', arguments: '{}' } },
],
},
{ role: 'tool', tool_call_id: 'call-1', content: 'document contents' },
],
tools: [
{
type: 'function',
function: {
name: 'inspect',
description: 'Inspect the document',
parameters: { type: 'object' },
},
},
],
temperature: 0.3,
stream: true,
})
expect(cb.onDelta).toHaveBeenCalledWith('done')
},
)
})

it('preserves a legacy custom endpoint and model in the outgoing request', async () => {
const settings = resolveAiSettings(
{ apiKey: 'test-key', model: 'MiniMax-M2.7', baseUrl: 'https://custom.example/v1' },
defaultAiSettings(),
)
settings.provider = 'custom'
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(jsonResponse({ choices: [{ message: { content: 'ok' } }] }))
vi.stubGlobal('fetch', fetchMock)
await chatForProvider(activeProvider(settings), settings.providers.custom, 'system', 'hi')
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('https://custom.example/v1/chat/completions')
expect(JSON.parse(init?.body as string).model).toBe('MiniMax-M2.7')
})
})
46 changes: 46 additions & 0 deletions packages/ai-provider/tests/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,52 @@ describe('provider registry', () => {
}
})

it('publishes MiniMax model metadata and regional endpoints', () => {
const minimax = AI_PROVIDERS.find((provider) => provider.id === 'minimax')!
expect(minimax.models).toEqual(['MiniMax-M3.1-Flash', 'MiniMax-M3'])
expect(minimax.defaultModel).toBe('MiniMax-M3.1-Flash')
expect(minimax.modelMetadata).toEqual({
'MiniMax-M3.1-Flash': {
contextWindow: 1_000_000,
maxOutputTokens: 524_288,
pricingUsdPerMillionTokens: { input: 0.15, output: 0.6, cacheRead: 0.015 },
inputModalities: ['text', 'image', 'video'],
thinkingModes: ['adaptive'],
},
'MiniMax-M3': {
contextWindow: 1_000_000,
pricingUsdPerMillionTokens: { input: 0.3, output: 1.2, cacheRead: 0.06 },
pricingNotes:
'Standard up to 512k input tokens; above 512k: input $0.60, output $2.40, cache read $0.12 per million tokens. Priority costs 1.5x standard.',
inputModalities: ['text', 'image', 'video'],
thinkingModes: ['adaptive', 'disabled'],
},
'MiniMax-M2.7': {
contextWindow: 204_800,
pricingUsdPerMillionTokens: {
input: 0.3,
output: 1.2,
cacheRead: 0.06,
cacheWrite: 0.375,
},
inputModalities: ['text'],
thinkingModes: ['always_on'],
},
})
expect(minimax.endpoints).toEqual([
{
region: 'global_en',
openaiBaseUrl: 'https://api.minimax.io/v1',
anthropicBaseUrl: 'https://api.minimax.io/anthropic',
},
{
region: 'cn_zh',
openaiBaseUrl: 'https://api.minimaxi.com/v1',
anthropicBaseUrl: 'https://api.minimaxi.com/anthropic',
},
])
})

it('marks Kimi as fixed-sampling (K3 rejects any temperature but 1)', () => {
expect(AI_PROVIDER_ADAPTERS.kimi.resolveEndpoint(config('kimi-k3'))).toEqual({
protocol: 'openai-compatible',
Expand Down