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
4 changes: 3 additions & 1 deletion examples/cli-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` or `ANTHROPIC_MODEL` |
| `openai` | `OPENAI_API_KEY` | newest mainline GPT (non-pro) | `--model <id>` or `OPENAI_MODEL` |
| `gemini` | `GEMINI_API_KEY` | newest stable Flash | `--model <id>` or `GEMINI_MODEL` |
| `minimax` | `MINIMAX_API_KEY` | newest mainline MiniMax (MiniMax-M) | `--model <id>` 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
Expand Down Expand Up @@ -113,7 +115,7 @@ For a persistent setup, copy `config.example.json` to `config.json` (or pass `--
```text
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted)
--provider <name> scripted | anthropic | openai | gemini | minimax (default: first one with a key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port)
Expand Down
14 changes: 10 additions & 4 deletions examples/cli-client/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ 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';

const USAGE = `usage: tsx cli.ts [options]
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
--provider <name> scripted | anthropic | openai | gemini | minimax (default: first one with an API key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH)
Expand All @@ -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);
Expand All @@ -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)`);
}
}
}
Expand Down Expand Up @@ -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}`);
Expand Down
70 changes: 70 additions & 0 deletions examples/cli-client/providers/minimax.ts
Original file line number Diff line number Diff line change
@@ -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<version>` model.
*/
private async resolveModel(): Promise<string> {
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<version> model found on the MiniMax API — pass --model or set MINIMAX_MODEL');
}
this.model = id;
return this.model;
}

async generate(request: GenerateRequest): Promise<GenerateResult> {
const model = await this.resolveModel();
const response = await this.client.chat.completions.create(toOpenAIRequest(request, model));
return fromOpenAIResponse(response);
}
}
2 changes: 1 addition & 1 deletion examples/cli-client/providers/scripted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
32 changes: 32 additions & 0 deletions examples/cli-client/test/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
}
});
});
Loading