Running the opt-in live suite (CM_LIVE_LLM=1 + ANTHROPIC_API_KEY) against
the real API immediately surfaced a launch blocker: Anthropic (and
OpenAI) restrict tool names to ^[a-zA-Z0-9_-]{1,128}$ — our ENTIRE
registry uses dotted names (clock.now, email.send, shell.exec, ...).
The scripted provider never enforced the pattern, so every real-model
deployment would have 400'd on the first tool call.
- Fix at the provider boundary, where it belongs: wire_tool_name /
internal_tool_name codec (dots <-> __) applied in BOTH HTTP providers
at all three sites (tools list, assistant tool_use echo, inbound
tool_use decode). Internal naming (DB step rows, scenarios, UI traces)
unchanged. Offline unit test round-trips every registry name through
the wire pattern
- New live tests, all passing against api.anthropic.com (Haiku 4.5):
- provider tool ROUND TRIP: real ToolUse arrives, ToolResult ships
back exactly as a checkpoint would reassemble it, model completes,
real usage events on the wire
- full runtime loop: real model calls clock.now, run completes, REAL
token usage metered, credits decremented
- the #1-risk validation: a real model's email.send intercepted ->
suspended -> approved -> checkpoint RESUMED against the live API ->
completed -> outbox exactly 1 (checkpoint/resume fidelity end to end)
- Stray TC_OPENAI_COMPAT_* envs renamed to CM_OPENAI_COMPAT_*
No credentials stored anywhere; the key was passed via env only.
163 Rust tests (+5 live, key-gated).
Co-Authored-By: Claude Fable 5 <[email protected]>
203 lines
6.6 KiB
Rust
203 lines
6.6 KiB
Rust
//! The COMPLETE production path against the real Anthropic API
|
|
//! (CM_LIVE_LLM=1 + ANTHROPIC_API_KEY): a real model drives the runtime
|
|
//! loop — ungated tool execution with real usage metering, and the §15
|
|
//! chain: gated email intercepted, checkpointed, approved, and RESUMED
|
|
//! against the live API (checkpoint/resume fidelity, the plan's #1 risk).
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use cm_domain::{
|
|
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
|
|
};
|
|
use cm_llm::AnthropicProvider;
|
|
use cm_runtime::{RunEventBody, Runtime, RuntimeConfig};
|
|
use cm_safety::{approvals, Decision};
|
|
|
|
const MODEL: &str = "claude-haiku-4-5-20251001";
|
|
|
|
fn live() -> Option<String> {
|
|
if std::env::var("CM_LIVE_LLM").as_deref() != Ok("1") {
|
|
eprintln!("skipped: set CM_LIVE_LLM=1 to run");
|
|
return None;
|
|
}
|
|
match std::env::var("ANTHROPIC_API_KEY") {
|
|
Ok(key) => Some(key),
|
|
Err(_) => {
|
|
eprintln!("skipped: ANTHROPIC_API_KEY not set");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn seeded(pool: &sqlx::PgPool) -> (Workspace, User, Agent) {
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Acme".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(pool, &ws).await.unwrap();
|
|
let owner = User {
|
|
id: UserId::new(),
|
|
workspace_id: ws.id,
|
|
email: format!("{}@acme.test", UserId::new()),
|
|
role: Role::Owner,
|
|
display_name: "Owner".into(),
|
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
};
|
|
cm_db::repo::users::insert(pool, &owner).await.unwrap();
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: ws.id,
|
|
name: "Scout".into(),
|
|
job_title: "Analyst".into(),
|
|
system_prompt: "Be terse. Use your tools when they apply.".into(),
|
|
avatar: String::new(),
|
|
accent: String::new(),
|
|
wallpaper: String::new(),
|
|
managed_by: owner.id,
|
|
status: AgentStatus::Online,
|
|
};
|
|
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
cm_db::repo::credits::add_lot(pool, ws.id, 1000, "live")
|
|
.await
|
|
.unwrap();
|
|
(ws, owner, agent)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_real_model_runs_tools_and_gets_metered() {
|
|
let Some(key) = live() else { return };
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (ws, _, agent) = seeded(&pool).await;
|
|
let rt = Runtime::new(
|
|
pool.clone(),
|
|
Arc::new(AnthropicProvider::new(key)),
|
|
RuntimeConfig::basic(MODEL, 600),
|
|
);
|
|
let session = cm_db::repo::sessions::create(&pool, agent.id, ws.id, "Live")
|
|
.await
|
|
.unwrap();
|
|
let started = rt
|
|
.send_message(
|
|
session.id,
|
|
"What is the current UTC time? Use your clock tool, then state it.",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut rx = started.events;
|
|
while let Ok(envelope) = rx.recv().await {
|
|
if matches!(
|
|
envelope.event,
|
|
RunEventBody::RunCompleted { .. } | RunEventBody::Error { .. }
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
let run = cm_db::repo::runs::get(&pool, started.run_id).await.unwrap();
|
|
assert_eq!(run.state, RunState::Completed);
|
|
|
|
// A real clock.now step happened.
|
|
let steps: i64 = sqlx::query_scalar(
|
|
"SELECT count(*) FROM steps s JOIN messages m ON m.id = s.message_id
|
|
WHERE m.session_id = $1 AND s.tool_name = 'clock.now' AND s.status = 'ok'",
|
|
)
|
|
.bind(session.id.as_uuid())
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(steps >= 1, "the model must have called the clock");
|
|
|
|
// Real usage metered, real credits burned.
|
|
let (tin, tout): (i64, i64) = sqlx::query_as(
|
|
"SELECT COALESCE(SUM(tokens_in),0)::BIGINT, COALESCE(SUM(tokens_out),0)::BIGINT
|
|
FROM usage_events WHERE workspace_id = $1",
|
|
)
|
|
.bind(ws.id.as_uuid())
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(tin > 0 && tout > 0, "real token usage: in={tin} out={tout}");
|
|
assert!(
|
|
cm_db::repo::credits::balance(&pool, ws.id).await.unwrap() < 1000,
|
|
"credits decremented"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_gated_chain_suspends_and_resumes_against_the_live_api() {
|
|
let Some(key) = live() else { return };
|
|
let pool = cm_testkit::test_pool().await;
|
|
let (ws, owner, agent) = seeded(&pool).await;
|
|
let rt = Runtime::new(
|
|
pool.clone(),
|
|
Arc::new(AnthropicProvider::new(key)),
|
|
RuntimeConfig::basic(MODEL, 600),
|
|
);
|
|
let session = cm_db::repo::sessions::create(&pool, agent.id, ws.id, "Live gated")
|
|
.await
|
|
.unwrap();
|
|
let started = rt
|
|
.send_message(
|
|
session.id,
|
|
"Use your email tool to send [email protected] an email with subject \
|
|
'Q2' and body 'Numbers attached.' Do it now without asking.",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut rx = started.events;
|
|
let mut suspended = false;
|
|
while let Ok(envelope) = rx.recv().await {
|
|
match envelope.event {
|
|
RunEventBody::RunSuspended { .. } => {
|
|
suspended = true;
|
|
break;
|
|
}
|
|
RunEventBody::Error { .. } | RunEventBody::RunCompleted { .. } => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(suspended, "a REAL model's email.send must be intercepted");
|
|
|
|
let outbox: i64 = sqlx::query_scalar("SELECT count(*) FROM outbox")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(outbox, 0, "nothing sends while pending");
|
|
|
|
// Approve → the checkpoint resumes against the LIVE API: the stored
|
|
// assistant tool_use turn + our tool_result must reassemble into a
|
|
// request Anthropic accepts (checkpoint fidelity, end to end).
|
|
let pending = approvals::list_pending(&pool, ws.id).await.unwrap();
|
|
assert_eq!(pending.len(), 1);
|
|
approvals::decide(&pool, pending[0].id, owner.id, Decision::Approve)
|
|
.await
|
|
.unwrap();
|
|
rt.resume_run(cm_safety::ResumeReady {
|
|
run_id: started.run_id,
|
|
approval_id: pending[0].id,
|
|
approved: true,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut completed = false;
|
|
for _ in 0..240 {
|
|
let run = cm_db::repo::runs::get(&pool, started.run_id).await.unwrap();
|
|
if run.state == RunState::Completed {
|
|
completed = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
assert!(completed, "resume against the live API must complete");
|
|
|
|
let outbox: i64 = sqlx::query_scalar("SELECT count(*) FROM outbox")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(outbox, 1, "approved email sent exactly once");
|
|
}
|