1use std::collections::HashMap;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use axum::http::HeaderMap;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ChatMessage {
21 pub role: String,
23 pub content: Value,
25}
26
27impl ChatMessage {
28 #[must_use]
30 pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
31 Self {
32 role: role.into(),
33 content: Value::String(content.into()),
34 }
35 }
36
37 #[must_use]
40 pub fn text_view(&self) -> String {
41 match &self.content {
42 Value::String(s) => s.clone(),
43 Value::Array(blocks) => blocks
44 .iter()
45 .filter_map(|b| b.get("text").and_then(Value::as_str))
46 .collect::<Vec<_>>()
47 .join("\n"),
48 _ => String::new(),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ModelRequest {
60 pub model: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub system: Option<String>,
65 pub messages: Vec<ChatMessage>,
67 pub max_tokens: u32,
69 #[serde(default)]
71 pub tools: Value,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ModelResponse {
77 pub model: String,
79 pub text: String,
81 pub in_tokens: u64,
83 pub out_tokens: u64,
85 pub raw: Value,
87}
88
89#[derive(Debug, Clone, thiserror::Error)]
91pub enum ProviderError {
92 #[error("transport error: {0}")]
94 Transport(String),
95 #[error("http {status}: {body}")]
97 Http {
98 status: u16,
100 body: String,
102 },
103 #[error("decode error: {0}")]
105 Decode(String),
106}
107
108impl ProviderError {
109 #[must_use]
112 pub fn is_failover_eligible(&self) -> bool {
113 match self {
114 ProviderError::Transport(_) => true,
115 ProviderError::Http { status, .. } => *status >= 500,
116 ProviderError::Decode(_) => false,
117 }
118 }
119}
120
121#[derive(Clone, Default)]
125pub struct Auth {
126 pub anthropic_key: Option<String>,
128 pub openai_key: Option<String>,
130}
131
132impl std::fmt::Debug for Auth {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.debug_struct("Auth")
135 .field("anthropic_key", &self.anthropic_key.as_ref().map(|_| "***"))
136 .field("openai_key", &self.openai_key.as_ref().map(|_| "***"))
137 .finish()
138 }
139}
140
141impl Auth {
142 #[must_use]
145 pub fn from_headers(headers: &HeaderMap) -> Self {
146 let anthropic_key = headers
147 .get("x-api-key")
148 .and_then(|v| v.to_str().ok())
149 .map(str::to_owned)
150 .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok());
151 let openai_key = headers
152 .get(axum::http::header::AUTHORIZATION)
153 .and_then(|v| v.to_str().ok())
154 .and_then(|v| v.strip_prefix("Bearer "))
155 .map(str::to_owned)
156 .or_else(|| std::env::var("OPENAI_API_KEY").ok());
157 Self {
158 anthropic_key,
159 openai_key,
160 }
161 }
162}
163
164#[async_trait]
167pub trait Provider: Send + Sync + std::fmt::Debug {
168 async fn complete(
174 &self,
175 req: &ModelRequest,
176 auth: &Auth,
177 ) -> Result<ModelResponse, ProviderError>;
178
179 fn id(&self) -> &str;
181}
182
183#[derive(Serialize)]
184struct AnthropicWireMessage<'a> {
185 role: &'a str,
186 content: &'a Value,
187}
188
189fn wire_model(model: &str) -> &str {
193 model.split_once('/').map_or(model, |(_, m)| m)
194}
195
196fn resolve_api_key(api_key_env: Option<&str>, byok_override: Option<&str>) -> String {
201 api_key_env
202 .and_then(|e| std::env::var(e).ok())
203 .or_else(|| byok_override.map(str::to_owned))
204 .unwrap_or_default()
205}
206
207#[derive(Serialize)]
208struct AnthropicWireRequest<'a> {
209 model: &'a str,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 system: Option<&'a str>,
212 max_tokens: u32,
213 messages: Vec<AnthropicWireMessage<'a>>,
214}
215
216#[derive(Debug, Clone)]
222pub struct AnthropicProvider {
223 pub id: String,
225 pub base_url: String,
227 pub api_key_env: Option<String>,
230 pub http: reqwest::Client,
232}
233
234#[async_trait]
235impl Provider for AnthropicProvider {
236 fn id(&self) -> &str {
237 &self.id
238 }
239
240 async fn complete(
241 &self,
242 req: &ModelRequest,
243 auth: &Auth,
244 ) -> Result<ModelResponse, ProviderError> {
245 let key = resolve_api_key(self.api_key_env.as_deref(), auth.anthropic_key.as_deref());
246 let body = AnthropicWireRequest {
247 model: wire_model(&req.model),
248 system: req.system.as_deref(),
249 max_tokens: req.max_tokens,
250 messages: req
251 .messages
252 .iter()
253 .map(|m| AnthropicWireMessage {
254 role: &m.role,
255 content: &m.content,
256 })
257 .collect(),
258 };
259
260 let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
261 let resp = self
262 .http
263 .post(url)
264 .header("x-api-key", key)
265 .header("anthropic-version", "2023-06-01")
266 .json(&body)
267 .send()
268 .await
269 .map_err(|e| ProviderError::Transport(e.to_string()))?;
270
271 let status = resp.status();
272 let bytes = resp
273 .bytes()
274 .await
275 .map_err(|e| ProviderError::Transport(e.to_string()))?;
276
277 if !status.is_success() {
278 return Err(ProviderError::Http {
279 status: status.as_u16(),
280 body: String::from_utf8_lossy(&bytes).into_owned(),
281 });
282 }
283
284 let json: Value =
285 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
286
287 let text = json
288 .get("content")
289 .and_then(Value::as_array)
290 .map(|blocks| {
291 blocks
292 .iter()
293 .filter_map(|b| b.get("text").and_then(Value::as_str))
294 .collect::<Vec<_>>()
295 .join("")
296 })
297 .ok_or_else(|| ProviderError::Decode("missing content[].text".to_owned()))?;
298
299 let in_tokens = json
300 .pointer("/usage/input_tokens")
301 .and_then(Value::as_u64)
302 .unwrap_or(0);
303 let out_tokens = json
304 .pointer("/usage/output_tokens")
305 .and_then(Value::as_u64)
306 .unwrap_or(0);
307
308 Ok(ModelResponse {
309 model: req.model.clone(),
310 text,
311 in_tokens,
312 out_tokens,
313 raw: json,
314 })
315 }
316}
317
318#[derive(Serialize)]
319struct OpenAiWireMessage<'a> {
320 role: &'a str,
321 content: Value,
322}
323
324#[derive(Serialize)]
325struct OpenAiWireRequest<'a> {
326 model: &'a str,
327 max_tokens: u32,
328 messages: Vec<OpenAiWireMessage<'a>>,
329}
330
331#[derive(Debug, Clone)]
337pub struct OpenAiProvider {
338 pub id: String,
340 pub base_url: String,
342 pub api_key_env: Option<String>,
346 pub http: reqwest::Client,
348}
349
350#[async_trait]
351impl Provider for OpenAiProvider {
352 fn id(&self) -> &str {
353 &self.id
354 }
355
356 async fn complete(
357 &self,
358 req: &ModelRequest,
359 auth: &Auth,
360 ) -> Result<ModelResponse, ProviderError> {
361 let key = resolve_api_key(self.api_key_env.as_deref(), auth.openai_key.as_deref());
362 let mut messages = Vec::with_capacity(req.messages.len() + 1);
363 if let Some(system) = req.system.as_deref() {
364 messages.push(OpenAiWireMessage {
365 role: "system",
366 content: Value::String(system.to_owned()),
367 });
368 }
369 messages.extend(req.messages.iter().map(|m| OpenAiWireMessage {
370 role: &m.role,
371 content: m.content.clone(),
372 }));
373 let body = OpenAiWireRequest {
374 model: wire_model(&req.model),
375 max_tokens: req.max_tokens,
376 messages,
377 };
378
379 let url = format!(
380 "{}/v1/chat/completions",
381 self.base_url.trim_end_matches('/')
382 );
383 let resp = self
384 .http
385 .post(url)
386 .header(axum::http::header::AUTHORIZATION, format!("Bearer {key}"))
387 .json(&body)
388 .send()
389 .await
390 .map_err(|e| ProviderError::Transport(e.to_string()))?;
391
392 let status = resp.status();
393 let bytes = resp
394 .bytes()
395 .await
396 .map_err(|e| ProviderError::Transport(e.to_string()))?;
397
398 if !status.is_success() {
399 return Err(ProviderError::Http {
400 status: status.as_u16(),
401 body: String::from_utf8_lossy(&bytes).into_owned(),
402 });
403 }
404
405 let json: Value =
406 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
407
408 let text = json
409 .pointer("/choices/0/message/content")
410 .and_then(Value::as_str)
411 .ok_or_else(|| ProviderError::Decode("missing choices[0].message.content".to_owned()))?
412 .to_owned();
413
414 let in_tokens = json
415 .pointer("/usage/prompt_tokens")
416 .and_then(Value::as_u64)
417 .unwrap_or(0);
418 let out_tokens = json
419 .pointer("/usage/completion_tokens")
420 .and_then(Value::as_u64)
421 .unwrap_or(0);
422
423 Ok(ModelResponse {
424 model: req.model.clone(),
425 text,
426 in_tokens,
427 out_tokens,
428 raw: json,
429 })
430 }
431}
432
433#[derive(Clone)]
435pub struct ProviderRegistry {
436 providers: HashMap<String, Arc<dyn Provider>>,
437}
438
439impl std::fmt::Debug for ProviderRegistry {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 f.debug_struct("ProviderRegistry")
442 .field("providers", &self.providers.keys().collect::<Vec<_>>())
443 .finish()
444 }
445}
446
447impl ProviderRegistry {
448 fn build_http_client() -> reqwest::Client {
453 reqwest::Client::builder()
454 .connect_timeout(std::time::Duration::from_secs(10))
455 .timeout(std::time::Duration::from_secs(120))
456 .build()
457 .unwrap_or_else(|_| reqwest::Client::new())
458 }
459
460 #[must_use]
462 pub fn new(anthropic_base: impl Into<String>, openai_base: impl Into<String>) -> Self {
463 Self::from_config(&[], anthropic_base, openai_base)
464 }
465
466 #[must_use]
472 pub fn from_config(
473 defs: &[firstpass_core::ProviderDef],
474 anthropic_base: impl Into<String>,
475 openai_base: impl Into<String>,
476 ) -> Self {
477 let http = Self::build_http_client();
478 let mut providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
479 providers.insert(
480 "anthropic".to_owned(),
481 Arc::new(AnthropicProvider {
482 id: "anthropic".to_owned(),
483 base_url: anthropic_base.into(),
484 api_key_env: None,
485 http: http.clone(),
486 }),
487 );
488 providers.insert(
489 "openai".to_owned(),
490 Arc::new(OpenAiProvider {
491 id: "openai".to_owned(),
492 base_url: openai_base.into(),
493 api_key_env: None,
494 http: http.clone(),
495 }),
496 );
497 for def in defs {
498 let provider: Arc<dyn Provider> = match def.dialect {
499 firstpass_core::Dialect::Anthropic => Arc::new(AnthropicProvider {
500 id: def.id.clone(),
501 base_url: def.base_url.clone(),
502 api_key_env: def.api_key_env.clone(),
503 http: http.clone(),
504 }),
505 firstpass_core::Dialect::Openai => Arc::new(OpenAiProvider {
506 id: def.id.clone(),
507 base_url: def.base_url.clone(),
508 api_key_env: def.api_key_env.clone(),
509 http: http.clone(),
510 }),
511 };
512 providers.insert(def.id.clone(), provider);
513 }
514 Self { providers }
515 }
516
517 #[must_use]
519 pub fn from_map(providers: HashMap<String, Arc<dyn Provider>>) -> Self {
520 Self { providers }
521 }
522
523 #[must_use]
525 pub fn get(&self, provider_id: &str) -> Option<Arc<dyn Provider>> {
526 self.providers.get(provider_id).cloned()
527 }
528}
529
530#[cfg(test)]
532#[derive(Debug, Clone, Default)]
533pub struct MockProvider {
534 id: String,
535 outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
536 calls: Arc<std::sync::Mutex<Vec<String>>>,
539 delay_ms: u64,
542}
543
544#[cfg(test)]
545impl MockProvider {
546 #[must_use]
548 pub fn new(
549 id: impl Into<String>,
550 outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
551 ) -> Self {
552 Self {
553 id: id.into(),
554 outcomes,
555 calls: Arc::default(),
556 delay_ms: 0,
557 }
558 }
559
560 #[must_use]
562 pub fn with_delay(mut self, ms: u64) -> Self {
563 self.delay_ms = ms;
564 self
565 }
566
567 #[must_use]
570 pub fn call_log(&self) -> Arc<std::sync::Mutex<Vec<String>>> {
571 Arc::clone(&self.calls)
572 }
573}
574
575#[cfg(test)]
576#[async_trait]
577impl Provider for MockProvider {
578 fn id(&self) -> &str {
579 &self.id
580 }
581
582 async fn complete(
583 &self,
584 req: &ModelRequest,
585 _auth: &Auth,
586 ) -> Result<ModelResponse, ProviderError> {
587 self.calls.lock().unwrap().push(req.model.clone());
588 if self.delay_ms > 0 {
589 tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
590 }
591 self.outcomes.get(&req.model).cloned().unwrap_or_else(|| {
592 Err(ProviderError::Decode(format!(
593 "no mock outcome configured for {}",
594 req.model
595 )))
596 })
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn wire_model_strips_the_provider_prefix() {
606 assert_eq!(wire_model("anthropic/claude-haiku-4-5"), "claude-haiku-4-5");
608 assert_eq!(wire_model("openai/gpt-5.5"), "gpt-5.5");
609 assert_eq!(wire_model("claude-opus-4-8"), "claude-opus-4-8"); }
611
612 #[test]
613 fn from_config_registers_custom_providers_alongside_builtins() {
614 let defs = vec![
615 firstpass_core::ProviderDef {
616 id: "groq".to_owned(),
617 dialect: firstpass_core::Dialect::Openai,
618 base_url: "https://api.groq.com/openai".to_owned(),
619 api_key_env: Some("GROQ_API_KEY".to_owned()),
620 },
621 firstpass_core::ProviderDef {
623 id: "openai".to_owned(),
624 dialect: firstpass_core::Dialect::Openai,
625 base_url: "https://my-azure.openai.azure.com".to_owned(),
626 api_key_env: Some("AZURE_OPENAI_KEY".to_owned()),
627 },
628 ];
629 let reg = ProviderRegistry::from_config(
630 &defs,
631 "https://api.anthropic.com",
632 "https://api.openai.com",
633 );
634 assert_eq!(reg.get("anthropic").unwrap().id(), "anthropic");
636 assert_eq!(reg.get("groq").unwrap().id(), "groq");
637 assert!(reg.get("does-not-exist").is_none());
639 }
640
641 #[test]
642 fn resolve_api_key_prefers_configured_env_then_byok() {
643 let path = std::env::var("PATH").expect("PATH is set");
645 assert_eq!(resolve_api_key(Some("PATH"), Some("byok")), path);
647 assert_eq!(
649 resolve_api_key(Some("FIRSTPASS_DEFINITELY_UNSET_KEY"), Some("byok")),
650 "byok"
651 );
652 assert_eq!(resolve_api_key(None, Some("byok")), "byok");
654 assert_eq!(resolve_api_key(None, None), "");
656 }
657
658 #[test]
659 fn anthropic_wire_forwards_tool_and_image_content_verbatim() {
660 let messages = [
664 ChatMessage::text("user", "hi"),
665 ChatMessage {
666 role: "assistant".to_owned(),
667 content: serde_json::json!([
668 { "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }
669 ]),
670 },
671 ChatMessage {
672 role: "user".to_owned(),
673 content: serde_json::json!([
674 { "type": "tool_result", "tool_use_id": "t1", "content": "2" },
675 { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
676 ]),
677 },
678 ];
679 let body = AnthropicWireRequest {
680 model: "claude-haiku-4-5",
681 system: None,
682 max_tokens: 64,
683 messages: messages
684 .iter()
685 .map(|m| AnthropicWireMessage {
686 role: &m.role,
687 content: &m.content,
688 })
689 .collect(),
690 };
691 let wire = serde_json::to_value(&body).unwrap();
692 assert_eq!(wire["messages"][0]["content"], serde_json::json!("hi"));
693 assert_eq!(
694 wire["messages"][1]["content"],
695 serde_json::json!([{ "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }])
696 );
697 assert_eq!(
698 wire["messages"][2]["content"],
699 serde_json::json!([
700 { "type": "tool_result", "tool_use_id": "t1", "content": "2" },
701 { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
702 ])
703 );
704 }
705
706 fn resp(model: &str, text: &str) -> ModelResponse {
707 ModelResponse {
708 model: model.to_owned(),
709 text: text.to_owned(),
710 in_tokens: 10,
711 out_tokens: 5,
712 raw: Value::Null,
713 }
714 }
715
716 #[test]
717 fn transport_and_5xx_are_failover_eligible() {
718 assert!(ProviderError::Transport("boom".into()).is_failover_eligible());
719 assert!(
720 ProviderError::Http {
721 status: 503,
722 body: String::new()
723 }
724 .is_failover_eligible()
725 );
726 }
727
728 #[test]
729 fn client_errors_and_decode_failures_are_hard() {
730 assert!(
731 !ProviderError::Http {
732 status: 400,
733 body: String::new()
734 }
735 .is_failover_eligible()
736 );
737 assert!(!ProviderError::Decode("bad json".into()).is_failover_eligible());
738 }
739
740 #[test]
741 fn auth_debug_never_prints_key_material() {
742 let auth = Auth {
743 anthropic_key: Some("sk-ant-super-secret".to_owned()),
744 openai_key: Some("sk-oai-super-secret".to_owned()),
745 };
746 let debug = format!("{auth:?}");
747 assert!(!debug.contains("super-secret"));
748 }
749
750 #[tokio::test]
751 async fn mock_provider_returns_configured_outcome() {
752 let mut outcomes = HashMap::new();
753 outcomes.insert(
754 "anthropic/claude-haiku-4-5".to_owned(),
755 Ok(resp("anthropic/claude-haiku-4-5", "hello")),
756 );
757 let provider = MockProvider::new("anthropic", outcomes);
758 let req = ModelRequest {
759 model: "anthropic/claude-haiku-4-5".to_owned(),
760 system: None,
761 messages: vec![],
762 max_tokens: 100,
763 tools: Value::Null,
764 };
765 let out = provider.complete(&req, &Auth::default()).await.unwrap();
766 assert_eq!(out.text, "hello");
767 }
768}