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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 and MiniMax-M2.7 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
Expand Down
11 changes: 10 additions & 1 deletion config/phantom.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -36,6 +36,15 @@ timeout_minutes: 240
# sonnet: glm-5.1
# haiku: glm-4.5-air
#
# Example: MiniMax with the two current text models
# provider:
# type: minimax
# api_key_env: MINIMAX_API_KEY
# model_mappings:
# opus: MiniMax-M3
# sonnet: MiniMax-M3
# haiku: MiniMax-M2.7
#
# Example: Murph runtime with OpenAI. Requires @murph/anthropic-sdk-shim to be
# installed or linked into this Phantom project.
# agent_runtime: murph
Expand Down
60 changes: 60 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 and MiniMax-M2.7 |
| `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 |
Expand Down Expand Up @@ -130,6 +131,65 @@ ZAI_API_KEY=<your-zai-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 supports both current models. The default Anthropic runtime uses the Anthropic-compatible endpoint, while Murph uses the OpenAI-compatible endpoint. The listed context window is the combined input and output budget.

| Model | Context window | Input modalities | Thinking |
|------|----------------|------------------|----------|
| `MiniMax-M3` | 1,000,000 tokens | text, image, video | adaptive or disabled |
| `MiniMax-M2.7` | 204,800 tokens | text | always on |

Phantom sends adaptive thinking by default for both runtime protocols. MiniMax-M3 also supports disabled thinking at the API level, while MiniMax-M2.7 always keeps thinking enabled.

```yaml
# phantom.yaml, default Anthropic runtime
model: claude-sonnet-4-6
provider:
type: minimax
api_key_env: MINIMAX_API_KEY
model_mappings:
opus: MiniMax-M3
sonnet: MiniMax-M3
haiku: MiniMax-M2.7
```

```bash
# .env
MINIMAX_API_KEY=<your-minimax-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
provider:
type: minimax
```

Pay-as-you-go prices are in USD per million tokens. MiniMax-M3 pricing depends on both the service tier and input length, so each tier is listed separately.

| Model / service tier | Input length | Input | Output | Cache read | Cache write |
|----------------------|--------------|-------|--------|------------|-------------|
| `MiniMax-M3` standard | up to 512k | $0.30 | $1.20 | $0.06 | n/a |
| `MiniMax-M3` standard | over 512k | $0.60 | $2.40 | $0.12 | n/a |
| `MiniMax-M3` priority | up to 512k | $0.45 | $1.80 | $0.09 | n/a |
| `MiniMax-M3` priority | over 512k | $0.90 | $3.60 | $0.18 | n/a |
| `MiniMax-M2.7` | all requests | $0.30 | $1.20 | $0.06 | $0.375 |

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.
Expand Down
5 changes: 5 additions & 0 deletions src/agent/__tests__/thinking-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ describe("getThinkingConfig", () => {
expect(getThinkingConfig("claude-mythos-preview")).toEqual({ type: "adaptive" });
});

test("MiniMax models use the adaptive request shape", () => {
expect(getThinkingConfig("MiniMax-M3")).toEqual({ type: "adaptive" });
expect(getThinkingConfig("MiniMax-M2.7")).toEqual({ type: "adaptive" });
});

test("Haiku 4.5 returns enabled + budgetTokens (Haiku rejects adaptive with 400)", () => {
const config = getThinkingConfig(JUDGE_MODEL_HAIKU);
expect(config.type).toBe("enabled");
Expand Down
64 changes: 64 additions & 0 deletions src/config/__tests__/loader-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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,
};
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/config/__tests__/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 78 additions & 3 deletions src/config/__tests__/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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();
}
});
Expand All @@ -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);
}
});
Expand Down
Loading