Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-claude/src/lib.rs

//! # nornir-claude — the ONE shared Anthropic **Claude Messages-API** client
//!
//! There is no official Anthropic Rust SDK, so nornir's viz Claude tab and
//! dwarves' `ClaudeTab` each grew a bespoke `reqwest` client + credential logic —
//! a textbook LAW #5 twin. This leaf is the single primitive both consume: a
//! provider-agnostic [`ClaudeTransport`] seam with a deterministic
//! [`OfflineTransport`] stub (no network, no key, robot/test-friendly) and a live
//! [`HttpTransport`] (feature `http`), plus the full credential-resolution chain
//! ([`resolve_credential`]). A third unrelated consumer wants exactly this:
//! "talk to `/v1/messages`, resolve the key the way the SDKs/CLI do, never
//! hardcode it".
//!
//! ## The seam
//!
//! ```ignore
//! use nornir_claude::{ClaudeTransport, OfflineTransport, WireMsg};
//! let t = OfflineTransport::echoing();
//! // rich, typed path:
//! let reply = t.send(&nornir_claude::ClaudeRequest::new(
//!     "claude-opus-4-8",
//!     vec![WireMsg::user("2+2?")],
//! )).unwrap();
//! // stringly-typed convenience — byte-identical shape to the facett-claude pane
//! // seam `fn send(&self, model: &str, messages: &[WireMsg]) -> Result<String,String>`,
//! // so a UI host (dwarves `ClaudeTab`, nornir viz) adopts this with a ~3-line
//! // adapter that maps its `WireMsg` and calls `send_wire`:
//! let reply = t.send_wire("claude-opus-4-8", &[WireMsg::user("2+2?")]).unwrap();
//! ```
//!
//! ## Credential resolution — first hit wins, never hardcoded
//!
//! [`resolve_credential`] mirrors the Anthropic SDKs / `ant` CLI order: env
//! `ANTHROPIC_API_KEY` (as the `x-api-key` header) → env `ANTHROPIC_AUTH_TOKEN`
//! (as `Authorization: Bearer …` **plus** `anthropic-beta: oauth-2025-04-20`) →
//! a per-session token (the UI's "paste a token" field, treated as `x-api-key`).
//! An unset `ANTHROPIC_API_KEY` does NOT mean "no credential" — it falls through.
//! A set-but-blank source is skipped. `ant`-profile-on-disk and Workload Identity
//! Federation are the documented next fall-through (see the crate `.nornir` docs).

use serde::{Deserialize, Serialize};

/// 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";
/// The `anthropic-beta` header value an OAuth bearer token requires.
pub const OAUTH_BETA: &str = "oauth-2025-04-20";
/// Default model — the constellation's most capable Opus-tier model. NEVER a
/// date-suffixed variant.
pub const DEFAULT_MODEL: &str = "claude-opus-4-8";
/// The models a selector offers (default first) — shared so consumers don't twin the list.
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;

/// Serialize the whole conversation across `--features testmatrix` self-tests.
pub mod selftest;

/// One conversation turn on the wire (`role` is `"user"` or `"assistant"`).
///
/// Structurally identical to `facett_claude::WireMsg` so a UI host maps between
/// the two with a trivial `.clone()` field copy — this leaf carries NO facett /
/// egui dependency (it is a pure HTTP-client primitive).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireMsg {
    pub role: String,
    pub content: String,
}

impl WireMsg {
    /// A user turn.
    pub fn user(content: impl Into<String>) -> Self {
        Self { role: "user".into(), content: content.into() }
    }
    /// An assistant turn.
    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, PartialEq, Eq, Serialize)]
pub struct ClaudeRequest {
    pub model: String,
    pub max_tokens: u32,
    pub messages: Vec<WireMsg>,
    /// A per-session token entered in a UI's "token login" field — used as the
    /// `x-api-key` credential when neither env source is set. NEVER serialized
    /// into the JSON body (`x-api-key` is a header), so `#[serde(skip)]`.
    #[serde(skip)]
    pub session_key: Option<String>,
}

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

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

/// A resolved Anthropic credential — carries the secret AND how to present it.
///
/// The two arms map to the two header shapes the API accepts; a consumer never
/// has to decide which header to use — [`Credential::apply_headers`] does it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Credential {
    /// A standard API key (`sk-ant-…`) — sent as the `x-api-key` header.
    ApiKey(String),
    /// An OAuth bearer token — sent as `Authorization: Bearer …` **plus** the
    /// `anthropic-beta: oauth-2025-04-20` header.
    Bearer(String),
}

