1use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::SystemTime;
9
10use async_trait::async_trait;
11use axum::http::HeaderMap;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ChatMessage {
22 pub role: String,
24 pub content: Value,
26}
27
28impl ChatMessage {
29 #[must_use]
31 pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
32 Self {
33 role: role.into(),
34 content: Value::String(content.into()),
35 }
36 }
37
38 #[must_use]
41 pub fn text_view(&self) -> String {
42 match &self.content {
43 Value::String(s) => s.clone(),
44 Value::Array(blocks) => blocks
45 .iter()
46 .filter_map(|b| b.get("text").and_then(Value::as_str))
47 .collect::<Vec<_>>()
48 .join("\n"),
49 _ => String::new(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ModelRequest {
61 pub model: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub system: Option<String>,
66 pub messages: Vec<ChatMessage>,
68 pub max_tokens: u32,
70 #[serde(default)]
72 pub tools: Value,
73 #[serde(default, skip_serializing_if = "Value::is_null")]
79 pub raw: Value,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ModelResponse {
85 pub model: String,
87 pub text: String,
89 pub in_tokens: u64,
91 pub out_tokens: u64,
93 pub raw: Value,
95}
96
97#[derive(Debug, Clone, thiserror::Error)]
99pub enum ProviderError {
100 #[error("transport error: {0}")]
102 Transport(String),
103 #[error("http {status}: {body}")]
105 Http {
106 status: u16,
108 body: String,
110 },
111 #[error("decode error: {0}")]
113 Decode(String),
114}
115
116impl ProviderError {
117 #[must_use]
120 pub fn is_failover_eligible(&self) -> bool {
121 match self {
122 ProviderError::Transport(_) => true,
123 ProviderError::Http { status, .. } => *status >= 500,
124 ProviderError::Decode(_) => false,
125 }
126 }
127}
128
129#[derive(Clone, Default)]
133pub struct Auth {
134 pub anthropic_key: Option<String>,
136 pub openai_key: Option<String>,
138}
139
140impl std::fmt::Debug for Auth {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.debug_struct("Auth")
143 .field("anthropic_key", &self.anthropic_key.as_ref().map(|_| "***"))
144 .field("openai_key", &self.openai_key.as_ref().map(|_| "***"))
145 .finish()
146 }
147}
148
149impl Auth {
150 #[must_use]
153 pub fn from_headers(headers: &HeaderMap) -> Self {
154 let anthropic_key = headers
155 .get("x-api-key")
156 .and_then(|v| v.to_str().ok())
157 .map(str::to_owned)
158 .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok());
159 let openai_key = headers
160 .get(axum::http::header::AUTHORIZATION)
161 .and_then(|v| v.to_str().ok())
162 .and_then(|v| v.strip_prefix("Bearer "))
163 .map(str::to_owned)
164 .or_else(|| std::env::var("OPENAI_API_KEY").ok());
165 Self {
166 anthropic_key,
167 openai_key,
168 }
169 }
170}
171
172#[async_trait]
175pub trait Provider: Send + Sync + std::fmt::Debug {
176 async fn complete(
182 &self,
183 req: &ModelRequest,
184 auth: &Auth,
185 ) -> Result<ModelResponse, ProviderError>;
186
187 fn id(&self) -> &str;
189
190 fn carries_structured_verbatim(&self, _inbound: firstpass_core::Dialect) -> bool {
205 false
206 }
207}
208
209fn wire_model(model: &str) -> &str {
213 model.split_once('/').map_or(model, |(_, m)| m)
214}
215
216fn resolve_api_key(api_key_env: Option<&str>, byok_override: Option<&str>) -> String {
221 api_key_env
222 .and_then(|e| std::env::var(e).ok())
223 .or_else(|| byok_override.map(str::to_owned))
224 .unwrap_or_default()
225}
226
227#[must_use]
232pub fn anthropic_wire_body(req: &ModelRequest) -> Value {
233 if let Value::Object(raw) = &req.raw {
234 let mut body = raw.clone();
235 body.insert(
236 "model".to_owned(),
237 Value::String(wire_model(&req.model).to_owned()),
238 );
239 body.remove("stream");
240 return Value::Object(body);
241 }
242 let messages: Vec<Value> = req
243 .messages
244 .iter()
245 .map(|m| serde_json::json!({ "role": m.role, "content": m.content }))
246 .collect();
247 let mut body = serde_json::json!({
248 "model": wire_model(&req.model),
249 "max_tokens": req.max_tokens,
250 "messages": messages,
251 });
252 if let Some(system) = req.system.as_deref() {
253 body["system"] = serde_json::json!(system);
254 }
255 if !req.tools.is_null() {
256 body["tools"] = req.tools.clone();
257 }
258 body
259}
260
261#[derive(Debug, Clone)]
267pub struct AnthropicProvider {
268 pub id: String,
270 pub base_url: String,
272 pub api_key_env: Option<String>,
275 pub http: reqwest::Client,
277}
278
279#[async_trait]
280impl Provider for AnthropicProvider {
281 fn id(&self) -> &str {
282 &self.id
283 }
284
285 fn carries_structured_verbatim(&self, inbound: firstpass_core::Dialect) -> bool {
286 inbound == firstpass_core::Dialect::Anthropic
287 }
288
289 async fn complete(
290 &self,
291 req: &ModelRequest,
292 auth: &Auth,
293 ) -> Result<ModelResponse, ProviderError> {
294 let key = resolve_api_key(self.api_key_env.as_deref(), auth.anthropic_key.as_deref());
295 let body = anthropic_wire_body(req);
296
297 let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
298 let resp = self
299 .http
300 .post(url)
301 .header("x-api-key", key)
302 .header("anthropic-version", "2023-06-01")
303 .json(&body)
304 .send()
305 .await
306 .map_err(|e| ProviderError::Transport(e.to_string()))?;
307
308 let status = resp.status();
309 let bytes = resp
310 .bytes()
311 .await
312 .map_err(|e| ProviderError::Transport(e.to_string()))?;
313
314 if !status.is_success() {
315 return Err(ProviderError::Http {
316 status: status.as_u16(),
317 body: String::from_utf8_lossy(&bytes).into_owned(),
318 });
319 }
320
321 let json: Value =
322 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
323 let (text, in_tokens, out_tokens) = anthropic_parse_response(&json)?;
324
325 Ok(ModelResponse {
326 model: req.model.clone(),
327 text,
328 in_tokens,
329 out_tokens,
330 raw: json,
331 })
332 }
333}
334
335fn anthropic_parse_response(json: &Value) -> Result<(String, u64, u64), ProviderError> {
339 let text = json
340 .get("content")
341 .and_then(Value::as_array)
342 .map(|blocks| {
343 blocks
344 .iter()
345 .filter_map(|b| b.get("text").and_then(Value::as_str))
346 .collect::<Vec<_>>()
347 .join("")
348 })
349 .ok_or_else(|| ProviderError::Decode("missing content[].text".to_owned()))?;
350
351 let in_tokens = json
352 .pointer("/usage/input_tokens")
353 .and_then(Value::as_u64)
354 .unwrap_or(0);
355 let out_tokens = json
356 .pointer("/usage/output_tokens")
357 .and_then(Value::as_u64)
358 .unwrap_or(0);
359
360 Ok((text, in_tokens, out_tokens))
361}
362
363fn anthropic_messages_body(req: &ModelRequest, anthropic_version: &str) -> Value {
368 if let Value::Object(raw) = &req.raw {
371 let mut body = raw.clone();
372 body.remove("model");
373 body.remove("stream");
374 body.insert(
375 "anthropic_version".to_owned(),
376 Value::String(anthropic_version.to_owned()),
377 );
378 return Value::Object(body);
379 }
380 let messages: Vec<Value> = req
381 .messages
382 .iter()
383 .map(|m| serde_json::json!({ "role": m.role, "content": m.content }))
384 .collect();
385 let mut body = serde_json::json!({
386 "anthropic_version": anthropic_version,
387 "max_tokens": req.max_tokens,
388 "messages": messages,
389 });
390 if let Some(system) = req.system.as_deref() {
391 body["system"] = serde_json::json!(system);
392 }
393 if !req.tools.is_null() {
394 body["tools"] = req.tools.clone();
395 }
396 body
397}
398
399#[must_use]
405pub fn openai_wire_body(req: &ModelRequest) -> Value {
406 if let Value::Object(raw) = &req.raw {
407 let mut body = raw.clone();
408 body.insert(
409 "model".to_owned(),
410 Value::String(wire_model(&req.model).to_owned()),
411 );
412 body.remove("stream");
413 return Value::Object(body);
414 }
415 let mut messages: Vec<Value> = Vec::with_capacity(req.messages.len() + 1);
417 if let Some(system) = req.system.as_deref() {
418 messages.push(serde_json::json!({ "role": "system", "content": system }));
419 }
420 messages.extend(
421 req.messages
422 .iter()
423 .map(|m| serde_json::json!({ "role": m.role, "content": m.content })),
424 );
425 let mut body = serde_json::json!({
426 "model": wire_model(&req.model),
427 "max_tokens": req.max_tokens,
428 "messages": messages,
429 });
430 if !req.tools.is_null() {
431 body["tools"] = req.tools.clone();
432 }
433 body
434}
435
436#[derive(Debug, Clone)]
442pub struct OpenAiProvider {
443 pub id: String,
445 pub base_url: String,
447 pub api_key_env: Option<String>,
451 pub http: reqwest::Client,
453}
454
455#[async_trait]
456impl Provider for OpenAiProvider {
457 fn id(&self) -> &str {
458 &self.id
459 }
460
461 fn carries_structured_verbatim(&self, inbound: firstpass_core::Dialect) -> bool {
462 inbound == firstpass_core::Dialect::Openai
463 }
464
465 async fn complete(
466 &self,
467 req: &ModelRequest,
468 auth: &Auth,
469 ) -> Result<ModelResponse, ProviderError> {
470 let key = resolve_api_key(self.api_key_env.as_deref(), auth.openai_key.as_deref());
471 let body = openai_wire_body(req);
472
473 let url = format!(
474 "{}/v1/chat/completions",
475 self.base_url.trim_end_matches('/')
476 );
477 let resp = self
478 .http
479 .post(url)
480 .header(axum::http::header::AUTHORIZATION, format!("Bearer {key}"))
481 .json(&body)
482 .send()
483 .await
484 .map_err(|e| ProviderError::Transport(e.to_string()))?;
485
486 let status = resp.status();
487 let bytes = resp
488 .bytes()
489 .await
490 .map_err(|e| ProviderError::Transport(e.to_string()))?;
491
492 if !status.is_success() {
493 return Err(ProviderError::Http {
494 status: status.as_u16(),
495 body: String::from_utf8_lossy(&bytes).into_owned(),
496 });
497 }
498
499 let json: Value =
500 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
501
502 let text = json
504 .pointer("/choices/0/message/content")
505 .and_then(Value::as_str)
506 .unwrap_or("")
507 .to_owned();
508
509 let in_tokens = json
510 .pointer("/usage/prompt_tokens")
511 .and_then(Value::as_u64)
512 .unwrap_or(0);
513 let out_tokens = json
514 .pointer("/usage/completion_tokens")
515 .and_then(Value::as_u64)
516 .unwrap_or(0);
517
518 Ok(ModelResponse {
519 model: req.model.clone(),
520 text,
521 in_tokens,
522 out_tokens,
523 raw: json,
524 })
525 }
526}
527
528fn gemini_request_body(req: &ModelRequest) -> Value {
535 let contents: Vec<Value> = req
536 .messages
537 .iter()
538 .map(|m| {
539 let role = if m.role == "assistant" {
540 "model"
541 } else {
542 "user"
543 };
544 serde_json::json!({ "role": role, "parts": [{ "text": m.text_view() }] })
545 })
546 .collect();
547 let mut body = serde_json::json!({
548 "contents": contents,
549 "generationConfig": { "maxOutputTokens": req.max_tokens },
550 });
551 if let Some(system) = req.system.as_deref() {
552 body["system_instruction"] = serde_json::json!({ "parts": [{ "text": system }] });
553 }
554 body
555}
556
557fn gemini_parse_response(json: &Value) -> Result<(String, u64, u64), ProviderError> {
560 let parts = json
561 .pointer("/candidates/0/content/parts")
562 .and_then(Value::as_array)
563 .ok_or_else(|| ProviderError::Decode("missing candidates[0].content.parts".to_owned()))?;
564 let text = parts
565 .iter()
566 .filter_map(|p| p.get("text").and_then(Value::as_str))
567 .collect::<Vec<_>>()
568 .join("");
569 let in_tokens = json
570 .pointer("/usageMetadata/promptTokenCount")
571 .and_then(Value::as_u64)
572 .unwrap_or(0);
573 let out_tokens = json
574 .pointer("/usageMetadata/candidatesTokenCount")
575 .and_then(Value::as_u64)
576 .unwrap_or(0);
577 Ok((text, in_tokens, out_tokens))
578}
579
580#[derive(Debug, Clone)]
587pub struct GeminiProvider {
588 pub id: String,
590 pub base_url: String,
592 pub api_key_env: Option<String>,
594 pub http: reqwest::Client,
596}
597
598#[async_trait]
599impl Provider for GeminiProvider {
600 fn id(&self) -> &str {
601 &self.id
602 }
603
604 async fn complete(
605 &self,
606 req: &ModelRequest,
607 auth: &Auth,
608 ) -> Result<ModelResponse, ProviderError> {
609 let key = resolve_api_key(self.api_key_env.as_deref(), auth.openai_key.as_deref());
610 let body = gemini_request_body(req);
611 let url = format!(
612 "{}/v1beta/models/{}:generateContent",
613 self.base_url.trim_end_matches('/'),
614 wire_model(&req.model),
615 );
616 let resp = self
617 .http
618 .post(url)
619 .header("x-goog-api-key", key)
620 .json(&body)
621 .send()
622 .await
623 .map_err(|e| ProviderError::Transport(e.to_string()))?;
624
625 let status = resp.status();
626 let bytes = resp
627 .bytes()
628 .await
629 .map_err(|e| ProviderError::Transport(e.to_string()))?;
630 if !status.is_success() {
631 return Err(ProviderError::Http {
632 status: status.as_u16(),
633 body: String::from_utf8_lossy(&bytes).into_owned(),
634 });
635 }
636
637 let json: Value =
638 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
639 let (text, in_tokens, out_tokens) = gemini_parse_response(&json)?;
640 Ok(ModelResponse {
641 model: req.model.clone(),
642 text,
643 in_tokens,
644 out_tokens,
645 raw: json,
646 })
647 }
648}
649
650struct AwsEnvCredentials {
654 access_key_id: String,
655 secret_access_key: String,
656 session_token: Option<String>,
657}
658
659impl AwsEnvCredentials {
660 fn from_env() -> Result<Self, ProviderError> {
661 let access_key_id = std::env::var("AWS_ACCESS_KEY_ID")
662 .map_err(|_| ProviderError::Transport("AWS_ACCESS_KEY_ID is not set".to_owned()))?;
663 let secret_access_key = std::env::var("AWS_SECRET_ACCESS_KEY")
664 .map_err(|_| ProviderError::Transport("AWS_SECRET_ACCESS_KEY is not set".to_owned()))?;
665 let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
666 Ok(Self {
667 access_key_id,
668 secret_access_key,
669 session_token,
670 })
671 }
672}
673
674fn bedrock_url(region: &str, model: &str) -> String {
676 format!("https://bedrock-runtime.{region}.amazonaws.com/model/{model}/invoke")
677}
678
679fn sign_bedrock(
690 url: &str,
691 region: &str,
692 body: &[u8],
693 creds: &AwsEnvCredentials,
694) -> Result<http::Request<Vec<u8>>, ProviderError> {
695 let host = url
696 .parse::<http::Uri>()
697 .ok()
698 .and_then(|u| u.host().map(str::to_owned))
699 .ok_or_else(|| ProviderError::Transport(format!("invalid bedrock URL: {url}")))?;
700
701 let identity: aws_smithy_runtime_api::client::identity::Identity =
702 aws_credential_types::Credentials::new(
703 creds.access_key_id.clone(),
704 creds.secret_access_key.clone(),
705 creds.session_token.clone(),
706 None,
707 "firstpass",
708 )
709 .into();
710
711 let signing_params: aws_sigv4::http_request::SigningParams<'_> =
712 aws_sigv4::sign::v4::SigningParams::builder()
713 .identity(&identity)
714 .region(region)
715 .name("bedrock")
716 .time(SystemTime::now())
717 .settings(aws_sigv4::http_request::SigningSettings::default())
718 .build()
719 .map_err(|e| ProviderError::Transport(format!("sigv4 signing params: {e}")))?
720 .into();
721
722 let headers = [
723 ("host", host.as_str()),
724 ("content-type", "application/json"),
725 ];
726 let signable = aws_sigv4::http_request::SignableRequest::new(
727 "POST",
728 url,
729 headers.into_iter(),
730 aws_sigv4::http_request::SignableBody::Bytes(body),
731 )
732 .map_err(|e| ProviderError::Transport(format!("sigv4 signable request: {e}")))?;
733
734 let (instructions, _signature) = aws_sigv4::http_request::sign(signable, &signing_params)
735 .map_err(|e| ProviderError::Transport(format!("sigv4 sign: {e}")))?
736 .into_parts();
737
738 let mut req = http::Request::builder()
739 .method("POST")
740 .uri(url)
741 .header("host", host)
742 .header("content-type", "application/json")
743 .body(body.to_vec())
744 .map_err(|e| ProviderError::Transport(format!("build bedrock request: {e}")))?;
745 instructions.apply_to_request_http1x(&mut req);
746 Ok(req)
747}
748
749#[derive(Debug, Clone)]
759pub struct BedrockProvider {
760 pub id: String,
762 pub region: Option<String>,
764 pub http: reqwest::Client,
766}
767
768#[async_trait]
769impl Provider for BedrockProvider {
770 fn id(&self) -> &str {
771 &self.id
772 }
773
774 fn carries_structured_verbatim(&self, inbound: firstpass_core::Dialect) -> bool {
775 inbound == firstpass_core::Dialect::Anthropic
776 }
777
778 async fn complete(
779 &self,
780 req: &ModelRequest,
781 _auth: &Auth,
782 ) -> Result<ModelResponse, ProviderError> {
783 let region = self.region.as_deref().ok_or_else(|| {
784 ProviderError::Transport("bedrock provider requires a region".to_owned())
785 })?;
786 let model = wire_model(&req.model);
787 let url = bedrock_url(region, model);
788 let body = anthropic_messages_body(req, "bedrock-2023-05-31");
789 let body_bytes =
790 serde_json::to_vec(&body).map_err(|e| ProviderError::Decode(e.to_string()))?;
791
792 let creds = AwsEnvCredentials::from_env()?;
793 let signed = sign_bedrock(&url, region, &body_bytes, &creds)?;
794 let http_req = reqwest::Request::try_from(signed)
795 .map_err(|e| ProviderError::Transport(format!("build reqwest request: {e}")))?;
796
797 let resp = self
798 .http
799 .execute(http_req)
800 .await
801 .map_err(|e| ProviderError::Transport(e.to_string()))?;
802
803 let status = resp.status();
804 let bytes = resp
805 .bytes()
806 .await
807 .map_err(|e| ProviderError::Transport(e.to_string()))?;
808 if !status.is_success() {
809 return Err(ProviderError::Http {
810 status: status.as_u16(),
811 body: String::from_utf8_lossy(&bytes).into_owned(),
812 });
813 }
814
815 let json: Value =
816 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
817 let (text, in_tokens, out_tokens) = anthropic_parse_response(&json)?;
818 Ok(ModelResponse {
819 model: req.model.clone(),
820 text,
821 in_tokens,
822 out_tokens,
823 raw: json,
824 })
825 }
826}
827
828fn vertex_url(region: &str, project: &str, model: &str) -> String {
830 format!(
831 "https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/anthropic/models/{model}:rawPredict"
832 )
833}
834
835#[derive(Debug, Clone)]
845pub struct VertexProvider {
846 pub id: String,
848 pub region: Option<String>,
850 pub project: Option<String>,
852 pub http: reqwest::Client,
854}
855
856#[async_trait]
857impl Provider for VertexProvider {
858 fn id(&self) -> &str {
859 &self.id
860 }
861
862 fn carries_structured_verbatim(&self, inbound: firstpass_core::Dialect) -> bool {
863 inbound == firstpass_core::Dialect::Anthropic
864 }
865
866 async fn complete(
867 &self,
868 req: &ModelRequest,
869 _auth: &Auth,
870 ) -> Result<ModelResponse, ProviderError> {
871 let region = self.region.as_deref().ok_or_else(|| {
872 ProviderError::Transport("vertex provider requires a region".to_owned())
873 })?;
874 let project = self.project.as_deref().ok_or_else(|| {
875 ProviderError::Transport("vertex provider requires a project".to_owned())
876 })?;
877 let model = wire_model(&req.model);
878 let url = vertex_url(region, project, model);
879 let body = anthropic_messages_body(req, "vertex-2023-10-16");
880
881 let provider = gcp_auth::provider()
884 .await
885 .map_err(|e| ProviderError::Transport(format!("gcp_auth provider: {e}")))?;
886 let token = provider
887 .token(&["https://www.googleapis.com/auth/cloud-platform"])
888 .await
889 .map_err(|e| ProviderError::Transport(format!("gcp_auth token: {e}")))?;
890
891 let resp = self
892 .http
893 .post(url)
894 .header(
895 axum::http::header::AUTHORIZATION,
896 format!("Bearer {}", token.as_str()),
897 )
898 .json(&body)
899 .send()
900 .await
901 .map_err(|e| ProviderError::Transport(e.to_string()))?;
902
903 let status = resp.status();
904 let bytes = resp
905 .bytes()
906 .await
907 .map_err(|e| ProviderError::Transport(e.to_string()))?;
908 if !status.is_success() {
909 return Err(ProviderError::Http {
910 status: status.as_u16(),
911 body: String::from_utf8_lossy(&bytes).into_owned(),
912 });
913 }
914
915 let json: Value =
916 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
917 let (text, in_tokens, out_tokens) = anthropic_parse_response(&json)?;
918 Ok(ModelResponse {
919 model: req.model.clone(),
920 text,
921 in_tokens,
922 out_tokens,
923 raw: json,
924 })
925 }
926}
927
928#[derive(Clone)]
930pub struct ProviderRegistry {
931 providers: HashMap<String, Arc<dyn Provider>>,
932}
933
934impl std::fmt::Debug for ProviderRegistry {
935 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
936 f.debug_struct("ProviderRegistry")
937 .field("providers", &self.providers.keys().collect::<Vec<_>>())
938 .finish()
939 }
940}
941
942impl ProviderRegistry {
943 fn build_http_client() -> reqwest::Client {
948 reqwest::Client::builder()
949 .connect_timeout(std::time::Duration::from_secs(10))
950 .timeout(std::time::Duration::from_secs(120))
951 .build()
952 .unwrap_or_else(|_| reqwest::Client::new())
953 }
954
955 #[must_use]
957 pub fn new(anthropic_base: impl Into<String>, openai_base: impl Into<String>) -> Self {
958 Self::from_config(&[], anthropic_base, openai_base)
959 }
960
961 #[must_use]
967 pub fn from_config(
968 defs: &[firstpass_core::ProviderDef],
969 anthropic_base: impl Into<String>,
970 openai_base: impl Into<String>,
971 ) -> Self {
972 let http = Self::build_http_client();
973 let mut providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
974 providers.insert(
975 "anthropic".to_owned(),
976 Arc::new(AnthropicProvider {
977 id: "anthropic".to_owned(),
978 base_url: anthropic_base.into(),
979 api_key_env: None,
980 http: http.clone(),
981 }),
982 );
983 providers.insert(
984 "openai".to_owned(),
985 Arc::new(OpenAiProvider {
986 id: "openai".to_owned(),
987 base_url: openai_base.into(),
988 api_key_env: None,
989 http: http.clone(),
990 }),
991 );
992 for def in defs {
993 let provider: Arc<dyn Provider> = match def.auth {
997 firstpass_core::AuthScheme::AwsSigv4 => Arc::new(BedrockProvider {
998 id: def.id.clone(),
999 region: def.region.clone(),
1000 http: http.clone(),
1001 }),
1002 firstpass_core::AuthScheme::GcpOauth => Arc::new(VertexProvider {
1003 id: def.id.clone(),
1004 region: def.region.clone(),
1005 project: def.project.clone(),
1006 http: http.clone(),
1007 }),
1008 firstpass_core::AuthScheme::ApiKey => match def.dialect {
1009 firstpass_core::Dialect::Anthropic => Arc::new(AnthropicProvider {
1010 id: def.id.clone(),
1011 base_url: def.base_url.clone(),
1012 api_key_env: def.api_key_env.clone(),
1013 http: http.clone(),
1014 }),
1015 firstpass_core::Dialect::Openai => Arc::new(OpenAiProvider {
1016 id: def.id.clone(),
1017 base_url: def.base_url.clone(),
1018 api_key_env: def.api_key_env.clone(),
1019 http: http.clone(),
1020 }),
1021 firstpass_core::Dialect::Gemini => Arc::new(GeminiProvider {
1022 id: def.id.clone(),
1023 base_url: def.base_url.clone(),
1024 api_key_env: def.api_key_env.clone(),
1025 http: http.clone(),
1026 }),
1027 },
1028 };
1029 providers.insert(def.id.clone(), provider);
1030 }
1031 Self { providers }
1032 }
1033
1034 #[must_use]
1036 pub fn from_map(providers: HashMap<String, Arc<dyn Provider>>) -> Self {
1037 Self { providers }
1038 }
1039
1040 #[must_use]
1042 pub fn get(&self, provider_id: &str) -> Option<Arc<dyn Provider>> {
1043 self.providers.get(provider_id).cloned()
1044 }
1045}
1046
1047#[cfg(test)]
1049#[derive(Debug, Clone, Default)]
1050pub struct MockProvider {
1051 id: String,
1052 outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
1053 calls: Arc<std::sync::Mutex<Vec<String>>>,
1056 delay_ms: u64,
1059}
1060
1061#[cfg(test)]
1062impl MockProvider {
1063 #[must_use]
1065 pub fn new(
1066 id: impl Into<String>,
1067 outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
1068 ) -> Self {
1069 Self {
1070 id: id.into(),
1071 outcomes,
1072 calls: Arc::default(),
1073 delay_ms: 0,
1074 }
1075 }
1076
1077 #[must_use]
1079 pub fn with_delay(mut self, ms: u64) -> Self {
1080 self.delay_ms = ms;
1081 self
1082 }
1083
1084 #[must_use]
1087 pub fn call_log(&self) -> Arc<std::sync::Mutex<Vec<String>>> {
1088 Arc::clone(&self.calls)
1089 }
1090}
1091
1092#[cfg(test)]
1093#[async_trait]
1094impl Provider for MockProvider {
1095 fn id(&self) -> &str {
1096 &self.id
1097 }
1098
1099 fn carries_structured_verbatim(&self, _inbound: firstpass_core::Dialect) -> bool {
1102 true
1103 }
1104
1105 async fn complete(
1106 &self,
1107 req: &ModelRequest,
1108 _auth: &Auth,
1109 ) -> Result<ModelResponse, ProviderError> {
1110 self.calls.lock().unwrap().push(req.model.clone());
1111 if self.delay_ms > 0 {
1112 tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
1113 }
1114 self.outcomes.get(&req.model).cloned().unwrap_or_else(|| {
1115 Err(ProviderError::Decode(format!(
1116 "no mock outcome configured for {}",
1117 req.model
1118 )))
1119 })
1120 }
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126
1127 #[test]
1128 fn wire_model_strips_the_provider_prefix() {
1129 assert_eq!(wire_model("anthropic/claude-haiku-4-5"), "claude-haiku-4-5");
1131 assert_eq!(wire_model("openai/gpt-5.5"), "gpt-5.5");
1132 assert_eq!(wire_model("claude-opus-4-8"), "claude-opus-4-8"); }
1134
1135 #[test]
1136 fn gemini_request_maps_roles_and_system_instruction() {
1137 let req = ModelRequest {
1138 model: "gemini/gemini-2.0-flash".to_owned(),
1139 system: Some("be terse".to_owned()),
1140 messages: vec![
1141 ChatMessage::text("user", "hi"),
1142 ChatMessage::text("assistant", "hello"),
1143 ],
1144 max_tokens: 256,
1145 tools: Value::Null,
1146 raw: Value::Null,
1147 };
1148 let body = gemini_request_body(&req);
1149 assert_eq!(body["system_instruction"]["parts"][0]["text"], "be terse");
1151 assert_eq!(body["generationConfig"]["maxOutputTokens"], 256);
1152 assert_eq!(body["contents"][0]["role"], "user");
1154 assert_eq!(body["contents"][0]["parts"][0]["text"], "hi");
1155 assert_eq!(body["contents"][1]["role"], "model");
1156 assert_eq!(body["contents"][1]["parts"][0]["text"], "hello");
1157 }
1158
1159 #[test]
1160 fn gemini_response_parses_text_and_usage() {
1161 let json = serde_json::json!({
1162 "candidates": [{ "content": { "role": "model", "parts": [
1163 { "text": "the answer " }, { "text": "is 42" }
1164 ] } }],
1165 "usageMetadata": { "promptTokenCount": 11, "candidatesTokenCount": 4 }
1166 });
1167 let (text, in_tok, out_tok) = gemini_parse_response(&json).unwrap();
1168 assert_eq!(text, "the answer is 42");
1169 assert_eq!(in_tok, 11);
1170 assert_eq!(out_tok, 4);
1171 assert!(gemini_parse_response(&serde_json::json!({ "candidates": [] })).is_err());
1173 }
1174
1175 #[test]
1176 fn from_config_wires_the_gemini_dialect() {
1177 let defs = vec![firstpass_core::ProviderDef {
1178 id: "gemini".to_owned(),
1179 dialect: firstpass_core::Dialect::Gemini,
1180 base_url: "https://generativelanguage.googleapis.com".to_owned(),
1181 api_key_env: Some("GEMINI_API_KEY".to_owned()),
1182 auth: firstpass_core::AuthScheme::ApiKey,
1183 region: None,
1184 project: None,
1185 }];
1186 let reg = ProviderRegistry::from_config(
1187 &defs,
1188 "https://api.anthropic.com",
1189 "https://api.openai.com",
1190 );
1191 assert_eq!(reg.get("gemini").unwrap().id(), "gemini");
1192 }
1193
1194 #[test]
1195 fn from_config_registers_custom_providers_alongside_builtins() {
1196 let defs = vec![
1197 firstpass_core::ProviderDef {
1198 id: "groq".to_owned(),
1199 dialect: firstpass_core::Dialect::Openai,
1200 base_url: "https://api.groq.com/openai".to_owned(),
1201 api_key_env: Some("GROQ_API_KEY".to_owned()),
1202 auth: firstpass_core::AuthScheme::ApiKey,
1203 region: None,
1204 project: None,
1205 },
1206 firstpass_core::ProviderDef {
1208 id: "openai".to_owned(),
1209 dialect: firstpass_core::Dialect::Openai,
1210 base_url: "https://my-azure.openai.azure.com".to_owned(),
1211 api_key_env: Some("AZURE_OPENAI_KEY".to_owned()),
1212 auth: firstpass_core::AuthScheme::ApiKey,
1213 region: None,
1214 project: None,
1215 },
1216 ];
1217 let reg = ProviderRegistry::from_config(
1218 &defs,
1219 "https://api.anthropic.com",
1220 "https://api.openai.com",
1221 );
1222 assert_eq!(reg.get("anthropic").unwrap().id(), "anthropic");
1224 assert_eq!(reg.get("groq").unwrap().id(), "groq");
1225 assert!(reg.get("does-not-exist").is_none());
1227 }
1228
1229 #[test]
1230 fn anthropic_messages_body_omits_model_and_includes_system_only_when_set() {
1231 let req = ModelRequest {
1232 model: "bedrock/anthropic.claude-3-5-haiku".to_owned(),
1233 system: Some("be terse".to_owned()),
1234 messages: vec![
1235 ChatMessage::text("user", "hi"),
1236 ChatMessage {
1237 role: "assistant".to_owned(),
1238 content: serde_json::json!([{ "type": "text", "text": "hello" }]),
1239 },
1240 ],
1241 max_tokens: 128,
1242 tools: Value::Null,
1243 raw: Value::Null,
1244 };
1245 let body = anthropic_messages_body(&req, "bedrock-2023-05-31");
1246 assert!(body.get("model").is_none());
1248 assert_eq!(body["anthropic_version"], "bedrock-2023-05-31");
1249 assert_eq!(body["max_tokens"], 128);
1250 assert_eq!(body["system"], "be terse");
1251 assert_eq!(body["messages"][0]["content"], serde_json::json!("hi"));
1252 assert_eq!(
1254 body["messages"][1]["content"],
1255 serde_json::json!([{ "type": "text", "text": "hello" }])
1256 );
1257
1258 let req_no_system = ModelRequest {
1260 system: None,
1261 ..req
1262 };
1263 let body2 = anthropic_messages_body(&req_no_system, "vertex-2023-10-16");
1264 assert!(body2.get("system").is_none());
1265 }
1266
1267 #[test]
1268 fn bedrock_url_construction_and_missing_region() {
1269 assert_eq!(
1270 bedrock_url("us-east-1", "anthropic.claude-3-5-haiku-20241022-v1:0"),
1271 "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-haiku-20241022-v1:0/invoke"
1272 );
1273 }
1274
1275 #[tokio::test]
1276 async fn bedrock_complete_errors_without_a_region() {
1277 let provider = BedrockProvider {
1278 id: "bedrock".to_owned(),
1279 region: None,
1280 http: reqwest::Client::new(),
1281 };
1282 let req = ModelRequest {
1283 model: "bedrock/anthropic.claude-3-5-haiku".to_owned(),
1284 system: None,
1285 messages: vec![],
1286 max_tokens: 16,
1287 tools: Value::Null,
1288 raw: Value::Null,
1289 };
1290 let err = provider.complete(&req, &Auth::default()).await.unwrap_err();
1291 assert!(matches!(err, ProviderError::Transport(_)));
1292 }
1293
1294 #[test]
1295 fn bedrock_signing_produces_a_sigv4_authorization_header() {
1296 let creds = AwsEnvCredentials {
1299 access_key_id: "AKIDEXAMPLE".to_owned(),
1300 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_owned(),
1301 session_token: None,
1302 };
1303 let url = bedrock_url("us-east-1", "anthropic.claude-3-5-haiku");
1304 let body = br#"{"anthropic_version":"bedrock-2023-05-31"}"#;
1305 let signed = sign_bedrock(&url, "us-east-1", body, &creds).unwrap();
1306
1307 let auth_header = signed
1308 .headers()
1309 .get("authorization")
1310 .and_then(|v| v.to_str().ok())
1311 .expect("authorization header present");
1312 assert!(auth_header.starts_with("AWS4-HMAC-SHA256"));
1313
1314 let host_header = signed
1315 .headers()
1316 .get("host")
1317 .and_then(|v| v.to_str().ok())
1318 .expect("host header present");
1319 assert_eq!(host_header, "bedrock-runtime.us-east-1.amazonaws.com");
1320 }
1321
1322 #[test]
1323 fn vertex_url_construction_and_missing_project() {
1324 assert_eq!(
1325 vertex_url("us-central1", "my-project", "claude-3-5-sonnet"),
1326 "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/anthropic/models/claude-3-5-sonnet:rawPredict"
1327 );
1328 }
1329
1330 #[tokio::test]
1331 async fn vertex_complete_errors_without_a_project() {
1332 let provider = VertexProvider {
1333 id: "vertex".to_owned(),
1334 region: Some("us-central1".to_owned()),
1335 project: None,
1336 http: reqwest::Client::new(),
1337 };
1338 let req = ModelRequest {
1339 model: "vertex/claude-3-5-sonnet".to_owned(),
1340 system: None,
1341 messages: vec![],
1342 max_tokens: 16,
1343 tools: Value::Null,
1344 raw: Value::Null,
1345 };
1346 let err = provider.complete(&req, &Auth::default()).await.unwrap_err();
1347 assert!(matches!(err, ProviderError::Transport(_)));
1348 }
1349
1350 #[test]
1351 fn from_config_wires_bedrock_and_vertex_auth_schemes() {
1352 let defs = vec![
1353 firstpass_core::ProviderDef {
1354 id: "bedrock".to_owned(),
1355 dialect: firstpass_core::Dialect::Anthropic,
1356 base_url: String::new(),
1357 api_key_env: None,
1358 auth: firstpass_core::AuthScheme::AwsSigv4,
1359 region: Some("us-east-1".to_owned()),
1360 project: None,
1361 },
1362 firstpass_core::ProviderDef {
1363 id: "vertex".to_owned(),
1364 dialect: firstpass_core::Dialect::Anthropic,
1365 base_url: String::new(),
1366 api_key_env: None,
1367 auth: firstpass_core::AuthScheme::GcpOauth,
1368 region: Some("us-central1".to_owned()),
1369 project: Some("my-project".to_owned()),
1370 },
1371 ];
1372 let reg = ProviderRegistry::from_config(
1373 &defs,
1374 "https://api.anthropic.com",
1375 "https://api.openai.com",
1376 );
1377 assert_eq!(reg.get("bedrock").unwrap().id(), "bedrock");
1378 assert_eq!(reg.get("vertex").unwrap().id(), "vertex");
1379 }
1380
1381 #[test]
1382 fn resolve_api_key_prefers_configured_env_then_byok() {
1383 let path = std::env::var("PATH").expect("PATH is set");
1385 assert_eq!(resolve_api_key(Some("PATH"), Some("byok")), path);
1387 assert_eq!(
1389 resolve_api_key(Some("FIRSTPASS_DEFINITELY_UNSET_KEY"), Some("byok")),
1390 "byok"
1391 );
1392 assert_eq!(resolve_api_key(None, Some("byok")), "byok");
1394 assert_eq!(resolve_api_key(None, None), "");
1396 }
1397
1398 #[test]
1399 fn anthropic_wire_forwards_tool_and_image_content_verbatim() {
1400 let messages = vec![
1404 ChatMessage::text("user", "hi"),
1405 ChatMessage {
1406 role: "assistant".to_owned(),
1407 content: serde_json::json!([
1408 { "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }
1409 ]),
1410 },
1411 ChatMessage {
1412 role: "user".to_owned(),
1413 content: serde_json::json!([
1414 { "type": "tool_result", "tool_use_id": "t1", "content": "2" },
1415 { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
1416 ]),
1417 },
1418 ];
1419 let wire = anthropic_wire_body(&ModelRequest {
1420 model: "anthropic/claude-haiku-4-5".to_owned(),
1421 system: None,
1422 messages,
1423 max_tokens: 64,
1424 tools: Value::Null,
1425 raw: Value::Null,
1426 });
1427 assert_eq!(wire["messages"][0]["content"], serde_json::json!("hi"));
1428 assert_eq!(
1429 wire["messages"][1]["content"],
1430 serde_json::json!([{ "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }])
1431 );
1432 assert_eq!(
1433 wire["messages"][2]["content"],
1434 serde_json::json!([
1435 { "type": "tool_result", "tool_use_id": "t1", "content": "2" },
1436 { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
1437 ])
1438 );
1439 }
1440
1441 fn resp(model: &str, text: &str) -> ModelResponse {
1442 ModelResponse {
1443 model: model.to_owned(),
1444 text: text.to_owned(),
1445 in_tokens: 10,
1446 out_tokens: 5,
1447 raw: Value::Null,
1448 }
1449 }
1450
1451 #[test]
1452 fn transport_and_5xx_are_failover_eligible() {
1453 assert!(ProviderError::Transport("boom".into()).is_failover_eligible());
1454 assert!(
1455 ProviderError::Http {
1456 status: 503,
1457 body: String::new()
1458 }
1459 .is_failover_eligible()
1460 );
1461 }
1462
1463 #[test]
1464 fn client_errors_and_decode_failures_are_hard() {
1465 assert!(
1466 !ProviderError::Http {
1467 status: 400,
1468 body: String::new()
1469 }
1470 .is_failover_eligible()
1471 );
1472 assert!(!ProviderError::Decode("bad json".into()).is_failover_eligible());
1473 }
1474
1475 #[test]
1476 fn auth_debug_never_prints_key_material() {
1477 let auth = Auth {
1478 anthropic_key: Some("sk-ant-super-secret".to_owned()),
1479 openai_key: Some("sk-oai-super-secret".to_owned()),
1480 };
1481 let debug = format!("{auth:?}");
1482 assert!(!debug.contains("super-secret"));
1483 }
1484
1485 #[tokio::test]
1486 async fn mock_provider_returns_configured_outcome() {
1487 let mut outcomes = HashMap::new();
1488 outcomes.insert(
1489 "anthropic/claude-haiku-4-5".to_owned(),
1490 Ok(resp("anthropic/claude-haiku-4-5", "hello")),
1491 );
1492 let provider = MockProvider::new("anthropic", outcomes);
1493 let req = ModelRequest {
1494 model: "anthropic/claude-haiku-4-5".to_owned(),
1495 system: None,
1496 messages: vec![],
1497 max_tokens: 100,
1498 tools: Value::Null,
1499 raw: Value::Null,
1500 };
1501 let out = provider.complete(&req, &Auth::default()).await.unwrap();
1502 assert_eq!(out.text, "hello");
1503 }
1504
1505 #[test]
1506 fn anthropic_wire_body_carries_raw_request_verbatim() {
1507 let raw = serde_json::json!({
1511 "model": "claude-opus-4-8",
1512 "stream": true,
1513 "max_tokens": 512,
1514 "temperature": 0.2,
1515 "tool_choice": { "type": "auto" },
1516 "tools": [{ "name": "get_weather", "input_schema": { "type": "object" } }],
1517 "thinking": { "type": "enabled", "budget_tokens": 1024 },
1518 "messages": [{ "role": "user", "content": "hi" }]
1519 });
1520 let req = ModelRequest {
1521 model: "anthropic/claude-haiku-4-5".to_owned(),
1522 system: None,
1523 messages: vec![ChatMessage::text("user", "hi")],
1524 max_tokens: 512,
1525 tools: raw["tools"].clone(),
1526 raw: raw.clone(),
1527 };
1528 let wire = anthropic_wire_body(&req);
1529 assert_eq!(wire["model"], "claude-haiku-4-5", "rung model swapped in");
1530 assert!(wire.get("stream").is_none(), "stream stripped");
1531 for field in [
1532 "temperature",
1533 "tool_choice",
1534 "tools",
1535 "thinking",
1536 "max_tokens",
1537 "messages",
1538 ] {
1539 assert_eq!(
1540 wire[field], raw[field],
1541 "field {field} must survive verbatim"
1542 );
1543 }
1544 }
1545
1546 #[test]
1547 fn bedrock_vertex_body_carries_raw_minus_model_plus_version() {
1548 let raw = serde_json::json!({
1549 "model": "claude-haiku-4-5",
1550 "stream": true,
1551 "max_tokens": 64,
1552 "tools": [{ "name": "t" }],
1553 "messages": [{ "role": "user", "content": "hi" }]
1554 });
1555 let req = ModelRequest {
1556 model: "bedrock/anthropic.claude-haiku".to_owned(),
1557 system: None,
1558 messages: vec![ChatMessage::text("user", "hi")],
1559 max_tokens: 64,
1560 tools: raw["tools"].clone(),
1561 raw: raw.clone(),
1562 };
1563 let body = anthropic_messages_body(&req, "bedrock-2023-05-31");
1564 assert!(body.get("model").is_none(), "model lives in the URL");
1565 assert!(body.get("stream").is_none());
1566 assert_eq!(body["anthropic_version"], "bedrock-2023-05-31");
1567 assert_eq!(body["tools"], raw["tools"]);
1568 assert_eq!(body["messages"], raw["messages"]);
1569 }
1570
1571 #[test]
1572 fn verbatim_carriers_are_dialect_aware() {
1573 use firstpass_core::Dialect;
1574 let reg = ProviderRegistry::new("http://localhost", "http://localhost");
1575 assert!(
1577 reg.get("anthropic")
1578 .unwrap()
1579 .carries_structured_verbatim(Dialect::Anthropic)
1580 );
1581 assert!(
1582 !reg.get("anthropic")
1583 .unwrap()
1584 .carries_structured_verbatim(Dialect::Openai)
1585 );
1586 assert!(
1588 reg.get("openai")
1589 .unwrap()
1590 .carries_structured_verbatim(Dialect::Openai)
1591 );
1592 assert!(
1593 !reg.get("openai")
1594 .unwrap()
1595 .carries_structured_verbatim(Dialect::Anthropic)
1596 );
1597 }
1598
1599 #[test]
1600 fn openai_wire_body_carries_raw_request_verbatim() {
1601 let raw = serde_json::json!({
1603 "model": "gpt-4o",
1604 "stream": true,
1605 "max_tokens": 256,
1606 "temperature": 0.7,
1607 "response_format": { "type": "json_object" },
1608 "tool_choice": "auto",
1609 "tools": [{ "type": "function", "function": { "name": "get_weather", "parameters": {} } }],
1610 "messages": [{ "role": "user", "content": "hi" }]
1611 });
1612 let req = ModelRequest {
1613 model: "openai/gpt-4.1-mini".to_owned(),
1614 system: None,
1615 messages: vec![ChatMessage::text("user", "hi")],
1616 max_tokens: 256,
1617 tools: Value::Null,
1618 raw: raw.clone(),
1619 };
1620 let wire = openai_wire_body(&req);
1621 assert_eq!(wire["model"], "gpt-4.1-mini", "rung model swapped in");
1622 assert!(wire.get("stream").is_none(), "stream stripped");
1623 for field in [
1624 "temperature",
1625 "tool_choice",
1626 "tools",
1627 "response_format",
1628 "messages",
1629 "max_tokens",
1630 ] {
1631 assert_eq!(
1632 wire[field], raw[field],
1633 "field {field} must survive verbatim"
1634 );
1635 }
1636 }
1637
1638 #[test]
1639 fn openai_wire_body_falls_back_to_normalized_fields() {
1640 let req = ModelRequest {
1642 model: "openai/gpt-4.1-mini".to_owned(),
1643 system: Some("be terse".to_owned()),
1644 messages: vec![
1645 ChatMessage::text("user", "hello"),
1646 ChatMessage::text("assistant", "hi"),
1647 ],
1648 max_tokens: 128,
1649 tools: Value::Null,
1650 raw: Value::Null,
1651 };
1652 let wire = openai_wire_body(&req);
1653 assert_eq!(wire["model"], "gpt-4.1-mini");
1654 assert_eq!(wire["messages"][0]["role"], "system");
1656 assert_eq!(wire["messages"][0]["content"], "be terse");
1657 assert_eq!(wire["messages"][1]["role"], "user");
1658 assert_eq!(wire["messages"][2]["role"], "assistant");
1659 }
1660}