Upgrade to pi-agent-core 0.52.8 and adopt proper API key resolution

Amolith and Shelley created

Replace the old pi-agent (0.9.0, ProviderTransport-based) with
pi-agent-core (0.52.8 from pi-mono) which takes getApiKey directly on
AgentOptions β€” no transport abstraction.

Upgrade pi-ai from 0.6.x to 0.52.8 (latest npm), which fixes the
sanitizeSurrogates crash on null content from reasoning models.

API key resolution now follows pi-coding-agent conventions:
- Custom models use a new 'api_key' config field (not headers)
- resolveConfigValue() supports three formats: bare env var names,
  explicit $VAR/${VAR} references, and !shell-command execution
- resolveHeaders() applies the same resolution to custom HTTP headers
- Built-in providers fall back to pi-ai's getEnvApiKey()

All tool imports updated: AgentTool moved from pi-ai to pi-agent-core.
Runner error handling preserved: checks agent.state.error, stopReason,
and empty responses.

Config schema gains optional 'api_key' field on CustomModelSchema.
README documents value resolution formats.

Tests: 112 total (22 new) covering resolveConfigValue, expandEnvVars,
resolveHeaders, and buildGetApiKey with literal keys, env vars, $VAR
references, shell commands, and provider isolation.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

AGENTS.md                       |   2 
README.md                       |  20 ++++
bun.lock                        |  17 ++
package.json                    |   4 
src/agent/model-resolver.ts     |   6 
src/agent/runner.ts             |  37 ++----
src/agent/tools/find.ts         |   2 
src/agent/tools/git/blame.ts    |   2 
src/agent/tools/git/checkout.ts |   2 
src/agent/tools/git/diff.ts     |   2 
src/agent/tools/git/log.ts      |   2 
src/agent/tools/git/refs.ts     |   2 
src/agent/tools/git/show.ts     |   2 
src/agent/tools/grep.ts         |   2 
src/agent/tools/index.ts        |   2 
src/agent/tools/ls.ts           |   2 
src/agent/tools/read.ts         |   2 
src/agent/tools/web-fetch.ts    |   2 
src/agent/tools/web-search.ts   |   2 
src/cli/output.ts               |   2 
src/config/schema.ts            |   1 
src/util/env.ts                 |  77 ++++++++++++++-
test/agent-runner.test.ts       | 173 +++++++++++++++++++++++-----------
23 files changed, 256 insertions(+), 109 deletions(-)

Detailed changes

AGENTS.md πŸ”—

@@ -66,6 +66,8 @@ Config uses TOML, validated against TypeBox schema (`src/config/schema.ts`).
 
 Model strings use `provider:model` format. `custom:name` prefix looks up custom model definitions from config's `[custom_models]` section. Built-in providers delegate to `@mariozechner/pi-ai`.
 
+API key resolution for custom models uses `resolveConfigValue()` from `src/util/env.ts`, which supports bare env var names, `$VAR` / `${VAR}` references, and `!shell-command` execution. Built-in providers fall back to `pi-ai`'s `getEnvApiKey()` (e.g. `ANTHROPIC_API_KEY`).
+
 ### Error Handling
 
 Custom error classes in `src/util/errors.ts` extend `RumiloError` with error codes:

README.md πŸ”—

@@ -39,6 +39,7 @@ You can define custom OpenAI-compatible endpoints like Ollama, vLLM, or self-hos
 provider = "ollama"
 api = "openai-completions"
 base_url = "http://localhost:11434/v1"
+api_key = "ollama"
 id = "ollama/llama3"
 name = "Llama 3 (Ollama)"
 reasoning = false
@@ -60,6 +61,7 @@ rumilo repo -u <uri> "query" --model custom:ollama
 - `provider`: Provider identifier (e.g., "ollama", "custom")
 - `api`: API type - typically "openai-completions"
 - `base_url`: API endpoint URL
+- `api_key`: API key (see value resolution below)
 - `id`: Unique model identifier
 - `name`: Human-readable display name
 - `reasoning`: Whether the model supports thinking/reasoning