impl Credential {
    /// The auth header pairs this credential contributes: `(name, value)`.
    /// `ApiKey` → one `x-api-key`; `Bearer` → `authorization` + `anthropic-beta`.
    pub fn auth_headers(&self) -> Vec<(&'static str, String)> {
        match self {
            Credential::ApiKey(k) => vec![("x-api-key", k.clone())],
            Credential::Bearer(t) => vec![
                ("authorization", format!("Bearer {t}")),
                ("anthropic-beta", OAUTH_BETA.to_string()),
            ],
        }
    }

    /// A short, secret-free label for logs/`state_json` (never leaks the value).
    pub fn source_label(&self) -> &'static str {
        match self {
            Credential::ApiKey(_) => "x-api-key",
            Credential::Bearer(_) => "bearer",
        }
    }
}

/// Resolve the Anthropic credential from the full precedence chain, first hit
/// wins: env `ANTHROPIC_API_KEY` → env `ANTHROPIC_AUTH_TOKEN` → the per-session
/// `session_key`. Returns `None` only when every source is blank/absent; callers
/// surface [`ClaudeError::NoCredential`].
pub fn resolve_credential(session_key: Option<&str>) -> Option<Credential> {
    let api_key = std::env::var("ANTHROPIC_API_KEY").ok();
    let auth_token = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
    resolve_credential_from(api_key.as_deref(), auth_token.as_deref(), session_key)
}

/// Pure precedence core of [`resolve_credential`] — split out so the ordering is
/// unit-testable without touching process-global env. Order (first non-blank
/// wins): `x-api-key` env → bearer env → session (as `x-api-key`).
pub fn resolve_credential_from(
    api_key: Option<&str>,
    auth_token: Option<&str>,
    session_key: Option<&str>,
) -> Option<Credential> {
    let nonblank = |s: Option<&str>| s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
    if let Some(k) = nonblank(api_key) {
        return Some(Credential::ApiKey(k));
    }
    if let Some(t) = nonblank(auth_token) {
        return Some(Credential::Bearer(t));
    }
    nonblank(session_key).map(Credential::ApiKey)
}

/// Is a credential available from a NON-interactive source (either env var)?
/// Read by a UI so its `state_json` can decide whether to show the "paste a
/// token" field — WITHOUT leaking the value.
pub fn credential_present() -> bool {
    let nonblank = |v: Result<String, _>| v.map(|s| !s.trim().is_empty()).unwrap_or(false);
    nonblank(std::env::var("ANTHROPIC_API_KEY")) || nonblank(std::env::var("ANTHROPIC_AUTH_TOKEN"))
}

/// Every way a send can fail — each renders to a clear, user-facing string.
#[derive(Clone, Debug, thiserror::Error)]
pub enum ClaudeError {
    /// No credential found from any source — the message points at each location.
    #[error(
        "no Anthropic credential found. Provide one of (first wins): \
         (1) export ANTHROPIC_API_KEY=sk-ant-… ; \
         (2) export ANTHROPIC_AUTH_TOKEN=<oauth token> ; \
         (3) paste a session token in the Claude pane. Then re-send."
    )]
    NoCredential,
    /// The `http` feature is compiled out, so there is no live transport.
    #[error(
        "the `http` feature is disabled in this build — rebuild `nornir-claude` \
         with `--features http` to talk to the Anthropic API."
    )]
    FeatureDisabled,
    /// A transport/connection error before an HTTP response was read.
    #[error("request failed: {0}")]
    Transport(String),
    /// A non-2xx HTTP response, with the API's error message if present.
    #[error("Anthropic API error {status}: {message}")]
    Api { status: u16, message: String },
    /// The model declined for safety reasons (`stop_reason: "refusal"`).
    #[error("the model declined the request (refusal: {0}).")]
    Refusal(String),
    /// The 200 body could not be decoded / carried no text block.
    #[error("could not read the response: {0}")]
    Decode(String),
}

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

    /// Stringly-typed convenience with the **exact** shape of the facett-claude
    /// pane seam (`fn send(&self, model, &[WireMsg]) -> Result<String, String>`),
    /// so a UI host adapts this transport with a trivial newtype that maps its
    /// own `WireMsg` and forwards here. Errors render via [`ClaudeError`]'s
    /// `Display`.
    fn send_wire(&self, model: &str, messages: &[WireMsg]) -> Result<String, String> {
        self.send(&ClaudeRequest::new(model, messages.to_vec())).map_err(|e| e.to_string())
    }
}

/// What an [`OfflineTransport`] replies with.
#[derive(Clone, Debug)]
enum OfflineReply {
    /// Echo the last user turn, tagged with the model (deterministic).
    Echo,
    /// A fixed canned reply.
    Canned(String),
    /// A preset error (drives the error-state path).
    Fail(ClaudeError),
}

