fix(planner): server-side model calls run on the subscription, not the metered key

The roster planner died with `400 — "Your credit balance is too low to access
the Anthropic API"` while every mission on the same machine kept running. Two
Anthropic credentials reach this server and they bill differently:
`ANTHROPIC_API_KEY` (sk-ant-api, metered, runs out) and the Claude Code
subscription token (sk-ant-oat) that every VM already uses.

`Runtime::complete` with a BARE model name — "claude-opus-4-8" — resolves to the
default provider, which is the metered key. Three server-side callers did that:
the roster planner, the Master Planner, and the claw enhancer. Missions were
never affected because `mission_runtime` deliberately sends only the
subscription token into a guest; the server had no equivalent rule.

`subscription::complete_or` is now that rule, and it is the ONE place a
subscription token becomes a provider — `evaluator::subscription_judge` had its
own copy, and two of them is how one ends up with a prefix check the other
lacks.

The `sk-ant-oat` prefix is checked rather than the variable name trusted: an
API key pasted into the OAuth slot would authenticate, work, and bill the
metered account — the same failure again, discovered weeks later.

`web_search` is carried explicitly rather than defaulted. The Master Planner and
the claw enhancer both pass `true`, and a helper that quietly dropped it would
have taken web search away from two features while every test still passed.

`validator_preflight` deliberately keeps `Runtime::complete`: it probes whatever
validator spec is configured (today `glm:glm-4.7`), and forcing it onto Anthropic
would make it prove the wrong thing. A test pins both halves — no other
server-side caller may regress to the metered key, and preflight must keep
probing the configured spec.

