From 17cf1376fee8da9bd6db2af42d6e4de1a38a7b39 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Tue, 14 Apr 2026 11:13:12 +0800 Subject: [PATCH 1/2] Add MiniMax-AI/cli as default skill tap --- packages/docs-web/src/content/docs/guides/skills.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/docs-web/src/content/docs/guides/skills.md b/packages/docs-web/src/content/docs/guides/skills.md index 8cfc5e5e81..d083ad6788 100644 --- a/packages/docs-web/src/content/docs/guides/skills.md +++ b/packages/docs-web/src/content/docs/guides/skills.md @@ -168,6 +168,7 @@ smaller box with a tastefully curated set of tools." |-------|---------|----------------| | `remotion-best-practices` | `npx skills add remotion-dev/skills` | Remotion animation patterns, API usage, gotchas (35 rules) | | `skill-creator` | `npx skills add anthropics/skills` | How to create new SKILL.md files | +| `mmx-cli` | `npx skills add MiniMax-AI/cli/skill` | Text, image, video, speech, and music generation via MiniMax AI | | Community skills | Browse [skills.sh](https://skills.sh) | Search 500K+ skills for any domain | ## Multiple Skills Per Node From be03c0adea02dc55868a81d33f1a620a72feb9d2 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sat, 18 Apr 2026 00:12:12 +0800 Subject: [PATCH 2/2] feat: add MiniMax provider support - Add MiniMax chat model provider using OpenAI-compatible API - Support MiniMax-M2.7 (default) and MiniMax-M2.7-highspeed models - Add MINIMAX_API_KEY environment variable support - Add unit tests for provider, config, and capabilities - Register MiniMax as a built-in provider in the registry - Export MiniMax provider and config from @archon/providers --- packages/providers/package.json | 4 +- packages/providers/src/index.ts | 2 + .../providers/src/minimax/capabilities.ts | 16 + packages/providers/src/minimax/config.ts | 30 ++ .../providers/src/minimax/provider.test.ts | 358 ++++++++++++++++++ packages/providers/src/minimax/provider.ts | 337 +++++++++++++++++ packages/providers/src/registry.test.ts | 22 +- packages/providers/src/registry.ts | 10 + packages/providers/src/types.ts | 7 + 9 files changed, 778 insertions(+), 8 deletions(-) create mode 100644 packages/providers/src/minimax/capabilities.ts create mode 100644 packages/providers/src/minimax/config.ts create mode 100644 packages/providers/src/minimax/provider.test.ts create mode 100644 packages/providers/src/minimax/provider.ts diff --git a/packages/providers/package.json b/packages/providers/package.json index cbe4a4617a..878894491b 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -12,11 +12,13 @@ "./codex/provider": "./src/codex/provider.ts", "./codex/config": "./src/codex/config.ts", "./codex/binary-resolver": "./src/codex/binary-resolver.ts", + "./minimax/provider": "./src/minimax/provider.ts", + "./minimax/config": "./src/minimax/config.ts", "./errors": "./src/errors.ts", "./registry": "./src/registry.ts" }, "scripts": { - "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts", + "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/minimax/provider.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index e24bb630eb..fbaef025f4 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -35,10 +35,12 @@ export { UnknownProviderError } from './errors'; // Provider classes export { ClaudeProvider } from './claude/provider'; export { CodexProvider } from './codex/provider'; +export { MiniMaxProvider } from './minimax/provider'; // Config parsers export { parseClaudeConfig, type ClaudeProviderDefaults } from './claude/config'; export { parseCodexConfig, type CodexProviderDefaults } from './codex/config'; +export { parseMiniMaxConfig, type MiniMaxProviderDefaults } from './minimax/config'; // Utilities (needed by consumers) export { resetCodexSingleton } from './codex/provider'; diff --git a/packages/providers/src/minimax/capabilities.ts b/packages/providers/src/minimax/capabilities.ts new file mode 100644 index 0000000000..98719d63a8 --- /dev/null +++ b/packages/providers/src/minimax/capabilities.ts @@ -0,0 +1,16 @@ +import type { ProviderCapabilities } from '../types'; + +export const MINIMAX_CAPABILITIES: ProviderCapabilities = { + sessionResume: false, + mcp: false, + hooks: false, + skills: false, + toolRestrictions: false, + structuredOutput: false, + envInjection: true, + costControl: false, + effortControl: false, + thinkingControl: false, + fallbackModel: false, + sandbox: false, +}; diff --git a/packages/providers/src/minimax/config.ts b/packages/providers/src/minimax/config.ts new file mode 100644 index 0000000000..a61b4405c6 --- /dev/null +++ b/packages/providers/src/minimax/config.ts @@ -0,0 +1,30 @@ +/** + * Typed config parsing for MiniMax provider defaults. + */ + +export interface MiniMaxProviderDefaults { + [key: string]: unknown; + model?: string; + baseURL?: string; +} + +// Re-export so consumers can import the type from either location +export type { MiniMaxProviderDefaults as MiniMaxConfig }; + +/** + * Parse raw assistantConfig into typed MiniMax defaults. + * Defensive: invalid fields are silently dropped. + */ +export function parseMiniMaxConfig(raw: Record): MiniMaxProviderDefaults { + const result: MiniMaxProviderDefaults = {}; + + if (typeof raw.model === 'string') { + result.model = raw.model; + } + + if (typeof raw.baseURL === 'string') { + result.baseURL = raw.baseURL; + } + + return result; +} diff --git a/packages/providers/src/minimax/provider.test.ts b/packages/providers/src/minimax/provider.test.ts new file mode 100644 index 0000000000..285f4c2f0d --- /dev/null +++ b/packages/providers/src/minimax/provider.test.ts @@ -0,0 +1,358 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { createMockLogger } from '../test/mocks/logger'; + +const mockLogger = createMockLogger(); +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), +})); + +import { MiniMaxProvider } from './provider'; +import { parseMiniMaxConfig } from './config'; +import { MINIMAX_CAPABILITIES } from './capabilities'; + +// ─── Config parser tests ───────────────────────────────────────────────── + +describe('parseMiniMaxConfig', () => { + test('returns empty object for empty input', () => { + expect(parseMiniMaxConfig({})).toEqual({}); + }); + + test('parses model field', () => { + expect(parseMiniMaxConfig({ model: 'MiniMax-M2.7' })).toEqual({ model: 'MiniMax-M2.7' }); + }); + + test('parses baseURL field', () => { + const cfg = parseMiniMaxConfig({ baseURL: 'https://api.minimaxi.com/v1' }); + expect(cfg.baseURL).toBe('https://api.minimaxi.com/v1'); + }); + + test('ignores invalid model type', () => { + expect(parseMiniMaxConfig({ model: 42 })).toEqual({}); + }); + + test('ignores invalid baseURL type', () => { + expect(parseMiniMaxConfig({ baseURL: true })).toEqual({}); + }); + + test('ignores unknown fields', () => { + const cfg = parseMiniMaxConfig({ model: 'MiniMax-M2.7', unknown: 'field' }); + expect(cfg).toEqual({ model: 'MiniMax-M2.7' }); + }); +}); + +// ─── Capabilities tests ────────────────────────────────────────────────── + +describe('MINIMAX_CAPABILITIES', () => { + test('has expected capability flags', () => { + expect(MINIMAX_CAPABILITIES).toEqual({ + sessionResume: false, + mcp: false, + hooks: false, + skills: false, + toolRestrictions: false, + structuredOutput: false, + envInjection: true, + costControl: false, + effortControl: false, + thinkingControl: false, + fallbackModel: false, + sandbox: false, + }); + }); +}); + +// ─── MiniMaxProvider unit tests ─────────────────────────────────────────── + +describe('MiniMaxProvider', () => { + let provider: MiniMaxProvider; + + beforeEach(() => { + provider = new MiniMaxProvider({ retryBaseDelayMs: 1 }); + mockLogger.error.mockClear(); + mockLogger.warn.mockClear(); + mockLogger.info.mockClear(); + mockLogger.debug.mockClear(); + }); + + describe('getType', () => { + test('returns minimax', () => { + expect(provider.getType()).toBe('minimax'); + }); + }); + + describe('getCapabilities', () => { + test('returns MINIMAX_CAPABILITIES', () => { + expect(provider.getCapabilities()).toEqual(MINIMAX_CAPABILITIES); + }); + + test('sessionResume is false', () => { + expect(provider.getCapabilities().sessionResume).toBe(false); + }); + + test('envInjection is true', () => { + expect(provider.getCapabilities().envInjection).toBe(true); + }); + + test('mcp and hooks are false', () => { + const caps = provider.getCapabilities(); + expect(caps.mcp).toBe(false); + expect(caps.hooks).toBe(false); + }); + }); + + describe('sendQuery', () => { + test('throws when MINIMAX_API_KEY is not set', async () => { + // Ensure no API key in env + const origKey = process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_API_KEY; + + try { + const gen = provider.sendQuery('hello', '/tmp'); + await expect(gen.next()).rejects.toThrow('MINIMAX_API_KEY is not set'); + } finally { + if (origKey !== undefined) process.env.MINIMAX_API_KEY = origKey; + } + }); + + test('uses injected env API key over process env', async () => { + const origKey = process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_API_KEY; + + // Mock fetch to return a minimal SSE response + const mockFetch = mock(() => + Promise.resolve( + new Response('data: {"choices":[{"delta":{"content":"hi"},"index":0}]}\ndata: [DONE]\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + try { + const gen = provider.sendQuery('hello', '/tmp', undefined, { + env: { MINIMAX_API_KEY: 'injected-key' }, + }); + const chunks: unknown[] = []; + for await (const chunk of gen) { + chunks.push(chunk); + } + // Should have called fetch with the right auth header + expect(mockFetch).toHaveBeenCalled(); + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = callArgs[1].headers as Record; + expect(headers['Authorization']).toBe('Bearer injected-key'); + } finally { + if (origKey !== undefined) process.env.MINIMAX_API_KEY = origKey; + } + }); + + test('aborts immediately if abortSignal is already aborted', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + const controller = new AbortController(); + controller.abort(); + + const gen = provider.sendQuery('hello', '/tmp', undefined, { + abortSignal: controller.signal, + }); + await expect(gen.next()).rejects.toThrow('Query aborted'); + }); + + test('uses default model MiniMax-M2.7 when none specified', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + + const mockFetch = mock(() => + Promise.resolve( + new Response('data: {"choices":[{"delta":{"content":"ok"},"index":0}]}\ndata: [DONE]\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + const chunks: unknown[] = []; + for await (const chunk of provider.sendQuery('hello', '/tmp')) { + chunks.push(chunk); + } + + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(callArgs[1].body as string) as Record; + expect(body.model).toBe('MiniMax-M2.7'); + }); + + test('uses custom model when specified via requestOptions', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + + const mockFetch = mock(() => + Promise.resolve( + new Response('data: {"choices":[{"delta":{"content":"ok"},"index":0}]}\ndata: [DONE]\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + for await (const _ of provider.sendQuery('hello', '/tmp', undefined, { + model: 'MiniMax-M2.7-highspeed', + })) { + // consume + } + + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(callArgs[1].body as string) as Record; + expect(body.model).toBe('MiniMax-M2.7-highspeed'); + }); + + test('includes system prompt when provided', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + + const mockFetch = mock(() => + Promise.resolve( + new Response('data: [DONE]\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + for await (const _ of provider.sendQuery('hello', '/tmp', undefined, { + systemPrompt: 'Be concise.', + })) { + // consume + } + + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(callArgs[1].body as string) as { + messages: { role: string; content: string }[]; + }; + expect(body.messages[0]).toEqual({ role: 'system', content: 'Be concise.' }); + }); + + test('uses custom base URL from assistantConfig', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + + const mockFetch = mock(() => + Promise.resolve( + new Response('data: [DONE]\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + for await (const _ of provider.sendQuery('hello', '/tmp', undefined, { + assistantConfig: { baseURL: 'https://api.minimaxi.com/v1' }, + })) { + // consume + } + + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(callArgs[0]).toContain('api.minimaxi.com'); + }); + + test('temperature is always 1.0 in request body', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + + const mockFetch = mock(() => + Promise.resolve( + new Response('data: [DONE]\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + for await (const _ of provider.sendQuery('hello', '/tmp')) { + // consume + } + + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(callArgs[1].body as string) as Record; + expect(body.temperature).toBe(1.0); + }); + + test('throws on auth error without retry', async () => { + process.env.MINIMAX_API_KEY = 'bad-key'; + + const mockFetch = mock(() => + Promise.resolve( + new Response('{"error":"unauthorized"}', { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + const gen = provider.sendQuery('hello', '/tmp'); + await expect(gen.next()).rejects.toThrow('MiniMax auth error'); + // Should NOT retry on auth errors + expect(mockFetch.mock.calls.length).toBe(1); + }); + + test('yields assistant chunk and result chunk from streaming response', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + + const sseBody = + 'data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}\n' + + 'data: {"choices":[{"delta":{"content":" world"},"index":0}]}\n' + + 'data: [DONE]\n'; + + const mockFetch = mock(() => + Promise.resolve( + new Response(sseBody, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + const chunks: unknown[] = []; + for await (const chunk of provider.sendQuery('hi', '/tmp')) { + chunks.push(chunk); + } + + expect(chunks).toContainEqual({ type: 'assistant', content: 'Hello' }); + expect(chunks).toContainEqual({ type: 'assistant', content: ' world' }); + // Last chunk should be result + const last = chunks[chunks.length - 1] as { type: string }; + expect(last.type).toBe('result'); + }); + + test('yields result chunk with token usage when provided', async () => { + process.env.MINIMAX_API_KEY = 'test-key'; + + const sseBody = + 'data: {"choices":[{"delta":{"content":"hi"},"index":0}]}\n' + + 'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}\n' + + 'data: [DONE]\n'; + + const mockFetch = mock(() => + Promise.resolve( + new Response(sseBody, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + + const chunks: unknown[] = []; + for await (const chunk of provider.sendQuery('hi', '/tmp')) { + chunks.push(chunk); + } + + const resultChunk = chunks.find( + (c): c is { type: 'result'; tokens: { input: number; output: number } } => + (c as { type: string }).type === 'result' + ); + expect(resultChunk?.tokens).toEqual({ input: 10, output: 5 }); + }); + }); +}); diff --git a/packages/providers/src/minimax/provider.ts b/packages/providers/src/minimax/provider.ts new file mode 100644 index 0000000000..04ad2a2c6f --- /dev/null +++ b/packages/providers/src/minimax/provider.ts @@ -0,0 +1,337 @@ +/** + * MiniMax provider + * + * Wraps MiniMax's OpenAI-compatible Chat Completions API. + * Uses streaming SSE for real-time token delivery. + * + * Authentication: MINIMAX_API_KEY environment variable + * Base URL: https://api.minimax.io/v1 (overseas, default) + * + * Supported models: + * - MiniMax-M2.7 (default) + * - MiniMax-M2.7-highspeed + */ +import type { + IAgentProvider, + SendQueryOptions, + MessageChunk, + ProviderCapabilities, +} from '../types'; +import { parseMiniMaxConfig } from './config'; +import { MINIMAX_CAPABILITIES } from './capabilities'; +import { createLogger } from '@archon/paths'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.minimax'); + return cachedLog; +} + +/** Default model when none is specified */ +const DEFAULT_MODEL = 'MiniMax-M2.7'; + +/** Overseas base URL (default) */ +const DEFAULT_BASE_URL = 'https://api.minimax.io/v1'; + +/** Maximum retries on rate-limit or transient errors */ +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 2000; + +const RATE_LIMIT_PATTERNS = ['rate limit', 'too many requests', '429', '1002', '1039']; +const AUTH_PATTERNS = ['unauthorized', 'authentication', 'invalid', '401', '403', '1004']; + +/** Classify MiniMax error for retry decisions */ +function classifyError(message: string): 'rate_limit' | 'auth' | 'unknown' { + const m = message.toLowerCase(); + if (RATE_LIMIT_PATTERNS.some(p => m.includes(p))) return 'rate_limit'; + if (AUTH_PATTERNS.some(p => m.includes(p))) return 'auth'; + return 'unknown'; +} + +/** + * OpenAI-compatible SSE chunk shape (simplified subset we care about). + */ +interface ChatChunkDelta { + content?: string; + role?: string; +} + +interface ChatChunkChoice { + delta: ChatChunkDelta; + finish_reason?: string | null; + index: number; +} + +interface ChatCompletionChunk { + id?: string; + object?: string; + choices: ChatChunkChoice[]; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; +} + +/** + * Parse a single SSE `data:` line into a ChatCompletionChunk. + * Returns null for non-data lines or the [DONE] sentinel. + */ +function parseSseLine(line: string): ChatCompletionChunk | null { + if (!line.startsWith('data:')) return null; + const payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') return null; + try { + return JSON.parse(payload) as ChatCompletionChunk; + } catch { + getLog().warn({ payload }, 'minimax.sse_parse_error'); + return null; + } +} + +/** + * Stream MiniMax OpenAI-compatible SSE response into Archon MessageChunks. + */ +async function* streamMiniMaxResponse( + response: Response, + abortSignal?: AbortSignal +): AsyncGenerator { + if (!response.body) { + throw new Error('MiniMax: response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let inputTokens = 0; + let outputTokens = 0; + let hasContent = false; + + try { + while (true) { + if (abortSignal?.aborted) { + throw new Error('Query aborted'); + } + + 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) { + const trimmed = line.trim(); + if (!trimmed) continue; + const chunk = parseSseLine(trimmed); + if (!chunk) continue; + + // Accumulate usage if present + if (chunk.usage) { + inputTokens = chunk.usage.prompt_tokens ?? inputTokens; + outputTokens = chunk.usage.completion_tokens ?? outputTokens; + } + + for (const choice of chunk.choices) { + const content = choice.delta.content; + if (content) { + hasContent = true; + yield { type: 'assistant', content }; + } + } + } + } + + // Handle any remaining buffer content + if (buffer.trim()) { + const chunk = parseSseLine(buffer.trim()); + if (chunk) { + for (const choice of chunk.choices) { + const content = choice.delta.content; + if (content) { + hasContent = true; + yield { type: 'assistant', content }; + } + } + } + } + } finally { + reader.releaseLock(); + } + + if (!hasContent) { + getLog().warn('minimax.empty_response'); + } + + yield { + type: 'result', + ...(inputTokens > 0 || outputTokens > 0 + ? { tokens: { input: inputTokens, output: outputTokens } } + : {}), + }; +} + +/** + * Build the request body for the chat completions API. + * Ensures temperature is within MiniMax's accepted range (0.0, 1.0]. + */ +function buildRequestBody( + messages: { role: string; content: string }[], + model: string, + systemPrompt?: string +): Record { + const allMessages: { role: string; content: string }[] = []; + + if (systemPrompt) { + allMessages.push({ role: 'system', content: systemPrompt }); + } + + allMessages.push(...messages); + + return { + model, + messages: allMessages, + stream: true, + stream_options: { include_usage: true }, + temperature: 1.0, // MiniMax requires (0.0, 1.0]; 1.0 is the safe default + }; +} + +// ─── MiniMax Provider ───────────────────────────────────────────────────── + +/** + * MiniMax chat provider. + * Implements IAgentProvider using MiniMax's OpenAI-compatible API. + * + * Capabilities: streaming chat completions, env injection. + * Not supported: session resume, MCP, hooks, skills, tool restrictions. + */ +export class MiniMaxProvider implements IAgentProvider { + private readonly retryBaseDelayMs: number; + + constructor(options?: { retryBaseDelayMs?: number }) { + this.retryBaseDelayMs = options?.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS; + } + + getCapabilities(): ProviderCapabilities { + return MINIMAX_CAPABILITIES; + } + + getType(): string { + return 'minimax'; + } + + /** + * Send a prompt to MiniMax and stream the response. + * + * Session resume is not supported — each call starts a fresh context. + * The prompt is sent as a single user message. + */ + async *sendQuery( + prompt: string, + _cwd: string, + _resumeSessionId?: string, + requestOptions?: SendQueryOptions + ): AsyncGenerator { + const assistantConfig = parseMiniMaxConfig(requestOptions?.assistantConfig ?? {}); + const model = requestOptions?.model ?? assistantConfig.model ?? DEFAULT_MODEL; + const baseURL = + requestOptions?.env?.MINIMAX_BASE_URL ?? assistantConfig.baseURL ?? DEFAULT_BASE_URL; + + // Resolve API key: injected env > process env + const apiKey = requestOptions?.env?.MINIMAX_API_KEY ?? process.env.MINIMAX_API_KEY; + + if (!apiKey) { + throw new Error( + 'MiniMax: MINIMAX_API_KEY is not set. ' + 'Set it in your environment or via ~/.env.local.' + ); + } + + const body = buildRequestBody( + [{ role: 'user', content: prompt }], + model, + requestOptions?.systemPrompt + ); + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (requestOptions?.abortSignal?.aborted) { + throw new Error('Query aborted'); + } + + getLog().debug({ model, attempt, baseURL }, 'minimax.query_started'); + + let response: Response; + try { + response = await fetch(`${baseURL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: requestOptions?.abortSignal, + }); + } catch (fetchErr) { + const err = fetchErr as Error; + if (err.name === 'AbortError') { + throw new Error('Query aborted'); + } + const errorClass = classifyError(err.message); + const shouldRetry = errorClass === 'rate_limit' && attempt < MAX_RETRIES; + getLog().error({ err, attempt, errorClass }, 'minimax.fetch_error'); + if (!shouldRetry) throw new Error(`MiniMax fetch error: ${err.message}`); + const delayMs = this.retryBaseDelayMs * Math.pow(2, attempt); + getLog().info({ attempt, delayMs }, 'minimax.retrying'); + await new Promise(resolve => setTimeout(resolve, delayMs)); + lastError = err; + continue; + } + + if (!response.ok) { + let errBody = ''; + try { + errBody = await response.text(); + } catch { + // ignore read errors + } + const errorMessage = `HTTP ${response.status}: ${errBody}`; + const errorClass = classifyError(errorMessage); + + getLog().error({ status: response.status, body: errBody, attempt }, 'minimax.http_error'); + + if (errorClass === 'auth') { + throw new Error(`MiniMax auth error: ${errorMessage}`); + } + + const shouldRetry = errorClass === 'rate_limit' && attempt < MAX_RETRIES; + if (!shouldRetry) { + throw new Error(`MiniMax error: ${errorMessage}`); + } + + const delayMs = this.retryBaseDelayMs * Math.pow(2, attempt); + getLog().info({ attempt, delayMs, errorClass }, 'minimax.retrying'); + await new Promise(resolve => setTimeout(resolve, delayMs)); + lastError = new Error(errorMessage); + continue; + } + + try { + yield* streamMiniMaxResponse(response, requestOptions?.abortSignal); + return; + } catch (streamErr) { + const err = streamErr as Error; + if (err.message === 'Query aborted') throw err; + getLog().error({ err, attempt }, 'minimax.stream_error'); + if (attempt >= MAX_RETRIES) throw err; + const delayMs = this.retryBaseDelayMs * Math.pow(2, attempt); + await new Promise(resolve => setTimeout(resolve, delayMs)); + lastError = err; + } + } + + throw lastError ?? new Error('MiniMax query failed after retries'); + } +} diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 7af9dd21e7..6a59c36fda 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -77,9 +77,7 @@ describe('registry', () => { test('throws UnknownProviderError for unknown type', () => { expect(() => getAgentProvider('unknown')).toThrow(UnknownProviderError); - expect(() => getAgentProvider('unknown')).toThrow( - "Unknown provider: 'unknown'. Available: claude, codex" - ); + expect(() => getAgentProvider('unknown')).toThrow("Unknown provider: 'unknown'"); }); test('throws UnknownProviderError for empty string', () => { @@ -191,23 +189,24 @@ describe('registry', () => { describe('getRegisteredProviders', () => { test('returns all registered providers', () => { const all = getRegisteredProviders(); - expect(all.length).toBe(2); + expect(all.length).toBe(3); const ids = all.map(r => r.id); expect(ids).toContain('claude'); expect(ids).toContain('codex'); + expect(ids).toContain('minimax'); }); test('includes community providers after registration', () => { registerProvider(makeMockRegistration('my-llm')); const all = getRegisteredProviders(); - expect(all.length).toBe(3); + expect(all.length).toBe(4); }); }); describe('getProviderInfoList', () => { test('returns API-safe projection without factory', () => { const infos = getProviderInfoList(); - expect(infos.length).toBe(2); + expect(infos.length).toBe(3); for (const info of infos) { expect(info).toHaveProperty('id'); expect(info).toHaveProperty('displayName'); @@ -223,6 +222,7 @@ describe('registry', () => { test('returns true for registered providers', () => { expect(isRegisteredProvider('claude')).toBe(true); expect(isRegisteredProvider('codex')).toBe(true); + expect(isRegisteredProvider('minimax')).toBe(true); }); test('returns false for unknown providers', () => { @@ -236,7 +236,7 @@ describe('registry', () => { registerBuiltinProviders(); registerBuiltinProviders(); const all = getRegisteredProviders(); - expect(all.length).toBe(2); + expect(all.length).toBe(3); }); }); @@ -267,5 +267,13 @@ describe('registry', () => { expect(reg.isModelCompatible('gpt-4')).toBe(true); expect(reg.isModelCompatible('o3-mini')).toBe(true); }); + + test('MiniMax registration matches MiniMax model patterns', () => { + const reg = getRegistration('minimax'); + expect(reg.isModelCompatible('MiniMax-M2.7')).toBe(true); + expect(reg.isModelCompatible('MiniMax-M2.7-highspeed')).toBe(true); + expect(reg.isModelCompatible('gpt-4')).toBe(false); + expect(reg.isModelCompatible('claude-3.5-sonnet')).toBe(false); + }); }); }); diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index 8c80d163b2..333ded86c5 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -15,8 +15,10 @@ import type { } from './types'; import { ClaudeProvider } from './claude/provider'; import { CodexProvider } from './codex/provider'; +import { MiniMaxProvider } from './minimax/provider'; import { CLAUDE_CAPABILITIES } from './claude/capabilities'; import { CODEX_CAPABILITIES } from './codex/capabilities'; +import { MINIMAX_CAPABILITIES } from './minimax/capabilities'; import { UnknownProviderError } from './errors'; import { createLogger } from '@archon/paths'; @@ -130,6 +132,14 @@ export function registerBuiltinProviders(): void { }, builtIn: true, }, + { + id: 'minimax', + displayName: 'MiniMax', + factory: () => new MiniMaxProvider(), + capabilities: MINIMAX_CAPABILITIES, + isModelCompatible: (model: string): boolean => model.startsWith('MiniMax-'), + builtIn: true, + }, ]; for (const entry of builtins) { diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index 435073d745..66dea08746 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -27,6 +27,13 @@ export interface CodexProviderDefaults { codexBinaryPath?: string; } +export interface MiniMaxProviderDefaults { + [key: string]: unknown; + model?: string; + /** Override the default base URL (https://api.minimax.io/v1). */ + baseURL?: string; +} + /** Generic per-provider defaults bag used by config surfaces and UI. */ export type ProviderDefaults = Record;