Reviewed GitHub issue and related comments.
UnderstandExamined relevant code files and documentation.
UnderstandImplemented refactoring and header resolution enhancements.
BuildUpdated README with usage instructions.
EditConfirmed all checks passed and implementation correctness.
VerifyAnalyze GitHub issue(s): https://github.com/badlogic/pi-mono/issues/909
For each issue:
Read the issue in full, including all comments and linked issues/PRs.
For bugs:
For feature requests:
Do NOT implement unless explicitly asked. Analyze and propose only.
The user wants me to analyze GitHub issue #909. Let me fetch the issue details first.
>gh issue view 909 --repo badlogic/pi-mono --json title,body,comments,labels,state{"body":"### What do you want to change?\n\nChange the way `headers` resolution in e.g. `models.json` works to support env vars and shell commands, analog to `apiKey`\n\nThis is technically a **breaking change**: if a header has a value that corresponds to an ENV var, this feature will display different behavior. Acceptable?\n\n### Why?\n\nTo configure a custom model provider, I need to set a separate api key via an http-header (`x-portkey-api-key`)\n\nI don't want to have the api key in the models.json and instead provide it via an environment variable\n\n### How? (optional)\n\nUpdate `model-registry.ts` to make headers resolution work the same way `resolveApiKeyConfig` does\n\nAlternatively, if we say that headers are less likely to be secrets (unless `apiKey`) we could also use special syntax like `env:MY_KEY` or `$MY_KEY` to make it more obvious. That would make it more confusing though, as it functions differently from `apiKey` resolution.","comments":[],"labels":[],"state":"OPEN","title":"Support environment variables and shell commands in http headers"}This is a feature request for supporting environment variables and shell commands in HTTP headers, similar to how apiKey resolution works. Let me read the relevant code files to understand the current implementation.
The issue mentions:
models.json - the configuration file for custom modelsmodel-registry.ts - contains the resolution logicLet me find and read these files.
Let me read the relevant code files to understand the current implementation.
>find /Users/badlogic/workspaces/pi-mono -name "model-registry.ts" -o -name "models.json" 2>/dev/null | head -20/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/model-registry.ts
/Users/badlogic/workspaces/pi-mono/packages/pods/dist/models.json
/Users/badlogic/workspaces/pi-mono/packages/pods/src/models.jsonLet me read the model-registry.ts file to understand how apiKey resolution currently works and how headers are handled.
Now I understand the code. Let me trace through the flow:
The resolveApiKeyConfig() function handles API key resolution:
!, it executes it as a shell commandHeaders are currently NOT resolved through this function. They are simply merged as literal strings in parseModels() and loadBuiltInModels().
The feature request is to make headers use the same resolution logic as apiKey.
Looking at where headers are handled:
parseModels(): let headers = providerConfig.headers || modelDef.headers ? { ...providerConfig.headers, ...modelDef.headers } : undefined;loadBuiltInModels(): headers: override.headers ? { ...m.headers, ...override.headers } : m.headersThe proposal is to apply resolveApiKeyConfig() to each header value so they can also be env vars or shell commands.
The user mentions this is technically a breaking change: if a header value happens to correspond to an ENV var name, it would now be resolved to that env var's value instead of being used as a literal string.
The user suggests alternative syntax like env:MY_KEY or $MY_KEY to make it opt-in, but notes this would be inconsistent with how apiKey works.
Let me also check if there are any models.json examples or documentation to understand the current behavior better.
Let me also check the models.json structure and any related documentation.
>grep -r "headers" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md 2>/dev/null | head -30/Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md:**Custom headers:**
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md: "headers": {That models.json was for pods, not the coding-agent's custom models. Let me look for documentation showing the models.json format for custom providers in the coding-agent.
>grep -A 50 "Custom headers" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md**Custom headers:**
```json
{
"providers": {
"custom-proxy": {
"baseUrl": "https://proxy.example.com/v1",
"apiKey": "YOUR_API_KEY",
"api": "anthropic-messages",
"headers": {
"User-Agent": "Mozilla/5.0 ...",
"X-Custom-Auth": "token"
},
"models": [...]
}
}
}
```
**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`:
```json
{
"providers": {
"anthropic": {
"baseUrl": "https://my-proxy.example.com/v1"
}
}
}
```
All built-in Anthropic models remain available with the new endpoint. Existing OAuth or API key auth continues to work.
To fully replace a built-in provider with custom models, include the `models` array:
```json
{
"providers": {
"anthropic": {
"baseUrl": "https://my-proxy.example.com/v1",
"apiKey": "ANTHROPIC_API_KEY",
"api": "anthropic-messages",
"models": [...]
}
}
}
```
**Authorization header:** Set `authHeader: true` to add `Authorization: Bearer <apiKey>` automatically.Now I have a complete picture of the issue. Let me also check the documentation around apiKey resolution to see how it's currently documented.
>grep -B 5 -A 30 "API key resolution" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md}
```
**Supported APIs:** `openai-completions`, `openai-responses`, `openai-codex-responses`, `anthropic-messages`, `google-generative-ai`
**API key resolution:** The `apiKey` field supports three formats:
- `"!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
**API override:** Set `api` at provider level (default for all models) or model level (override per model).
**Custom headers:**
```json
{
"providers": {
"custom-proxy": {
"baseUrl": "https://proxy.example.com/v1",
"apiKey": "YOUR_API_KEY",
"api": "anthropic-messages",
"headers": {
"User-Agent": "Mozilla/5.0 ...",
"X-Custom-Auth": "token"
},
"models": [...]
}
}
}
```
**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`:
```json
--
- Model selection and thinking level
- Tools (built-in subsets, custom tools)
- Resources via `ResourceLoader` (extensions, skills, prompts, themes, context files, system prompt)
- Session persistence (`SessionManager`)
- Settings (`SettingsManager`)
- API key resolution and OAuth
**Philosophy:** "Omit to discover, provide to override." Resource discovery is handled by `ResourceLoader`, while model and tool options still follow the omit or override pattern.
> See [SDK Documentation](docs/sdk.md) for the full API reference. See [examples/sdk/](examples/sdk/) for working examples from minimal to full control.
### RPC Mode
For embedding pi from other languages or with process isolation:
```bash
pi --mode rpc --no-session
```
Send JSON commands on stdin:
```json
{"type":"prompt","message":"List all .ts files"}
{"type":"abort"}
```
> See [RPC Documentation](docs/rpc.md) for the full protocol.
### HTML Export
```bash
pi --export session.jsonl # Auto-generated filename
pi --export session.jsonl output.html # Custom filename
```
Works with session files.Now I have full context. Let me analyze this issue and prepare my proposal.
Issue #909: Support environment variables and shell commands in HTTP headers
This is a feature request to extend header value resolution to support the same patterns as apiKey:
"!command" - Execute shell command, use stdoutCurrent behavior:
apiKey field: Resolved via resolveApiKeyConfig() which handles all three patternsheaders field: Values used as literal strings only (no env var or command resolution)Requested behavior:
Breaking change consideration: The user acknowledges this is technically breaking: if a header value happens to match an environment variable name, it would now resolve to that env var's value instead of being used literally.
Example: {"headers": {"X-Api-Key": "HOME"}} would now resolve to /Users/username instead of literal "HOME".
Implementation approach: The most concise implementation would be to add a helper function that resolves header values and apply it wherever headers are processed.
Let me identify all the places where headers need to be resolved:
parseModels() - for custom models with headersloadBuiltInModels() - when applying header overrides to built-in modelsThe fix is straightforward: create a helper function to resolve headers (applying resolveApiKeyConfig to each value) and use it in both locations.
Type: Feature Request
Title: Support environment variables and shell commands in HTTP headers
In model-registry.ts, the apiKey field supports three resolution patterns via resolveApiKeyConfig():
"!command" - Executes shell command, uses stdout (cached)However, headers values are not resolved and are used as literal strings only.
Apply the same resolution logic to each header value, enabling configurations like:
{ "providers": { "my-proxy": { "baseUrl": "https://proxy.example.com/v1", "apiKey": "MY_API_KEY", "headers": { "x-portkey-api-key": "PORTKEY_API_KEY" // <-- resolve from env var } } }}Yes, this is technically a breaking change. If a header value happens to match an environment variable name, the behavior changes:
{"x-header": "HOME"} → literal "HOME"{"x-header": "HOME"} → /Users/username (from env)However, this edge case is unlikely in practice (header values rarely match env var names by accident), and the new behavior is more useful.
Affected file: packages/coding-agent/src/core/model-registry.ts
Changes:
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 = resolveApiKeyConfig(value); if (resolvedValue) { resolved[key] = resolvedValue; } } return Object.keys(resolved).length > 0 ? resolved : undefined;}parseModels() (around line 254) to resolve headers:// Before merging headersconst providerHeaders = resolveHeaders(providerConfig.headers);const modelHeaders = resolveHeaders(modelDef.headers);let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined;loadBuiltInModels() (around line 196) to resolve override headers:// When applying overridesheaders: override.headers ? { ...m.headers, ...resolveHeaders(override.headers) } : m.headers,README.md to document header resolution:**Custom headers:**Header values support the same resolution as `apiKey`:- `"!command"` - Execute command, use stdout- Environment variable name - Use env var value- Literal value - Use directly| File | Change |
|---|---|
packages/coding-agent/src/core/model-registry.ts | Add resolveHeaders() helper, update parseModels() and loadBuiltInModels() |
packages/coding-agent/README.md | Document header resolution |
packages/coding-agent/CHANGELOG.md | Add entry under ## [Unreleased] |
resolveApiKeyConfig should rename that then, implement
The user wants me to implement the feature. I need to:
resolveApiKeyConfig to something more generic (like resolveConfigValue)resolveHeaders helper functionparseModels() to resolve headersloadBuiltInModels() to resolve override headersLet me start by reading the full file again to make sure I have the exact text for edits.
Now I have all the context I need. Let me implement the feature:
resolveApiKeyConfig to resolveConfigValue (more generic name)executeApiKeyCommand to executeCommand (more generic name)resolveHeaders() helper functionparseModels() to use resolveHeaders()loadBuiltInModels() to use resolveHeaders()loadCustomModels() to use resolveHeaders() when building overridesLet me make the edits.
Now I'll implement the changes. Starting with the model-registry.ts file:
Now I need to:
resolveConfigValue instead of resolveApiKeyConfigloadBuiltInModels() to resolve override headersloadCustomModels() to resolve headers in overridesparseModels() to resolve headersNow update loadBuiltInModels() to resolve override headers.
Now update parseModels() to resolve headers.
Now I need to update the README.md documentation. Let me read the relevant section first.
Now let me update the README documentation:
>grep -n -A 20 "Custom headers:" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md | head -35691:**Custom headers:**
692-
693-```json
694-{
695- "providers": {
696- "custom-proxy": {
697- "baseUrl": "https://proxy.example.com/v1",
698- "apiKey": "YOUR_API_KEY",
699- "api": "anthropic-messages",
700- "headers": {
701- "User-Agent": "Mozilla/5.0 ...",
702- "X-Custom-Auth": "token"
703- },
704- "models": [...]
705- }
706- }
707-}
708-```
709-
710-**Overriding built-in providers:**
711-Now I need to update the README to document that headers support the same resolution as apiKey. Let me read more context around the documentation to get the exact text to edit.
Now let me update the documentation. I need to:
Let me edit the documentation.
Now update the CHANGELOG.md
Now update the changelog:
I should also add an "Added" entry for the new feature.
Now let me run the check command to ensure everything compiles and passes linting.
Now let's run the check to ensure everything compiles correctly:
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui
Checked 416 files in 261ms. Fixed 1 file.
> @mariozechner/pi-web-ui@0.49.3 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 37ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.Everything compiles and passes linting. Let me verify my changes are correct by reading the modified file.
All checks pass. Let me verify the implementation by reading the modified code:
The implementation looks good. Let me also see the places where we use resolveHeaders() and resolveConfigValue() to make sure everything is connected properly.
>cd /Users/badlogic/workspaces/pi-mono && grep -n "resolveHeaders\|resolveConfigValue" packages/coding-agent/src/core/model-registry.ts119:function resolveConfigValue(config: string): string | undefined {
152:function resolveHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
156: const resolvedValue = resolveConfigValue(value);
185: return resolveConfigValue(keyConfig);
250: const resolvedHeaders = resolveHeaders(override.headers);
373: const providerHeaders = resolveHeaders(providerConfig.headers);
374: const modelHeaders = resolveHeaders(modelDef.headers);
379: const resolvedKey = resolveConfigValue(providerConfig.apiKey);The implementation is complete. Let me summarize the changes:
resolveApiKeyConfig to resolveConfigValue (more generic name)2. Renamed executeApiKeyCommand to executeCommand (more generic name)resolveHeaders() helper function that applies resolveConfigValue() to each header valueloadBuiltInModels() to use resolveHeaders() on override headers (line 250)parseModels() to use resolveHeaders() on provider and model headers (lines 373-374)resolveConfigValue() (lines 185, 379)All checks pass. The implementation is complete and ready for commit.
Implementation complete. Summary of changes:
packages/coding-agent/src/core/model-registry.ts:
resolveApiKeyConfig → resolveConfigValue (generic name)executeApiKeyCommand → executeCommand (generic name)resolveHeaders() helper that applies resolveConfigValue() to each header valueloadBuiltInModels(): resolves override headers before applyingparseModels(): resolves provider and model headers before mergingpackages/coding-agent/README.md:
apiKey and headersapiKeypackages/coding-agent/CHANGELOG.md:
commit and push, add "Thanks, implemented in main"
The user wants me to commit and push the changes, then add a comment to the GitHub issue thanking them and noting it's implemented in main.
>cd /Users/badlogic/workspaces/pi-mono && git add packages/coding-agent/src/core/model-registry.ts packages/coding-agent/README.md packages/coding-agent/CHANGELOG.md && git commit -m "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"Running formatting, linting, and type checking...
> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui
Checked 416 files in 255ms. No fixes applied.
> @mariozechner/pi-web-ui@0.49.3 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 37ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.
✅ All pre-commit checks passed!
[main e0742d82] feat(coding-agent): support env vars and shell commands in headers
3 files changed, 40 insertions(+), 20 deletions(-)Good, committed successfully. Now I need to push and then add a comment to the issue.
>cd /Users/badlogic/workspaces/pi-mono && git pushTo https://github.com/badlogic/pi-mono.git
7af1919d..e0742d82 main -> main>gh issue comment 909 --repo badlogic/pi-mono --body "Thanks, implemented in main"https://github.com/badlogic/pi-mono/issues/909#issuecomment-3786630368Both push and comment were successful. Done!
Done. Committed, pushed, and commented on #909.
{ "providers": { "my-proxy": { "baseUrl": "https://proxy.example.com/v1", "apiKey": "MY_API_KEY", "headers": { "x-portkey-api-key": "PORTKEY_API_KEY" // <-- resolve from env var } } }}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 = resolveApiKeyConfig(value); if (resolvedValue) { resolved[key] = resolvedValue; } } return Object.keys(resolved).length > 0 ? resolved : undefined;}// Before merging headersconst providerHeaders = resolveHeaders(providerConfig.headers);const modelHeaders = resolveHeaders(modelDef.headers);let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined;// When applying overridesheaders: override.headers ? { ...m.headers, ...resolveHeaders(override.headers) } : m.headers,**Custom headers:**Header values support the same resolution as `apiKey`:- `"!command"` - Execute command, use stdout- Environment variable name - Use env var value- Literal value - Use directly