use std::sync::Arc;
pub use nornir_claude::{
assemble_text, credential_present, resolve_credential, ClaudeError, ClaudeRequest,
ClaudeTransport, Credential, OfflineTransport as FakeTransport, WireMsg as ClaudeMessage,
ANTHROPIC_VERSION, DEFAULT_MODEL, MAX_TOKENS, MESSAGES_ENDPOINT, MODELS,
};
pub fn anthropic_key_file() -> std::path::PathBuf {
crate::config::nornir_home().join("anthropic-key")
}
pub fn key_from_file() -> Option<String> {
let path = anthropic_key_file();
let raw = std::fs::read_to_string(&path).ok()?;
let key = raw.lines().next().unwrap_or("").trim().to_string();
(!key.is_empty()).then_some(key)
}
pub fn resolve_api_key(session: Option<&str>) -> Option<String> {
let env = std::env::var("ANTHROPIC_API_KEY").ok();
resolve_api_key_from(env.as_deref(), key_from_file().as_deref(), session)
}
pub fn resolve_api_key_from(
env: Option<&str>,
file: Option<&str>,
session: Option<&str>,
) -> Option<String> {
[env, file, session]
.into_iter()
.flatten()
.map(str::trim)
.find(|k| !k.is_empty())
.map(str::to_string)
}
pub fn api_key_present() -> bool {
std::env::var("ANTHROPIC_API_KEY").map(|k| !k.trim().is_empty()).unwrap_or(false)
|| key_from_file().is_some()
}
#[cfg(feature = "claude")]
pub struct HttpTransport;
#[cfg(feature = "claude")]
impl ClaudeTransport for HttpTransport {
fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
let key = resolve_api_key(req.session_key.as_deref()).ok_or(ClaudeError::NoCredential)?;
nornir_claude::send_request(&Credential::ApiKey(key), req)
}
}
pub fn default_transport() -> Arc<dyn ClaudeTransport> {
#[cfg(feature = "claude")]
{
Arc::new(HttpTransport)
}
#[cfg(not(feature = "claude"))]
{
Arc::new(DisabledTransport)
}
}
#[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)
}
}
#[cfg(test)]
mod key_resolution_tests {
use super::*;
#[test]
fn precedence_is_env_then_file_then_session() {
assert_eq!(
resolve_api_key_from(Some("env-key"), Some("file-key"), Some("sess-key")).as_deref(),
Some("env-key"),
);
assert_eq!(
resolve_api_key_from(None, Some("file-key"), Some("sess-key")).as_deref(),
Some("file-key"),
);
assert_eq!(
resolve_api_key_from(Some(" "), Some("file-key"), Some("sess-key")).as_deref(),
Some("file-key"),
);
assert_eq!(
resolve_api_key_from(None, None, Some("sess-key")).as_deref(),
Some("sess-key"),
);
assert_eq!(resolve_api_key_from(None, Some(""), Some(" ")), None);
assert_eq!(resolve_api_key_from(None, None, None), None);
}
#[test]
fn key_file_is_under_nornir_home_and_is_not_the_bearer_token() {
let p = anthropic_key_file();
assert!(p.ends_with(".nornir/anthropic-key"), "path: {}", p.display());
assert!(!p.ends_with(".nornir/token"), "must not reuse the bearer token file");
}
#[test]
fn request_built_from_domain_ladder_hits_the_leaf_offline_transport() {
let req = ClaudeRequest::new(DEFAULT_MODEL, vec![ClaudeMessage::user("2+2?")])
.with_session_key(Some("sk-ant-session".into()));
let reply = FakeTransport::echoing().send(&req).unwrap();
assert_eq!(reply, format!("[offline · {DEFAULT_MODEL}] 2+2?"));
}
}