diff --git a/examples/cli-client/README.md b/examples/cli-client/README.md index c75d086e12..9d606baabe 100644 --- a/examples/cli-client/README.md +++ b/examples/cli-client/README.md @@ -23,18 +23,24 @@ The model sits behind one small interface (`providers/provider.ts`); each file i | `anthropic` | `ANTHROPIC_API_KEY` (or an OAuth-style `ANTHROPIC_AUTH_TOKEN`) | newest Sonnet, resolved from the models API | `--model ` or `ANTHROPIC_MODEL` | | `openai` | `OPENAI_API_KEY` | newest mainline GPT (non-pro) | `--model ` or `OPENAI_MODEL` | | `gemini` | `GEMINI_API_KEY` | newest stable Flash | `--model ` or `GEMINI_MODEL` | +| `minimax` | `MINIMAX_API_KEY` | `MiniMax-M3` | `--model ` or `MINIMAX_MODEL` | | `scripted` | nothing — the keyless default | n/a (replays canned turns) | n/a | ```bash ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --provider anthropic OPENAI_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --provider openai GEMINI_API_KEY=… pnpm --filter @mcp-examples/cli-client start -- --provider gemini +MINIMAX_API_KEY=… pnpm --filter @mcp-examples/cli-client start -- --provider minimax -# pin an exact model instead of the resolved latest +# pin an exact model instead of the provider default ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --provider anthropic --model claude-sonnet-4-5 +MINIMAX_API_KEY=… pnpm --filter @mcp-examples/cli-client start -- --provider minimax --model MiniMax-M2.7 + +# use the China endpoint instead of the default global endpoint +MINIMAX_API_KEY=… MINIMAX_BASE_URL=https://api.minimaxi.com/v1 pnpm --filter @mcp-examples/cli-client start -- --provider minimax ``` -Model ids are deliberately not hardcoded: unless pinned, each provider asks its own models API for the newest mid-tier model, so the example keeps working as vendors ship new ones. The `scripted` provider replays a fixed conversation — it is what CI uses (see [testing](#how-this-example-is-tested)), and what you get when no key is set. +Unless pinned, providers use either a documented default or their models API to select a model. The `scripted` provider replays a fixed conversation — it is what CI uses (see [testing](#how-this-example-is-tested)), and what you get when no key is set. ## Pair it with todos-server (two terminals) @@ -113,8 +119,8 @@ For a persistent setup, copy `config.example.json` to `config.json` (or pass `-- ```text --server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) --config mcpServers config file (default: ./config.json, falling back to spawning todos-server) ---provider scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted) ---model pin a model id (default: the provider's latest mid-tier model) +--provider scripted | anthropic | openai | gemini | minimax (default: first one with a key in the env, else scripted) +--model pin a model id (default: the provider's configured model) --root workspace root exposed to servers via roots/list (repeatable; default: cwd) --callback-port fixed loopback port for the OAuth callback (default: a free port) --legacy use the 2025 initialize handshake instead of probing for 2026-07-28 diff --git a/examples/cli-client/cli.ts b/examples/cli-client/cli.ts index 5b29c5a848..0ae0ca7dbf 100644 --- a/examples/cli-client/cli.ts +++ b/examples/cli-client/cli.ts @@ -19,6 +19,7 @@ import { createSession, handleUserInput } from './host/loop'; import { createCompleter, ReadlineUI } from './host/ui'; import { AnthropicProvider } from './providers/anthropic'; import { GeminiProvider } from './providers/gemini'; +import { MiniMaxProvider } from './providers/minimax'; import { OpenAIProvider } from './providers/openai'; import type { LLMProvider } from './providers/provider'; import { ScriptedProvider } from './providers/scripted'; @@ -26,7 +27,7 @@ import { ScriptedProvider } from './providers/scripted'; const USAGE = `usage: tsx cli.ts [options] --server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) --config mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server) - --provider scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted) + --provider scripted | anthropic | openai | gemini | minimax (default: first one with an API key in the env, else scripted) --model pin a model id (default: the provider's latest mid-tier model) --root workspace root exposed to servers via roots/list (repeatable; default: cwd) --callback-port fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH) @@ -43,7 +44,9 @@ function pickProvider(name: string | undefined, model: string | undefined): LLMP ? 'openai' : process.env.GEMINI_API_KEY ? 'gemini' - : 'scripted'); + : process.env.MINIMAX_API_KEY + ? 'minimax' + : 'scripted'); switch (chosen) { case 'anthropic': { return new AnthropicProvider(model); @@ -54,11 +57,14 @@ function pickProvider(name: string | undefined, model: string | undefined): LLMP case 'gemini': { return new GeminiProvider(model); } + case 'minimax': { + return new MiniMaxProvider(model); + } case 'scripted': { return new ScriptedProvider(); } default: { - throw new Error(`Unknown provider "${chosen}" (expected scripted | anthropic | openai | gemini)`); + throw new Error(`Unknown provider "${chosen}" (expected scripted | anthropic | openai | gemini | minimax)`); } } } @@ -133,7 +139,7 @@ try { if (provider.name === 'scripted') { ui.status( - 'provider: scripted (no API key found — replies are canned; set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY or pass --provider)' + 'provider: scripted (no API key found — replies are canned; set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / MINIMAX_API_KEY or pass --provider)' ); } else { ui.status(`provider: ${provider.name}`); diff --git a/examples/cli-client/providers/minimax.ts b/examples/cli-client/providers/minimax.ts new file mode 100644 index 0000000000..1457e3a258 --- /dev/null +++ b/examples/cli-client/providers/minimax.ts @@ -0,0 +1,47 @@ +import OpenAI from 'openai'; + +import { fromOpenAIResponse, toOpenAIRequest } from './openai'; +import type { GenerateRequest, GenerateResult, LLMProvider } from './provider'; + +export const DEFAULT_MINIMAX_MODEL = 'MiniMax-M3'; + +export const MINIMAX_BASE_URLS = { + global: 'https://api.minimax.io/v1', + china: 'https://api.minimaxi.com/v1' +} as const; + +export function resolveMiniMaxModel(model?: string, environmentModel?: string): string { + return model ?? environmentModel ?? DEFAULT_MINIMAX_MODEL; +} + +export function resolveMiniMaxBaseURL(baseURL?: string): string { + return baseURL ?? MINIMAX_BASE_URLS.global; +} + +/** + * MiniMax is exposed through an OpenAI-compatible Chat Completions endpoint, so this + * mapping reuses the openai request/response translation and only swaps the client + * wiring: `MINIMAX_API_KEY` for auth and `MINIMAX_BASE_URL` for the regional endpoint + * (the global endpoint by default; use the China endpoint to route there). + */ +export class MiniMaxProvider implements LLMProvider { + readonly name = 'minimax'; + private readonly client: OpenAI; + private readonly model: string; + + constructor(model?: string) { + if (!process.env.MINIMAX_API_KEY) { + throw new Error('MINIMAX_API_KEY is not set — export it or pick a different --provider'); + } + this.client = new OpenAI({ + apiKey: process.env.MINIMAX_API_KEY, + baseURL: resolveMiniMaxBaseURL(process.env.MINIMAX_BASE_URL) + }); + this.model = resolveMiniMaxModel(model, process.env.MINIMAX_MODEL); + } + + async generate(request: GenerateRequest): Promise { + const response = await this.client.chat.completions.create(toOpenAIRequest(request, this.model)); + return fromOpenAIResponse(response); + } +} diff --git a/examples/cli-client/providers/scripted.ts b/examples/cli-client/providers/scripted.ts index 20bc1de865..fa7f26bd3e 100644 --- a/examples/cli-client/providers/scripted.ts +++ b/examples/cli-client/providers/scripted.ts @@ -28,7 +28,7 @@ export class ScriptedProvider implements LLMProvider { const turn = this.turns[this.next++]; if (!turn) { return Promise.resolve({ - text: '(scripted provider has no turns left — run with --provider anthropic|openai|gemini for a real model)', + text: '(scripted provider has no turns left — run with --provider anthropic|openai|gemini|minimax for a real model)', toolCalls: [], stopReason: 'end_turn', model: 'scripted' diff --git a/examples/cli-client/test/providers.test.ts b/examples/cli-client/test/providers.test.ts index fa42c37f6b..8a6c688749 100644 --- a/examples/cli-client/test/providers.test.ts +++ b/examples/cli-client/test/providers.test.ts @@ -5,6 +5,13 @@ import { describe, expect, it } from 'vitest'; import { fromAnthropicResponse, toAnthropicRequest } from '../providers/anthropic'; import { fromGeminiResponse, toGeminiRequest } from '../providers/gemini'; +import { + DEFAULT_MINIMAX_MODEL, + MINIMAX_BASE_URLS, + MiniMaxProvider, + resolveMiniMaxBaseURL, + resolveMiniMaxModel +} from '../providers/minimax'; import { fromOpenAIResponse, toOpenAIRequest } from '../providers/openai'; import type { ChatMessage, GenerateRequest } from '../providers/provider'; import { ScriptedProvider } from '../providers/scripted'; @@ -166,3 +173,31 @@ describe('scripted provider', () => { expect(exhausted.text).toContain('no turns left'); }); }); + +describe('minimax mapping', () => { + it('uses MiniMax-M3 by default and allows model overrides', () => { + expect(resolveMiniMaxModel()).toBe(DEFAULT_MINIMAX_MODEL); + expect(resolveMiniMaxModel(undefined, 'MiniMax-M2.7')).toBe('MiniMax-M2.7'); + expect(resolveMiniMaxModel('MiniMax-M3', 'MiniMax-M2.7')).toBe('MiniMax-M3'); + }); + + it('uses the global endpoint by default and supports the China endpoint', () => { + expect(resolveMiniMaxBaseURL()).toBe(MINIMAX_BASE_URLS.global); + expect(MINIMAX_BASE_URLS.global).toBe('https://api.minimax.io/v1'); + expect(resolveMiniMaxBaseURL(MINIMAX_BASE_URLS.china)).toBe('https://api.minimaxi.com/v1'); + }); + + it('requires MINIMAX_API_KEY to construct', () => { + const previous = process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_API_KEY; + try { + expect(() => new MiniMaxProvider()).toThrow(/MINIMAX_API_KEY/); + } finally { + if (previous === undefined) { + delete process.env.MINIMAX_API_KEY; + } else { + process.env.MINIMAX_API_KEY = previous; + } + } + }); +});