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
14 changes: 10 additions & 4 deletions examples/cli-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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` | `MiniMax-M3` | `--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
# 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)

Expand Down Expand Up @@ -113,8 +119,8 @@ 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)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--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 configured 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)
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
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
47 changes: 47 additions & 0 deletions examples/cli-client/providers/minimax.ts
Original file line number Diff line number Diff line change
@@ -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<GenerateResult> {
const response = await this.client.chat.completions.create(toOpenAIRequest(request, this.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
35 changes: 35 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,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';
Expand Down Expand Up @@ -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;
}
}
});
});
Loading