diff --git a/README.md b/README.md index 3eacb356..870ed97b 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,11 @@ This is what happens when you give an AI its own computer. ## Bring Your Own Model -Phantom is not locked to any single AI backend. It ships with support for seven providers out of the box, configured through a single YAML block: +Phantom is not locked to any single AI backend. It ships with support for eight providers out of the box, configured through a single YAML block: - **Anthropic** (default) - Claude Opus, Sonnet, Haiku - **Z.AI** - GLM-5.1 and GLM-4.5-Air via [Z.AI's Anthropic-compatible API](https://docs.z.ai/guides/llm/glm-5). Roughly 15x cheaper than Claude Opus for comparable coding quality. +- **MiniMax** - MiniMax-M3.1-Flash (default) and MiniMax-M3 through Anthropic- and OpenAI-compatible endpoints - **OpenRouter** - 100+ models through one key - **Ollama** - Any GGUF model on your own GPU, zero API cost - **vLLM** - Self-hosted inference with OpenAI-compatible endpoints diff --git a/config/phantom.yaml b/config/phantom.yaml index 6def3965..ffe5ebe1 100644 --- a/config/phantom.yaml +++ b/config/phantom.yaml @@ -24,7 +24,7 @@ timeout_minutes: 240 # the chosen provider; authentication happens at the env var named below. # # provider: -# type: anthropic # anthropic | zai | openrouter | vllm | ollama | litellm | custom +# type: anthropic # anthropic | zai | minimax | openrouter | vllm | ollama | litellm | custom # # api_key_env: ANTHROPIC_API_KEY # # Example: GLM-5.1 via Z.AI's Anthropic-compatible API (15x cheaper than Opus) @@ -36,6 +36,13 @@ timeout_minutes: 240 # sonnet: glm-5.1 # haiku: glm-4.5-air # +# Example: MiniMax defaults to MiniMax-M3.1-Flash for every tier. +# Set model: MiniMax-M3 to select the other current text model. +# Explicit model IDs and saved model_mappings remain supported. +# provider: +# type: minimax +# api_key_env: MINIMAX_API_KEY +# # Example: Murph runtime with OpenAI. Requires @murph/anthropic-sdk-shim to be # installed or linked into this Phantom project. # agent_runtime: murph diff --git a/docs/providers.md b/docs/providers.md index bed71244..86a867e3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -43,6 +43,7 @@ phantom start --agent-runtime murph | `anthropic` (default) | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | Claude Opus, Sonnet, Haiku | | `openai` | `https://api.openai.com` | `OPENAI_API_KEY` | Murph runtime only | | `zai` | `https://api.z.ai/api/anthropic` | `ZAI_API_KEY` | GLM-5.1 and GLM-4.5-Air, roughly 15x cheaper than Opus | +| `minimax` | `https://api.minimax.io/anthropic` | `MINIMAX_API_KEY` | MiniMax-M3.1-Flash (default) and MiniMax-M3 | | `openrouter` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | 100+ models through a single key | | `vllm` | `http://localhost:8000` | none | Self-hosted OpenAI-compatible inference | | `ollama` | `http://localhost:11434` | none | Local GGUF models, zero API cost | @@ -130,6 +131,58 @@ ZAI_API_KEY= Both the main agent and every evolution judge route through Z.AI. The `claude-sonnet-4-6` model name is translated to `glm-5.1` on the wire by the `model_mappings` block. +### MiniMax + +The `minimax` preset defaults to `MiniMax-M3.1-Flash` for the main agent and tier aliases in both runtimes. Set `model: MiniMax-M3` to select the other current model. Explicit model IDs and saved `model_mappings`, including legacy IDs, are preserved. The default Anthropic runtime uses the Anthropic-compatible endpoint, while Murph uses the OpenAI-compatible endpoint. + +| Model | Context window | API input modalities | Thinking | +|------|----------------|----------------------|----------| +| `MiniMax-M3.1-Flash` | 1,000,000 tokens | text, image, video | adaptive only | +| `MiniMax-M3` | 1,000,000 tokens | text, image, video | adaptive or disabled | + +Phantom explicitly requests adaptive thinking for both current models. Flash rejects disabled thinking and supports low, medium, high, xhigh, and max effort (API default: max); Phantom exposes low, medium, high, and max. With the default runtime, Flash at max uses the API default to avoid the pinned Agent SDK downgrading custom models to high. An explicit `CLAUDE_CODE_EFFORT_LEVEL` override is preserved. M3 ignores effort; its API default is disabled on Anthropic and adaptive on OpenAI. API modalities describe the model capabilities, not additional Phantom attachment support. + +```yaml +# phantom.yaml, default Anthropic runtime +provider: + type: minimax + api_key_env: MINIMAX_API_KEY +``` + +```bash +# .env +MINIMAX_API_KEY= +``` + +Choose the endpoint for the runtime protocol and region: + +| Runtime protocol | Global default | China override | +|------------------|----------------|----------------| +| Anthropic | `https://api.minimax.io/anthropic` | `https://api.minimaxi.com/anthropic` | +| Murph OpenAI-compatible | `https://api.minimax.io/v1` | `https://api.minimaxi.com/v1` | + +The Anthropic client appends `/v1/messages`, so its base URL must end at `/anthropic`; the resulting global request URL is `https://api.minimax.io/anthropic/v1/messages`. To use the China endpoint, set the matching value from the table as `provider.base_url`. + +For the OpenAI-compatible route, select Murph and use the concrete model ID: + +```yaml +# phantom.yaml, Murph runtime +agent_runtime: murph +model: MiniMax-M3.1-Flash +provider: + type: minimax +``` + +Standard pay-as-you-go prices are in USD per million tokens. M3 priority pricing is 1.5 times the standard rate. Cache-write pricing is not specified here. + +| Model | Input length | Input | Output | Cache read | +|-------|--------------|-------|--------|------------| +| `MiniMax-M3.1-Flash` | all | $0.15 | $0.60 | $0.015 | +| `MiniMax-M3` | at most 512k | $0.30 | $1.20 | $0.06 | +| `MiniMax-M3` | above 512k | $0.60 | $2.40 | $0.12 | + +See the [MiniMax Anthropic API documentation](https://platform.minimax.io/docs/api-reference/text-anthropic-api) and [current pricing](https://platform.minimax.io/docs/guides/pricing-paygo) for API details. + ### Ollama (local, free) Run any GGUF model on your own GPU. No API key needed. diff --git a/src/agent/__tests__/minimax-http.test.ts b/src/agent/__tests__/minimax-http.test.ts new file mode 100644 index 00000000..22733993 --- /dev/null +++ b/src/agent/__tests__/minimax-http.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { buildAgentRuntimeEnv, resolveAgentRuntimeModel } from "../../config/providers.ts"; +import { PhantomConfigSchema } from "../../config/schemas.ts"; +import { __setAgentSdkQueryForTests, configureAgentSdkRuntime, query } from "../agent-sdk.ts"; +import { getThinkingConfig } from "../thinking-config.ts"; + +type CapturedRequest = { + model: string; + thinking?: { type: string }; + output_config?: { effort?: string }; + service_tier?: string; +}; + +// A local HTTP fixture exercises the pinned CLI's serialization, including its +// custom-model effort handling, which a query() mock cannot detect. +for (const effort of ["max", "low", "medium", "high"] as const) { + test(`MiniMax Flash serializes ${effort} effort without downgrading max`, async () => { + const directory = mkdtempSync(join(import.meta.dir, ".minimax-http-")); + const captures: CapturedRequest[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request): Promise { + if (new URL(request.url).pathname !== "/anthropic/v1/messages") { + return new Response("Unknown fixture endpoint", { status: 404 }); + } + const body = (await request.json()) as CapturedRequest; + captures.push(body); + const events = [ + { + type: "message_start", + message: { + id: "msg_fixture", + type: "message", + role: "assistant", + model: body.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Fixture response" } }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 2 }, + }, + { type: "message_stop" }, + ]; + return new Response( + events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), + { + headers: { "Content-Type": "text/event-stream" }, + }, + ); + }, + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20_000); + try { + await configureAgentSdkRuntime({ agentRuntime: "anthropic", env: {} }); + __setAgentSdkQueryForTests(null); + const config = PhantomConfigSchema.parse({ name: "fixture", effort, provider: { type: "minimax" } }); + const model = resolveAgentRuntimeModel(config, config.model); + const stream = query({ + prompt: "Return a fixture response.", + options: { + model, + effort, + thinking: getThinkingConfig(model), + cwd: directory, + tools: [], + settingSources: [], + persistSession: false, + maxTurns: 1, + abortController: controller, + env: { + ...buildAgentRuntimeEnv(config, model), + PATH: process.env.PATH, + HOME: directory, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}/anthropic`, + ANTHROPIC_API_KEY: "fixture-only", + ANTHROPIC_AUTH_TOKEN: "fixture-only", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + DISABLE_AUTOUPDATER: "1", + DISABLE_TELEMETRY: "1", + }, + }, + }); + for await (const message of stream) { + if (message.type === "result") expect(message.is_error).toBe(false); + } + expect(captures).toHaveLength(1); + expect(captures[0]?.model).toBe("MiniMax-M3.1-Flash"); + expect(captures[0]?.thinking).toEqual({ type: "adaptive" }); + expect(captures[0]?.output_config?.effort).toBe(effort === "max" ? undefined : effort); + expect(captures[0]?.service_tier).toBeUndefined(); + } finally { + clearTimeout(timeout); + server.stop(true); + rmSync(directory, { recursive: true, force: true }); + } + }, 30_000); +} diff --git a/src/agent/__tests__/thinking-config.test.ts b/src/agent/__tests__/thinking-config.test.ts index ccd06291..0168e056 100644 --- a/src/agent/__tests__/thinking-config.test.ts +++ b/src/agent/__tests__/thinking-config.test.ts @@ -26,6 +26,18 @@ describe("getThinkingConfig", () => { expect(getThinkingConfig("claude-mythos-preview")).toEqual({ type: "adaptive" }); }); + test("current MiniMax models use the adaptive request shape", () => { + expect(getThinkingConfig("MiniMax-M3.1-Flash")).toEqual({ type: "adaptive" }); + expect(getThinkingConfig("MiniMax-M3")).toEqual({ type: "adaptive" }); + }); + + test("MiniMax-M2.7 keeps thinking enabled", () => { + expect(getThinkingConfig("MiniMax-M2.7")).toEqual({ + type: "enabled", + budgetTokens: 8192, + }); + }); + test("Haiku 4.5 returns enabled + budgetTokens (Haiku rejects adaptive with 400)", () => { const config = getThinkingConfig(JUDGE_MODEL_HAIKU); expect(config.type).toBe("enabled"); diff --git a/src/agent/agent-sdk.ts b/src/agent/agent-sdk.ts index 60b1dd33..256b7f20 100644 --- a/src/agent/agent-sdk.ts +++ b/src/agent/agent-sdk.ts @@ -73,6 +73,22 @@ export function tool( } export function query(params: AgentSdkQueryParams): Query { + const options = params.options; + // The pinned Anthropic CLI clamps max effort to high for custom model IDs. + // Omit the field for Flash's API default (max), without changing other efforts + // or an operator's explicit environment override. Murph owns its own transport. + if ( + activeQuery === anthropicQuery && + options?.model === "MiniMax-M3.1-Flash" && + options.effort === "max" && + !options.env?.CLAUDE_CODE_EFFORT_LEVEL && + !process.env.CLAUDE_CODE_EFFORT_LEVEL + ) { + return activeQuery({ + ...params, + options: { ...options, env: { ...options.env, CLAUDE_CODE_EFFORT_LEVEL: "auto" } }, + }); + } return activeQuery(params); } diff --git a/src/agent/thinking-config.ts b/src/agent/thinking-config.ts index 6d96d558..718031e1 100644 --- a/src/agent/thinking-config.ts +++ b/src/agent/thinking-config.ts @@ -20,6 +20,7 @@ import type { ThinkingConfig } from "@anthropic-ai/claude-agent-sdk"; const ADAPTIVE_PREFIXES: readonly string[] = [ + "MiniMax-M3", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", @@ -27,6 +28,7 @@ const ADAPTIVE_PREFIXES: readonly string[] = [ ]; const MANUAL_ONLY_PREFIXES: readonly string[] = [ + "MiniMax-M2.7", "claude-haiku-4", "claude-haiku-3", "claude-sonnet-3", diff --git a/src/config/__tests__/loader-metadata.test.ts b/src/config/__tests__/loader-metadata.test.ts index 63e9a54a..783a6851 100644 --- a/src/config/__tests__/loader-metadata.test.ts +++ b/src/config/__tests__/loader-metadata.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { loadConfig } from "../loader.ts"; +import { buildProviderEnv } from "../providers.ts"; // Distinct TEST_DIR from loader.test.ts so the two suites cannot race on the // same filesystem path when bun runs them in parallel. @@ -24,6 +25,7 @@ describe("loadConfig secret_source", () => { "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "ZAI_API_KEY", + "MINIMAX_API_KEY", "OPENROUTER_API_KEY", "LITELLM_KEY", ] as const; @@ -32,6 +34,7 @@ describe("loadConfig secret_source", () => { ANTHROPIC_AUTH_TOKEN: undefined, OPENAI_API_KEY: undefined, ZAI_API_KEY: undefined, + MINIMAX_API_KEY: undefined, OPENROUTER_API_KEY: undefined, LITELLM_KEY: undefined, }; @@ -149,6 +152,67 @@ provider: expect(process.env.OPENAI_API_KEY).toBeUndefined(); }); + test("secret_source: metadata with Murph MiniMax populates only MINIMAX_API_KEY", async () => { + const stubBody = "minimax-provider-token"; + globalThis.fetch = mock((url: string | Request) => { + expect(String(url)).toBe("http://gateway.test/v1/secrets/provider_token"); + return Promise.resolve( + new Response(stubBody, { + status: 200, + headers: { "X-Phantom-Rotation-Id": "1" }, + }), + ); + }) as unknown as typeof fetch; + + const path = writeYaml( + "metadata-murph-minimax.yaml", + ` +name: metadata-minimax +agent_runtime: murph +secret_source: metadata +secret_source_url: http://gateway.test +provider: + type: minimax +`, + ); + await loadConfig(path); + expect(process.env.MINIMAX_API_KEY).toBe(stubBody); + expect(process.env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(process.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(process.env.OPENAI_API_KEY).toBeUndefined(); + expect(process.env.ZAI_API_KEY).toBeUndefined(); + }); + + test("secret_source: metadata with default MiniMax reaches the runtime environment", async () => { + const stubBody = "minimax-provider-token"; + globalThis.fetch = mock((url: string | Request) => { + expect(String(url)).toBe("http://gateway.test/v1/secrets/provider_token"); + return Promise.resolve( + new Response(stubBody, { + status: 200, + headers: { "X-Phantom-Rotation-Id": "1" }, + }), + ); + }) as unknown as typeof fetch; + + const path = writeYaml( + "metadata-default-minimax.yaml", + ` +name: metadata-minimax +secret_source: metadata +secret_source_url: http://gateway.test +provider: + type: minimax +`, + ); + const config = await loadConfig(path); + const env = buildProviderEnv(config); + + expect(process.env.MINIMAX_API_KEY).toBe(stubBody); + expect(env.ANTHROPIC_API_KEY).toBe(stubBody); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe(stubBody); + }); + test("secret_source: metadata resolves whole-string ${secret:NAME} references in nested config", async () => { // Use peers.test_peer.token, an existing schema field that accepts an // arbitrary string and is nested. We assert the resolved value via a diff --git a/src/config/__tests__/loader.test.ts b/src/config/__tests__/loader.test.ts index 524e53a3..898f2515 100644 --- a/src/config/__tests__/loader.test.ts +++ b/src/config/__tests__/loader.test.ts @@ -460,6 +460,28 @@ provider: } }); + test("loads a MiniMax provider block", async () => { + const path = writeYaml( + "minimax-provider.yaml", + ` +name: test +provider: + type: minimax + api_key_env: MINIMAX_API_KEY + model_mappings: + sonnet: MiniMax-M3 +`, + ); + try { + const config = await loadConfig(path); + expect(config.provider.type).toBe("minimax"); + expect(config.provider.api_key_env).toBe("MINIMAX_API_KEY"); + expect(config.provider.model_mappings?.sonnet).toBe("MiniMax-M3"); + } finally { + cleanup(); + } + }); + test("loads provider.type: openai when Murph runtime is selected", async () => { const path = writeYaml( "openai-provider-murph.yaml", diff --git a/src/config/__tests__/minimax-defaults.test.ts b/src/config/__tests__/minimax-defaults.test.ts new file mode 100644 index 00000000..06ebc9af --- /dev/null +++ b/src/config/__tests__/minimax-defaults.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { getThinkingConfig } from "../../agent/thinking-config.ts"; +import { JUDGE_MODEL_HAIKU, JUDGE_MODEL_OPUS, JUDGE_MODEL_SONNET } from "../../evolution/judge-models.ts"; +import { buildAgentRuntimeEnv, resolveAgentRuntimeModel } from "../providers.ts"; +import { PhantomConfigSchema } from "../schemas.ts"; + +for (const agent_runtime of ["anthropic", "murph"] as const) { + describe(`MiniMax defaults: ${agent_runtime}`, () => { + test("resolves the default agent and every judge tier to adaptive Flash", () => { + const config = PhantomConfigSchema.parse({ name: "test", agent_runtime, provider: { type: "minimax" } }); + for (const requested of [ + config.model, + "opus", + "sonnet", + "haiku", + JUDGE_MODEL_OPUS, + JUDGE_MODEL_SONNET, + JUDGE_MODEL_HAIKU, + ]) { + const model = resolveAgentRuntimeModel(config, requested); + expect(model).toBe("MiniMax-M3.1-Flash"); + expect(getThinkingConfig(model)).toEqual({ type: "adaptive" }); + const env = buildAgentRuntimeEnv(config, model); + if (agent_runtime === "murph") { + expect(env.MURPH_MODEL).toBe(model); + expect(env.OPENAI_BASE_URL).toBe("https://api.minimax.io/v1"); + } else { + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe(model); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(model); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe(model); + } + } + }); + + test("preserves explicit current, legacy, and custom model IDs", () => { + for (const model of ["MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "saved-custom-model"]) { + const config = PhantomConfigSchema.parse({ name: "test", agent_runtime, model, provider: { type: "minimax" } }); + expect(resolveAgentRuntimeModel(config, model)).toBe(model); + expect(resolveAgentRuntimeModel(config, JUDGE_MODEL_HAIKU)).toBe(model); + } + }); + + test("preserves partial saved mappings before the concrete model fallback", () => { + const config = PhantomConfigSchema.parse({ + name: "test", + agent_runtime, + model: "MiniMax-M3", + provider: { type: "minimax", model_mappings: { haiku: "MiniMax-M2.7", sonnet: "custom-model" } }, + }); + expect(resolveAgentRuntimeModel(config, JUDGE_MODEL_HAIKU)).toBe("MiniMax-M2.7"); + expect(resolveAgentRuntimeModel(config, "sonnet")).toBe("custom-model"); + expect(resolveAgentRuntimeModel(config, "opus")).toBe("MiniMax-M3"); + expect(resolveAgentRuntimeModel(config, "MiniMax-M3.1-Flash")).toBe("MiniMax-M3.1-Flash"); + }); + + test("fills only missing saved mappings with Flash", () => { + const config = PhantomConfigSchema.parse({ + name: "test", + agent_runtime, + provider: { type: "minimax", model_mappings: { haiku: "MiniMax-M2.7" } }, + }); + expect(resolveAgentRuntimeModel(config, "haiku")).toBe("MiniMax-M2.7"); + expect(resolveAgentRuntimeModel(config, "sonnet")).toBe("MiniMax-M3.1-Flash"); + }); + + test("leaves other provider models unchanged", () => { + for (const type of ["anthropic", "zai", "openrouter", "custom"] as const) { + const config = PhantomConfigSchema.parse({ name: "test", agent_runtime, provider: { type } }); + expect(resolveAgentRuntimeModel(config, config.model)).toBe(config.model); + } + }); + }); +} diff --git a/src/config/__tests__/providers.test.ts b/src/config/__tests__/providers.test.ts index d720c4b4..41bc4695 100644 --- a/src/config/__tests__/providers.test.ts +++ b/src/config/__tests__/providers.test.ts @@ -29,6 +29,7 @@ const WATCHED = [ "ANTHROPIC_BASE_URL", "OPENAI_API_KEY", "ZAI_API_KEY", + "MINIMAX_API_KEY", "OPENROUTER_API_KEY", "LITELLM_KEY", "MURPH_PROVIDER", @@ -69,7 +70,17 @@ describe("ProviderSchema", () => { }); test("accepts each valid provider type", () => { - for (const type of ["anthropic", "openai", "zai", "openrouter", "vllm", "ollama", "litellm", "custom"] as const) { + for (const type of [ + "anthropic", + "openai", + "zai", + "minimax", + "openrouter", + "vllm", + "ollama", + "litellm", + "custom", + ] as const) { const parsed = ProviderSchema.parse({ type }); expect(parsed.type).toBe(type); } @@ -173,6 +184,31 @@ describe("buildProviderEnv: zai preset", () => { }); }); +describe("buildProviderEnv: MiniMax preset", () => { + test("sets the global Anthropic endpoint, credentials, model mappings, and disables betas", () => { + process.env.MINIMAX_API_KEY = "minimax-secret"; + const config = makeConfig({ + type: "minimax", + model_mappings: { sonnet: "MiniMax-M3", haiku: "MiniMax-M2.7" }, + }); + const env = buildProviderEnv(config); + + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.minimax.io/anthropic"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("minimax-secret"); + expect(env.ANTHROPIC_API_KEY).toBe("minimax-secret"); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("MiniMax-M3"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("MiniMax-M2.7"); + expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe("1"); + }); + + test("accepts the China Anthropic endpoint override", () => { + const config = makeConfig({ type: "minimax", base_url: "https://api.minimaxi.com/anthropic" }); + const env = buildProviderEnv(config); + + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.minimaxi.com/anthropic"); + }); +}); + describe("buildProviderEnv: ollama preset", () => { test("sets local base_url, no auth token, disables betas", () => { const config = makeConfig({ type: "ollama" }); @@ -331,6 +367,35 @@ describe("buildAgentRuntimeEnv: Murph runtime", () => { expect(env.OPENROUTER_API_KEY).toBe("openrouter-secret"); }); + test("emits the MiniMax OpenAI-compatible route env", () => { + process.env.MINIMAX_API_KEY = "minimax-secret"; + const config = makeConfig( + { type: "minimax", model_mappings: { sonnet: "MiniMax-M3" } }, + { agent_runtime: "murph", model: "sonnet" }, + ); + const model = resolveAgentRuntimeModel(config, config.model); + const env = buildAgentRuntimeEnv(config, model); + + expect(model).toBe("MiniMax-M3"); + expect(env.MURPH_PROVIDER).toBe("openai-compat"); + expect(env.MURPH_PROVIDER_CONFIG).toBe("custom"); + expect(env.MURPH_MODEL).toBe("MiniMax-M3"); + expect(env.OPENAI_BASE_URL).toBe("https://api.minimax.io/v1"); + expect(env.OPENAI_API_KEY).toBe("minimax-secret"); + expect(env.MINIMAX_API_KEY).toBe(""); + }); + + test("accepts the MiniMax China OpenAI endpoint override", () => { + const config = makeConfig( + { type: "minimax", base_url: "https://api.minimaxi.com/v1" }, + { agent_runtime: "murph", model: "MiniMax-M2.7" }, + ); + const env = buildAgentRuntimeEnv(config, config.model); + + expect(env.MURPH_PROVIDER).toBe("openai-compat"); + expect(env.OPENAI_BASE_URL).toBe("https://api.minimaxi.com/v1"); + }); + test("emits no credential key for Ollama and vLLM by default", () => { for (const type of ["ollama", "vllm"] as const) { const config = makeConfig({ type }, { agent_runtime: "murph", model: "local-model" }); @@ -378,7 +443,17 @@ describe("buildAgentRuntimeEnv: Murph runtime", () => { describe("PROVIDER_PRESETS", () => { test("contains every provider type declared in the schema", () => { - for (const type of ["anthropic", "openai", "zai", "openrouter", "vllm", "ollama", "litellm", "custom"] as const) { + for (const type of [ + "anthropic", + "openai", + "zai", + "minimax", + "openrouter", + "vllm", + "ollama", + "litellm", + "custom", + ] as const) { expect(PROVIDER_PRESETS[type]).toBeDefined(); } }); @@ -389,7 +464,7 @@ describe("PROVIDER_PRESETS", () => { }); test("every non-anthropic Anthropic-compatible preset disables betas by default", () => { - for (const type of ["zai", "openrouter", "vllm", "ollama", "litellm", "custom"] as const) { + for (const type of ["zai", "minimax", "openrouter", "vllm", "ollama", "litellm", "custom"] as const) { expect(PROVIDER_PRESETS[type].disable_betas).toBe(true); } }); diff --git a/src/config/loader.ts b/src/config/loader.ts index d0ba5f0e..a33f9005 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -172,10 +172,8 @@ export async function loadConfig(path?: string): Promise { const fetcher = new MetadataSecretFetcher(baseUrl); // Resolve the provider token first so process.env is populated before any - // downstream code that reads it. Anthropic runtime keeps the existing - // ANTHROPIC_API_KEY plus ANTHROPIC_AUTH_TOKEN behavior. Murph runtime - // populates only the selected provider key so ambient credentials do not - // steer the native route. + // downstream code that reads it. Provider-specific presets receive their + // selected key, while the default generic credential behavior remains intact. const providerToken = await fetcher.get(config.provider.secret_name); populateMetadataProviderEnv(config, providerToken); @@ -191,15 +189,14 @@ function validateRuntimeProviderCombination(config: PhantomConfig): void { } function populateMetadataProviderEnv(config: PhantomConfig, providerToken: string): void { - if (config.agent_runtime !== "murph") { - process.env.ANTHROPIC_API_KEY = providerToken; - process.env.ANTHROPIC_AUTH_TOKEN = providerToken; - return; - } const key = selectedProviderSecretEnvKey(config); if (key) { process.env[key] = providerToken; } + if (config.agent_runtime !== "murph" && (!key || key === "ANTHROPIC_API_KEY")) { + process.env.ANTHROPIC_API_KEY = providerToken; + process.env.ANTHROPIC_AUTH_TOKEN = providerToken; + } } /** diff --git a/src/config/providers.ts b/src/config/providers.ts index 66d72c71..8b718ede 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -17,6 +17,7 @@ export const PROVIDER_TYPES = [ "anthropic", "openai", "zai", + "minimax", "openrouter", "vllm", "ollama", @@ -86,6 +87,11 @@ export const PROVIDER_PRESETS: Readonly> = api_key_env: "ZAI_API_KEY", disable_betas: true, }, + minimax: { + base_url: "https://api.minimax.io/anthropic", + api_key_env: "MINIMAX_API_KEY", + disable_betas: true, + }, openrouter: { base_url: "https://openrouter.ai/api/v1", api_key_env: "OPENROUTER_API_KEY", @@ -136,6 +142,7 @@ const MURPH_ROUTE_ENV_KEYS = [ const MURPH_CREDENTIAL_ENV_KEYS = [ "OPENAI_API_KEY", "ZAI_API_KEY", + "MINIMAX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "DASHSCOPE_API_KEY", @@ -193,6 +200,16 @@ export function selectedProviderSecretEnvKey(config: PhantomConfig): string | un } export function resolveAgentRuntimeModel(config: PhantomConfig, requestedModel: string, tier?: ModelTier): string { + // Resolve before choosing thinking options, including the Haiku reflection path. + // Explicit model IDs and saved mappings remain authoritative. + if (config.provider.type === "minimax") { + const mappedTier = tier ?? tierForRequestedModel(requestedModel); + if (!mappedTier) return requestedModel; + return ( + config.provider.model_mappings?.[mappedTier] ?? + (!tierForRequestedModel(config.model) ? config.model : "MiniMax-M3.1-Flash") + ); + } if (config.agent_runtime !== "murph") { return requestedModel; } @@ -247,6 +264,13 @@ export function buildMurphProviderEnv( addSelectedCredential(env, sourceEnvKey, "ZAI_API_KEY"); break; } + case "minimax": { + env.MURPH_PROVIDER = "openai-compat"; + env.MURPH_PROVIDER_CONFIG = "custom"; + env.OPENAI_BASE_URL = provider.base_url ?? "https://api.minimax.io/v1"; + addSelectedCredential(env, sourceEnvKey, "OPENAI_API_KEY"); + break; + } case "openrouter": { env.MURPH_PROVIDER = "openai-compat"; env.MURPH_PROVIDER_CONFIG = "openrouter"; @@ -346,7 +370,14 @@ export function buildProviderEnv(config: PhantomConfig): Record // Why: the bundled cli.js reads these three vars to resolve the opus/sonnet/haiku // aliases to concrete model IDs on the chosen provider. A Z.AI user who sets // `model: opus` in phantom.yaml gets GLM-5.1 on the wire if opus is mapped here. - const mappings = provider.model_mappings; + const mappings = + provider.type === "minimax" + ? { + opus: resolveAgentRuntimeModel(config, "opus"), + sonnet: resolveAgentRuntimeModel(config, "sonnet"), + haiku: resolveAgentRuntimeModel(config, "haiku"), + } + : provider.model_mappings; if (mappings?.opus) { env.ANTHROPIC_DEFAULT_OPUS_MODEL = mappings.opus; }