diff --git a/commands/config.md b/commands/config.md index d00d3a92..5db1bd6a 100644 --- a/commands/config.md +++ b/commands/config.md @@ -14,8 +14,8 @@ Parse `$ARGUMENTS` to identify what the user wants. If no arguments are given, s 2. Display all settings clearly: **General** - - Model: (e.g. `opus`, `sonnet`, `haiku`, `glm` or "default") - - API token: (first 5 chars + "..." or "not configured"; used when `model` is `glm`) + - Model: (e.g. `opus`, `sonnet`, `haiku`, `glm`, `minimax` or "default") + - API token: (first 5 chars + "..." or "not configured"; used when `model` is `glm` or a `minimax` preset) - Fallback model: (e.g. `glm`, `sonnet`, or "not configured") - Fallback API token: (first 5 chars + "..." or "not configured") - Timezone: (e.g. `America/New_York` or "UTC") @@ -285,8 +285,8 @@ Location: `.claude/claudeclaw/settings.json` | Key | Type | Description | |----------------------------|------------|------------------------------------------------| -| `model` | string | Claude model (`opus`, `sonnet`, `haiku`, `glm`, or full ID). Empty = default | -| `api` | string | API token used when model is `glm` (mapped to `ANTHROPIC_AUTH_TOKEN`) | +| `model` | string | Claude model (`opus`, `sonnet`, `haiku`, `glm`, `minimax`/`minimax-m3`/`minimax-m2.7`, or full ID). Append `-cn` to a MiniMax alias for the China endpoint. Empty = default | +| `api` | string | API token used when model is `glm` or a `minimax` preset (mapped to `ANTHROPIC_AUTH_TOKEN`) | | `fallback.model` | string | Backup model used automatically if primary run returns rate-limit text (recommend `glm` for provider diversity) | | `fallback.api` | string | API token used with `fallback.model` (optional) | | `timezone` | string | IANA timezone name (e.g. `America/New_York`) | diff --git a/commands/start.md b/commands/start.md index f74a82d2..1a9027de 100644 --- a/commands/start.md +++ b/commands/start.md @@ -217,8 +217,8 @@ Defaults: `WEB_HOST=127.0.0.1`, `WEB_PORT=4632` unless changed via settings or ` } } ``` -- `model` — Claude model to use (`opus`, `sonnet`, `haiku`, `glm`, or full model ID). Empty string uses default. Ignored when `agentic.enabled` is true. -- `api` — API token used when `model` is `glm` (passed as `ANTHROPIC_AUTH_TOKEN` for that provider path). +- `model` — Claude model to use (`opus`, `sonnet`, `haiku`, `glm`, `minimax`/`minimax-m3`/`minimax-m2.7`, or full model ID). Append `-cn` to a MiniMax alias to use the China endpoint. Empty string uses default. Ignored when `agentic.enabled` is true. +- `api` — API token used when `model` is `glm` or a `minimax` preset (passed as `ANTHROPIC_AUTH_TOKEN` for that provider path). - `fallback.model` — backup model used automatically if the primary run returns a rate-limit message. Prefer `glm` for provider diversity. - `fallback.api` — optional API token to use with `fallback.model`. - `agentic.enabled` — when true, automatically routes tasks to appropriate models based on task type diff --git a/src/__tests__/provider-preset.test.ts b/src/__tests__/provider-preset.test.ts new file mode 100644 index 00000000..0ad9a114 --- /dev/null +++ b/src/__tests__/provider-preset.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "bun:test"; +import { resolveModelArg, resolveMinimaxModel, buildChildEnv } from "../runner"; + +describe("resolveMinimaxModel", () => { + it("maps the bare minimax alias to the latest model on the global endpoint", () => { + expect(resolveMinimaxModel("minimax")).toEqual({ + baseUrl: "https://api.minimax.io/anthropic", + modelId: "MiniMax-M3", + }); + }); + + it("maps explicit MiniMax model aliases", () => { + expect(resolveMinimaxModel("minimax-m3")?.modelId).toBe("MiniMax-M3"); + expect(resolveMinimaxModel("minimax-m2.7")?.modelId).toBe("MiniMax-M2.7"); + }); + + it("routes to the China endpoint with the -cn suffix", () => { + expect(resolveMinimaxModel("minimax-m3-cn")).toEqual({ + baseUrl: "https://api.minimaxi.com/anthropic", + modelId: "MiniMax-M3", + }); + expect(resolveMinimaxModel("minimax-cn")?.baseUrl).toBe( + "https://api.minimaxi.com/anthropic", + ); + }); + + it("is case-insensitive and trims surrounding whitespace", () => { + expect(resolveMinimaxModel(" MiniMax-M3 ")?.modelId).toBe("MiniMax-M3"); + }); + + it("returns null for non-preset and unknown MiniMax models", () => { + expect(resolveMinimaxModel("opus")).toBeNull(); + expect(resolveMinimaxModel("minimax-m9")).toBeNull(); + }); +}); + +describe("resolveModelArg", () => { + it("passes MiniMax presets through as canonical model IDs", () => { + expect(resolveModelArg("minimax-m2.7")).toBe("MiniMax-M2.7"); + expect(resolveModelArg("minimax-m3-cn")).toBe("MiniMax-M3"); + }); + + it("suppresses the flag for empty models", () => { + expect(resolveModelArg(" ")).toBeNull(); + }); + + it("passes standard models through unchanged", () => { + expect(resolveModelArg("opus")).toBe("opus"); + }); +}); + +describe("buildChildEnv", () => { + it("routes MiniMax models to the selected Anthropic-compatible endpoint", () => { + expect(buildChildEnv({}, "minimax-m3", "token").ANTHROPIC_BASE_URL).toBe( + "https://api.minimax.io/anthropic", + ); + expect(buildChildEnv({}, "minimax-m2.7-cn", "token").ANTHROPIC_BASE_URL).toBe( + "https://api.minimaxi.com/anthropic", + ); + }); + + it("passes the api token through as ANTHROPIC_AUTH_TOKEN", () => { + expect(buildChildEnv({}, "minimax", "example-token").ANTHROPIC_AUTH_TOKEN).toBe( + "example-token", + ); + }); + + it("leaves the base URL unset for default models", () => { + expect(buildChildEnv({}, "opus", "").ANTHROPIC_BASE_URL).toBeUndefined(); + }); +}); diff --git a/src/runner.ts b/src/runner.ts index 171642c9..8964c1b7 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -323,7 +323,56 @@ function isNotFoundError(error: unknown): boolean { return /enoent|no such file or directory/i.test(message); } -function buildChildEnv(baseEnv: Record, model: string, api: string): Record { +/** Anthropic-compatible base URLs for the MiniMax provider preset. */ +const MINIMAX_ANTHROPIC_BASE_URLS = { + global: "https://api.minimax.io/anthropic", + cn: "https://api.minimaxi.com/anthropic", +} as const; + +/** Canonical MiniMax model IDs, keyed by the accepted `model` setting aliases. */ +const MINIMAX_MODEL_IDS: Record = { + minimax: "MiniMax-M3", + "minimax-m3": "MiniMax-M3", + "minimax-m2.7": "MiniMax-M2.7", +}; + +/** + * Resolve the MiniMax provider preset for a configured `model` string. + * + * Users select a MiniMax model with `minimax`, `minimax-m3`, or `minimax-m2.7` + * (the bare `minimax` alias maps to the latest MiniMax-M3). Append `-cn` to any + * of those to route to the China endpoint instead of the global one. Returns + * null when the model is not a MiniMax preset. + */ +export function resolveMinimaxModel(model: string): { baseUrl: string; modelId: string } | null { + let normalized = model.trim().toLowerCase(); + if (!normalized.startsWith("minimax")) return null; + + let baseUrl = MINIMAX_ANTHROPIC_BASE_URLS.global; + if (normalized.endsWith("-cn")) { + baseUrl = MINIMAX_ANTHROPIC_BASE_URLS.cn; + normalized = normalized.slice(0, -"-cn".length); + } + + const modelId = MINIMAX_MODEL_IDS[normalized]; + return modelId ? { baseUrl, modelId } : null; +} + +/** + * Resolve the value passed to the Claude CLI `--model` flag, or null when no + * flag should be added. GLM uses its provider default (no flag); MiniMax presets + * are rewritten to their canonical model IDs; anything else is passed through. + */ +export function resolveModelArg(model: string): string | null { + const trimmed = model.trim(); + if (!trimmed) return null; + if (trimmed.toLowerCase() === "glm") return null; + const minimax = resolveMinimaxModel(trimmed); + if (minimax) return minimax.modelId; + return trimmed; +} + +export function buildChildEnv(baseEnv: Record, model: string, api: string): Record { const childEnv: Record = { ...baseEnv }; const normalizedModel = model.trim().toLowerCase(); @@ -334,6 +383,11 @@ function buildChildEnv(baseEnv: Record, model: string, api: stri childEnv.API_TIMEOUT_MS = "3000000"; } + const minimax = resolveMinimaxModel(model); + if (minimax) { + childEnv.ANTHROPIC_BASE_URL = minimax.baseUrl; + } + return childEnv; } @@ -414,8 +468,8 @@ async function runClaudeOnce( cwd?: string ): Promise<{ rawStdout: string; stderr: string; exitCode: number }> { const args = [...baseArgs]; - const normalizedModel = model.trim().toLowerCase(); - if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); + const modelArg = resolveModelArg(model); + if (modelArg) args.push("--model", modelArg); const proc = Bun.spawn(args, { stdout: "pipe", @@ -483,8 +537,8 @@ async function runClaudeStream( onToolEvent?: (line: string) => void ): Promise<{ rawStdout: string; stderr: string; exitCode: number; sessionId?: string }> { const args = [...baseArgs]; - const normalizedModel = model.trim().toLowerCase(); - if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); + const modelArg = resolveModelArg(model); + if (modelArg) args.push("--model", modelArg); const proc = Bun.spawn(args, { stdout: "pipe", @@ -635,8 +689,8 @@ async function runClaudeStreaming( onToolEvent?: (line: string) => void ): Promise<{ result: string; stderr: string; exitCode: number; sessionId?: string; isRateLimit: boolean }> { const args = [...baseArgs]; - const normalizedModel = model.trim().toLowerCase(); - if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); + const modelArg = resolveModelArg(model); + if (modelArg) args.push("--model", modelArg); const proc = Bun.spawn(args, { stdout: "pipe", @@ -1502,8 +1556,8 @@ async function streamClaude( args.push("--append-system-prompt", appendParts.join("\n\n")); } - const normalizedModel = model.trim().toLowerCase(); - if (model.trim() && normalizedModel !== "glm") args.push("--model", model.trim()); + const modelArg = resolveModelArg(model); + if (modelArg) args.push("--model", modelArg); const childEnv = buildChildEnv(cleanSpawnEnv(), model, api);