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:
@@ -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`.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user