Repository navigation
Feature: static per-provider headers (providers.<name>.headers) #1290
Description
Is your feature request related to a problem? Please describe.
Operators sometimes need a fixed header on every call to one provider:
- A provider reached through an authenticating edge proxy or API gateway (Cloudflare AI Gateway
cf-aig-authorization, Azure APIMOcp-Apim-Subscription-Key, an in-houseX-Edge-Auth). - A self-hosted runtime (vLLM, Ollama, llama.cpp) behind a reverse proxy that wants its own token.
- Static routing or attribution headers (
OpenAI-Organization,OpenAI-Project,X-Tenant).
GoModel has no way to set these today. MCP servers have headers; providers do not. #1203 came from this gap: the author needs an edge-proxy token on Gemini calls and is reaching for plugin upstream headers. Plugin headers are the wrong tool for that. They are per request rather than per provider, so a secret meant for one provider follows failover to other providers and into gateway-internal calls (a response-phase llmjudge call inherits the request context). The built-in headeredit plugin also refuses names containing token or secret. In #1203 the author themselves calls a fixed proxy secret "provider config".
Describe the solution you'd like
A headers map on each provider, sent on every call to that provider's API.
Config
providers:
gemini:
type: gemini
api_key: ${env:GEMINI_API_KEY}
base_url: https://edge.example.com/gemini/v1beta
headers:
X-Edge-Auth: ${file:/run/secrets/edge-token}
X-Tenant: acmemap[string]string, one value per name, matchingmcp.servers.<name>.headers.- Env var:
<PROVIDER>_HEADERSas a JSON object (GEMINI_HEADERS='{"X-Edge-Auth":"${file:/run/secrets/edge-token}"}'). References are resolved inside the string values, as other JSON variables already do. The env var replaces the YAML map instead of merging, so the effective set is never a mix of the two. - Default: empty. Nothing changes for deployments that don't set it.
Secrets
Values accept secret references (${env:...}, ${file:...}, and extension schemes). Header names do not. This reuses the existing pipeline, so no new mechanism is needed:
- Resolved only for providers that survive the env overlay and credential filter. An unresolvable reference stops startup, or fails the reload and keeps the running config, naming the field (
providers.gemini.headers.X-Edge-Auth) but never the value. - Rotation: a changed referenced value rebuilds that provider, the same path as
proxy_url. - Dashboard and admin API follow the existing rules for references: only the master key can add or change one (
403 secret_reference_requires_master_keyotherwise), and only the master key can changetype,base_url,backendorproxy_urlon a provider whose headers hold one, because those decide where the secret is sent. - Encryption at rest: add
headersvalues to the encrypted fields ofprovider_credentials, next toapi_keysandproxy_url.
Redaction
Mask by default, not by name. Secret-bearing header names vary too much (cf-aig-authorization, Ocp-Apim-Subscription-Key, X-Edge-Auth) for the core.IsCredentialHeader list to catch them.
- The admin API and dashboard return names in clear text and values masked. Values made only of references, optionally after an auth scheme (
Bearer ${env:TOKEN}), are shown. This is the rule MCP headers already use (mcpHeaderShown,internal/admin/secret_redaction.go). Sending the mask back keeps the stored value. - Config export (feat(admin): export the effective configuration as config.yaml #1268) masks the values in the same way.
- Logs and errors name the header, never the value. That includes debug logs of outbound requests and a header value Go's HTTP client rejects.
- Outbound provider headers are not audit-logged today. Keep it that way.
Where the headers go
Apply them where each provider builds its outbound request, not as a process-wide transport, so they reach the provider's API and nothing else.
- Covered: every request
llmclientsends for the provider, including inference, streaming, model discovery and listing, passthrough (/p/<provider>/...), batches and files, and token counting. Add the provider's headers tollmclient.Configand apply them inbuildRequest. - Realtime: add them to the
RealtimeHTTPTarget.Headersof the websocket upgrade in one place (router_realtime.go), not separately in each of the 5 realtime providers. - Auxiliary clients built from the same provider config: the semantic-cache embedder must send them too, or it fails behind the same proxy.
- Never sent to: OAuth and identity token endpoints (Vertex service-account tokens, workload identity exchange, subscription token refresh), MCP, or any other provider. Failover to another provider uses that provider's headers.
- Cross-origin redirects are already stopped (
stopCrossOriginRedirects), so a header can't follow a redirect to another host.
Precedence and reserved names
- Configured headers are applied last and replace an existing value with the same name. A client can't override them through passthrough, which matters for
X-Tenant-style and proxy-auth headers. If feat(plugins): apply plugin upstream headers to provider calls #1203 lands, plugin upstream headers must not override them either. - Because they are applied last, names that would break auth, transport or the provider protocol are rejected at load and save time:
- All types: the
core.IsCredentialHeaderlist, hop-by-hop and transport headers (Host,Content-Length,Transfer-Encoding,Connection,Keep-Alive,Te,Trailer,Upgrade,Expect,Proxy-*), plusContent-Type,Accept-Encoding(setting it turns off Go's automatic gzip decompression) andIdempotency-Key. The error for a credential header points toapi_key/api_keys, so there stays one way to set credentials and key rotation keeps working. - Per type, declared next to
CredentialFieldsin the provider registration: for exampleanthropic-versionandanthropic-betaforanthropic(overridinganthropic-betadrops the OAuth flag),xi-api-keyforelevenlabs(also add it tocore.credentialHeaders; it is missing today),x-goog-user-projectforgemini/vertex,x-amz-*forbedrock/bedrockmantle, and the objective and fairness headers forllmd.
- All types: the
- Validation: names must be valid HTTP tokens and unique ignoring case. Values must not be empty or contain CR, LF or NUL. Cap the map at 32 headers and 8 KiB in total.
Dashboard (Providers page)
The form is driven by GET /admin/provider-credentials/types. Add headers to every schema the way proxy_url and models are added (credentialSchema in internal/providers/credential_schema.go, advanced: true), together with a new reserved_headers list so the form can reject reserved names inline.
Control: key/value rows with "Add header" and remove buttons. MCP has the same rows inline in McpServerEditor.svelte. Move them into a shared component used by both pages: a second user justifies it.
Cases to handle:
| Case | Behaviour |
|---|---|
| New provider | No rows; the section sits under Advanced. Placeholder X-Edge-Auth / ${env:EDGE_TOKEN}. |
| Edit, literal value | Name shown, value masked. Leaving the mask keeps the stored value; clearing the row deletes the header. Renaming a row with a masked value is rejected ("re-enter the value"), because the mask can't move to a new name. |
| Edit, reference-only value | Shown as stored (${file:...} or Bearer ${env:...}). |
| Reference without the master key | Saving a new or changed reference gets 403 secret_reference_requires_master_key, shown on that row. Re-saving unchanged rows works. |
base_url, type, backend or proxy_url changed on a provider whose headers hold a reference |
Master key only; the 403 is shown on the changed field. |
| Reserved name | Inline error on the row. For a credential name: "use the API keys field". For a type-reserved name: the reason, for example "set by GoModel for Anthropic". |
| Duplicate name ignoring case, empty value, invalid characters | Inline errors on the row, matching the server's param (headers.<Name>) so a server rejection lands on the same row. |
| Type change | Rows are kept, since every type accepts headers. Rows the new type reserves are flagged instead of dropped silently. |
Provider declared in config.yaml or env (read-only row) |
Header names listed, values masked, no editing. Consistent with the rest of the Config badge rows. |
| List view | Optional: a "2 headers" hint next to the auth label. Names only, never values. |
| Unresolvable reference on save | 400 naming the field and scheme, shown on the row. |
Per-type hints in the help text: Azure (APIM in front: Ocp-Apim-Subscription-Key), OpenAI (OpenAI-Organization, OpenAI-Project), self-hosted runtimes (reverse-proxy auth), Cloudflare AI Gateway (cf-aig-authorization).
Types where v1 should hide the field (omit it from their schema):
bedrock(AWS SDK, notllmclient) andbedrockmantle(SigV4 in its transport): SigV4 signs the headers present at signing time, so a proxy that removes its auth header before forwarding breaks the signature. That is exactly the proxy case this feature exists for. Add them once a signed or unsigned choice is designed.chatgpt,githubcopilot,kimicode,opencodego: subscription clients that talk to vendor endpoints with fixed client headers and token refresh flows. There's no proxy use case, and extra headers risk breaking those flows. Revisit if someone asks.
The backend rejects headers for these types too, so the config file and the dashboard agree.
Docs
docs/advanced/config-yaml.mdxand.env.template: the setting, default (empty), the env var, and an edge-proxy example.docs/advanced/secret-references.mdx: add provider header values to the "Dashboard-managed credentials" table and the field-path examples (provider_credentials.gemini.headers.X-Edge-Auth).docs/advanced/encryption-at-rest.mdx: add the new encrypted field.- One table of reserved names, with the precedence rule.
Tests
- Config: YAML and env parsing, env-replaces-YAML, reference resolution and failure messages that name the field without the value.
- Validation: reserved names for all types and per type, case-insensitive duplicates, CR/LF, the size cap, rejection on hidden types.
llmclient: headers applied last; a passthrough client header with the same name is overridden; credentials and reserved headers untouched.- Coverage: one
providertestcapture each for chat, streaming, model listing and passthrough; realtime upgrade headers; semantic-cache embedder. - Isolation: a failover attempt to another provider carries none of the first provider's headers; Vertex token fetch carries none.
- Admin API: masking, mask round-trip, reference visibility, master-key rules, mask on rename.
- Dashboard: the cases table above, in
providersConfigLogicunit tests.
Describe alternatives you've considered
- Plugin upstream headers (feat(plugins): apply plugin upstream headers to provider calls #1203). Per request rather than per provider, so secrets follow failover and internal calls. A static header shouldn't need a plugin. Still useful for dynamic per-request headers, which is a separate need.
- A process-wide transport that injects headers, or reusing the
proxy_urlHTTP client. Simplest to wire, but the shared client also carries token and identity calls, so a proxy secret would reach Google or AWS token endpoints unless every request's host is checked. Applying headers where the provider builds its request avoids that and keeps the rule explicit. - Name-based redaction with
IsCredentialHeader. Misses most real proxy-auth names. Masking every value matches MCP and is safer. - Allowing
Authorizationoverrides for keyless providers. Two ways to set a credential;api_keyalready covers it.
Additional context
- Related: feat(plugins): apply plugin upstream headers to provider calls #1203 (plugin upstream headers), feat(admin): export the effective configuration as config.yaml #1268 (config export must mask these values).
- Prior art in the codebase:
mcp.servers.<name>.headers(config shape, masking, encryption at rest, dashboard rows) andproxy_url(secret references inside a URL, master-key rules, rebuild on rotation).
Activity