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:
co-authored by
Claude Opus 5
parent
52500a689c
commit
d48bdbc9a7
@@ -23,7 +23,6 @@ use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_MODEL: &str = "claude-opus-4-8";
|
||||
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// Cap the raw material we send to the model. Missions can produce
|
||||
/// hundreds of KB of agent output; we slice by turn and by phase
|
||||
@@ -34,21 +33,26 @@ fn model_name() -> String {
|
||||
std::env::var("CLAWMATES_SUMMARIZER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
|
||||
}
|
||||
|
||||
pub fn spawn(pool: PgPool) {
|
||||
/// The runtime is carried purely so the summarizer can reach the SAME
|
||||
/// providers as everything else. It used to hand-roll its own HTTPS POST with
|
||||
/// `x-api-key: $ANTHROPIC_API_KEY`, which is why no audit of `.complete(` call
|
||||
/// sites ever found it — and why every phase summary on this deployment died
|
||||
/// with "credit balance is too low" while the phases themselves ran fine.
|
||||
pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(45)).await;
|
||||
let mut ticker = tokio::time::interval(POLL_INTERVAL);
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(e) = sweep_once(&pool).await {
|
||||
if let Err(e) = sweep_once(&pool, &runtime).await {
|
||||
eprintln!("phase_summarizer: sweep failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn sweep_once(pool: &PgPool) -> Result<(), String> {
|
||||
async fn sweep_once(pool: &PgPool, runtime: &cm_runtime::Runtime) -> Result<(), String> {
|
||||
// Terminal phases with no summary yet.
|
||||
let rows = sqlx::query(
|
||||
"SELECT mp.id, mp.mission_id, mp.kind
|
||||
@@ -65,7 +69,7 @@ async fn sweep_once(pool: &PgPool) -> Result<(), String> {
|
||||
let phase_id: Uuid = row.get("id");
|
||||
let mission_id: Uuid = row.get("mission_id");
|
||||
let kind: String = row.get("kind");
|
||||
if let Err(e) = summarize_one(pool, mission_id, phase_id, &kind).await {
|
||||
if let Err(e) = summarize_one(pool, runtime, mission_id, phase_id, &kind).await {
|
||||
// Persist an error row so we don't infinite-retry a broken
|
||||
// phase — the UI can surface "summary unavailable: <e>".
|
||||
eprintln!("phase_summarizer: {phase_id} ({kind}) failed: {e}");
|
||||
@@ -77,6 +81,7 @@ async fn sweep_once(pool: &PgPool) -> Result<(), String> {
|
||||
|
||||
async fn summarize_one(
|
||||
pool: &PgPool,
|
||||
runtime: &cm_runtime::Runtime,
|
||||
mission_id: Uuid,
|
||||
phase_id: Uuid,
|
||||
kind: &str,
|
||||
@@ -105,7 +110,7 @@ async fn summarize_one(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let (narrative, structured) = call_anthropic(kind, &material).await?;
|
||||
let (narrative, structured, answered_by) = call_anthropic(runtime, kind, &material).await?;
|
||||
let metrics = structured
|
||||
.get("metrics")
|
||||
.cloned()
|
||||
@@ -128,7 +133,7 @@ async fn summarize_one(
|
||||
mission_id,
|
||||
phase_id,
|
||||
kind,
|
||||
&model_name(),
|
||||
&answered_by,
|
||||
&narrative,
|
||||
&metrics,
|
||||
&sources,
|
||||
@@ -323,58 +328,25 @@ async fn collect_material(
|
||||
})
|
||||
}
|
||||
|
||||
async fn call_anthropic(kind: &str, material: &PhaseMaterial) -> Result<(String, Value), String> {
|
||||
let api_key =
|
||||
std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY unset".to_string())?;
|
||||
/// Returns the narrative, the parsed object, and **the model that answered** —
|
||||
/// which may be a fallback link rather than `model_name()`, and is recorded as
|
||||
/// such.
|
||||
async fn call_anthropic(
|
||||
runtime: &cm_runtime::Runtime,
|
||||
kind: &str,
|
||||
material: &PhaseMaterial,
|
||||
) -> Result<(String, Value, String), String> {
|
||||
let model = model_name();
|
||||
let system = system_prompt(kind);
|
||||
let user = user_prompt(kind, material);
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"system": system,
|
||||
"messages": [ { "role": "user", "content": user } ]
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("http client: {e}"))?;
|
||||
let resp = client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", &api_key)
|
||||
.header("anthropic-version", ANTHROPIC_API_VERSION)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("anthropic call: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let code = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"anthropic {code}: {}",
|
||||
&body[..body.len().min(500)]
|
||||
));
|
||||
}
|
||||
let json: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("anthropic json: {e}"))?;
|
||||
let raw = json
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
|
||||
})
|
||||
.and_then(|b| b.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or_else(|| "anthropic response missing text block".to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
let (raw, answered_by) = crate::subscription::complete_with_fallback(
|
||||
runtime, &system, &user, &model, 4096, false,
|
||||
)
|
||||
.await?;
|
||||
let raw = raw.trim().to_string();
|
||||
if raw.is_empty() {
|
||||
return Err("anthropic returned empty text".into());
|
||||
return Err(format!("{answered_by} returned empty text"));
|
||||
}
|
||||
// Model returns a JSON object; extract narrative + rest.
|
||||
let parsed: Value = serde_json::from_str(&strip_code_fence(&raw)).map_err(|e| {
|
||||
@@ -392,7 +364,7 @@ async fn call_anthropic(kind: &str, material: &PhaseMaterial) -> Result<(String,
|
||||
if narrative.is_empty() {
|
||||
return Err("summarizer response missing narrative".into());
|
||||
}
|
||||
Ok((narrative, parsed))
|
||||
Ok((narrative, parsed, answered_by))
|
||||
}
|
||||
|
||||
/// Trim a leading/trailing ```json … ``` fence the model sometimes wraps
|
||||
|
||||
Reference in New Issue
Block a user