feat(llm): the subscription is the default provider, with a recorded fallback chain

Two changes so an empty metered account stops being a platform outage.

1. `build_provider` prefers the subscription token over ANTHROPIC_API_KEY.
   A bare model name resolves to whatever this returns, so making it the
   subscription means no server-side call can reach the metered key by
   construction — rather than by a source-grep test that already missed four
   call sites once. The metered key remains a fallback and now warns loudly
   when it is the one in use; boot no longer requires it at all.

2. `complete_with_fallback` walks a declared chain when a model has no
   capacity: opus -> haiku -> glm:glm-4.7 by default, overridable via
   CLAWMATES_MODEL_FALLBACK, empty to disable. Measured on gw-04 today: opus
   and sonnet return 429 on the subscription while haiku, GLM and Kimi all
   return 200, so a capped window no longer means "the planner is gone".

The chain returns the model that ANSWERED, and every caller persists it —
mission_plan_proposals.author_model, mission_team_proposals.author_model, and
the swarm's step role. A plan drafted by the third link and filed as an opus
plan is a silent quality change, which is the failure shape this project keeps
paying for. Two negative controls hold the design: the chain never retries the
model that just failed as its own fallback, and it steps down ONLY for a
capacity failure — walking it on a malformed prompt would ask three models the
same bad question and report the third one's confusion.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-08 22:56:01 -07:00
co-authored by Claude Opus 5
parent ee5a939ce6
commit 9c9439a271
5 changed files with 161 additions and 20 deletions
+13 -10
View File
@@ -115,7 +115,10 @@ pub async fn suggest(
// `Runtime::complete` with a bare model name resolves to the default
// provider, which is the pay-as-you-go key; this planner died with
// "credit balance is too low" while missions on the same box ran fine.
let raw = crate::subscription::complete_or(
// `author_model` is what ANSWERED, not what was asked for. When opus is
// capped the chain steps down to haiku and then to GLM, and a plan drafted
// by the third link but filed as an opus plan is a silent quality change.
let (raw, author_model) = crate::subscription::complete_with_fallback(
&state.runtime,
ROSTER_SYSTEM,
&prompt,
@@ -124,12 +127,12 @@ pub async fn suggest(
false,
)
.await
.map_err(|e| {
eprintln!("mission {id}: roster proposal failed: {e}");
// A rate-limited subscription is a 503 the operator can act on, not
// a 500 that reads as "this server is broken".
crate::subscription::as_api_error(&e)
})?;
.map_err(|e| {
eprintln!("mission {id}: roster proposal failed: {e}");
// A rate-limited subscription is a 503 the operator can act on, not
// a 500 that reads as "this server is broken".
crate::subscription::as_api_error(&e)
})?;
// A model that answered with prose around its JSON has still answered; a
// model that answered with nothing usable has not, and that is a refusal
@@ -158,7 +161,7 @@ pub async fn suggest(
id,
ws.as_uuid().to_owned(),
&stored,
PLANNER_MODEL,
&author_model,
)
.await
.map_err(|e| {
@@ -167,7 +170,7 @@ pub async fn suggest(
})?;
eprintln!(
"mission_roster: mission {id}{} proposed {} member(s): {}",
PLANNER_MODEL,
author_model,
roster.members.len(),
roster
.members
@@ -180,7 +183,7 @@ pub async fn suggest(
Ok(Json(ProposalResponse {
id: pid,
roster: stored,
author_model: PLANNER_MODEL.to_string(),
author_model,
status: "proposed".into(),
}))
}