nornir 0.5.2

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 🤖 **Claude "SDK"** — a tiny direct client for the Anthropic **Messages API**.
//!
//! There is no official Anthropic Rust SDK (the Agent SDK is TS/Python), so this
//! calls `POST https://api.anthropic.com/v1/messages` directly with `reqwest`,
//! the headers `x-api-key: $ANTHROPIC_API_KEY` + `anthropic-version: 2023-06-01`,
//! and the default model `claude-opus-4-8`. v1 is **non-streaming** (a clean
//! assembled-response path); SSE streaming is a documented follow-up.
//!
//! The HTTP call lives behind the [`ClaudeTransport`] trait so tests inject a
//! [`FakeTransport`] and NO real API call ever happens in CI. When the key is
//! absent the real transport returns a clear [`ClaudeError::NoApiKey`] (which the
//! 🤖 Claude tab renders as "set ANTHROPIC_API_KEY"), never a panic.
//!
//! The pure types ([`ClaudeRequest`]/[`ClaudeMessage`]/[`ClaudeError`]) + the
//! trait + the [`FakeTransport`] compile whenever `viz` is on; the real
//! [`HttpTransport`] (the only thing that pulls in `reqwest`) is gated on the
//! `claude` feature.

use std::sync::Arc;

/// The Anthropic Messages API endpoint (non-streaming + streaming share it).
pub const MESSAGES_ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
/// The `anthropic-version` header value pinned for this client.
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
/// Default model — Anthropic's most capable Opus-tier model.
pub const DEFAULT_MODEL: &str = "claude-opus-4-8";
/// The models the 🤖 Claude tab offers in its selector (default first).
pub const MODELS: &[&str] = &["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"];
/// The output-token ceiling this v1 client requests per turn.
pub const MAX_TOKENS: u32 = 4096;

/// One conversation turn on the wire (`role` is `"user"` or `"assistant"`).
#[derive(Clone, Debug, serde::Serialize)]
pub struct ClaudeMessage {
    pub role: String,
    pub content: String,
}

impl ClaudeMessage {
    pub fn user(content: impl Into<String>) -> Self {
        Self { role: "user".into(), content: content.into() }
    }
    pub fn assistant(content: impl Into<String>) -> Self {
        Self { role: "assistant".into(), content: content.into() }
    }
}

/// A non-streaming Messages API request body.
#[derive(Clone, Debug, serde::Serialize)]
pub struct ClaudeRequest {
    pub model: String,
    pub max_tokens: u32,
    pub messages: Vec<ClaudeMessage>,
    /// A per-session API token entered in the 🤖 Claude tab (the "token login"
    /// flow, N8) — used as the `x-api-key` header when `ANTHROPIC_API_KEY` is not
    /// exported. NEVER serialized into the request body (`x-api-key` is a header),
    /// so it is `#[serde(skip)]`.
    #[serde(skip)]
    pub api_key: Option<String>,
}

impl ClaudeRequest {
    /// Build a request for `model` over the full conversation `messages`.
    pub fn new(model: impl Into<String>, messages: Vec<ClaudeMessage>) -> Self {
        Self { model: model.into(), max_tokens: MAX_TOKENS, messages, api_key: None }
    }

    /// Attach a per-session API token (the tab's token-login field). Empty/blank
    /// tokens are ignored so the env var still wins the resolution below.
    pub fn with_api_key(mut self, key: Option<String>) -> Self {
        self.api_key = key.filter(|k| !k.trim().is_empty());
        self
    }
}

/// Every way a send can fail — each renders to a clear, user-facing string.
#[derive(Clone, Debug)]
pub enum ClaudeError {
    /// `ANTHROPIC_API_KEY` is unset — the tab shows the "set the key" hint.
    NoApiKey,
    /// The `claude` feature is compiled out, so there is no real transport.
    FeatureDisabled,
    /// A transport/connection error before an HTTP response was read.
    Transport(String),
    /// A non-2xx HTTP response, with the API's error message if present.
    Api { status: u16, message: String },
    /// The model declined for safety reasons (`stop_reason: "refusal"`).
    Refusal(String),
    /// The 200 body could not be decoded / carried no text block.
    Decode(String),
}

impl std::fmt::Display for ClaudeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ClaudeError::NoApiKey => write!(
                f,
                "ANTHROPIC_API_KEY is not set — export it, then re-send \
                 (e.g. `export ANTHROPIC_API_KEY=sk-ant-…`)."
            ),
            ClaudeError::FeatureDisabled => write!(
                f,
                "the `claude` feature is disabled in this build — rebuild with \
                 default features to talk to the Anthropic API."
            ),
            ClaudeError::Transport(e) => write!(f, "request failed: {e}"),
            ClaudeError::Api { status, message } => {
                write!(f, "Anthropic API error {status}: {message}")
            }
            ClaudeError::Refusal(cat) => {
                write!(f, "the model declined the request (refusal: {cat}).")
            }
            ClaudeError::Decode(e) => write!(f, "could not read the response: {e}"),
        }
    }
}

