ADR-0134: Per-repo model/harness selection — run Claude and GLM projects side by side¶
- Status: Accepted
- Date: 2026-08-14
- Supersedes: none
- Superseded by: none
- Related: ADR-0110 (the provider/harness backend split this builds on), ADR-0119 (the process-wide failover this composes with), ADR-0009 (the per-repo
HydraFlowConfigisolation this relies on) - Enforcement: enforced
- Binds: both
Enforced by: pytest:tests/test_repo_backend.py pytest:tests/test_config_repo_provider.py pytest:tests/test_base_runner_repo_provider.py pytest:tests/test_base_subprocess_runner_repo_provider.py pytest:tests/test_dashboard_routes_state.py
Context¶
ADR-0110 split the provider (billing/endpoint) axis from the harness (CLI
tool) axis and gave every agentic role its own *_provider dial
(implementation_provider, review_provider, planner_provider,
triage_provider, ac_provider) plus a maintenance_provider/
maintenance_model knob that coherently routes the maintenance role-set
(wiki compilation, ADR review, transcript summary, ...) to GLM. ADR-0119 added
a controller that reroutes Claude work spawns to GLM on a credit cap. Both
prove the two-backend capability works in one process — but selection is
still factory-wide: every registered repo shares the same *_provider
defaults (env/config-file), and the individual per-role dials are
config-file/env-only — they were never added to settings_registry.SETTINGS,
so an operator could not even set them per-repo through the running system
(PATCH /api/control/config?repo=X, the Settings drawer) without dropping to
5 separate fields.
Issue #11211 asks for the natural next step: "I want to set model / harness
per project, not just a factory-wide default" — e.g. one repo running the
core factory pipeline on Claude while a second, concurrently-supervised repo
runs the same pipeline on GLM. Each registered repo already owns an
independent HydraFlowConfig instance (repo_store.py/repo_runtime.py,
ADR-0009's per-repo isolation) — the missing piece is a single per-repo dial
that governs the work loop role-set at spawn time, mirroring
maintenance_provider's "one knob, coherent provider+model" pattern but for
implement/review/plan/triage instead of maintenance, and threaded through
the Settings-drawer-editable / RepoRecord.overrides-persisted seam every
other per-repo setting already uses.
Decision¶
Add a repo-wide repo_provider/repo_model dial, resolved at spawn time,
layered UNDER any explicit per-role dial and UNDER credit-failover.
-
Config (
config.py) —repo_provider: Literal["claude", "zai"] = "claude"andrepo_model: str = ""(validated glm- when non-empty, mirroringcredit_failover_model). Registered insettings_registry.SETTINGS(Model Routinggroup) — the only* step needed to make them Settings-drawer-editable andPATCH /api/control/config?repo=X-mutable, since the route derives its allowlist fromset(SETTINGS). Persisted exactly like every other per-repo setting, viaRepoRecord.overrides→valid_stored_overrides→load_runtime_config(overrides=...)— no new persistence mechanism. -
Spawn routing (
repo_backend.apply_repo_provider) — a new function mirroringapply_credit_failover's exact contract: no-op unless the provider handed to it is still"claude"(an explicit per-role dial that already routed a spawn off Claude always wins) ANDconfig.repo_provider == "zai"AND aZAI_API_KEYis present (never reroute to an endpoint with no key — that would send a glm-* model to the wrong backend). Rewrites--modeltorepo_model(orcredit_failover_modelas a fallback whenrepo_modelis unset). Applied at the SAME two seams credit-failover already reroutes, immediately BEFORE it:base_runner._executeandBaseSubprocessRunner.run. Combined resolution order at each seam:
role dial (explicit non-claude *_provider) > repo_provider > credit-failover
Because BaseSubprocessRunner-based runners (e.g. the ADR-0050 Auto-Agent
preflight loop) have no role-level provider dial of their own — they
always spawn on native Claude — repo_provider is the only lever that
routes their spawns to GLM, closing the gap ADR-0119 left in that seam
(it only reroutes there under credit exhaustion, never under a standing
per-repo choice).
-
Concurrency — no new plumbing. Each
RepoRuntimealready owns an independentHydraFlowConfig, event bus, and orchestrator (its own runner pools), so two repos on differentrepo_providervalues already run concurrently by construction (ADR-0009). Credit-exhaustion pause isolation (#9807) is already per-orchestrator-instance-scoped across repos — one repo's Claude cap never pauses a different repo's loops. The one genuinely process-wide piece iscredit_failover's engage flag (ADR-0119, §Consequences: "one shared Claude subscription") — it composes correctly with no code change:apply_repo_providerruns first and, once it has already routed a repo's spawn to"zai",apply_credit_failover's ownprovider != "claude"guard is a no-op. A GLM-native repo is therefore immune to a Claude-cap failover engaged by a different, Claude-native repo sharing the process; a repo left on the factory default still fails over, unchanged from pre-#11211 behavior. Verified intests/test_repo_backend.py(composition, through the realapply_repo_provider/apply_credit_failoverseam functions) andtests/scenarios/test_multi_repo_backend_routing.py(two repos, two backends, live simultaneously; a sibling's engaged failover doesn't move the GLM-native repo's resolved provider or model). Not covered: within a single GLM-pinned repo's own orchestrator, the #9807 pause-classifier (orchestrator._loop_providers) has no knowledge ofrepo_provider— see the Known gap below. -
Cost attribution — no new plumbing. The rerouted
cmd(with its rewritten--model) flows unchanged intoparse_command_tool_modelandPromptTelemetry.record, exactly as credit-failover's reroute already does — the z.ai cost double-count fix and per-repo cost rollups (dashboard_routes/_cost_rollups.py,_cost_merge.py) attribute correctly for free. -
UI —
RepoRuntimeInfo.provider(new field, sourced from_state_routes._effective_repo_provider) surfaces the resolved backend — not just the configured dial — toGET /api/runtimes/GET /api/runtimes/{slug}:repo_provider == "zai"with noZAI_API_KEYpresent resolves to"claude"(mirroringapply_repo_provider's own fail-safe), so the badge never claims GLM for a repo whose spawns are silently staying on Claude.RepoOverview.jsxbadges a non-default repo ("GLM") in the multi-repo portfolio row. The Settings drawer needs no bespoke component —repo_provider/repo_modelrender generically like every otherSETTINGSentry, in the same "Model Routing" group aszai_base_url.ZAI_API_KEYpresence is read fromGET /api/control/settings-schema'sprovider_keys(keyed by the same"zai"backend credit-failover and ADR-0110 already use); the backend exposes the key status generically, butRuntimeSettingsPanel.providerKeyStatuswas extended with acurrentValueargument so therepo_providerrow itself badges that zai key status when"zai"is selected — its prior<provider>_base_url-only match never reached therepo_providerdial, so without this an operator selecting GLM for a repo would see no missing-key signal.
Consequences¶
- A repo's backend choice is a single dial, not five separate
*_providerfields — an operator setsrepo_provider=zai(+repo_model) once per repo and everyBaseRunner-based work-loop role (implement/review/plan/ triage) and everyBaseSubprocessRunner-based spawn for that repo routes to GLM. - An explicit per-role dial still wins.
repo_provideronly acts on a spawn still resolving to"claude"; a repo that wants e.g.triagealone routed differently setstriage_providerdirectly and it is untouched byrepo_provider. - Maintenance loops are untouched —
maintenance_providerdials that role-set independently, same as it does today under ADR-0119. - Known gap: direct
stream_claude_with_telemetrycallers are NOT covered.repo_provideris wired at the same two seamsapply_credit_failoveruses (base_runner._execute,BaseSubprocessRunner.run) — mirroring ADR-0119's identical scope, not a regression from it. Roles that spawn directly throughrunner_utils.stream_claude_with_telemetrybypass both seams and stay on their own*_providerdial regardless ofrepo_provider: the AC generator (acceptance_criteria.py,ac_provider), the verification judge (verification_judge.py,review_provider), andreport_issue(report_issue_loop.py, hardcoded"claude"). A repo set torepo_provider=zaiwith these dials left at their"claude"default still spawns AC generation, AC precheck, the verification judge, and report-issue on native Claude. Fixing this belongs at the call sites (an explicitac_provider/review_provideroverride, or a future per-role fallback torepo_provider), not inside the shared seam — blanket-wiringstream_claude_with_telemetrywould also reroutereport_issue, which is maintenance-class and must stay untouched per the bullet above. Tracked as a follow-up (#11235); not yet enforced by this ADR's test list. - Known gap: the #9807 credit-pause classifier does not consult
repo_provider.orchestrator._loop_providersmaps only the maintenance loop names to their own*_providerdial via_BACKEND_WORKER_LOOPS; every other loop — including the four work loopsrepo_providerroutes (implement/review/plan/triage) — always classifies asPROVIDER_ANTHROPICfor pause-scoping, regardless ofrepo_provider. Combined with the gap above (ac/judge/report_issue still spawning on native Claude for a GLM-pinned repo), an Anthropic cap raised by one of those still-Claude seams pauses that same repo's ownimplement/review/plan/triageloops and terminates its harness runner pools — even though those loops' own spawns already route to GLM. This does not contradict the Concurrency section's isolation claim (that claim is about one repo's pause never reaching a different repo's loops, which still holds); it is a distinct, intra-repo classification gap the pause-scoping code predates #11211 for the existing per-role*_providerdials and this ADR does not close. Tracked as a follow-up (#11238); not yet enforced by this ADR's test list. - The
credit_failoverprocess-wide engage flag is unchanged and still a known, documented limitation (ADR-0119 §Consequences: "one shared Claude subscription"). This ADR does not scope it per-repo; a GLM-native repo is already immune by construction (it never presents a"claude"provider toapply_credit_failover), and a Claude-native repo sharing the process correctly still shares the process's Claude-cap fate. - Requires
ZAI_API_KEY, exactly like credit-failover — without it,repo_provider=zaiis configured but inert (spawns silently stay on Claude), matching the "fail open to the safe backend, never mid-spawn" precedent ADR-0119 established.