/// The **deterministic offline stub** — the crate's built-in [`ClaudeTransport`]
/// so a pane runs (and every robot test drives) with **no network and no
/// secrets**. It records every request it saw so a test can assert on the wire.
///
/// This replaces the ad-hoc `OfflineClaudeTransport` dwarves hand-rolled in its
/// demo: `ClaudeTab::offline()` injects one of these; the send→reply loop is
/// proven in code (no AI, no key).
pub struct OfflineTransport {
    reply: OfflineReply,
    seen: std::sync::Mutex<Vec<ClaudeRequest>>,
}

impl OfflineTransport {
    /// The default: echo the last user turn, tagged with the model — e.g. sending
    /// `"2+2?"` under `claude-opus-4-8` yields `"[offline · claude-opus-4-8] 2+2?"`.
    pub fn echoing() -> Self {
        Self { reply: OfflineReply::Echo, seen: std::sync::Mutex::new(Vec::new()) }
    }
    /// Always answer with the fixed `reply` text.
    pub fn replying(reply: impl Into<String>) -> Self {
        Self { reply: OfflineReply::Canned(reply.into()), seen: std::sync::Mutex::new(Vec::new()) }
    }
    /// Always fail with `err` (drives the error-state render).
    pub fn failing(err: ClaudeError) -> Self {
        Self { reply: OfflineReply::Fail(err), seen: std::sync::Mutex::new(Vec::new()) }
    }
    /// The requests this stub has been sent, in order (for assertions).
    pub fn requests(&self) -> Vec<ClaudeRequest> {
        self.seen.lock().unwrap_or_else(|e| e.into_inner()).clone()
    }
}

impl Default for OfflineTransport {
    fn default() -> Self {
        Self::echoing()
    }
}

impl ClaudeTransport for OfflineTransport {
    fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
        self.seen.lock().unwrap_or_else(|e| e.into_inner()).push(req.clone());
        match &self.reply {
            OfflineReply::Echo => {
                let last_user = req
                    .messages
                    .iter()
                    .rev()
                    .find(|m| m.role == "user")
                    .map(|m| m.content.as_str())
                    .unwrap_or("");
                Ok(format!("[offline · {}] {last_user}", req.model))
            }
            OfflineReply::Canned(s) => Ok(s.clone()),
            OfflineReply::Fail(e) => Err(e.clone()),
        }
    }
}

/// 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)
}

/// POST `req` to [`MESSAGES_ENDPOINT`] with an **explicitly-resolved**
/// [`Credential`], bypassing the internal env credential chain. This is the raw
/// transport primitive a consumer with its OWN credential resolution consumes so
/// it never re-hand-rolls the reqwest POST + wire/error mapping. Example: nornir's
/// viz layer resolves an Anthropic key from a domain ladder the leaf can't know
/// (env → `<home>/.nornir/anthropic-key` file → per-session token), wraps the
/// result as [`Credential::ApiKey`], and calls this — deleting its bespoke POST
/// twin while keeping its file-aware resolution. [`HttpTransport::send`] is just
/// [`resolve_credential`] followed by this.
#[cfg(feature = "http")]
pub fn send_request(cred: &Credential, req: &ClaudeRequest) -> Result<String, ClaudeError> {
    let client = tls_roots(reqwest::blocking::Client::builder())
        .build()
        .map_err(|e| ClaudeError::Transport(format!("http client: {e}")))?;
    let mut builder = client
        .post(MESSAGES_ENDPOINT)
        .header("anthropic-version", ANTHROPIC_VERSION)
        .header("content-type", "application/json");
    for (name, value) in cred.auth_headers() {
        builder = builder.header(name, value);
    }
    let resp = builder.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()))
}

/// The production transport used at runtime when the `http` feature is on: a
/// blocking `reqwest` POST to [`MESSAGES_ENDPOINT`] carrying the resolved
/// [`Credential`]'s headers, `anthropic-version`, and the request JSON. This is
/// [`resolve_credential`] (the env credential chain) + [`send_request`].
#[cfg(feature = "http")]
pub struct HttpTransport;

#[cfg(feature = "http")]
impl ClaudeTransport for HttpTransport {
    fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
        let cred = resolve_credential(req.session_key.as_deref()).ok_or(ClaudeError::NoCredential)?;
        send_request(&cred, req)
    }
}

