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()),
)
}