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
This commit is contained in:
Mario Zechner 2026-01-22 21:50:22 +01:00
parent 7af1919d31
commit e0742d8217
3 changed files with 40 additions and 20 deletions

View file

@ -4,11 +4,13 @@
### Breaking Changes ### 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)) - 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)) - 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 ### 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 - `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)) - 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)) - `/reload` command to reload extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))

View file

@ -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` **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) - `"!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 - 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). **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": { "providers": {
"custom-proxy": { "custom-proxy": {
"baseUrl": "https://proxy.example.com/v1", "baseUrl": "https://proxy.example.com/v1",
"apiKey": "YOUR_API_KEY", "apiKey": "MY_API_KEY",
"api": "anthropic-messages", "api": "anthropic-messages",
"headers": { "headers": {
"User-Agent": "Mozilla/5.0 ...", "x-portkey-api-key": "PORTKEY_API_KEY",
"X-Custom-Auth": "token" "x-secret": "!op read 'op://vault/item/secret'"
}, },
"models": [...] "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:** **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`: To route a built-in provider (anthropic, openai, google, etc.) through a proxy without redefining all models, just specify the `baseUrl`:

View file

@ -112,19 +112,19 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult {
const commandResultCache = new Map<string, string | undefined>(); const commandResultCache = new Map<string, string | undefined>();
/** /**
* 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) * - 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) * - Otherwise checks environment variable first, then treats as literal (not cached)
*/ */
function resolveApiKeyConfig(keyConfig: string): string | undefined { function resolveConfigValue(config: string): string | undefined {
if (keyConfig.startsWith("!")) { if (config.startsWith("!")) {
return executeApiKeyCommand(keyConfig); return executeCommand(config);
} }
const envValue = process.env[keyConfig]; const envValue = process.env[config];
return envValue || keyConfig; return envValue || config;
} }
function executeApiKeyCommand(commandConfig: string): string | undefined { function executeCommand(commandConfig: string): string | undefined {
if (commandResultCache.has(commandConfig)) { if (commandResultCache.has(commandConfig)) {
return commandResultCache.get(commandConfig); return commandResultCache.get(commandConfig);
} }
@ -146,7 +146,22 @@ function executeApiKeyCommand(commandConfig: string): string | undefined {
return result; 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<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;
}
/** Clear the config value command cache. Exported for testing. */
export function clearApiKeyCache(): void { export function clearApiKeyCache(): void {
commandResultCache.clear(); commandResultCache.clear();
} }
@ -167,7 +182,7 @@ export class ModelRegistry {
this.authStorage.setFallbackResolver((provider) => { this.authStorage.setFallbackResolver((provider) => {
const keyConfig = this.customProviderApiKeys.get(provider); const keyConfig = this.customProviderApiKeys.get(provider);
if (keyConfig) { if (keyConfig) {
return resolveApiKeyConfig(keyConfig); return resolveConfigValue(keyConfig);
} }
return undefined; return undefined;
}); });
@ -232,10 +247,11 @@ export class ModelRegistry {
if (!override) return models; if (!override) return models;
// Apply baseUrl/headers override to all models of this provider // Apply baseUrl/headers override to all models of this provider
const resolvedHeaders = resolveHeaders(override.headers);
return models.map((m) => ({ return models.map((m) => ({
...m, ...m,
baseUrl: override.baseUrl ?? m.baseUrl, 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; if (!api) continue;
// Merge headers: provider headers are base, model headers override // Merge headers: provider headers are base, model headers override
let headers = // Resolve env vars and shell commands in header values
providerConfig.headers || modelDef.headers const providerHeaders = resolveHeaders(providerConfig.headers);
? { ...providerConfig.headers, ...modelDef.headers } const modelHeaders = resolveHeaders(modelDef.headers);
: undefined; let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined;
// If authHeader is true, add Authorization header with resolved API key // If authHeader is true, add Authorization header with resolved API key
if (providerConfig.authHeader && providerConfig.apiKey) { if (providerConfig.authHeader && providerConfig.apiKey) {
const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey); const resolvedKey = resolveConfigValue(providerConfig.apiKey);
if (resolvedKey) { if (resolvedKey) {
headers = { ...headers, Authorization: `Bearer ${resolvedKey}` }; headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
} }