@@ -67,6 +69,24 @@ rumilo repo -u <uri> "query" --model custom:ollama
 - `cost`: Cost per million tokens (can use 0 for local models)
 - `context_window`: Maximum context size in tokens
 - `max_tokens`: Maximum output tokens
+- `headers`: Optional custom HTTP headers (values support same resolution as `api_key`)
+
+#### Value Resolution
+
+The `api_key` and `headers` fields support three formats, following [pi-coding-agent conventions](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/models.md):
+
+- **Environment variable name:** bare name is checked as env var, then used as literal
+  ```toml
+  api_key = "MY_API_KEY"   # resolves process.env.MY_API_KEY, or literal "MY_API_KEY"
+  ```
+- **Env var reference:** explicit `$VAR` or `${VAR}`
+  ```toml
+  api_key = "$MY_API_KEY"  # always resolves from env
+  ```
+- **Shell command:** `!command` executes and uses stdout
+  ```toml
+  api_key = "!security find-generic-password -ws 'my-api'"
+  ```
 
 #### Compatibility Flags (Optional)
 

bun.lock πŸ”—

@@ -5,8 +5,8 @@
     "": {
       "name": "rumilo",
       "dependencies": {
-        "@mariozechner/pi-agent": "^0.9.0",
-        "@mariozechner/pi-ai": "^0.6.1",
+        "@mariozechner/pi-agent-core": "/home/exedev/repos/personal/pi-mono/packages/agent",
+        "@mariozechner/pi-ai": "^0.52.8",
         "@sinclair/typebox": "^0.32.14",
         "@tabstack/sdk": "^2.1.0",
         "kagi-ken": "github:czottmann/kagi-ken#1.2.0",
@@ -21,21 +21,145 @@
     },
   },
   "packages": {
-    "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.61.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-GnlOXrPxow0uoaVB3DGNh9EJBU1MyagCBCLpU+bwDVlj/oOPYIwoiasMWlykkfYcQOrDP2x/zHnRD0xN7PeZPw=="],
+    "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="],
+
+    "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
+
+    "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
+
+    "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
+
+    "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
+
+    "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
+

package.json πŸ”—

@@ -15,8 +15,8 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@mariozechner/pi-ai": "^0.6.1",
-    "@mariozechner/pi-agent": "^0.9.0",
+    "@mariozechner/pi-agent-core": "/home/exedev/repos/personal/pi-mono/packages/agent",
+    "@mariozechner/pi-ai": "^0.52.8",
     "@sinclair/typebox": "^0.32.14",
     "@tabstack/sdk": "^2.1.0",
     "kagi-ken": "github:czottmann/kagi-ken#1.2.0",

src/agent/model-resolver.ts πŸ”—

@@ -4,6 +4,7 @@ import {
   type RumiloConfig,
 } from "../config/schema.js";
 import { ConfigError } from "../util/errors.js";
