Claude Code behind an LLM gateway: the credential decides who pays, and without one the proxy gets your claude.ai login
Twelve beta values per request under a subscription login, ten under a gateway token, and the pair that vanishes says more than the docs tables do. What a gateway must pass through untouched, which default quietly gets more expensive, and why pinning it with allowedProviders also takes away a developer's own debugging listener.
Contents
Two environment variables decide what happens when Claude Code goes through an LLM gateway. ANTHROPIC_BASE_URL decides where requests go. A gateway credential, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY or an apiKeyHelper, decides who pays for them. Most setup guides treat the pair as one step, but they are two modes. With the base URL alone, the subscription pays and the proxy receives your claude.ai login on every request: a debugging mode for one person’s own machine. With a gateway credential, an organization pays per token, and two values leave the wire, the OAuth beta and the one-hour cache beta. A team gateway belongs in the second mode.
To see the difference on the wire, I pointed Claude Code 2.1.303 at a small listener on localhost that logged each request and answered with an error, so nothing went upstream. Three runs: base URL only, base URL plus a dummy gateway token, and the second again with the gateway hint headers switched on. Protocol details below come from the gateway docs, observations from those three logs, and the recommendations are my own reading of both.
What changes when Claude Code talks to an LLM gateway?
Routing changes in every case, and so does one feature: Remote Control is off whenever ANTHROPIC_BASE_URL points at a host other than Anthropic’s, even if you sign in with claude.ai. Billing, limits and the rest change only when a gateway credential is active. With the base URL alone, a saved claude.ai login stays the credential, so the plan’s limits and billing apply and the gateway is a router with a log.
The table assumes a machine signed in with claude.ai, no other credential or provider configured, and default cache settings:
| Base URL only | Base URL + gateway credential | |
|---|---|---|
| Credential on the wire | the saved claude.ai login | the gateway’s credential |
| Billing | your subscription | per token, to whoever owns the provider credential behind the gateway |
| Subscription usage limits | apply | don’t apply |
| Default prompt cache TTL, main conversation | one hour, within plan usage | five minutes |
| Remote Control | off | off |
| Voice dictation | available per the docs (not tested here) | off |
| claude.ai connectors | no warning in my run | off, with a startup warning |
The connectors row comes from my run, not the docs. With the dummy token set, claude -p printed:
claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and takes precedence over your claude.ai login
The cache row is a default, not a limit. Per the prompt caching page, Claude Code requests the one-hour TTL on its own only on a Claude subscription within plan usage; with an API key, a cloud provider or usage credits, the main conversation gets five minutes. You can ask for the hour back with promptCacheTtl: "1h" in settings or CLAUDE_CODE_PROMPT_CACHE_TTL= (Claude Code 2.1.242 or later), and the gateway then has to forward the beta value that carries it. Anthropic’s own Claude apps gateway, which ships in the claude binary and also turns subscription login off, doesn’t offer the one-hour TTL at all. Leave the default in place and a long session with breaks over five minutes rewrites its cache on every return.
How do you connect Claude Code to an existing gateway?
Get the base URL and a credential from whoever runs the gateway, test them with curl, then set two variables. If an admin already distributed them through managed settings, claude starts without a login screen. Per the connect page, /status then shows an Anthropic base URL line plus an Auth token or API key line naming the variable in use, and a Login method line naming a claude.ai account means the credential never arrived.
The credential variable must match the header the gateway reads. ANTHROPIC_AUTH_TOKEN goes out as Authorization: Bearer, ANTHROPIC_API_KEY as x-api-key, and an apiKeyHelper value in both. If nobody told you which one, start with the bearer token. The docs’ check:
export ANTHROPIC_BASE_URL=https://llm-gateway.example.com
export ANTHROPIC_AUTH_TOKEN=sk-gateway-key
curl -sS -w '\n%{http_code}\n' -X POST "$ANTHROPIC_BASE_URL/v1/messages" \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model": "claude-sonnet-4-6", "max_tokens": 1, "messages": [{"role": "user", "content": "."}]}'A 401 usually means the wrong variable or a revoked key, though it can also be the gateway’s own provider credential failing upstream; the gateway’s logs tell the two apart. An “unknown model” error is a pass: the gateway authenticated you before it looked at the model name.
Once it works, move the two values into the env block of ~/.claude/settings.json, not your shell profile. Shell exports don’t reliably reach background agents, and the settings-file value wins when both set the same variable. Never put the credential in a project’s .claude/settings.json, which is committed. The VS Code extension wants the same pair in claudeCode., and the desktop app ignores both and reads its own third-party inference configuration.
What does Claude Code send to an LLM gateway?
In my runs, a HEAD /api/hello warm-up probe with no credential (the docs say it is skipped when an HTTP proxy or client certificate is configured), then POST /v1/ with a session ID header, the full anthropic-beta list and the whole Messages API body. This is a trimmed version of the listener I used. It keeps header names, cuts the two credential headers down to a seven-character prefix and their length, records the model, the top-level body keys and the start of the first system block, and always answers 503:
import json, sys
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
def redact(name, value):
if name.lower() in ("authorization", "x-api-key"):
token = value.split(" ")[-1]
return f"<redacted prefix={token[:7]} len={len(token)}>"
return value
class Handler(BaseHTTPRequestHandler):
def handle_any(self):
n = int(self.headers.get("content-length") or 0)
body = self.rfile.read(n) if n else b""
rec = {"method": self.command, "path": self.path,
"headers": {k: redact(k, v) for k, v in self.headers.items()}}
try:
j = json.loads(body)
rec["model"] = j.get("model")
rec["body_keys"] = sorted(j)
rec["system0"] = (j.get("system") or [{}])[0].get("text", "")[:60]
except (ValueError, AttributeError):
pass
with open("requests.jsonl", "a") as f:
f.write(json.dumps(rec) + "\n")
self.send_response(503)
self.end_headers()
do_GET = do_POST = do_HEAD = handle_any
def log_message(self, *args):
pass
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()Only those two headers are redacted, so if you set ANTHROPIC_CUSTOM_HEADERS with a secret in it, that lands in the file as is. Run the listener with python3 listen.py 8765, make sure no env block in your settings files sets a base URL or a credential (a settings-file value beats the export and would send the run to the real gateway), and in another terminal:
ANTHROPIC_BASE_URL=http://127.0.0.1:8765 timeout 60 claude -p "say ok" --model haiku < /dev/nullFor the second run add ANTHROPIC_AUTH_TOKEN= to the same line, and for the third also CLAUDE_CODE_GATEWAY_HINT_HEADERS=. Claude Code retries a 503, so each run logs several copies of the same request; one is enough. The first model request of the base-URL-only run, trimmed:
POST /v1/messages?beta=true model claude-haiku-5-5
Authorization: Bearer <redacted prefix=sk-ant- len=108>
User-Agent: claude-cli/2.1.303 (external, sdk-cli)
X-Claude-Code-Session-Id: f85023e8-...
anthropic-version: 2023-06-01
anthropic-beta: oauth-2025-04-20, interleaved-thinking-2025-05-14,
thinking-token-count-2026-05-13, context-management-2025-06-27,
prompt-caching-scope-2026-01-05, claude-code-20250219,
per-turn-control-2026-07-01, mid-conversation-tool-changes-2026-07-01,
inline-tools-2026-09-15, advisor-tool-2026-03-01, effort-2025-11-24,
extended-cache-ttl-2025-04-11
body keys: context_management, max_tokens, messages, metadata, model,
output_config, stream, system, thinking, tools
system[0]: x-anthropic-billing-header: cc_version=2.1.303...With the dummy gateway token, the same request carried ten beta values instead of twelve. oauth-2025-04-20 was gone, which is expected, and so was extended-cache-ttl-2025-04-11, which fits the documented switch of the default from one hour to five minutes. The listener never answered with a real response, so this shows the request, not what was cached or billed.
The per-agent attribution headers the docs describe, x-claude-code-agent-id and, for nested agents, x-claude-code-parent-agent-id, appear only on requests from subagents, and none were spawned in these runs. The routing hints are off by default behind a custom base URL, because a proxy that rejects unknown headers would fail the request. With CLAUDE_CODE_GATEWAY_HINT_HEADERS= (hints need Claude Code 2.1.273 or later, the prompt ID 2.1.283) my third run added x-claude-code-request-class: main and x-claude-code-prompt-id. The docs list four more, for agent type, compaction and tool durations, that appear only when those events happen. None of them carries prompt text.
That first system entry is the attribution block. The Claude API at api.anthropic.com strips it when it arrives unchanged as the first system block; any other upstream receives it as prompt text. A gateway that reorders the system array or flattens it into a string pushes the block into the prompt and the cache key, and one that merges other system text into that first entry is worse: the API treats the merged entry as attribution and drops all of it. If the gateway must reshape system content, set CLAUDE_CODE_ATTRIBUTION_HEADER= on the clients instead.
Does a Claude Code gateway see your claude.ai login?
In base-URL-only mode, yes. The bearer token on each model request in my first run was not a gateway key: with no API key on the machine, it is the saved claude.ai login, which is why the oauth-2025-04-20 beta rides along and why the docs tell gateway operators to forward that value or the upstream returns 401. Whoever runs the proxy receives your subscription’s credential with each request.
On your own machine, with a listener like the one above that forwards nothing, that is how you see what your sessions send without touching anyone else’s account. As a team design it is a different thing. A shared gateway in that mode collects every developer’s personal login, and it serves an organization’s traffic on individual consumer plans. The usage policy that takes effect on November 12 names “services that route requests through consumer subscriptions” among the unauthorized ways to provide access, which I went through in the usage policy comparison. A gateway that knows who is calling, budgets them and keeps audit logs needs its own credentials, and per-token billing to an organization account comes with them.
That mode has costs beyond the bill. Claude Code in Slack and cloud sessions aren’t covered by the gateway at all, so if traffic must stay on it, those surfaces have to be off for those users. The gateway routes model requests only: version checks, telemetry, the fast mode check and the WebFetch domain check still go elsewhere unless you turn them off or allow them through. And the gateway becomes software you maintain against every Claude Code release.
What it buys, besides the controls, is a better cost unit. x-claude-code-session-id groups a session’s requests without parsing bodies, and the agent ID headers split a session that spawns subagents into its parts. That answers which kind of task is expensive, where per-developer totals only say who spent. The docs warn that an agent ID identifies an agent, never a person.
What must an LLM gateway forward for Claude Code features to work?
Toward an Anthropic-format upstream: everything that starts with anthropic-, the request body unchanged, and the stream as it arrives. The protocol page calls these open lists. Claude Code adds anthropic-beta values and body fields with releases, so a gateway that allowlists what it sees today breaks the next feature on the day it ships. A gateway that takes Anthropic-format requests but forwards them to Amazon Bedrock or Google Cloud has the opposite job, translating the schema, or it can expose that provider’s own format to clients instead. Each half-forwarded feature fails its own way:
- Beta and body pairs. Strip a beta header but pass its body field, and the upstream answers
400, oftenExtra inputs are not permitted. Context management, beta tool fields andoutput_configall pair this way. Only when both halves are missing does the feature turn off quietly. - Prompt caching. Drop or move
cache_controlmarkers, or turn block-form content into strings, and nothing errors: every turn bills as uncached input. That is the hardest of these to notice, and the diagnostics in finding what broke your prompt cache are how you would. - Streaming. A gateway that buffers responses makes Claude Code stall, and one that strips SSE
pingevents can trip the idle timeout during a long thinking pause. - Response headers. Forward
anthropic-ratelimit-unified-*andx-should-retryunchanged, returnretry-afterin seconds, and pass error bodies through as is. Claude Code’s recovery from a rejected capability matches the upstream’s wording. The rate-limit headers also feed the plan-usage display, the same numbers behind the statusline’srate_limitsfields.
One failure isn’t the gateway’s fault. A web application firewall in front of it can return 403 with an HTML body while the gateway logs nothing, because Claude Code prompts contain XML-style tags and source code that trip cross-site-scripting body rules. A short curl test passes and a real session fails. Exempt /v1/messages from body inspection.
If you can’t forward everything, CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS= stops most pre-release capabilities. It is not a compatibility guarantee: the OAuth value, extended context, interleaved thinking, effort and the adaptive thinking field stay, as does anything a developer adds through ANTHROPIC_BETAS or CLAUDE_CODE_EXTRA_BODY.
How does allowedProviders lock Claude Code to one gateway?
It refuses any session pointed anywhere else. Set allowedProviders to ["customEndpoint"] in managed settings and pin the gateway’s address in the same file’s env block. Claude Code then accepts ANTHROPIC_BASE_URL only with exactly that value and refuses Anthropic directly, a cloud provider, or a developer’s own proxy, at startup, at login, and on the next API call if something changes mid-session. It needs Claude Code 2.1.285 or later. On Linux the file is /etc/, and the helper it names is a script the admin installs that prints only the developer’s gateway credential:
{
"allowedProviders": ["customEndpoint"],
"env": {
"ANTHROPIC_BASE_URL": "https://llm-gateway.example.com"
},
"apiKeyHelper": "/usr/local/bin/get-gateway-key"
}A managed ANTHROPIC_BASE_URL already overrides a developer’s shell export. What allowedProviders adds is closing the other doors: provider variables like CLAUDE_CODE_USE_BEDROCK, a direct sign-in, a second base URL. It is the difference I argued for in A prompt is not an invariant: the rule sits where the harness enforces it. Leave forceLoginMethod, forceLoginOrgUUID and forceLoginGatewayUrl out of managed settings when you distribute a gateway credential. Any of them blocks the credential at startup, and developers can’t proceed.
Whether to lock is a governance question, and the lock has a cost the docs don’t spell out. I didn’t test a pinned machine, but per the settings reference the localhost listener above would be refused there, since it changes the base URL. A developer can no longer swap their endpoint for a listener to see what a session sends. If the gateway’s logs are where they would debug instead, give them read access to their own sessions there.
What should you check before rolling out a Claude Code gateway?
Decide the credential mode first, because it decides the bill: base URL alone for one person’s own debugging, a gateway credential for anything shared. Then decide the cache TTL on purpose instead of inheriting five minutes. Run a real session through the gateway, not just curl, and check /status, how fast the first long reply starts to stream, and the cache read figures over a few turns. Point Claude Code at a logging listener once and read the dozen or so anthropic-beta values it sends. Your gateway has to pass all of them, plus the ones the next release adds.
01 / Discussion
No comment section here — all discussions happen on X.
02 / More articles
Claude Code fork subagents in claude -p: what 2.1.302 changed for headless runs
Offered is not used: a headless model may never pick a fork, and the official docs still describe the old default. Checking which subagent your pipeline got, why a background fork hands a parser two answers, and where switching it off pays.
Claude Code statusline and ccusage: the native rate limits, and a 5-hour block that ends at a different time
Two tools answer one question with different evidence: the server reports a reset time, a log parser infers one. When they disagree by hours, believe the server and keep the parser for history. The dollar figure, on a subscription, is an exchange rate.
Claude Code vs Cursor: seven ways developers split the work, now that neither tool is stuck on one surface
Each vendor erased the old editor against terminal line from its own end. What still divides them is quieter: a config folder that Cursor reads live while Claude Code imports Cursor rules once, and permission systems that ignore each other's deny rules.