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
+28 -2
View File
@@ -24,11 +24,32 @@ async fn main() -> ExitCode {
/// Instantiates the configured LLM provider. The Anthropic key comes from
/// the environment until the secret broker lands in P2.
///
/// The **subscription wins** when both credentials are present. This is the
/// structural half of the fix that `cm_api::subscription` does per-call: a bare
/// model name resolves to whatever this function returns, so making that the
/// subscription means no server-side call can reach the metered key by
/// accident — by construction, rather than by a source-grep test that has
/// already missed four call sites once. The metered key stays usable as a
/// fallback for deployments that have credit; ours does not, which is what
/// made the ordering matter.
fn build_provider(config: &AppConfig) -> Result<Arc<dyn LlmProvider>, String> {
match config.llm.provider {
LlmProviderKind::Anthropic => {
let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| "llm.provider = \"anthropic\" requires ANTHROPIC_API_KEY")?;
if let Some(provider) = cm_api::subscription::provider() {
println!(
"clawmates-server: default LLM provider = Claude Code subscription \
(bare model names bill no metered key)"
);
return Ok(Arc::new(provider));
}
let key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
"llm.provider = \"anthropic\" needs a credential: either \
ANTHROPIC_OAUTH_TOKEN / CLAUDE_CODE_OAUTH_TOKEN (sk-ant-oat…, \
the Claude Code subscription, preferred) or ANTHROPIC_API_KEY \
(sk-ant-api…, metered)"
.to_string()
})?;
// A subscription OAuth token pasted where an API key belongs
// authenticates nothing here and fails on the first model call,
// far from the mistake. Both start `sk-ant-`, so the confusion is
@@ -40,6 +61,11 @@ fn build_provider(config: &AppConfig) -> Result<Arc<dyn LlmProvider>, String> {
bearer auth and is what the phase evaluator reads."
.to_string());
}
eprintln!(
"clawmates-server: WARNING — no subscription token; the default LLM \
provider is the METERED ANTHROPIC_API_KEY and every bare model name \
bills it"
);
Ok(Arc::new(AnthropicProvider::new(key)))
}
LlmProviderKind::OpenAiCompat => {