use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use axum::http::HeaderMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: Value,
}
impl ChatMessage {
#[must_use]
pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: role.into(),
content: Value::String(content.into()),
}
}
#[must_use]
pub fn text_view(&self) -> String {
match &self.content {
Value::String(s) => s.clone(),
Value::Array(blocks) => blocks
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRequest {
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
pub messages: Vec<ChatMessage>,
pub max_tokens: u32,
#[serde(default)]
pub tools: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelResponse {
pub model: String,
pub text: String,
pub in_tokens: u64,
pub out_tokens: u64,
pub raw: Value,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ProviderError {
#[error("transport error: {0}")]
Transport(String),
#[error("http {status}: {body}")]
Http {
status: u16,
body: String,
},
#[error("decode error: {0}")]
Decode(String),
}
impl ProviderError {
#[must_use]
pub fn is_failover_eligible(&self) -> bool {
match self {
ProviderError::Transport(_) => true,
ProviderError::Http { status, .. } => *status >= 500,
ProviderError::Decode(_) => false,
}
}
}
#[derive(Clone, Default)]
pub struct Auth {
pub anthropic_key: Option<String>,
pub openai_key: Option<String>,
}
impl std::fmt::Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Auth")
.field("anthropic_key", &self.anthropic_key.as_ref().map(|_| "***"))
.field("openai_key", &self.openai_key.as_ref().map(|_| "***"))
.finish()
}
}
impl Auth {
#[must_use]
pub fn from_headers(headers: &HeaderMap) -> Self {
let anthropic_key = headers
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok());
let openai_key = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_owned)
.or_else(|| std::env::var("OPENAI_API_KEY").ok());
Self {
anthropic_key,
openai_key,
}
}
}
#[async_trait]
pub trait Provider: Send + Sync + std::fmt::Debug {
async fn complete(
&self,
req: &ModelRequest,
auth: &Auth,
) -> Result<ModelResponse, ProviderError>;
fn id(&self) -> &str;
}
#[derive(Serialize)]
struct AnthropicWireMessage<'a> {
role: &'a str,
content: &'a Value,
}
fn wire_model(model: &str) -> &str {
model.split_once('/').map_or(model, |(_, m)| m)
}
fn resolve_api_key(api_key_env: Option<&str>, byok_override: Option<&str>) -> String {
api_key_env
.and_then(|e| std::env::var(e).ok())
.or_else(|| byok_override.map(str::to_owned))
.unwrap_or_default()
}
#[derive(Serialize)]
struct AnthropicWireRequest<'a> {
model: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<&'a str>,
max_tokens: u32,
messages: Vec<AnthropicWireMessage<'a>>,
}
#[derive(Debug, Clone)]
pub struct AnthropicProvider {
pub id: String,
pub base_url: String,
pub api_key_env: Option<String>,
pub http: reqwest::Client,
}
#[async_trait]
impl Provider for AnthropicProvider {
fn id(&self) -> &str {
&self.id
}
async fn complete(
&self,
req: &ModelRequest,
auth: &Auth,
) -> Result<ModelResponse, ProviderError> {
let key = resolve_api_key(self.api_key_env.as_deref(), auth.anthropic_key.as_deref());
let body = AnthropicWireRequest {
model: wire_model(&req.model),
system: req.system.as_deref(),
max_tokens: req.max_tokens,
messages: req
.messages
.iter()
.map(|m| AnthropicWireMessage {
role: &m.role,
content: &m.content,
})
.collect(),
};
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let resp = self
.http
.post(url)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|e| ProviderError::Transport(e.to_string()))?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|e| ProviderError::Transport(e.to_string()))?;
if !status.is_success() {
return Err(ProviderError::Http {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).into_owned(),
});
}
let json: Value =
serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
let text = json
.get("content")
.and_then(Value::as_array)
.map(|blocks| {
blocks
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
})
.ok_or_else(|| ProviderError::Decode("missing content[].text".to_owned()))?;
let in_tokens = json
.pointer("/usage/input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
let out_tokens = json
.pointer("/usage/output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
Ok(ModelResponse {
model: req.model.clone(),
text,
in_tokens,
out_tokens,
raw: json,
})
}
}
#[derive(Serialize)]
struct OpenAiWireMessage<'a> {
role: &'a str,
content: Value,
}
#[derive(Serialize)]
struct OpenAiWireRequest<'a> {
model: &'a str,
max_tokens: u32,
messages: Vec<OpenAiWireMessage<'a>>,
}
#[derive(Debug, Clone)]
pub struct OpenAiProvider {
pub id: String,
pub base_url: String,
pub api_key_env: Option<String>,
pub http: reqwest::Client,
}
#[async_trait]
impl Provider for OpenAiProvider {
fn id(&self) -> &str {
&self.id
}
async fn complete(
&self,
req: &ModelRequest,
auth: &Auth,
) -> Result<ModelResponse, ProviderError> {
let key = resolve_api_key(self.api_key_env.as_deref(), auth.openai_key.as_deref());
let mut messages = Vec::with_capacity(req.messages.len() + 1);
if let Some(system) = req.system.as_deref() {
messages.push(OpenAiWireMessage {
role: "system",
content: Value::String(system.to_owned()),
});
}
messages.extend(req.messages.iter().map(|m| OpenAiWireMessage {
role: &m.role,
content: m.content.clone(),
}));
let body = OpenAiWireRequest {
model: wire_model(&req.model),
max_tokens: req.max_tokens,
messages,
};
let url = format!(
"{}/v1/chat/completions",
self.base_url.trim_end_matches('/')
);
let resp = self
.http
.post(url)
.header(axum::http::header::AUTHORIZATION, format!("Bearer {key}"))
.json(&body)
.send()
.await
.map_err(|e| ProviderError::Transport(e.to_string()))?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|e| ProviderError::Transport(e.to_string()))?;
if !status.is_success() {
return Err(ProviderError::Http {
status: status.as_u16(),
body: String::from_utf8_lossy(&bytes).into_owned(),
});
}
let json: Value =
serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
let text = json
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.ok_or_else(|| ProviderError::Decode("missing choices[0].message.content".to_owned()))?
.to_owned();
let in_tokens = json
.pointer("/usage/prompt_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
let out_tokens = json
.pointer("/usage/completion_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
Ok(ModelResponse {
model: req.model.clone(),
text,
in_tokens,
out_tokens,
raw: json,
})
}
}
#[derive(Clone)]
pub struct ProviderRegistry {
providers: HashMap<String, Arc<dyn Provider>>,
}
impl std::fmt::Debug for ProviderRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderRegistry")
.field("providers", &self.providers.keys().collect::<Vec<_>>())
.finish()
}
}
impl ProviderRegistry {
fn build_http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[must_use]
pub fn new(anthropic_base: impl Into<String>, openai_base: impl Into<String>) -> Self {
Self::from_config(&[], anthropic_base, openai_base)
}
#[must_use]
pub fn from_config(
defs: &[firstpass_core::ProviderDef],
anthropic_base: impl Into<String>,
openai_base: impl Into<String>,
) -> Self {
let http = Self::build_http_client();
let mut providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
providers.insert(
"anthropic".to_owned(),
Arc::new(AnthropicProvider {
id: "anthropic".to_owned(),
base_url: anthropic_base.into(),
api_key_env: None,
http: http.clone(),
}),
);
providers.insert(
"openai".to_owned(),
Arc::new(OpenAiProvider {
id: "openai".to_owned(),
base_url: openai_base.into(),
api_key_env: None,
http: http.clone(),
}),
);
for def in defs {
let provider: Arc<dyn Provider> = match def.dialect {
firstpass_core::Dialect::Anthropic => Arc::new(AnthropicProvider {
id: def.id.clone(),
base_url: def.base_url.clone(),
api_key_env: def.api_key_env.clone(),
http: http.clone(),
}),
firstpass_core::Dialect::Openai => Arc::new(OpenAiProvider {
id: def.id.clone(),
base_url: def.base_url.clone(),
api_key_env: def.api_key_env.clone(),
http: http.clone(),
}),
};
providers.insert(def.id.clone(), provider);
}
Self { providers }
}
#[must_use]
pub fn from_map(providers: HashMap<String, Arc<dyn Provider>>) -> Self {
Self { providers }
}
#[must_use]
pub fn get(&self, provider_id: &str) -> Option<Arc<dyn Provider>> {
self.providers.get(provider_id).cloned()
}
}
#[cfg(test)]
#[derive(Debug, Clone, Default)]
pub struct MockProvider {
id: String,
outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
calls: Arc<std::sync::Mutex<Vec<String>>>,
delay_ms: u64,
}
#[cfg(test)]
impl MockProvider {
#[must_use]
pub fn new(
id: impl Into<String>,
outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
) -> Self {
Self {
id: id.into(),
outcomes,
calls: Arc::default(),
delay_ms: 0,
}
}
#[must_use]
pub fn with_delay(mut self, ms: u64) -> Self {
self.delay_ms = ms;
self
}
#[must_use]
pub fn call_log(&self) -> Arc<std::sync::Mutex<Vec<String>>> {
Arc::clone(&self.calls)
}
}
#[cfg(test)]
#[async_trait]
impl Provider for MockProvider {
fn id(&self) -> &str {
&self.id
}
async fn complete(
&self,
req: &ModelRequest,
_auth: &Auth,
) -> Result<ModelResponse, ProviderError> {
self.calls.lock().unwrap().push(req.model.clone());
if self.delay_ms > 0 {
tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
}
self.outcomes.get(&req.model).cloned().unwrap_or_else(|| {
Err(ProviderError::Decode(format!(
"no mock outcome configured for {}",
req.model
)))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_model_strips_the_provider_prefix() {
assert_eq!(wire_model("anthropic/claude-haiku-4-5"), "claude-haiku-4-5");
assert_eq!(wire_model("openai/gpt-5.5"), "gpt-5.5");
assert_eq!(wire_model("claude-opus-4-8"), "claude-opus-4-8"); }
#[test]
fn from_config_registers_custom_providers_alongside_builtins() {
let defs = vec![
firstpass_core::ProviderDef {
id: "groq".to_owned(),
dialect: firstpass_core::Dialect::Openai,
base_url: "https://api.groq.com/openai".to_owned(),
api_key_env: Some("GROQ_API_KEY".to_owned()),
},
firstpass_core::ProviderDef {
id: "openai".to_owned(),
dialect: firstpass_core::Dialect::Openai,
base_url: "https://my-azure.openai.azure.com".to_owned(),
api_key_env: Some("AZURE_OPENAI_KEY".to_owned()),
},
];
let reg = ProviderRegistry::from_config(
&defs,
"https://api.anthropic.com",
"https://api.openai.com",
);
assert_eq!(reg.get("anthropic").unwrap().id(), "anthropic");
assert_eq!(reg.get("groq").unwrap().id(), "groq");
assert!(reg.get("does-not-exist").is_none());
}
#[test]
fn resolve_api_key_prefers_configured_env_then_byok() {
let path = std::env::var("PATH").expect("PATH is set");
assert_eq!(resolve_api_key(Some("PATH"), Some("byok")), path);
assert_eq!(
resolve_api_key(Some("FIRSTPASS_DEFINITELY_UNSET_KEY"), Some("byok")),
"byok"
);
assert_eq!(resolve_api_key(None, Some("byok")), "byok");
assert_eq!(resolve_api_key(None, None), "");
}
#[test]
fn anthropic_wire_forwards_tool_and_image_content_verbatim() {
let messages = [
ChatMessage::text("user", "hi"),
ChatMessage {
role: "assistant".to_owned(),
content: serde_json::json!([
{ "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }
]),
},
ChatMessage {
role: "user".to_owned(),
content: serde_json::json!([
{ "type": "tool_result", "tool_use_id": "t1", "content": "2" },
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
]),
},
];
let body = AnthropicWireRequest {
model: "claude-haiku-4-5",
system: None,
max_tokens: 64,
messages: messages
.iter()
.map(|m| AnthropicWireMessage {
role: &m.role,
content: &m.content,
})
.collect(),
};
let wire = serde_json::to_value(&body).unwrap();
assert_eq!(wire["messages"][0]["content"], serde_json::json!("hi"));
assert_eq!(
wire["messages"][1]["content"],
serde_json::json!([{ "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }])
);
assert_eq!(
wire["messages"][2]["content"],
serde_json::json!([
{ "type": "tool_result", "tool_use_id": "t1", "content": "2" },
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
])
);
}
fn resp(model: &str, text: &str) -> ModelResponse {
ModelResponse {
model: model.to_owned(),
text: text.to_owned(),
in_tokens: 10,
out_tokens: 5,
raw: Value::Null,
}
}
#[test]
fn transport_and_5xx_are_failover_eligible() {
assert!(ProviderError::Transport("boom".into()).is_failover_eligible());
assert!(
ProviderError::Http {
status: 503,
body: String::new()
}
.is_failover_eligible()
);
}
#[test]
fn client_errors_and_decode_failures_are_hard() {
assert!(
!ProviderError::Http {
status: 400,
body: String::new()
}
.is_failover_eligible()
);
assert!(!ProviderError::Decode("bad json".into()).is_failover_eligible());
}
#[test]
fn auth_debug_never_prints_key_material() {
let auth = Auth {
anthropic_key: Some("sk-ant-super-secret".to_owned()),
openai_key: Some("sk-oai-super-secret".to_owned()),
};
let debug = format!("{auth:?}");
assert!(!debug.contains("super-secret"));
}
#[tokio::test]
async fn mock_provider_returns_configured_outcome() {
let mut outcomes = HashMap::new();
outcomes.insert(
"anthropic/claude-haiku-4-5".to_owned(),
Ok(resp("anthropic/claude-haiku-4-5", "hello")),
);
let provider = MockProvider::new("anthropic", outcomes);
let req = ModelRequest {
model: "anthropic/claude-haiku-4-5".to_owned(),
system: None,
messages: vec![],
max_tokens: 100,
tools: Value::Null,
};
let out = provider.complete(&req, &Auth::default()).await.unwrap();
assert_eq!(out.text, "hello");
}
}