+import { resolveHeaders } from "../util/env.js";
 
 export function resolveModel(
   modelString: string,
@@ -77,8 +78,9 @@ function buildCustomModel(config: CustomModelConfig): Model<any> {
     maxTokens: config.max_tokens,
   };
 
-  if (config.headers) {
-    model.headers = config.headers;
+  const resolvedHeaders = resolveHeaders(config.headers);
+  if (resolvedHeaders) {
+    model.headers = resolvedHeaders;
   }
 
   if (config.compat) {

src/agent/runner.ts πŸ”—

@@ -1,9 +1,9 @@
-import { Agent, ProviderTransport, type AgentEvent } from "@mariozechner/pi-agent";
-import { getApiKey, type AgentTool, type AssistantMessage } from "@mariozechner/pi-ai";
+import { Agent, type AgentEvent, type AgentTool } from "@mariozechner/pi-agent-core";
+import { getEnvApiKey, type AssistantMessage } from "@mariozechner/pi-ai";
 import type { RumiloConfig } from "../config/schema.js";
 import { resolveModel } from "./model-resolver.js";
 import { AgentError } from "../util/errors.js";
-import { expandEnvVars } from "../util/env.js";
+import { resolveConfigValue } from "../util/env.js";
 
 export interface AgentRunOptions {
   model: string;
@@ -19,34 +19,25 @@ export interface AgentRunResult {
 }
 
 /**
- * Build a getApiKey callback that checks custom model headers first,
- * then falls back to pi-ai's built-in env-var lookup.
+ * Build a getApiKey callback for the Agent.
  *
- * Custom models may specify `Authorization: "Bearer $SOME_ENV_VAR"` in their
- * headers config. We extract the bearer token and expand env var references
- * so the value reaches the OpenAI-compatible SDK as a real API key.
+ * Resolution order:
+ * 1. Custom model config β€” if a custom model for this provider defines an
+ *    `apiKey` field, resolve it via `resolveConfigValue` (supports env var
+ *    names, `$VAR` references, and `!shell` commands).
+ * 2. pi-ai’s built-in env-var lookup (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.).
  */
 export function buildGetApiKey(config: RumiloConfig): (provider: string) => string | undefined {
   return (provider: string) => {
     if (config.custom_models) {
       for (const model of Object.values(config.custom_models)) {
-        if (model.provider === provider && model.headers) {
-          const authHeader = model.headers["Authorization"] ?? model.headers["authorization"];
-          if (authHeader) {
-            const expanded = expandEnvVars(authHeader);
-            const match = expanded.match(/^Bearer\s+(.+)$/i);
-            if (match) {
-              return match[1];
-            }
-            return expanded;
-          }
+        if (model.provider === provider && model.api_key) {
+          return resolveConfigValue(model.api_key);
         }
       }
     }
 
-    // Fall back to pi-ai's built-in env var resolution
-    // (handles anthropic β†’ ANTHROPIC_API_KEY, openai β†’ OPENAI_API_KEY, etc.)
-    return getApiKey(provider);
+    return getEnvApiKey(provider);
   };
 }
 
@@ -57,9 +48,7 @@ export async function runAgent(query: string, options: AgentRunOptions): Promise
       model: resolveModel(options.model, options.config),
       tools: options.tools,
     },
-    transport: new ProviderTransport({
-      getApiKey: buildGetApiKey(options.config),
-    }),
+    getApiKey: buildGetApiKey(options.config),
   });
 
   if (options.onEvent) {

src/agent/tools/find.ts πŸ”—

@@ -1,7 +1,7 @@
 import { spawnSync } from "node:child_process";
 import { relative } from "node:path";
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
 import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
 import { ToolInputError } from "../../util/errors.js";

src/agent/tools/git/blame.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
 import { formatSize, truncateHead } from "../../../util/truncate.js";

src/agent/tools/git/checkout.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
 

src/agent/tools/git/diff.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
 import { formatSize, truncateHead } from "../../../util/truncate.js";

src/agent/tools/git/log.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
 

src/agent/tools/git/refs.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import simpleGit from "simple-git";
 import { formatSize, truncateHead } from "../../../util/truncate.js";
 

src/agent/tools/git/show.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import simpleGit from "simple-git";
 import { ToolInputError } from "../../../util/errors.js";
 import { formatSize, truncateHead } from "../../../util/truncate.js";

src/agent/tools/grep.ts πŸ”—

@@ -3,7 +3,7 @@ import { createInterface } from "node:readline";
 import { readFileSync, statSync } from "node:fs";
 import { relative, basename } from "node:path";
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
 import {
   DEFAULT_MAX_BYTES,

src/agent/tools/index.ts πŸ”—

@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 
 export type ToolFactory = (workspacePath: string) => AgentTool<any>;
 

src/agent/tools/ls.ts πŸ”—

@@ -1,7 +1,7 @@
 import { existsSync, readdirSync, statSync } from "node:fs";
 import { join } from "node:path";
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
 import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
 

src/agent/tools/read.ts πŸ”—

@@ -1,6 +1,6 @@
 import { readFile, stat } from "node:fs/promises";
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import { resolveReadPath, ensureWorkspacePath } from "./path-utils.js";
 import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../util/truncate.js";
 import { ToolInputError } from "../../util/errors.js";

src/agent/tools/web-fetch.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import Tabstack from "@tabstack/sdk";
 import { FetchError, ToolInputError } from "../../util/errors.js";
 

src/agent/tools/web-search.ts πŸ”—

@@ -1,5 +1,5 @@
 import { Type } from "@sinclair/typebox";
-import type { AgentTool } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@mariozechner/pi-agent-core";
 import { search } from "kagi-ken";
 import { FetchError, ToolInputError } from "../../util/errors.js";
 

src/cli/output.ts πŸ”—

@@ -1,4 +1,4 @@
-import type { AgentEvent } from "@mariozechner/pi-agent";
+import type { AgentEvent } from "@mariozechner/pi-agent-core";
 
 const MAX_OUTPUT_LINES = 20;
 

src/config/schema.ts πŸ”—

@@ -16,6 +16,7 @@ const CustomModelSchema = Type.Object({
   }),
   context_window: Type.Number(),
   max_tokens: Type.Number(),
+  api_key: Type.Optional(Type.String()),
   headers: Type.Optional(Type.Record(Type.String(), Type.String())),
   compat: Type.Optional(
     Type.Object({

src/util/env.ts πŸ”—

@@ -1,10 +1,75 @@
+import { execSync } from "node:child_process";
+
 /**
- * Expand `$VAR` and `${VAR}` references to their environment variable values.
- * Returns the string unchanged if it contains no references.
+ * Resolve a configuration value (API key, header value, etc.) to a concrete string.
+ *
+ * Resolution order, following pi-coding-agent's convention:
+ * - `"!command"` β€” executes the rest as a shell command, uses trimmed stdout
+ * - `"$VAR"` or `"${VAR}"` β€” treats as an env var reference (with sigil)
+ * - Otherwise checks `process.env[value]` β€” bare name is tried as env var
+ * - If no env var matches, the string is used as a literal value
+ *
+ * Returns `undefined` only when a shell command fails or produces empty output.
+ */
+export function resolveConfigValue(value: string): string | undefined {
+  if (value.startsWith("!")) {
+    return executeShellCommand(value.slice(1));
+  }
+
+  // Explicit $VAR or ${VAR} reference
+  const envRef = value.match(/^\$\{(.+)\}$|^\$([A-Za-z_][A-Za-z0-9_]*)$/);
+  if (envRef) {
+    const name = envRef[1] ?? envRef[2]!;
+    return process.env[name] ?? undefined;
+  }
+
+  // Bare name β€” check as env var first, then use as literal
+  const envValue = process.env[value];
+  return envValue || value;
+}
+
+/**
+ * Expand `$VAR` and `${VAR}` references embedded within a larger string.
+ * Unlike `resolveConfigValue`, this handles mixed literal + env-var strings
+ * like `"Bearer $API_KEY"`.
  */
 export function expandEnvVars(value: string): string {
-  return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, braced, bare) => {
-    const name = braced ?? bare;
-    return process.env[name] ?? "";
-  });
+  return value.replace(
+    /\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
+    (_, braced, bare) => {
+      const name = braced ?? bare;
+      return process.env[name] ?? "";
+    },
+  );
+}
+
+/**
+ * Resolve all values in a headers record using `resolveConfigValue`.
+ * Drops entries whose values resolve to `undefined`.
+ */
+export function resolveHeaders(
+  headers: Record<string, string> | undefined,
+): Record<string, string> | undefined {
+  if (!headers) return undefined;
+  const resolved: Record<string, string> = {};
+  for (const [key, value] of Object.entries(headers)) {
+    const resolvedValue = resolveConfigValue(value);
+    if (resolvedValue) {
+      resolved[key] = resolvedValue;
+    }
+  }
+  return Object.keys(resolved).length > 0 ? resolved : undefined;
+}
+
+function executeShellCommand(command: string): string | undefined {
+  try {
+    const output = execSync(command, {
+      encoding: "utf-8",
+      timeout: 10_000,
+      stdio: ["ignore", "pipe", "ignore"],
+    });
+    return output.trim() || undefined;
+  } catch {
+    return undefined;
+  }
 }

test/agent-runner.test.ts πŸ”—

@@ -1,6 +1,6 @@
 import { describe, test, expect, beforeAll, afterAll } from "bun:test";
 import { AgentError } from "../src/util/errors.js";
-import { expandEnvVars } from "../src/util/env.js";
+import { expandEnvVars, resolveConfigValue, resolveHeaders } from "../src/util/env.js";
 import { buildGetApiKey } from "../src/agent/runner.js";
 import type { RumiloConfig } from "../src/config/schema.js";
 
@@ -10,6 +10,27 @@ const stubConfig: RumiloConfig = {
   repo: { model: "anthropic:test", default_depth: 1, blob_limit: "5m" },
 };
 
+function customModel(provider: string, apiKey?: string): RumiloConfig {
+  return {
+    ...stubConfig,
+    custom_models: {
+      mymodel: {
+        id: "m1",
+        name: "M1",
+        api: "openai-completions" as any,
+        provider,
+        base_url: "http://localhost:8000/v1",
+        reasoning: false,
+        input: ["text"],
+        cost: { input: 0, output: 0 },
+        context_window: 8192,
+        max_tokens: 4096,
+        ...(apiKey ? { api_key: apiKey } : {}),
+      },
+    },
+  };
+}
+
 describe("AgentError", () => {
   test("has correct name, code, and inherits from Error", () => {
     const err = new AgentError("boom");
@@ -20,6 +41,48 @@ describe("AgentError", () => {
   });
 });
 
+describe("resolveConfigValue", () => {
+  const saved: Record<string, string | undefined> = {};
+
+  beforeAll(() => {
+    saved["RUMILO_TEST_KEY"] = process.env["RUMILO_TEST_KEY"];
+    process.env["RUMILO_TEST_KEY"] = "resolved-value";
+  });
+
+  afterAll(() => {
+    if (saved["RUMILO_TEST_KEY"] === undefined) delete process.env["RUMILO_TEST_KEY"];
+    else process.env["RUMILO_TEST_KEY"] = saved["RUMILO_TEST_KEY"];
+  });
+
+  test("resolves bare env var name", () => {
+    expect(resolveConfigValue("RUMILO_TEST_KEY")).toBe("resolved-value");
+  });
+
+  test("resolves $VAR reference", () => {
+    expect(resolveConfigValue("$RUMILO_TEST_KEY")).toBe("resolved-value");
+  });
+
+  test("resolves ${VAR} reference", () => {
+    expect(resolveConfigValue("${RUMILO_TEST_KEY}")).toBe("resolved-value");
+  });
+
+  test("treats unknown name as literal", () => {
+    expect(resolveConfigValue("sk-literal-key-12345")).toBe("sk-literal-key-12345");
+  });
+
+  test("returns undefined for unknown $VAR", () => {
+    expect(resolveConfigValue("$RUMILO_NONEXISTENT_XYZ")).toBeUndefined();
+  });
+
+  test("executes shell commands with ! prefix", () => {
+    expect(resolveConfigValue("!echo hello")).toBe("hello");
+  });
+
+  test("returns undefined for failing shell command", () => {
+    expect(resolveConfigValue("!false")).toBeUndefined();
+  });
+});
+
 describe("expandEnvVars", () => {
   const saved: Record<string, string | undefined> = {};
 
@@ -58,6 +121,34 @@ describe("expandEnvVars", () => {
   });
 });
 
+describe("resolveHeaders", () => {
+  const saved: Record<string, string | undefined> = {};
+
+  beforeAll(() => {
+    saved["RUMILO_HDR_KEY"] = process.env["RUMILO_HDR_KEY"];
+    process.env["RUMILO_HDR_KEY"] = "hdr-value";
+  });
+
+  afterAll(() => {
+    if (saved["RUMILO_HDR_KEY"] === undefined) delete process.env["RUMILO_HDR_KEY"];
+    else process.env["RUMILO_HDR_KEY"] = saved["RUMILO_HDR_KEY"];
+  });
+
+  test("returns undefined for undefined input", () => {
+    expect(resolveHeaders(undefined)).toBeUndefined();
+  });
+
+  test("resolves header values via resolveConfigValue", () => {
+    const result = resolveHeaders({ "X-Key": "RUMILO_HDR_KEY" });
+    expect(result).toEqual({ "X-Key": "hdr-value" });
+  });
+
+  test("drops entries that resolve to undefined", () => {
+    const result = resolveHeaders({ "X-Key": "$RUMILO_NONEXISTENT_XYZ" });
+    expect(result).toBeUndefined();
+  });
+});
+
 describe("buildGetApiKey", () => {
   const saved: Record<string, string | undefined> = {};
 
@@ -85,73 +176,39 @@ describe("buildGetApiKey", () => {
     expect(getKey("unknown-provider")).toBeUndefined();
   });
 
-  test("extracts bearer token from custom model Authorization header", () => {
-    const config: RumiloConfig = {
-      ...stubConfig,
-      custom_models: {
-        mymodel: {
-          id: "m1",
-          name: "M1",
-          api: "openai-completions" as any,
-          provider: "myprovider",
-          base_url: "http://localhost:8000/v1",
-          reasoning: false,
-          input: ["text"],
-          cost: { input: 0, output: 0 },
-          context_window: 8192,
-          max_tokens: 4096,
-          headers: { Authorization: "Bearer sk-literal-key" },
-        },
-      },
-    };
+  test("resolves literal api_key from custom model", () => {
+    const config = customModel("myprovider", "sk-literal-key");
     const getKey = buildGetApiKey(config);
     expect(getKey("myprovider")).toBe("sk-literal-key");
   });
 
-  test("expands env vars in Authorization header", () => {
-    const config: RumiloConfig = {
-      ...stubConfig,
-      custom_models: {
-        mymodel: {
-          id: "m1",
-          name: "M1",
-          api: "openai-completions" as any,
-          provider: "myprovider",
-          base_url: "http://localhost:8000/v1",
-          reasoning: false,
-          input: ["text"],
-          cost: { input: 0, output: 0 },
-          context_window: 8192,
-          max_tokens: 4096,
-          headers: { Authorization: "Bearer $CUSTOM_KEY" },
-        },
-      },
-    };
+  test("resolves api_key via env var name", () => {
+    const config = customModel("myprovider", "CUSTOM_KEY");
     const getKey = buildGetApiKey(config);
     expect(getKey("myprovider")).toBe("sk-custom-test");
   });
 
+  test("resolves api_key via $VAR reference", () => {
+    const config = customModel("myprovider", "$CUSTOM_KEY");
+    const getKey = buildGetApiKey(config);
+    expect(getKey("myprovider")).toBe("sk-custom-test");
+  });
+
+  test("resolves api_key via shell command", () => {
+    const config = customModel("myprovider", "!echo shell-key");
+    const getKey = buildGetApiKey(config);
+    expect(getKey("myprovider")).toBe("shell-key");
+  });
+
   test("custom model provider doesn't shadow built-in provider lookup", () => {
-    const config: RumiloConfig = {
-      ...stubConfig,
-      custom_models: {
-        mymodel: {
-          id: "m1",
-          name: "M1",
-          api: "openai-completions" as any,
-          provider: "other-provider",
-          base_url: "http://localhost:8000/v1",
-          reasoning: false,
-          input: ["text"],
-          cost: { input: 0, output: 0 },
-          context_window: 8192,
-          max_tokens: 4096,
-          headers: { Authorization: "Bearer sk-other" },
-        },
-      },
-    };
+    const config = customModel("other-provider", "sk-other");
+    const getKey = buildGetApiKey(config);
+    expect(getKey("anthropic")).toBe("sk-ant-test");
+  });
+
+  test("falls back to env var lookup when custom model has no api_key", () => {
+    const config = customModel("anthropic");
     const getKey = buildGetApiKey(config);
-    // anthropic should still resolve from env, not from the custom model
     expect(getKey("anthropic")).toBe("sk-ant-test");
   });
 });