Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 27 additions & 0 deletions config/phantom.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,30 @@ timeout_minutes: 240
# url: https://data.ghostwright.dev/mcp
# token: "bearer-token-for-data"
# description: "Data Analyst Phantom"

# Provider selection. Defaults to Anthropic. Uncomment and customize to use
# a different backend. Both the main agent and every LLM judge flow through
# the chosen provider; authentication happens at the env var named below.
#
# provider:
# type: anthropic # anthropic | zai | 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)
# provider:
# type: zai
# api_key_env: ZAI_API_KEY
# model_mappings:
# opus: glm-5.1
# sonnet: glm-5.1
# haiku: glm-4.5-air
#
# Example: Local vLLM server hosting any OpenAI-compatible model
# provider:
# type: vllm
# base_url: http://localhost:8000
#
# Example: Local Ollama (free, runs on your GPU)
# provider:
# type: ollama
# base_url: http://localhost:11434
1 change: 1 addition & 0 deletions src/agent/__tests__/prompt-assembler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const baseConfig: PhantomConfig = {
port: 3100,
role: "swe",
model: "claude-opus-4-6",
provider: { type: "anthropic" },
effort: "max",
max_budget_usd: 0,
timeout_minutes: 240,
Expand Down
8 changes: 8 additions & 0 deletions src/agent/judge-query.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod/v4";
import { buildProviderEnv } from "../config/providers.ts";
import type { PhantomConfig } from "../config/types.ts";
import { extractTextFromMessage } from "./message-utils.ts";

Expand Down Expand Up @@ -107,6 +108,12 @@ export async function runJudgeQuery<T>(
const schemaJson = z.toJSONSchema(options.schema);
const judgePrompt = buildJudgePrompt(options.systemPrompt, schemaJson);

// Judges must flow through the same provider as the main agent so that
// auth, base URL, model mappings, and beta headers are consistent. Without
// this, a Z.AI deployment would silently route judges back to Anthropic
// whenever ANTHROPIC_API_KEY happened to be set in the shell.
const providerEnv = buildProviderEnv(config);

const queryStream = query({
prompt: options.userMessage,
options: {
Expand All @@ -121,6 +128,7 @@ export async function runJudgeQuery<T>(
maxTurns: 1,
effort: "low",
persistSession: false,
env: { ...process.env, ...providerEnv },
},
});

Expand Down
15 changes: 15 additions & 0 deletions src/agent/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Database } from "bun:sqlite";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { buildProviderEnv } from "../config/providers.ts";
import type { PhantomConfig } from "../config/types.ts";
import type { EvolvedConfig } from "../evolution/types.ts";
import type { MemoryContextBuilder } from "../memory/context-builder.ts";
Expand Down Expand Up @@ -62,6 +63,13 @@ export class AgentRuntime {
return this.lastTrackedFiles;
}

// Accessor used by EvolutionEngine.resolveJudgeMode() to inspect the provider
// config without piercing encapsulation on every other runtime field. Returning
// the same reference is fine: PhantomConfig is treated as immutable after load.
getPhantomConfig(): PhantomConfig {
return this.config;
}

async handleMessage(
channelId: string,
conversationId: string,
Expand Down Expand Up @@ -155,6 +163,12 @@ export class AgentRuntime {
let cost: AgentCost = emptyCost();
let emittedThinking = false;

// Provider env is computed per call so operators can hot-swap provider
// config between queries without restarting the process. The map is merged
// on top of process.env so provider-specific overrides win, and everything
// else (PATH, HOME, credential files) is inherited intact.
const providerEnv = buildProviderEnv(this.config);

const runSdkQuery = async (useResume: boolean): Promise<void> => {
const queryStream = query({
prompt: text,
Expand All @@ -172,6 +186,7 @@ export class AgentRuntime {
effort: this.config.effort,
...(this.config.max_budget_usd > 0 ? { maxBudgetUsd: this.config.max_budget_usd } : {}),
abortController: controller,
env: { ...process.env, ...providerEnv },
hooks: {
PreToolUse: [commandBlocker],
PostToolUse: [fileTracker.hook],
Expand Down
136 changes: 136 additions & 0 deletions src/config/__tests__/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,4 +342,140 @@ port: 3100
cleanup();
}
});

test("defaults provider to anthropic when block is absent", () => {
const path = writeYaml(
"no-provider.yaml",
`
name: test
`,
);
try {
const config = loadConfig(path);
expect(config.provider.type).toBe("anthropic");
expect(config.provider.base_url).toBeUndefined();
} finally {
cleanup();
}
});

test("loads a zai provider block", () => {
const path = writeYaml(
"zai-provider.yaml",
`
name: test
provider:
type: zai
api_key_env: ZAI_API_KEY
model_mappings:
opus: glm-5.1
`,
);
try {
const config = loadConfig(path);
expect(config.provider.type).toBe("zai");
expect(config.provider.api_key_env).toBe("ZAI_API_KEY");
expect(config.provider.model_mappings?.opus).toBe("glm-5.1");
} finally {
cleanup();
}
});

test("PHANTOM_PROVIDER_TYPE env var overrides YAML provider.type", () => {
const path = writeYaml(
"env-provider-type.yaml",
`
name: test
provider:
type: anthropic
`,
);
const saved = process.env.PHANTOM_PROVIDER_TYPE;
try {
process.env.PHANTOM_PROVIDER_TYPE = "ollama";
const config = loadConfig(path);
expect(config.provider.type).toBe("ollama");
} finally {
if (saved !== undefined) {
process.env.PHANTOM_PROVIDER_TYPE = saved;
} else {
process.env.PHANTOM_PROVIDER_TYPE = undefined;
}
cleanup();
}
});

test("PHANTOM_PROVIDER_TYPE with unknown value leaves YAML provider.type alone", () => {
const path = writeYaml(
"env-provider-type-bad.yaml",
`
name: test
provider:
type: zai
`,
);
const saved = process.env.PHANTOM_PROVIDER_TYPE;
try {
process.env.PHANTOM_PROVIDER_TYPE = "mystery-llm";
const config = loadConfig(path);
expect(config.provider.type).toBe("zai");
} finally {
if (saved !== undefined) {
process.env.PHANTOM_PROVIDER_TYPE = saved;
} else {
process.env.PHANTOM_PROVIDER_TYPE = undefined;
}
cleanup();
}
});

test("PHANTOM_PROVIDER_BASE_URL env var overrides YAML provider.base_url", () => {
const path = writeYaml(
"env-provider-baseurl.yaml",
`
name: test
provider:
type: custom
base_url: http://old.example.com
`,
);
const saved = process.env.PHANTOM_PROVIDER_BASE_URL;
try {
process.env.PHANTOM_PROVIDER_BASE_URL = "https://new.example.com/v1";
const config = loadConfig(path);
expect(config.provider.base_url).toBe("https://new.example.com/v1");
} finally {
if (saved !== undefined) {
process.env.PHANTOM_PROVIDER_BASE_URL = saved;
} else {
process.env.PHANTOM_PROVIDER_BASE_URL = undefined;
}
cleanup();
}
});

test("PHANTOM_PROVIDER_BASE_URL with malformed URL is ignored", () => {
const path = writeYaml(
"env-provider-baseurl-bad.yaml",
`
name: test
provider:
type: custom
base_url: http://old.example.com
`,
);
const saved = process.env.PHANTOM_PROVIDER_BASE_URL;
try {
process.env.PHANTOM_PROVIDER_BASE_URL = "not a url";
const config = loadConfig(path);
expect(config.provider.base_url).toBe("http://old.example.com");
} finally {
if (saved !== undefined) {
process.env.PHANTOM_PROVIDER_BASE_URL = saved;
} else {
process.env.PHANTOM_PROVIDER_BASE_URL = undefined;
}
cleanup();
}
});
});
Loading
Loading