Skip to content

Feature: static per-provider headers (providers.<name>.headers) #1290

Open

Description

@SantiagoDePolonia

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 APIM Ocp-Apim-Subscription-Key, an in-house X-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: acme
  • map[string]string, one value per name, matching mcp.servers.<name>.headers.
  • Env var: <PROVIDER>_HEADERS as 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_key otherwise), and only the master key can change type, base_url, backend or proxy_url on a provider whose headers hold one, because those decide where the secret is sent.
  • Encryption at rest: add headers values to the encrypted fields of provider_credentials, next to api_keys and proxy_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 llmclient sends for the provider, including inference, streaming, model discovery and listing, passthrough (/p/<provider>/...), batches and files, and token counting. Add the provider's headers to llmclient.Config and apply them in buildRequest.
  • Realtime: add them to the RealtimeHTTPTarget.Headers of 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

  1. 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.
  2. 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.IsCredentialHeader list, hop-by-hop and transport headers (Host, Content-Length, Transfer-Encoding, Connection, Keep-Alive, Te, Trailer, Upgrade, Expect, Proxy-*), plus Content-Type, Accept-Encoding (setting it turns off Go's automatic gzip decompression) and Idempotency-Key. The error for a credential header points to api_key/api_keys, so there stays one way to set credentials and key rotation keeps working.
    • Per type, declared next to CredentialFields in the provider registration: for example anthropic-version and anthropic-beta for anthropic (overriding anthropic-beta drops the OAuth flag), xi-api-key for elevenlabs (also add it to core.credentialHeaders; it is missing today), x-goog-user-project for gemini/vertex, x-amz-* for bedrock/bedrockmantle, and the objective and fairness headers for llmd.
  3. 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, not llmclient) and bedrockmantle (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.mdx and .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 providertest capture 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 providersConfigLogic unit 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_url HTTP 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 Authorization overrides for keyless providers. Two ways to set a credential; api_key already covers it.

Additional context

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions