ADR-058: Claude Subscription Backend¶
Status: Accepted
Date: 2026-08-19
Related: agentic_v2/models/backends_claude.py
(ClaudeSubscriptionBackend, subscription_env, claude_sdk_available),
agentic_v2/models/backends.py (PREFIX_MAP, _register_cloud_backends),
agentic_v2/agents/implementations/claude_sdk_agent.py (ClaudeSDKAgent),
pyproject.toml (the claude extra), .github/workflows/ci.yml
(claude-subscription-tests)
Context¶
_register_cloud_backends probes each provider by API key:
AnthropicBackend is constructed only when ANTHROPIC_API_KEY resolves, and
its __post_init__ raises ValueError without one. An operator who pays for a
Claude subscription rather than API credits therefore has no Claude backend
at all. auto_configure_backend logs "No cloud LLM provider credentials
configured" and returns an Ollama-only MultiBackend on a machine where the
operator is signed in to Claude and could be spending that subscription.
Subscription credentials are not an environment variable. They live in the
Claude Code CLI's own credential store, and claude-agent-sdk — which drives
that CLI — is the only supported way to spend them programmatically. There is no
documented wire contract for AnthropicBackend's httpx client to speak instead.
The repo already declares a claude extra (anthropic, claude-agent-sdk) and
already ships ClaudeSDKAgent, a wrapper around the Agent SDK. That wrapper was
non-functional: it dispatched on message.type == "result", but no
claude-agent-sdk message class carries a .type attribute — they are plain
dataclasses. Every call raised AttributeError on the first message of the run.
Its default model, claude-opus-4-6, was also stale.
A second defect only shows up when the two halves are combined, and it is the
one that matters most here. agentic_v2.models.secrets writes resolved secrets
into os.environ, so after auto_configure_backend() runs, ANTHROPIC_API_KEY
is set in the process. The SDK spawns the CLI with
{**os.environ, **options.env}. The child therefore inherits the API key and
authenticates with it, silently switching credential class: the call bills the
API account rather than the subscription, and fails outright when that key is
invalid or unfunded. Reproduced directly — a deliberately invalid key in
os.environ produced 401 API key is invalid from a call that succeeds on the
subscription once the variable is blanked.
Decision¶
Ship ClaudeSubscriptionBackend behind the claude: prefix, registered by
capability rather than by key.
claude_sdk_available()gates registration onclaude-agent-sdkimporting, the same shape asOnnxBackend's_onnx_runtime_available()probe. There is no credential to probe: the CLI owns resolution, and testing it here would mean shelling out on every registration or reading a credential store this module must never touch. A signed-out CLI surfaces at call time with the sign-in instruction.- Registration is additive.
AnthropicBackendand theanthropic:prefix are untouched, so an operator holding both a key and a subscription keeps both paths and chooses per call which one to spend.claudecounts as a cloud key forauto_configure_backend's no-cloud-provider warning, because it is one.
Scrub the credential variables from the CLI subprocess. subscription_env()
blanks ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN in ClaudeAgentOptions.env.
They are set to empty rather than removed because options.env is merged over
os.environ and so can only override, never unset; the CLI treats an empty value
as absent and falls through to the signed-in account. Caller-supplied env
entries are applied last, so deliberately choosing API-key billing remains
possible — it just has to be stated rather than inherited by accident.
Fix ClaudeSDKAgent rather than leave a broken surface next to a working
one. Dispatch moves to isinstance against the SDK dataclasses, the default
model becomes claude-opus-5, the same credential scrub applies, and stream()
is annotated and documented as yielding SDK message objects rather than the
(type, content) tuples its docstring claimed. Because the wrapper raised on
first use, none of this can break an existing caller.
Surface the transport's limits instead of emulating them.
ClaudeAgentOptions exposes no sampling temperature, so temperature is
accepted and ignored with one debug log per instance — raising would make the
backend undispatchable through MultiBackend, which always passes one. Tool
schemas raise instead: the Agent SDK executes its own tools rather than returning
tool calls for the runtime's registry to run, and a response with tool_calls:
None would read to a caller as "the model chose not to call a tool".
Keep the extra out of the default install and close the skip blind spot. CI
installs dev,server,mcp,langchain,tracing, never claude, so the tests
importorskip everywhere else — the exact failure mode that let an ADR-047
regression through on the EK side. A dedicated claude-subscription-tests job
installs the extra and hard-guards import claude_agent_sdk before running, and
agentic_v2/models/backends_claude.py joins the coverage omit list on the
same reasoning as ek_provider.py.
Consequences¶
- An operator with only a Claude subscription gets a working cloud backend, and
auto_configure_backendstops reporting no cloud provider on their machine. - Model ids route explicitly:
claude:claude-opus-5spends the subscription,anthropic:claude-opus-5spends the API key. The credential class is a property of the call, visible at the call site, not of ambient environment. - Sampling is not controllable on this path. Tier routing that depends on
temperature will not behave the same through
claude:as throughanthropic:. Useeffortwhere depth matters. react_loop-style tool dispatch is unavailable throughclaude:. Work needing caller-executed tools must route toanthropic:.- Subscription rate-limit windows are a new failure mode with no API-key analogue; a rejected window raises rather than returning empty text, so the router's error classification sees a failure rather than a bad answer.
- The
claudeextra carries a prerequisite pip cannot express — the Claude Code CLI must be installed and signed in. A missing CLI is reported with that instruction rather than as a transport error. - One more CI job, and one more optional-extra surface to keep current with the SDK's major version.
Alternatives considered¶
Fall back to the subscription automatically under anthropic: when no API key
is present. Rejected: it makes the credential class — and therefore who gets
billed — depend on ambient environment rather than on the call. A config that
worked yesterday would start spending a different account after an unrelated
.env edit, with nothing at the call site to show it.
Use the anthropic SDK instead of claude-agent-sdk. It reaches the
Messages API with full sampling and tool-schema support, which would fit
LLMBackend far better. But it authenticates with an API key or an
ant auth login profile, and neither is the subscription sign-in — it solves a
different problem. It remains the better choice if sampling parity ever matters
more than subscription reach.
Reimplement the credential exchange over httpx, keeping one backend. Rejected as not possible rather than merely costly: the exchange is internal to the CLI with no documented wire contract, so this would mean depending on a private surface that can change without notice.
Leave ClaudeSDKAgent broken and ship only the backend. Rejected: it is the
surface an operator finds first when looking for Agent SDK support, it fails with
an AttributeError that suggests nothing about the cause, and it carries the
identical credential-inheritance defect.