use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
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, in_tokens, out_tokens) = anthropic_parse_response(&json)?;
Ok(ModelResponse {
model: req.model.clone(),
text,
in_tokens,
out_tokens,
raw: json,
})
}
}
fn anthropic_parse_response(json: &Value) -> Result<(String, u64, u64), ProviderError> {
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((text, in_tokens, out_tokens))
}
fn anthropic_messages_body(req: &ModelRequest, anthropic_version: &str) -> Value {
let messages: Vec<Value> = req
.messages
.iter()
.map(|m| serde_json::json!({ "role": m.role, "content": m.content }))
.collect();
let mut body = serde_json::json!({
"anthropic_version": anthropic_version,
"max_tokens": req.max_tokens,
"messages": messages,
});
if let Some(system) = req.system.as_deref() {
body["system"] = serde_json::json!(system);
}
body
}
#[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,
})
}
}
fn gemini_request_body(req: &ModelRequest) -> Value {
let contents: Vec<Value> = req
.messages
.iter()
.map(|m| {
let role = if m.role == "assistant" {
"model"
} else {
"user"
};
serde_json::json!({ "role": role, "parts": [{ "text": m.text_view() }] })
})
.collect();
let mut body = serde_json::json!({
"contents": contents,
"generationConfig": { "maxOutputTokens": req.max_tokens },
});
if let Some(system) = req.system.as_deref() {
body["system_instruction"] = serde_json::json!({ "parts": [{ "text": system }] });
}
body
}
fn gemini_parse_response(json: &Value) -> Result<(String, u64, u64), ProviderError> {
let parts = json
.pointer("/candidates/0/content/parts")
.and_then(Value::as_array)
.ok_or_else(|| ProviderError::Decode("missing candidates[0].content.parts".to_owned()))?;
let text = parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("");
let in_tokens = json
.pointer("/usageMetadata/promptTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0);
let out_tokens = json
.pointer("/usageMetadata/candidatesTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0);
Ok((text, in_tokens, out_tokens))
}
#[derive(Debug, Clone)]
pub struct GeminiProvider {
pub id: String,
pub base_url: String,
pub api_key_env: Option<String>,
pub http: reqwest::Client,
}
#[async_trait]
impl Provider for GeminiProvider {
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 body = gemini_request_body(req);
let url = format!(
"{}/v1beta/models/{}:generateContent",
self.base_url.trim_end_matches('/'),
wire_model(&req.model),
);
let resp = self
.http
.post(url)
.header("x-goog-api-key", 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, in_tokens, out_tokens) = gemini_parse_response(&json)?;
Ok(ModelResponse {
model: req.model.clone(),
text,
in_tokens,
out_tokens,
raw: json,
})
}
}
struct AwsEnvCredentials {
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
}
impl AwsEnvCredentials {
fn from_env() -> Result<Self, ProviderError> {
let access_key_id = std::env::var("AWS_ACCESS_KEY_ID")
.map_err(|_| ProviderError::Transport("AWS_ACCESS_KEY_ID is not set".to_owned()))?;
let secret_access_key = std::env::var("AWS_SECRET_ACCESS_KEY")
.map_err(|_| ProviderError::Transport("AWS_SECRET_ACCESS_KEY is not set".to_owned()))?;
let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
Ok(Self {
access_key_id,
secret_access_key,
session_token,
})
}
}
fn bedrock_url(region: &str, model: &str) -> String {
format!("https://bedrock-runtime.{region}.amazonaws.com/model/{model}/invoke")
}
fn sign_bedrock(
url: &str,
region: &str,
body: &[u8],
creds: &AwsEnvCredentials,
) -> Result<http::Request<Vec<u8>>, ProviderError> {
let host = url
.parse::<http::Uri>()
.ok()
.and_then(|u| u.host().map(str::to_owned))
.ok_or_else(|| ProviderError::Transport(format!("invalid bedrock URL: {url}")))?;
let identity: aws_smithy_runtime_api::client::identity::Identity =
aws_credential_types::Credentials::new(
creds.access_key_id.clone(),
creds.secret_access_key.clone(),
creds.session_token.clone(),
None,
"firstpass",
)
.into();
let signing_params: aws_sigv4::http_request::SigningParams<'_> =
aws_sigv4::sign::v4::SigningParams::builder()
.identity(&identity)
.region(region)
.name("bedrock")
.time(SystemTime::now())
.settings(aws_sigv4::http_request::SigningSettings::default())
.build()
.map_err(|e| ProviderError::Transport(format!("sigv4 signing params: {e}")))?
.into();
let headers = [
("host", host.as_str()),
("content-type", "application/json"),
];
let signable = aws_sigv4::http_request::SignableRequest::new(
"POST",
url,
headers.into_iter(),
aws_sigv4::http_request::SignableBody::Bytes(body),
)
.map_err(|e| ProviderError::Transport(format!("sigv4 signable request: {e}")))?;
let (instructions, _signature) = aws_sigv4::http_request::sign(signable, &signing_params)
.map_err(|e| ProviderError::Transport(format!("sigv4 sign: {e}")))?
.into_parts();
let mut req = http::Request::builder()
.method("POST")
.uri(url)
.header("host", host)
.header("content-type", "application/json")
.body(body.to_vec())
.map_err(|e| ProviderError::Transport(format!("build bedrock request: {e}")))?;
instructions.apply_to_request_http1x(&mut req);
Ok(req)
}
#[derive(Debug, Clone)]
pub struct BedrockProvider {
pub id: String,
pub region: Option<String>,
pub http: reqwest::Client,
}
#[async_trait]
impl Provider for BedrockProvider {
fn id(&self) -> &str {
&self.id
}
async fn complete(
&self,
req: &ModelRequest,
_auth: &Auth,
) -> Result<ModelResponse, ProviderError> {
let region = self.region.as_deref().ok_or_else(|| {
ProviderError::Transport("bedrock provider requires a region".to_owned())
})?;
let model = wire_model(&req.model);
let url = bedrock_url(region, model);
let body = anthropic_messages_body(req, "bedrock-2023-05-31");
let body_bytes =
serde_json::to_vec(&body).map_err(|e| ProviderError::Decode(e.to_string()))?;
let creds = AwsEnvCredentials::from_env()?;
let signed = sign_bedrock(&url, region, &body_bytes, &creds)?;
let http_req = reqwest::Request::try_from(signed)
.map_err(|e| ProviderError::Transport(format!("build reqwest request: {e}")))?;
let resp = self
.http
.execute(http_req)
.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, in_tokens, out_tokens) = anthropic_parse_response(&json)?;
Ok(ModelResponse {
model: req.model.clone(),
text,
in_tokens,
out_tokens,
raw: json,
})
}
}
fn vertex_url(region: &str, project: &str, model: &str) -> String {
format!(
"https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/anthropic/models/{model}:rawPredict"
)
}
#[derive(Debug, Clone)]
pub struct VertexProvider {
pub id: String,
pub region: Option<String>,
pub project: Option<String>,
pub http: reqwest::Client,
}
#[async_trait]
impl Provider for VertexProvider {
fn id(&self) -> &str {
&self.id
}
async fn complete(
&self,
req: &ModelRequest,
_auth: &Auth,
) -> Result<ModelResponse, ProviderError> {
let region = self.region.as_deref().ok_or_else(|| {
ProviderError::Transport("vertex provider requires a region".to_owned())
})?;
let project = self.project.as_deref().ok_or_else(|| {
ProviderError::Transport("vertex provider requires a project".to_owned())
})?;
let model = wire_model(&req.model);
let url = vertex_url(region, project, model);
let body = anthropic_messages_body(req, "vertex-2023-10-16");
let provider = gcp_auth::provider()
.await
.map_err(|e| ProviderError::Transport(format!("gcp_auth provider: {e}")))?;
let token = provider
.token(&["https://www.googleapis.com/auth/cloud-platform"])
.await
.map_err(|e| ProviderError::Transport(format!("gcp_auth token: {e}")))?;
let resp = self
.http
.post(url)
.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {}", token.as_str()),
)
.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, in_tokens, out_tokens) = anthropic_parse_response(&json)?;
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.auth {
firstpass_core::AuthScheme::AwsSigv4 => Arc::new(BedrockProvider {
id: def.id.clone(),
region: def.region.clone(),
http: http.clone(),
}),
firstpass_core::AuthScheme::GcpOauth => Arc::new(VertexProvider {
id: def.id.clone(),
region: def.region.clone(),
project: def.project.clone(),
http: http.clone(),
}),
firstpass_core::AuthScheme::ApiKey => 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(),
}),
firstpass_core::Dialect::Gemini => Arc::new(GeminiProvider {
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 gemini_request_maps_roles_and_system_instruction() {
let req = ModelRequest {
model: "gemini/gemini-2.0-flash".to_owned(),
system: Some("be terse".to_owned()),
messages: vec![
ChatMessage::text("user", "hi"),
ChatMessage::text("assistant", "hello"),
],
max_tokens: 256,
tools: Value::Null,
};
let body = gemini_request_body(&req);
assert_eq!(body["system_instruction"]["parts"][0]["text"], "be terse");
assert_eq!(body["generationConfig"]["maxOutputTokens"], 256);
assert_eq!(body["contents"][0]["role"], "user");
assert_eq!(body["contents"][0]["parts"][0]["text"], "hi");
assert_eq!(body["contents"][1]["role"], "model");
assert_eq!(body["contents"][1]["parts"][0]["text"], "hello");
}
#[test]
fn gemini_response_parses_text_and_usage() {
let json = serde_json::json!({
"candidates": [{ "content": { "role": "model", "parts": [
{ "text": "the answer " }, { "text": "is 42" }
] } }],
"usageMetadata": { "promptTokenCount": 11, "candidatesTokenCount": 4 }
});
let (text, in_tok, out_tok) = gemini_parse_response(&json).unwrap();
assert_eq!(text, "the answer is 42");
assert_eq!(in_tok, 11);
assert_eq!(out_tok, 4);
assert!(gemini_parse_response(&serde_json::json!({ "candidates": [] })).is_err());
}
#[test]
fn from_config_wires_the_gemini_dialect() {
let defs = vec![firstpass_core::ProviderDef {
id: "gemini".to_owned(),
dialect: firstpass_core::Dialect::Gemini,
base_url: "https://generativelanguage.googleapis.com".to_owned(),
api_key_env: Some("GEMINI_API_KEY".to_owned()),
auth: firstpass_core::AuthScheme::ApiKey,
region: None,
project: None,
}];
let reg = ProviderRegistry::from_config(
&defs,
"https://api.anthropic.com",
"https://api.openai.com",
);
assert_eq!(reg.get("gemini").unwrap().id(), "gemini");
}
#[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()),
auth: firstpass_core::AuthScheme::ApiKey,
region: None,
project: None,
},
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()),
auth: firstpass_core::AuthScheme::ApiKey,
region: None,
project: None,
},
];
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 anthropic_messages_body_omits_model_and_includes_system_only_when_set() {
let req = ModelRequest {
model: "bedrock/anthropic.claude-3-5-haiku".to_owned(),
system: Some("be terse".to_owned()),
messages: vec![
ChatMessage::text("user", "hi"),
ChatMessage {
role: "assistant".to_owned(),
content: serde_json::json!([{ "type": "text", "text": "hello" }]),
},
],
max_tokens: 128,
tools: Value::Null,
};
let body = anthropic_messages_body(&req, "bedrock-2023-05-31");
assert!(body.get("model").is_none());
assert_eq!(body["anthropic_version"], "bedrock-2023-05-31");
assert_eq!(body["max_tokens"], 128);
assert_eq!(body["system"], "be terse");
assert_eq!(body["messages"][0]["content"], serde_json::json!("hi"));
assert_eq!(
body["messages"][1]["content"],
serde_json::json!([{ "type": "text", "text": "hello" }])
);
let req_no_system = ModelRequest {
system: None,
..req
};
let body2 = anthropic_messages_body(&req_no_system, "vertex-2023-10-16");
assert!(body2.get("system").is_none());
}
#[test]
fn bedrock_url_construction_and_missing_region() {
assert_eq!(
bedrock_url("us-east-1", "anthropic.claude-3-5-haiku-20241022-v1:0"),
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-haiku-20241022-v1:0/invoke"
);
}
#[tokio::test]
async fn bedrock_complete_errors_without_a_region() {
let provider = BedrockProvider {
id: "bedrock".to_owned(),
region: None,
http: reqwest::Client::new(),
};
let req = ModelRequest {
model: "bedrock/anthropic.claude-3-5-haiku".to_owned(),
system: None,
messages: vec![],
max_tokens: 16,
tools: Value::Null,
};
let err = provider.complete(&req, &Auth::default()).await.unwrap_err();
assert!(matches!(err, ProviderError::Transport(_)));
}
#[test]
fn bedrock_signing_produces_a_sigv4_authorization_header() {
let creds = AwsEnvCredentials {
access_key_id: "AKIDEXAMPLE".to_owned(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_owned(),
session_token: None,
};
let url = bedrock_url("us-east-1", "anthropic.claude-3-5-haiku");
let body = br#"{"anthropic_version":"bedrock-2023-05-31"}"#;
let signed = sign_bedrock(&url, "us-east-1", body, &creds).unwrap();
let auth_header = signed
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.expect("authorization header present");
assert!(auth_header.starts_with("AWS4-HMAC-SHA256"));
let host_header = signed
.headers()
.get("host")
.and_then(|v| v.to_str().ok())
.expect("host header present");
assert_eq!(host_header, "bedrock-runtime.us-east-1.amazonaws.com");
}
#[test]
fn vertex_url_construction_and_missing_project() {
assert_eq!(
vertex_url("us-central1", "my-project", "claude-3-5-sonnet"),
"https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet:rawPredict"
);
}
#[tokio::test]
async fn vertex_complete_errors_without_a_project() {
let provider = VertexProvider {
id: "vertex".to_owned(),
region: Some("us-central1".to_owned()),
project: None,
http: reqwest::Client::new(),
};
let req = ModelRequest {
model: "vertex/claude-3-5-sonnet".to_owned(),
system: None,
messages: vec![],
max_tokens: 16,
tools: Value::Null,
};
let err = provider.complete(&req, &Auth::default()).await.unwrap_err();
assert!(matches!(err, ProviderError::Transport(_)));
}
#[test]
fn from_config_wires_bedrock_and_vertex_auth_schemes() {
let defs = vec![
firstpass_core::ProviderDef {
id: "bedrock".to_owned(),
dialect: firstpass_core::Dialect::Anthropic,
base_url: String::new(),
api_key_env: None,
auth: firstpass_core::AuthScheme::AwsSigv4,
region: Some("us-east-1".to_owned()),
project: None,
},
firstpass_core::ProviderDef {
id: "vertex".to_owned(),
dialect: firstpass_core::Dialect::Anthropic,
base_url: String::new(),
api_key_env: None,
auth: firstpass_core::AuthScheme::GcpOauth,
region: Some("us-central1".to_owned()),
project: Some("my-project".to_owned()),
},
];
let reg = ProviderRegistry::from_config(
&defs,
"https://api.anthropic.com",
"https://api.openai.com",
);
assert_eq!(reg.get("bedrock").unwrap().id(), "bedrock");
assert_eq!(reg.get("vertex").unwrap().id(), "vertex");
}
#[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");
}
}