fix(llm): two modules were posting to Anthropic behind the providers' back

The research scenario passed 4/4 and the log underneath it said:

  phase_summarizer: ... failed: anthropic 400 Bad Request: "Your credit
  balance is too low to access the Anthropic API"

`phase_summarizer` and `mission_refiner` each built their own reqwest POST to
the Messages API with `x-api-key: $ANTHROPIC_API_KEY`. No audit of `.complete(`
call sites could have found them — they never touched a provider — so every
phase summary and every mission-brief refinement on this deployment had been
failing against an empty account while the phases themselves ran fine. The
summarizer even persisted an error row per phase, which is why nothing ever
retried loudly enough to notice.

Both now go through `subscription::complete_with_fallback`, so they inherit the
subscription-first credential choice, the 429 backoff, and the opus -> haiku ->
glm chain. The summarizer records the model that ANSWERED in
mission_phase_summaries.model rather than the one it asked for.

The guard is a source WALK, not a file list: any .rs under cm-api/src that
mentions the Messages API host or `x-api-key` fails the test. A hand-listed set
of files is exactly what let these two hide.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-08 23:11:38 -07:00
co-authored by Claude Opus 5
parent 52500a689c
commit d48bdbc9a7
5 changed files with 91 additions and 115 deletions
+44
View File
@@ -343,6 +343,50 @@ mod tests {
assert!(!is_transient(&LlmError::Wire("bad json".into())));
}
/// Nobody hand-rolls their own Anthropic HTTP call.
///
/// `phase_summarizer` did — its own `reqwest` POST to `api.anthropic.com`
/// with `x-api-key: $ANTHROPIC_API_KEY`. No audit of `.complete(` call
/// sites could ever have found it, and it was the last thing on this
/// deployment still billing an account with no credit: every phase summary
/// died with "credit balance is too low" while the phases themselves ran.
/// A call site is only routable if it goes through a provider, so walk the
/// whole crate rather than a hand-listed set of files.
#[test]
fn no_module_talks_to_anthropic_behind_the_providers_back() {
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("readable source dir") {
let path = entry.expect("readable entry").path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
walk(&root, &mut files);
assert!(files.len() > 20, "source walk found suspiciously few files");
for path in files {
// This module names the host in prose; it is the one that may.
if path.ends_with("subscription.rs") {
continue;
}
let src = std::fs::read_to_string(&path).expect("readable source");
for needle in ["api.anthropic.com", "\"x-api-key\""] {
assert!(
!src.contains(needle),
"{} contains {needle} — build the request through cm_llm and \
route it via `subscription::complete_or`, so credential \
choice and the capacity fallback live in ONE place",
path.display()
);
}
}
}
/// The chain never retries the capped model as its own fallback.
///
/// Without the filter, asking for haiku while haiku is capped would try