From e0742d8217a4572d944aa274656cd478519117c5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 22 Jan 2026 21:50:22 +0100 Subject: [PATCH] feat(coding-agent): support env vars and shell commands in headers Header values in models.json now resolve using the same logic as apiKey: - Environment variable names are resolved to their values - Shell commands prefixed with ! are executed - Literal values are used directly This is a minor breaking change: if a header value accidentally matches an env var name, it will now resolve to that env var's value. Fixes #909 --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/README.md | 12 +++-- .../coding-agent/src/core/model-registry.ts | 46 +++++++++++++------ 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2128bd27..9c2a9bbd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,11 +4,13 @@ ### Breaking Changes +- Header values in `models.json` now resolve environment variables (if a header value matches an env var name, the env var value is used). This may change behavior if a literal header value accidentally matches an env var name. ([#909](https://github.com/badlogic/pi-mono/issues/909)) - Resource loading now uses `ResourceLoader` only and settings.json uses arrays for extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645)) - Removed `discoverAuthStorage` and `discoverModels` from the SDK. `AuthStorage` and `ModelRegistry` now default to `~/.pi/agent` paths unless you pass an `agentDir` ([#645](https://github.com/badlogic/pi-mono/issues/645)) ### Added +- Header values in `models.json` now support environment variables and shell commands, matching `apiKey` resolution ([#909](https://github.com/badlogic/pi-mono/issues/909)) - `markdown.codeBlockIndent` setting to customize code block indentation in rendered output - Extension package management with `pi install`, `pi remove`, `pi update`, and `pi list` commands ([#645](https://github.com/badlogic/pi-mono/issues/645)) - `/reload` command to reload extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 79d78c43..8e5433d2 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -681,10 +681,10 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`: **Supported APIs:** `openai-completions`, `openai-responses`, `openai-codex-responses`, `anthropic-messages`, `google-generative-ai` -**API key resolution:** The `apiKey` field supports three formats: +**Value resolution:** The `apiKey` and `headers` fields support three formats for their values: - `"!command"` - Executes the command and uses stdout (e.g., `"!security find-generic-password -ws 'anthropic'"` for macOS Keychain, `"!op read 'op://vault/item/credential'"` for 1Password) - Environment variable name (e.g., `"MY_API_KEY"`) - Uses the value of the environment variable -- Literal value - Used directly as the API key +- Literal value - Used directly **API override:** Set `api` at provider level (default for all models) or model level (override per model). @@ -695,11 +695,11 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`: "providers": { "custom-proxy": { "baseUrl": "https://proxy.example.com/v1", - "apiKey": "YOUR_API_KEY", + "apiKey": "MY_API_KEY", "api": "anthropic-messages", "headers": { - "User-Agent": "Mozilla/5.0 ...", - "X-Custom-Auth": "token" + "x-portkey-api-key": "PORTKEY_API_KEY", + "x-secret": "!op read 'op://vault/item/secret'" }, "models": [...] } @@ -707,6 +707,8 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`: } ``` +Header values use the same resolution as `apiKey`: environment variables, shell commands (`!`), or literal values. + **Overriding built-in providers:** To route a built-in provider (anthropic, openai, google, etc.) through a proxy without redefining all models, just specify the `baseUrl`: diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index c957685e..87728418 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -112,19 +112,19 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult { const commandResultCache = new Map(); /** - * Resolve an API key config value to an actual key. + * Resolve a config value (API key, header value, etc.) to an actual value. * - If starts with "!", executes the rest as a shell command and uses stdout (cached) * - Otherwise checks environment variable first, then treats as literal (not cached) */ -function resolveApiKeyConfig(keyConfig: string): string | undefined { - if (keyConfig.startsWith("!")) { - return executeApiKeyCommand(keyConfig); +function resolveConfigValue(config: string): string | undefined { + if (config.startsWith("!")) { + return executeCommand(config); } - const envValue = process.env[keyConfig]; - return envValue || keyConfig; + const envValue = process.env[config]; + return envValue || config; } -function executeApiKeyCommand(commandConfig: string): string | undefined { +function executeCommand(commandConfig: string): string | undefined { if (commandResultCache.has(commandConfig)) { return commandResultCache.get(commandConfig); } @@ -146,7 +146,22 @@ function executeApiKeyCommand(commandConfig: string): string | undefined { return result; } -/** Clear the API key command cache. Exported for testing. */ +/** + * Resolve all header values using the same resolution logic as API keys. + */ +function resolveHeaders(headers: Record | undefined): Record | undefined { + if (!headers) return undefined; + const resolved: Record = {}; + 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; +} + +/** Clear the config value command cache. Exported for testing. */ export function clearApiKeyCache(): void { commandResultCache.clear(); } @@ -167,7 +182,7 @@ export class ModelRegistry { this.authStorage.setFallbackResolver((provider) => { const keyConfig = this.customProviderApiKeys.get(provider); if (keyConfig) { - return resolveApiKeyConfig(keyConfig); + return resolveConfigValue(keyConfig); } return undefined; }); @@ -232,10 +247,11 @@ export class ModelRegistry { if (!override) return models; // Apply baseUrl/headers override to all models of this provider + const resolvedHeaders = resolveHeaders(override.headers); return models.map((m) => ({ ...m, baseUrl: override.baseUrl ?? m.baseUrl, - headers: override.headers ? { ...m.headers, ...override.headers } : m.headers, + headers: resolvedHeaders ? { ...m.headers, ...resolvedHeaders } : m.headers, })); }); } @@ -353,14 +369,14 @@ export class ModelRegistry { if (!api) continue; // Merge headers: provider headers are base, model headers override - let headers = - providerConfig.headers || modelDef.headers - ? { ...providerConfig.headers, ...modelDef.headers } - : undefined; + // Resolve env vars and shell commands in header values + const providerHeaders = resolveHeaders(providerConfig.headers); + const modelHeaders = resolveHeaders(modelDef.headers); + let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined; // If authHeader is true, add Authorization header with resolved API key if (providerConfig.authHeader && providerConfig.apiKey) { - const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey); + const resolvedKey = resolveConfigValue(providerConfig.apiKey); if (resolvedKey) { headers = { ...headers, Authorization: `Bearer ${resolvedKey}` }; }