impl std::error::Error for ClaudeError {}

/// Is `ANTHROPIC_API_KEY` present in the environment? Read by the tab so its
/// `state_json` can surface the key state without leaking the value.
pub fn api_key_present() -> bool {
    std::env::var("ANTHROPIC_API_KEY").map(|k| !k.is_empty()).unwrap_or(false)
}

/// The HTTP seam: the real path POSTs to Anthropic; tests inject a fake.
pub trait ClaudeTransport: Send + Sync {
    /// Send `req` and return the assembled assistant text (all `text` blocks
    /// concatenated), or a [`ClaudeError`].
    fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError>;
}

/// The production transport used at runtime when the `claude` feature is on.
#[cfg(feature = "claude")]
pub struct HttpTransport;

#[cfg(feature = "claude")]
impl ClaudeTransport for HttpTransport {
    fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
        // Token resolution (N8): a per-session token entered in the tab wins, then
        // the exported `ANTHROPIC_API_KEY`. Absent both → the "set the key" hint.
        let key = req
            .api_key
            .clone()
            .filter(|k| !k.trim().is_empty())
            .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty()))
            .ok_or(ClaudeError::NoApiKey)?;
        let client = reqwest::blocking::Client::new();
        let resp = client
            .post(MESSAGES_ENDPOINT)
            .header("x-api-key", key)
            .header("anthropic-version", ANTHROPIC_VERSION)
            .header("content-type", "application/json")
            .json(req)
            .send()
            .map_err(|e| ClaudeError::Transport(e.to_string()))?;
        let status = resp.status();
        let body: serde_json::Value =
            resp.json().map_err(|e| ClaudeError::Decode(e.to_string()))?;
        if !status.is_success() {
            let message = body
                .get("error")
                .and_then(|e| e.get("message"))
                .and_then(|m| m.as_str())
                .unwrap_or("unknown error")
                .to_string();
            return Err(ClaudeError::Api { status: status.as_u16(), message });
        }
        if body.get("stop_reason").and_then(|s| s.as_str()) == Some("refusal") {
            let cat = body
                .get("stop_details")
                .and_then(|d| d.get("category"))
                .and_then(|c| c.as_str())
                .unwrap_or("safety")
                .to_string();
            return Err(ClaudeError::Refusal(cat));
        }
        assemble_text(&body).ok_or_else(|| {
            ClaudeError::Decode("no text block in the response content".into())
        })
    }
}

/// Concatenate every `type == "text"` block in a Messages API response body.
/// Shared so a future streaming path can reuse the assembly.
pub fn assemble_text(body: &serde_json::Value) -> Option<String> {
    let blocks = body.get("content")?.as_array()?;
    let text: String = blocks
        .iter()
        .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
        .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
        .collect();
    Some(text)
}

/// The default runtime transport: the real HTTP client with the `claude`
/// feature, otherwise a stub that reports the feature is disabled.
pub fn default_transport() -> Arc<dyn ClaudeTransport> {
    #[cfg(feature = "claude")]
    {
        Arc::new(HttpTransport)
    }
    #[cfg(not(feature = "claude"))]
    {
        Arc::new(DisabledTransport)
    }
}

/// Stand-in transport when the `claude` feature is compiled out.
#[cfg(not(feature = "claude"))]
pub struct DisabledTransport;

#[cfg(not(feature = "claude"))]
impl ClaudeTransport for DisabledTransport {
    fn send(&self, _req: &ClaudeRequest) -> Result<String, ClaudeError> {
        Err(ClaudeError::FeatureDisabled)
    }
}

/// A deterministic transport for tests: no network, no key. Either echoes a
/// canned reply or returns a preset error, and records every request it saw.
pub struct FakeTransport {
    reply: Result<String, ClaudeError>,
    seen: std::sync::Mutex<Vec<ClaudeRequest>>,
}

impl FakeTransport {
    /// A fake that always answers with `reply` text.
    pub fn replying(reply: impl Into<String>) -> Self {
        Self { reply: Ok(reply.into()), seen: std::sync::Mutex::new(Vec::new()) }
    }
    /// A fake that always fails with `err` (drives the error-state path).
    pub fn failing(err: ClaudeError) -> Self {
        Self { reply: Err(err), seen: std::sync::Mutex::new(Vec::new()) }
    }
    /// The requests this fake has been sent (for assertions).
    pub fn requests(&self) -> Vec<ClaudeRequest> {
        self.seen.lock().unwrap().clone()
    }
}

impl ClaudeTransport for FakeTransport {
    fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
        self.seen.lock().unwrap().push(req.clone());
        self.reply.clone()
    }
}