ADR-0110: Provider/Harness Backend Split — z.ai as a Claude-harness backend¶
Status: Accepted Accepted on: 2026-07-25 — operator-approved (route agentic maintenance loops to GLM while work stays on Claude). Amended on: 2026-08-19 — the session tap adds a first-class gateway transport without collapsing the upstream billing/provider axis. Date: 2026-07-25 Enforcement: enforced Enforced by: pytest:tests/test_config_env.py::TestEnvVarOverrideTable::test_env_bool_override_truthy_values Amends: ADR-0001 (Five Concurrent Async Loops — background workers gain a per-role harness backend dial)
Context¶
HydraFlow spawned every worker through two entangled axes: *_tool
(Literal["claude","codex","gemini","pi"] — which agentic CLI binary) and
*_provider (Literal["claude","openrouter","zai","kimi"] — which
OpenAI-compatible HTTP backend, present only on the 7 one-shot no-tools loops).
An inline comment in src/runner_utils.py asserted the load-bearing
assumption: "the agentic path stays on the Claude harness — Anthropic doesn't
support routing it to non-Claude models."
That assumption is only half true. z.ai / GLM ships an Anthropic-compatible
endpoint (/api/anthropic) built for exactly this: point the Claude CLI at it
via ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN and the agentic, tool-using
harness runs on GLM. So an operator can run maintenance on GLM, work on
Claude — the one-shot maintenance loops already route to z.ai over HTTP; the
agentic ones could not.
Two other problems compounded it:
background_modelwas a blunt global. When set it back-filled every default*_model(_apply_profile_overrides), which silently stranded a GLM model on a claude-provider (Anthropic) role — a config Anthropic rejects.- Credit exhaustion on the agentic path was a universal kill switch.
raise_if_credit_exhaustedraised with no provider (→anthropic), so a GLM cap on a z.ai-routed spawn would pause Claude work. The one-shot HTTP path already scoped per-provider (ADR-lineage #9807); the agentic path did not.
Decision¶
1. Two orthogonal axes. tool = which CLI binary (claude | codex —
gemini and pi are removed; only two harnesses are supported). provider =
which API/billing backend (claude | zai), now available on every role,
not just the one-shot loops.
2. z.ai has two registry faces. The existing one-shot HTTP face
(zai_base_url → /api/paas/v4) is joined by a harness face
(zai_harness_base_url → /api/anthropic, src/runner_utils.py:_HARNESS_BACKENDS),
consumed by the Claude CLI. Both authenticate from ZAI_API_KEY (env-only
secret).
3. Per-spawn env, never global. src/runner_utils.py:resolve_harness_env
returns {} for claude/anthropic (a pristine env — the main workers are
untouched) and, for zai, ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN while
clearing ANTHROPIC_API_KEY so a host key can't shadow the token. It is merged
into a per-spawn env in stream_claude_process and _claude_cli_complete —
never exported globally, which is the invariant that keeps every
provider=claude spawn on Anthropic regardless of z.ai config elsewhere.
4. Provider-scoped model validation. _harmonize_tool_model_defaults
validates the (tool, provider, model) triple: a glm-* model requires
provider=zai and tool=claude; a zai provider requires a glm-* model.
This turns the old silent leak into a loud config-load error. A coherent
maintenance_provider/maintenance_model knob routes the maintenance role-set
together (provider AND model), never the work loops.
5. Per-(tool, api) credit scoping. Both credit checks
(raise_if_credit_exhausted, _post_stream_result) tag CreditExhaustedError
with the resolved provider, so a GLM cap pauses only z.ai-routed loops and an
Anthropic cap only Anthropic-routed loops. The z.ai/GLM billing phrasings are
added to src/subprocess_util.py:_CREDIT_PATTERNS for the harness (stderr) path.
6. Runner activation. src/base_runner.py:BaseRunner resolves each runner's
provider via an overridable _resolve_provider() / PROVIDER_FIELD hook; the
four core streaming runners (implementation/planner/review/triage) set their
dial. Default claude is a no-op, so behavior is unchanged until an operator
flips a dial.
7. Gateway is a transport, not an upstream identity (2026-08-19).
provider=gateway selects the Anthropic-compatible session-tap transport. Each
spawn still carries an explicit upstream binding (anthropic or
zai-harness) for key policy, ledger attribution, pricing, and credit scoping.
The gateway resolver mints a short-lived virtual credential asynchronously and
fails closed; it never treats a mint failure as permission to fall through to
the worker's direct provider environment.
8. Credential isolation and rollout ratchet (2026-08-19). In gateway mode,
the final host or Docker spawn environment is scrubbed of all real provider and
gateway-control credentials before the virtual token is overlaid. Direct
Claude/z.ai behavior remains unchanged for roles that have not migrated. An
explicit fleet ratchet validates that every gateway-capable Claude-harness role
is routed through the gateway. Enabling the ratchet is the terminal deployment
profile: untouched provider dials are promoted to gateway, while an explicitly
configured direct Claude/z.ai harness value is a configuration error rather
than an observable-but-permitted bypass. The default remains off so an install
without a deployed gateway continues to start safely.
9. Coverage is a meta-observability signal, not transport control
(2026-08-19). src/gateway_coverage_loop.py:GatewayCoverageLoop reads the
gateway ledger and the known bypass telemetry to publish a repo-scoped
coverage snapshot. It never proxies traffic, mints credentials, or enforces a
budget. The deployable gateway itself is rooted at
src/hydraflow_gateway/app.py:create_app; keeping these symbols separate makes
the operational transport and the read-only completeness gauge independently
auditable.
Consequences¶
- An operator routes any wired role to GLM by config alone (no code change):
set the role's
*_provider=zaiand aglm-*model, or usemaintenance_*. - The main coding workers stay on Claude by default; the isolation invariant (per-spawn override) guarantees a z.ai config can't hijack them.
- Gateway migration can proceed role by role without changing model selection or credit ownership. The first canary is a bounded maintenance role; safe defaults remain direct until the gateway service and control credential are deployed.
- Once the ratchet is enabled, gateway availability is fleet availability and workers no longer possess provider credentials. This is intentionally a deployment decision rather than an implicit fallback.
- A backend cap is scoped, not global — GLM exhaustion no longer halts Claude work.
-
The tool axis is
claude+codexonly; gemini/pi are un-configurable and un-spawnable (their inert transcript-shape parsers remain as defensive code). -
Key-split clarification (2026-08-15, #11267): the Decision's "both faces authenticate from
ZAI_API_KEY" is superseded in one direction — the harness face now PREFERSZAI_CODING_PLAN_KEY(flat-rate GLM Coding Plan) and falls back toZAI_API_KEY; the one-shot face deliberately stays onZAI_API_KEYalone so background traffic never bills the plan quota. Credit probes follow the harness key when the two diverge.
Alternatives considered¶
- A new
claude-zaitool enum value — re-fuses CLI and endpoint into one axis (the coupling this ADR removes) and pushes credit scoping into tool-string parsing. Rejected. - A full backend-profile registry (every role → a named backend record) — cleanest long-term but the largest refactor; deferred until a third harness backend appears. This split can grow into it.
Related¶
- Follow-ups (not in the landing PR): route a top-level agentic work loop to
z.ai with orchestrator pause-scoping (extend
orchestrator_common.py:_BACKEND_WORKER_LOOPS); telemetry cost attribution for agentic-on-z.ai spend; a MockWorld scenario + sandbox e2e for the harness routing; asentry_providerdial. The one-shot maintenance loops already route to z.ai over HTTP today. - Spec:
docs/superpowers/specs/2026-07-25-provider-backend-split-zai-harness.md.