From 6c372c7af7ca723b0a79c091ef57a85cbae09581 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:09:04 +0800 Subject: [PATCH 1/2] Add MiniMax model metadata and regional endpoints --- packages/ai-provider/src/providers.ts | 31 ++++++++++++++++++ packages/ai-provider/src/types.ts | 22 +++++++++++++ packages/ai-provider/tests/registry.test.ts | 35 +++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/packages/ai-provider/src/providers.ts b/packages/ai-provider/src/providers.ts index 864f51dc..1019976f 100644 --- a/packages/ai-provider/src/providers.ts +++ b/packages/ai-provider/src/providers.ts @@ -146,6 +146,37 @@ export const AI_PROVIDERS: AiProviderMeta[] = [ models: ['MiniMax-M3', 'MiniMax-M2.7'], defaultModel: 'MiniMax-M3', keyPlaceholder: 'eyJ...', + modelMetadata: { + 'MiniMax-M3': { + contextWindow: 1_000_000, + pricingUsdPerMillionTokens: { input: 0.6, output: 2.4, cacheRead: 0.12 }, + 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', diff --git a/packages/ai-provider/src/types.ts b/packages/ai-provider/src/types.ts index fcf0c890..f07c9274 100644 --- a/packages/ai-provider/src/types.ts +++ b/packages/ai-provider/src/types.ts @@ -42,6 +42,28 @@ 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 + /** Regional endpoints published by the provider. */ + endpoints?: AiProviderEndpoint[] +} + +export interface AiModelMetadata { + contextWindow: number + 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 { diff --git a/packages/ai-provider/tests/registry.test.ts b/packages/ai-provider/tests/registry.test.ts index 04e42d59..fdb5c47a 100644 --- a/packages/ai-provider/tests/registry.test.ts +++ b/packages/ai-provider/tests/registry.test.ts @@ -90,6 +90,41 @@ describe('provider registry', () => { } }) + it('publishes MiniMax model metadata and regional endpoints', () => { + const minimax = AI_PROVIDERS.find((provider) => provider.id === 'minimax')! + expect(minimax.modelMetadata).toEqual({ + 'MiniMax-M3': { + contextWindow: 1_000_000, + pricingUsdPerMillionTokens: { input: 0.6, output: 2.4, cacheRead: 0.12 }, + 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', From 19daed422d3737e4a92b753bb259e4545939c576 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Tue, 20 Oct 2026 18:48:17 +0800 Subject: [PATCH 2/2] feat(ai-provider): default MiniMax to M3.1 Flash --- packages/ai-provider/src/providers.ts | 17 +- packages/ai-provider/src/types.ts | 3 + packages/ai-provider/tests/minimax.test.ts | 180 ++++++++++++++++++++ packages/ai-provider/tests/registry.test.ts | 13 +- 4 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 packages/ai-provider/tests/minimax.test.ts diff --git a/packages/ai-provider/src/providers.ts b/packages/ai-provider/src/providers.ts index 1019976f..0956c936 100644 --- a/packages/ai-provider/src/providers.ts +++ b/packages/ai-provider/src/providers.ts @@ -142,14 +142,23 @@ 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.6, output: 2.4, cacheRead: 0.12 }, + 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'], }, diff --git a/packages/ai-provider/src/types.ts b/packages/ai-provider/src/types.ts index f07c9274..66c5272a 100644 --- a/packages/ai-provider/src/types.ts +++ b/packages/ai-provider/src/types.ts @@ -50,6 +50,9 @@ export interface AiProviderMeta { 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 diff --git a/packages/ai-provider/tests/minimax.test.ts b/packages/ai-provider/tests/minimax.test.ts new file mode 100644 index 00000000..0d8f5760 --- /dev/null +++ b/packages/ai-provider/tests/minimax.test.ts @@ -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() + .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() + .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() + .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() + .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') + }) +}) diff --git a/packages/ai-provider/tests/registry.test.ts b/packages/ai-provider/tests/registry.test.ts index fdb5c47a..81e4e6af 100644 --- a/packages/ai-provider/tests/registry.test.ts +++ b/packages/ai-provider/tests/registry.test.ts @@ -92,10 +92,21 @@ 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.6, output: 2.4, cacheRead: 0.12 }, + 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'], },