Files
clawmates/crates/cm-llm/src/provider.rs
T
Omar SobhandClaude Fable 5 5407111a89 Live Anthropic validation — and the platform-breaking bug it caught
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]>
2026-06-10 12:55:51 -05:00

156 lines
4.3 KiB
Rust

use futures::stream::BoxStream;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Provider-neutral conversation role.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChatRole {
User,
Assistant,
}
/// Provider-neutral message content. Tool results travel as user-role parts,
/// matching both wire formats' conventions.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
// Struct variant (not newtype): internally-tagged enums cannot
// serialize newtype primitives, and this type round-trips through
// `agent_runs.checkpoint`.
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: Value,
},
}
/// Anthropic and OpenAI both restrict tool names to
/// `^[a-zA-Z0-9_-]{1,128}$`; our registry uses dotted names
/// (`clock.now`, `email.send`). The HTTP providers encode dots as `__`
/// on the wire and decode on receipt — internal naming (DB step rows,
/// scenarios, UI traces) never changes. No registry tool may contain
/// a literal `__`.
pub fn wire_tool_name(internal: &str) -> String {
internal.replace('.', "__")
}
pub fn internal_tool_name(wire: &str) -> String {
wire.replace("__", ".")
}
impl ContentPart {
/// Convenience constructor for plain text parts.
pub fn text(value: impl Into<String>) -> ContentPart {
ContentPart::Text { text: value.into() }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: ChatRole,
pub parts: Vec<ContentPart>,
}
/// A tool offered to the model. `input_schema` is JSON Schema.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDescriptor {
pub name: String,
pub description: String,
pub input_schema: Value,
}
/// The checkpointable, provider-neutral request. This struct is what gets
/// serialized into `agent_runs.checkpoint`, so resume works identically
/// across providers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatRequest {
pub system: String,
pub messages: Vec<ChatMessage>,
pub tools: Vec<ToolDescriptor>,
pub model: String,
pub max_tokens: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUse,
MaxTokens,
}
/// Streaming events every provider normalizes to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LlmEvent {
TextDelta(String),
ToolUse {
id: String,
name: String,
input: Value,
},
/// Token accounting for this provider call (drives credit metering).
Usage {
input_tokens: u32,
output_tokens: u32,
},
Stop(StopReason),
}
#[derive(Debug, thiserror::Error)]
pub enum LlmError {
#[error("scenario error: {0}")]
Scenario(String),
#[error("transport error: {0}")]
Transport(String),
#[error("provider returned an error: {0}")]
Api(String),
#[error("malformed provider response: {0}")]
Wire(String),
}
pub type EventStream = BoxStream<'static, Result<LlmEvent, LlmError>>;
#[async_trait::async_trait]
pub trait LlmProvider: Send + Sync {
async fn stream(&self, request: ChatRequest) -> Result<EventStream, LlmError>;
}
#[cfg(test)]
mod wire_name_tests {
use super::*;
#[test]
fn dotted_registry_names_round_trip_the_wire_codec() {
for name in [
"clock.now",
"email.send",
"files.write",
"files.list",
"files.delete",
"routine.schedule",
"chat.send",
"chat.inbox",
"browser.goto",
"shell.exec",
"slack.post",
] {
let wire = wire_tool_name(name);
assert!(
wire.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"{wire} must satisfy the Anthropic/OpenAI name pattern"
);
assert_eq!(internal_tool_name(&wire), name, "lossless round trip");
}
}
}