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.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
Expand Down
9 changes: 8 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,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
Expand Down
53 changes: 53 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.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 |
Expand Down Expand Up @@ -130,6 +131,58 @@ 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 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=<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.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.
Expand Down
109 changes: 109 additions & 0 deletions src/agent/__tests__/minimax-http.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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);
}
12 changes: 12 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,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");
Expand Down
16 changes: 16 additions & 0 deletions src/agent/agent-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ export function tool<Schema extends AnyZodRawShape>(
}

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);
}

Expand Down
2 changes: 2 additions & 0 deletions src/agent/thinking-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
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",
"claude-mythos",
];

const MANUAL_ONLY_PREFIXES: readonly string[] = [
"MiniMax-M2.7",
"claude-haiku-4",
"claude-haiku-3",
"claude-sonnet-3",
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
Loading