258 lib tests.
This commit is contained in:
Omar Sobh
2026-08-08 09:34:19 -07:00
parent d84d17207f
commit 72046e7985
6 changed files with 189 additions and 20 deletions
+4 -14
View File
@@ -393,20 +393,10 @@ fn subscription_model() -> String {
/// case in the platform for a bare model call: fixed prompt, no tools, no /// case in the platform for a bare model call: fixed prompt, no tools, no
/// memory, one JSON answer. /// memory, one JSON answer.
fn subscription_judge() -> Option<cm_llm::AnthropicProvider> { fn subscription_judge() -> Option<cm_llm::AnthropicProvider> {
let token = std::env::var("ANTHROPIC_OAUTH_TOKEN").ok()?; // One definition of "the subscription", shared with the planner. This
let token = token.trim(); // carried its own copy; two of them is how one gets a prefix check the
if token.is_empty() { // other lacks.
return None; crate::subscription::provider()
}
if !token.starts_with("sk-ant-oat") {
eprintln!(
"evaluator: ANTHROPIC_OAUTH_TOKEN is set but is not a setup token \
(expected sk-ant-oat…) — ignoring it and using {}",
evaluator_model()
);
return None;
}
Some(cm_llm::AnthropicProvider::new(token.to_string()))
} }
/// Judge whether `condition` holds given `evidence`. /// Judge whether `condition` holds given `evidence`.
+1
View File
@@ -24,6 +24,7 @@ pub mod mission_delivery;
pub mod microvm_client; pub mod microvm_client;
pub mod microvm_executor; pub mod microvm_executor;
pub mod microvm_turn_executor; pub mod microvm_turn_executor;
pub mod subscription;
pub mod vm_placement; pub mod vm_placement;
pub mod vm_stop_gate; pub mod vm_stop_gate;
pub mod mission_fs; pub mod mission_fs;
+5 -1
View File
@@ -577,7 +577,11 @@ pub async fn enhance_brain(
let user_prompt = format!( let user_prompt = format!(
"BRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}" "BRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
); );
let raw = match runtime.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true).await { let raw = match crate::subscription::complete_or(
&runtime, ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true,
)
.await
{
Ok(t) => t, Ok(t) => t,
Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Opus error: {e}")})); return; } Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Opus error: {e}")})); return; }
}; };
+12 -3
View File
@@ -111,9 +111,18 @@ pub async fn suggest(
}, },
); );
let raw = state // On the SUBSCRIPTION, like every mission VM — not the metered API key.
.runtime // `Runtime::complete` with a bare model name resolves to the default
.complete(ROSTER_SYSTEM, &prompt, PLANNER_MODEL, 2000, false) // 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(
&state.runtime,
ROSTER_SYSTEM,
&prompt,
PLANNER_MODEL,
2000,
false,
)
.await .await
.map_err(|e| { .map_err(|e| {
eprintln!("mission {id}: roster proposal failed: {e}"); eprintln!("mission {id}: roster proposal failed: {e}");
+5 -1
View File
@@ -132,7 +132,11 @@ pub async fn planner_chat(
}; };
let user_prompt = format!("{hierarchy}{topology_lock}\n\n=== CONVERSATION ===\n{convo}\n\nRespond now (JSON only)."); let user_prompt = format!("{hierarchy}{topology_lock}\n\n=== CONVERSATION ===\n{convo}\n\nRespond now (JSON only).");
let system = planner_system_for(&body.mode); let system = planner_system_for(&body.mode);
let raw = match runtime.complete(&system, &user_prompt, "claude-opus-4-8", 8000, true).await { let raw = match crate::subscription::complete_or(
&runtime, &system, &user_prompt, "claude-opus-4-8", 8000, true,
)
.await
{
Ok(t) => t, Ok(t) => t,
Err(e) => { yield sse(json!({"stage":"error","label":format!("Opus error: {e}")})); return; } Err(e) => { yield sse(json!({"stage":"error","label":format!("Opus error: {e}")})); return; }
}; };
+161
View File
@@ -0,0 +1,161 @@
//! The Anthropic provider backed by the SUBSCRIPTION token, not the metered key.
//!
//! Two Anthropic credentials reach this server and they bill differently:
//!
//! - `ANTHROPIC_API_KEY` (`sk-ant-api…`) — metered, pay-as-you-go, and the thing
//! that runs out. Every mission VM already avoids it: `mission_runtime` sends
//! only the subscription token into a guest, deliberately.
//! - `ANTHROPIC_OAUTH_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` (`sk-ant-oat…`) — the
//! Claude Code subscription, which is what the CLI inside every VM runs on.
//!
//! Server-side model calls that went through `Runtime::complete` with a bare
//! model name resolved to the DEFAULT provider — the metered key. So the roster
//! planner died with
//! `400 … "Your credit balance is too low to access the Anthropic API"` while
//! every mission on the same machine kept running fine on the subscription.
//! The harness reported it honestly as FAIL-NORUN rather than a passing scenario,
//! which is the only reason it was visible at all.
//!
//! This is the one place that turns the subscription token into a provider.
//! `evaluator::subscription_judge` had its own copy; there is now one.
/// The subscription-backed provider, or `None` when no usable token is present.
///
/// Checks the `sk-ant-oat` prefix rather than trusting the variable name: an
/// `sk-ant-api` key pasted into the OAuth slot would authenticate and then bill
/// the metered account, which is the failure this module exists to prevent —
/// silently, and with the same error weeks later.
pub fn provider() -> Option<cm_llm::AnthropicProvider> {
for var in ["ANTHROPIC_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"] {
let Ok(token) = std::env::var(var) else {
continue;
};
let token = token.trim();
if token.is_empty() {
continue;
}
if !is_subscription_token(token) {
eprintln!(
"subscription: {var} is set but is not a Claude Code setup token \
(expected sk-ant-oat…) — ignoring it rather than billing the \
metered key by accident"
);
continue;
}
return Some(cm_llm::AnthropicProvider::new(token.to_string()));
}
None
}
/// Whether a token is a Claude Code subscription token rather than an API key.
pub fn is_subscription_token(token: &str) -> bool {
token.trim().starts_with("sk-ant-oat")
}
/// One completion on the subscription, mirroring `Runtime::complete`'s contract
/// so a caller can swap between them without reshaping its call.
///
/// Falls back to the caller's runtime when no subscription token exists, so a
/// deployment without one behaves exactly as it did before.
pub async fn complete_or(
runtime: &cm_runtime::Runtime,
system: &str,
user: &str,
model: &str,
max_tokens: u32,
// Carried explicitly rather than defaulted. The Master Planner and the claw
// enhancer both pass `true`, and a helper that quietly dropped it would take
// web search away from two features while every test still passed.
web_search: bool,
) -> Result<String, String> {
let Some(provider) = provider() else {
return runtime
.complete(system, user, model, max_tokens, web_search)
.await;
};
complete_with(&provider, system, user, model, max_tokens, web_search).await
}
/// Stream one request and collect its text.
async fn complete_with(
provider: &cm_llm::AnthropicProvider,
system: &str,
user: &str,
model: &str,
max_tokens: u32,
web_search: bool,
) -> Result<String, String> {
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider};
use futures::StreamExt as _;
let request = ChatRequest {
system: system.to_string(),
model: model.to_string(),
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text(user)],
}],
tools: vec![],
max_tokens,
web_search,
};
let mut stream = provider
.stream(request)
.await
.map_err(|e| format!("subscription call: {e}"))?;
let mut text = String::new();
while let Some(event) = stream.next().await {
match event {
Ok(LlmEvent::TextDelta(t)) => text.push_str(&t),
Ok(_) => {}
Err(e) => return Err(format!("subscription stream: {e}")),
}
}
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
/// Every server-side model call that should be on the subscription IS.
///
/// `validator_preflight` is the deliberate exception: it probes whatever
/// spec an operator configured (today `glm:glm-4.7`), and forcing it onto
/// Anthropic would make it prove the wrong thing — it exists to answer "is
/// the configured validator reachable".
#[test]
fn no_server_side_call_silently_uses_the_metered_key() {
let sources = [
("routes/mission_roster.rs", include_str!("routes/mission_roster.rs")),
("routes/planner.rs", include_str!("routes/planner.rs")),
("routes/claws.rs", include_str!("routes/claws.rs")),
];
for (name, src) in sources {
assert!(
!src.contains("runtime.complete("),
"{name} calls Runtime::complete directly — a bare model name there \
resolves to the DEFAULT provider, which is the metered API key. \
Use `subscription::complete_or`."
);
}
// And the exception stays an exception, on purpose.
assert!(
include_str!("validator_preflight.rs").contains("runtime.complete("),
"validator_preflight must keep probing the CONFIGURED spec"
);
}
/// A metered key in the OAuth slot must be REFUSED, not used.
///
/// Accepting it would authenticate, work, and bill the pay-as-you-go account
/// — the exact bill this module exists to stop, discovered weeks later when
/// it runs out mid-mission.
#[test]
fn only_a_setup_token_counts_as_the_subscription() {
assert!(is_subscription_token("sk-ant-oat01-abc"));
assert!(!is_subscription_token("sk-ant-api03-abc"));
assert!(!is_subscription_token(""));
assert!(!is_subscription_token("oat-but-not-anthropic"));
}
}