Repository navigation
feat(config): send [model.headers] on every call to the model endpoint, agents' included - #195
Merged
Merged
Conversation
…t, agents' included Some endpoints need a header on every request: Hugging Face's router bills an organization only when each request carries X-HF-Bill-To. [model.params.extra_headers] reached only agent-env's own calls, so every agent had to invent its own variable for it. [model.headers] declares them once, with env:/secret: references resolved per call like api_key. They go only to the configured endpoint, since a header may carry a credential: - agent-env's own calls get them as extra_headers ([model.params.extra_headers] wins per header); - deployed agents (the judge agent included), the unit-test verifier and installed agents that declare the litellm_extra_headers param get them as LITELLM_EXTRA_HEADERS, a JSON object, set next to LITELLM_BASE_URL; the verifier also gets ANTHROPIC_CUSTOM_HEADERS; - an agent or call pointed at another URL gets none, and an explicit LITELLM_EXTRA_HEADERS wins. Core only writes LITELLM_EXTRA_HEADERS, never reads it. The protocol's reference model sends it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nsensitively; keep secrets out of install logs
- A2AAgent.deploy reads the URL an agent will use from its own default_env_vars too, so an agent
declaring the configured endpoint itself still gets [model.headers].
- [model.params.extra_headers] overrides a [model.headers] entry whatever the case of its name.
- install_agent logs each command's template, not the substituted command: a resolved
{litellm_api_key} or {litellm_extra_headers} reached the log before.
- The protocol's reference model sends LITELLM_EXTRA_HEADERS only to the endpoint AgentEnv
configured, and never lets one replace its own Authorization.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w, and say when they're held back - A [model.headers] name must be an HTTP token and a value visible ASCII, spaces and tabs: checked at load for a literal and when a reference resolves, so a non-ASCII value fails with a config error instead of inside an agent's client, and a CR or LF can't split one header into two. - config show and explain mask every literal under a `headers` key ([model.headers] and [model.params.extra_headers]): a name like X-Api-Key doesn't say it carries a key. References still show. - Headers held back from a URL that isn't the configured endpoint are logged; a trailing slash on either URL no longer counts as a different endpoint, for the headers and for the configured key alike. - The unit-test verifier fails when configured headers don't resolve, rather than running without them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o their values never enter the Sailbox With inject_model_key on, an agent's Sailbox already never holds its model key: Sail adds it to requests to the model endpoint as they leave. [model.headers] arrived as plain LITELLM_EXTRA_HEADERS, so a credential header undid that guarantee. - Each header value is a Sail secret of its own, AGENTENV_LITELLM_H_<sha256(name, value)>, kept like the key's; the saved policy sets each header from its secret next to the key's two. - Every command and file agent-env sends carries LITELLM_EXTRA_HEADERS (and ANTHROPIC_CUSTOM_HEADERS) with the names kept and sail-injected-header as each value, replaced as a whole so a short value can't rewrite other text; the policy overwrites the placeholders. - Reconnect recognises a policy with headers, and recovers their values for scrubbing from this process or from the configured [model.headers]; anyone else's policy is still unknown. - A Sailbox refuses a command or file carrying header values Sail doesn't inject for it, as it does a key. - A header named authorization or x-api-key, which the key is set as, is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…em as written, count their holders apart - Sail's rule for the headers matches the model endpoint's path prefix (/v1/ for https://host/v1), so another service on the same host never gets them; the key still goes with every request to the host, in a second rule, as before. Reconnect recognises both shapes. - LITELLM_EXTRA_HEADERS a deploy wrote in its own spelling (compact JSON, say) is scrubbed as written too, instead of slipping past the scrub and getting the deploy refused; a value that isn't a JSON object of strings is refused with a message saying so. - The headers a process remembers for scrubbing are released by their own holders, so terminating a Sailbox no longer drops them for another with a different key and the same headers. - The unit-test verifier resolves configured headers only for a variable the step doesn't set itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lling a Sailbox's headers were written in - A LITELLM_EXTRA_HEADERS that isn't a JSON object of strings is refused without quoting it: it may hold a credential. - The headers a process remembers for scrubbing keep every spelling the deploys sharing them wrote, so a Sailbox reconnected after another one held the same headers in another spelling still scrubs its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…il, table-driven scoping tests - The unit-test verifier no longer sets ANTHROPIC_CUSTOM_HEADERS: nothing in this repository reads it, and its "Name: Value" lines were a second encoding Sail had to scrub and detect. Agents and the verifier get [model.headers] as LITELLM_EXTRA_HEADERS alone. - Sail remembers each holder's headers in its own entry, dropped with the holder, instead of reference- counting header sets: Sailboxes sharing headers can't drop each other's, whatever spelling each wrote. - Header assignments in a file or argv word are found wherever they are, not only the first. - The scoping tests for headers_for, resolve_call, deploys and the verifier are tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale
force-pushed
the
feat/model-headers
branch
from
October 14, 2026 17:59
b31d873 to
1bfee23
Compare
…ding the key stays whole A [model.headers] value that is the model key itself (a gateway token, say) was rewritten by the key's scrub first, so the headers' JSON no longer matched and the deploy was refused. The headers go first now. The secret names' hash length is one named constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Some model endpoints need a header on every request. Hugging Face's Inference Providers router, for one, bills an organization only when each request carries
X-HF-Bill-To: <org>.Today a header can reach agent-env's own calls (
[model.params.extra_headers]), but not an agent's. Deployed agents only receiveLITELLM_BASE_URLandLITELLM_API_KEY, so each agent invents its own variable for the header, and tasks end up storing who pays.What
[model.headers]declares the endpoint's headers once. Values may beenv:orsecret:references, which stay raw and resolve per call, likeapi_key. The headers go only to the configured endpoint, since a header can carry a credential. The rule lives in one place,ModelConfig.headers_for.resolve_calladds them toextra_headers; a[model.params.extra_headers]entry wins, whatever the case of its namedeploy_agent, the auto-deployed judge agent)LITELLM_EXTRA_HEADERS, a JSON object, set inA2AAgent.deployfor the URL the agent will use (the passed one, else its owndefault_env_vars)run_container_unit_tests_verifierLITELLM_EXTRA_HEADERS, reserved from seed columnsinstall_agentlitellm_extra_headersplaceholder ({}when unset), opt-in throughrequired_paramsLITELLM_EXTRA_HEADERSonly to theLITELLM_BASE_URLAgentEnv set, and never lets one replace its ownAuthorizationRules:
litellm_base_url,user_overrides.litellm_base_url,judge_litellm_base_url, an agent's own endpoint when it isn't the configured one, and model overrides. A trailing slash on either URL doesn't count as a different endpoint, for the headers or the configured key.LITELLM_EXTRA_HEADERSin the task's or the agent's env wins.LITELLM_EXTRA_HEADERS, never reads it, so exporting it in the shell does nothing.config showandconfig explainmask every literal value under aheaderskey ([model.headers],[model.params.extra_headers]), since a name likeX-Api-Keydoesn't say it carries a key. References still show.Sail (
sail_vmwithinject_model_key, the default). The headers are added at egress, like the key, so their values never enter the Sailbox:AGENTENV_LITELLM_H_<sha256(name, value)[:32]>, kept like the key's.match.path.prefix,/v1/forhttps://host/v1). Another service on the same host never gets them. The key still goes with every request to the host, in a second rule, as before.LITELLM_EXTRA_HEADERSwith the names kept andsail-injected-headeras each value. The JSON is replaced whole, in agent-env's spelling or the deploy's, so a short value can't rewrite unrelated text.[model.headers].authorizationorx-api-key(which the key is set as) is refused, as is a value that isn't a JSON object of strings. The error doesn't quote the value.Also fixed here:
install_agentlogs each command's template instead of the substituted command. That was already true of a resolved{litellm_api_key}before this PR (Greptile spotted it).Choices to review:
LITELLM_EXTRA_HEADERSis a container env var, the channel the endpoint and key already use. Every agent receives it at startup, including installed CLIs and agents not built on the protocol SDK.[model.params]does reach agents, but only those whose config declaresmodel_params, and only per prompt.LITELLM_prefix matches the existing pair. LiteLLM itself reads none of them.secret:reference fails before any container starts. The verifier only warns, matching how it already treats a missing key or URL.Tests
tst/unit/config/test_model.py:None, or no endpoint;resolve_callto the configured endpoint vs a foreign override vs a model override;[model.params.extra_headers]precedence.tst/unit/task_step/local_container_urls_test.py:tst/unit/task_step/install_agent_test.py:executewith both placeholders; the values reach the command, and the log shows only the template.authorizationis dropped), and with a different endpoint.test_unread_keys: the expected[model]key list now includesheaders.tst/unit/providers/sandbox_providers/sail_vm_model_key_test.py: 16 new tests, with a fake Sail SDK, covering:tst/unit/config/test_describe.py: header values masked whatever the header is called.Chaos and regression testing (Linux, real local sandboxes)
The setup:
:4000(the configured endpoint) and:4001(a foreign one);At
5534c3a, all 64 checks pass:env:and asecret:header; the agent, the judge on litellm's OpenAI route (strictjson_schema) and on its Anthropic routedeploy_agent.litellm_base_url,user_overrides.litellm_base_url, an agent's own endpoint (foreign and configured),judge_litellm_base_url, explicitenv_vars$(…), backticks,;, backslashes, JSON, 4 KB) arrive byte for byte, and no canary command ransecret:fails before any request or container; a non-string value fails with the config error; a shellLITELLM_BASE_URLtakes the headers with it; a shellLITELLM_EXTRA_HEADERSis ignored:4000carried the headers, and nothing reached:4001Regression, branch vs main
230aa0fon the same box:[model.headers]: agent and judge requests carry identical header names and the sameAuthorization.gateway_testfailures on both, and the same 491 capability skips.Live on Sail (with a real key)
The reference agent was deployed on
sail_vmwith a literal and asecret:header, against an HTTPS endpoint that doesn't resolve, so no request left Sail. 13/13 checks pass./v1/and the key on every path, and holds no value;LITELLM_EXTRA_HEADERSandLITELLM_API_KEYare placeholders, and no value appears anywhere in its env;In transit, 19/19. This used a temporary public HTTPS stub, logging every request's headers, as the model endpoint; it's deleted now.
/v1/got key and headers, and one to/other/got the key alone;Not in this PR
ANTHROPIC_CUSTOM_HEADERSfor Claude Code in the unit-test verifier's container. Nothing in this repo reads it, so I dropped it to keep one header encoding. A step can set it inenv_vars, and it can come back when it has a consumer.resolve_call, and S2 reproduced it. It's tracked separately.🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; both numbered findings are fixed and no new blocking issue remains.Summary
Adds endpoint-scoped
[model.headers]to direct model calls, deployed agents, the unit-test verifier, and opt-in agent installation. Sail keeps the values outside the box and adds them as requests leave.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A["model.headers"] --> B["ModelConfig.headers_for"] B --> C{"Configured endpoint?"} C -->|No| D["No configured headers"] C -->|Yes| E["Resolve and check values"] E --> F["Direct call: extra_headers"] E --> G["Agent or verifier: LITELLM_EXTRA_HEADERS"] E --> H["Install: opt-in placeholder"] G --> I{"Sail injection enabled?"} I -->|No| J["Agent sends values"] I -->|Yes| K["Box receives placeholders"] K --> L["Sail adds values under endpoint path"]Reviews (10) · Last reviewed commit: "fix(sail_vm): scrub the headers' JSON be..." · Reviewed by Greptile