From b14851e6051a1812a05a51e2804d423dfaa91b0d Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:36:54 +0000 Subject: [PATCH] Add MiniMax provider to the cli-client example host Add a minimax LLMProvider that reuses the existing OpenAI-compatible Chat Completions mapping, registered via MINIMAX_API_KEY / --provider minimax, with model resolution from the models API, README docs, and unit tests for model selection and the API-key requirement. Co-Authored-By: Claude --- examples/cli-client/README.md | 4 +- examples/cli-client/cli.ts | 14 +++-- examples/cli-client/providers/minimax.ts | 70 ++++++++++++++++++++++ examples/cli-client/providers/scripted.ts | 2 +- examples/cli-client/test/providers.test.ts | 32 ++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 examples/cli-client/providers/minimax.ts diff --git a/examples/cli-client/README.md b/examples/cli-client/README.md index c75d086e12..2053ba4ffa 100644 --- a/examples/cli-client/README.md +++ b/examples/cli-client/README.md @@ -23,12 +23,14 @@ 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` | newest mainline MiniMax (MiniMax-M) | `--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 ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --provider anthropic --model claude-sonnet-4-5 @@ -113,7 +115,7 @@ 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) +--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 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) 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..2dcae939db --- /dev/null +++ b/examples/cli-client/providers/minimax.ts @@ -0,0 +1,70 @@ +import OpenAI from 'openai'; + +import { fromOpenAIResponse, toOpenAIRequest } from './openai'; +import type { GenerateRequest, GenerateResult, LLMProvider } from './provider'; + +/** Mainline MiniMax chat model ids look like `MiniMax-M3`, `MiniMax-M2.7`, … */ +const MAINLINE_MODEL = /^MiniMax-M\d+(?:\.\d+)?$/; + +/** + * Pick the newest model matching the mainline pattern from a model list, so the example + * keeps working as MiniMax ships new versions without hardcoding any id here. + */ +export function newestMainlineModel(models: ReadonlyArray<{ id: string; created: number }>): string | undefined { + let newest: { id: string; created: number } | undefined; + for (const model of models) { + if (!MAINLINE_MODEL.test(model.id)) continue; + if (newest === undefined || model.created > newest.created) { + newest = model; + } + } + return newest?.id; +} + +/** + * 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 — point it at the China endpoint to route there). + */ +export class MiniMaxProvider implements LLMProvider { + readonly name = 'minimax'; + private readonly client: OpenAI; + private 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: process.env.MINIMAX_BASE_URL ?? 'https://api.minimax.io/v1' + }); + this.model = model ?? process.env.MINIMAX_MODEL; + } + + /** + * Model ids change faster than examples do, so nothing is hardcoded here: unless pinned + * via `--model` / `MINIMAX_MODEL`, ask the API for its model list and use the newest + * mainline `MiniMax-M` model. + */ + private async resolveModel(): Promise { + if (this.model) return this.model; + const models: Array<{ id: string; created: number }> = []; + for await (const model of this.client.models.list()) { + models.push(model); + } + const id = newestMainlineModel(models); + if (id === undefined) { + throw new Error('No mainline MiniMax-M model found on the MiniMax API — pass --model or set MINIMAX_MODEL'); + } + this.model = id; + return this.model; + } + + async generate(request: GenerateRequest): Promise { + const model = await this.resolveModel(); + const response = await this.client.chat.completions.create(toOpenAIRequest(request, 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..cb8c5882f3 100644 --- a/examples/cli-client/test/providers.test.ts +++ b/examples/cli-client/test/providers.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { fromAnthropicResponse, toAnthropicRequest } from '../providers/anthropic'; import { fromGeminiResponse, toGeminiRequest } from '../providers/gemini'; +import { MiniMaxProvider, newestMainlineModel } from '../providers/minimax'; import { fromOpenAIResponse, toOpenAIRequest } from '../providers/openai'; import type { ChatMessage, GenerateRequest } from '../providers/provider'; import { ScriptedProvider } from '../providers/scripted'; @@ -166,3 +167,34 @@ describe('scripted provider', () => { expect(exhausted.text).toContain('no turns left'); }); }); + +describe('minimax mapping', () => { + it('picks the newest mainline MiniMax-M model from a model list', () => { + expect( + newestMainlineModel([ + { id: 'MiniMax-M2.7', created: 100 }, + { id: 'MiniMax-Hailuo-2.3', created: 200 }, + { id: 'MiniMax-M3', created: 300 } + ]) + ).toBe('MiniMax-M3'); + }); + + it('returns undefined when no mainline model matches', () => { + expect(newestMainlineModel([{ id: 'MiniMax-Hailuo-2.3', created: 100 }])).toBeUndefined(); + expect(newestMainlineModel([])).toBeUndefined(); + }); + + 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; + } + } + }); +});