/// Serialize env-mutating tests. `resolve_credential`/`credential_present` read
/// process-global `ANTHROPIC_*` env; a test that sets/unsets them must take
/// `let _g = crate::serial_guard();` to run one-at-a-time. Poison-tolerant.
#[cfg(test)]
pub(crate) fn serial_guard() -> std::sync::MutexGuard<'static, ()> {
    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
    LOCK.lock().unwrap_or_else(|e| e.into_inner())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn credential_precedence_is_api_key_then_bearer_then_session() {
        // api-key env wins over everything.
        assert_eq!(
            resolve_credential_from(Some("k"), Some("t"), Some("s")),
            Some(Credential::ApiKey("k".into())),
        );
        // bearer wins when api-key is absent/blank.
        assert_eq!(
            resolve_credential_from(Some("  "), Some("t"), Some("s")),
            Some(Credential::Bearer("t".into())),
        );
        assert_eq!(
            resolve_credential_from(None, Some("t"), Some("s")),
            Some(Credential::Bearer("t".into())),
        );
        // session (as x-api-key) is the last fallback.
        assert_eq!(
            resolve_credential_from(None, None, Some("s")),
            Some(Credential::ApiKey("s".into())),
        );
        // everything blank → None (RED-when-broken: a broken precedence that
        // returned Some here would surface a bogus empty-key 401 at runtime).
        assert_eq!(resolve_credential_from(Some(" "), Some(""), None), None);
        assert_eq!(resolve_credential_from(None, None, None), None);
    }

    #[test]
    fn resolve_credential_reads_env_in_order() {
        let _g = serial_guard();
        std::env::remove_var("ANTHROPIC_API_KEY");
        std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
        // no env, no session → None.
        assert_eq!(resolve_credential(None), None);
        assert!(!credential_present());
        // auth token alone → bearer.
        std::env::set_var("ANTHROPIC_AUTH_TOKEN", "oat-123");
        assert_eq!(resolve_credential(None), Some(Credential::Bearer("oat-123".into())));
        assert!(credential_present());
        // api key beats the auth token.
        std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-xyz");
        assert_eq!(resolve_credential(None), Some(Credential::ApiKey("sk-ant-xyz".into())));
        std::env::remove_var("ANTHROPIC_API_KEY");
        std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
        // only a pasted session token → x-api-key (and NOT "present" — it's interactive).
        assert_eq!(resolve_credential(Some("paste")), Some(Credential::ApiKey("paste".into())));
        assert!(!credential_present());
    }

    #[test]
    fn credential_maps_to_the_right_headers() {
        assert_eq!(
            Credential::ApiKey("k".into()).auth_headers(),
            vec![("x-api-key", "k".to_string())],
        );
        assert_eq!(
            Credential::Bearer("t".into()).auth_headers(),
            vec![
                ("authorization", "Bearer t".to_string()),
                ("anthropic-beta", OAUTH_BETA.to_string()),
            ],
        );
    }

    #[test]
    fn offline_echo_reflects_the_last_user_turn_and_records_requests() {
        let t = OfflineTransport::echoing();
        let req = ClaudeRequest::new(
            "claude-opus-4-8",
            vec![WireMsg::user("hi"), WireMsg::assistant("hello"), WireMsg::user("2+2?")],
        );
        let reply = t.send(&req).unwrap();
        // RED-when-broken: an echo that dropped the last-user selection would not
        // contain "2+2?" tagged with the model.
        assert_eq!(reply, "[offline · claude-opus-4-8] 2+2?");
        assert_eq!(t.requests(), vec![req]);
    }

    #[test]
    fn offline_canned_and_failing_paths() {
        assert_eq!(
            OfflineTransport::replying("canned").send_wire("m", &[WireMsg::user("x")]),
            Ok("canned".into()),
        );
        let err = OfflineTransport::failing(ClaudeError::NoCredential)
            .send_wire("m", &[WireMsg::user("x")])
            .unwrap_err();
        assert!(err.contains("no Anthropic credential"), "{err}");
    }

    #[test]
    fn send_wire_matches_the_facett_pane_seam_shape() {
        // The convenience method returns the assembled text for a canned reply and
        // an error string for a failing one — exactly what a UI host renders.
        let t = OfflineTransport::echoing();
        let out: Result<String, String> = t.send_wire("claude-sonnet-5", &[WireMsg::user("yo")]);
        assert_eq!(out, Ok("[offline · claude-sonnet-5] yo".into()));
    }

    #[test]
    fn assemble_text_concatenates_only_text_blocks() {
        let body = serde_json::json!({
            "content": [
                {"type": "thinking", "text": "…"},
                {"type": "text", "text": "Hello "},
                {"type": "text", "text": "world"},
                {"type": "tool_use", "text": "ignored"}
            ]
        });
        assert_eq!(assemble_text(&body).as_deref(), Some("Hello world"));
        // no content array → None.
        assert_eq!(assemble_text(&serde_json::json!({})), None);
    }
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour: the ring provider and
/// the bundled Mozilla roots, not aws-lc-rs + the OS trust store (2026-10-02).
#[cfg(feature = "http")]
fn tls_roots(b: reqwest::blocking::ClientBuilder) -> reqwest::blocking::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}