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)]
16pub struct ChatMessage {
17 pub role: String,
19 pub content: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ModelRequest {
30 pub model: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub system: Option<String>,
35 pub messages: Vec<ChatMessage>,
37 pub max_tokens: u32,
39 #[serde(default)]
41 pub tools: Value,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ModelResponse {
47 pub model: String,
49 pub text: String,
51 pub in_tokens: u64,
53 pub out_tokens: u64,
55 pub raw: Value,
57}
58
59#[derive(Debug, Clone, thiserror::Error)]
61pub enum ProviderError {
62 #[error("transport error: {0}")]
64 Transport(String),
65 #[error("http {status}: {body}")]
67 Http {
68 status: u16,
70 body: String,
72 },
73 #[error("decode error: {0}")]
75 Decode(String),
76}
77
78impl ProviderError {
79 #[must_use]
82 pub fn is_failover_eligible(&self) -> bool {
83 match self {
84 ProviderError::Transport(_) => true,
85 ProviderError::Http { status, .. } => *status >= 500,
86 ProviderError::Decode(_) => false,
87 }
88 }
89}
90
91#[derive(Clone, Default)]
95pub struct Auth {
96 pub anthropic_key: Option<String>,
98 pub openai_key: Option<String>,
100}
101
102impl std::fmt::Debug for Auth {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.debug_struct("Auth")
105 .field("anthropic_key", &self.anthropic_key.as_ref().map(|_| "***"))
106 .field("openai_key", &self.openai_key.as_ref().map(|_| "***"))
107 .finish()
108 }
109}
110
111impl Auth {
112 #[must_use]
115 pub fn from_headers(headers: &HeaderMap) -> Self {
116 let anthropic_key = headers
117 .get("x-api-key")
118 .and_then(|v| v.to_str().ok())
119 .map(str::to_owned)
120 .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok());
121 let openai_key = headers
122 .get(axum::http::header::AUTHORIZATION)
123 .and_then(|v| v.to_str().ok())
124 .and_then(|v| v.strip_prefix("Bearer "))
125 .map(str::to_owned)
126 .or_else(|| std::env::var("OPENAI_API_KEY").ok());
127 Self {
128 anthropic_key,
129 openai_key,
130 }
131 }
132}
133
134#[async_trait]
137pub trait Provider: Send + Sync + std::fmt::Debug {
138 async fn complete(
144 &self,
145 req: &ModelRequest,
146 auth: &Auth,
147 ) -> Result<ModelResponse, ProviderError>;
148
149 fn id(&self) -> &str;
151}
152
153#[derive(Serialize)]
154struct AnthropicWireMessage<'a> {
155 role: &'a str,
156 content: &'a str,
157}
158
159fn wire_model(model: &str) -> &str {
163 model.split_once('/').map_or(model, |(_, m)| m)
164}
165
166#[derive(Serialize)]
167struct AnthropicWireRequest<'a> {
168 model: &'a str,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 system: Option<&'a str>,
171 max_tokens: u32,
172 messages: Vec<AnthropicWireMessage<'a>>,
173}
174
175#[derive(Debug, Clone)]
181pub struct AnthropicProvider {
182 pub base_url: String,
184 pub http: reqwest::Client,
186}
187
188#[async_trait]
189impl Provider for AnthropicProvider {
190 fn id(&self) -> &str {
191 "anthropic"
192 }
193
194 async fn complete(
195 &self,
196 req: &ModelRequest,
197 auth: &Auth,
198 ) -> Result<ModelResponse, ProviderError> {
199 let key = auth.anthropic_key.as_deref().unwrap_or_default();
200 let body = AnthropicWireRequest {
201 model: wire_model(&req.model),
202 system: req.system.as_deref(),
203 max_tokens: req.max_tokens,
204 messages: req
205 .messages
206 .iter()
207 .map(|m| AnthropicWireMessage {
208 role: &m.role,
209 content: &m.content,
210 })
211 .collect(),
212 };
213
214 let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
215 let resp = self
216 .http
217 .post(url)
218 .header("x-api-key", key)
219 .header("anthropic-version", "2023-06-01")
220 .json(&body)
221 .send()
222 .await
223 .map_err(|e| ProviderError::Transport(e.to_string()))?;
224
225 let status = resp.status();
226 let bytes = resp
227 .bytes()
228 .await
229 .map_err(|e| ProviderError::Transport(e.to_string()))?;
230
231 if !status.is_success() {
232 return Err(ProviderError::Http {
233 status: status.as_u16(),
234 body: String::from_utf8_lossy(&bytes).into_owned(),
235 });
236 }
237
238 let json: Value =
239 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
240
241 let text = json
242 .get("content")
243 .and_then(Value::as_array)
244 .map(|blocks| {
245 blocks
246 .iter()
247 .filter_map(|b| b.get("text").and_then(Value::as_str))
248 .collect::<Vec<_>>()
249 .join("")
250 })
251 .ok_or_else(|| ProviderError::Decode("missing content[].text".to_owned()))?;
252
253 let in_tokens = json
254 .pointer("/usage/input_tokens")
255 .and_then(Value::as_u64)
256 .unwrap_or(0);
257 let out_tokens = json
258 .pointer("/usage/output_tokens")
259 .and_then(Value::as_u64)
260 .unwrap_or(0);
261
262 Ok(ModelResponse {
263 model: req.model.clone(),
264 text,
265 in_tokens,
266 out_tokens,
267 raw: json,
268 })
269 }
270}
271
272#[derive(Serialize)]
273struct OpenAiWireMessage<'a> {
274 role: &'a str,
275 content: &'a str,
276}
277
278#[derive(Serialize)]
279struct OpenAiWireRequest<'a> {
280 model: &'a str,
281 max_tokens: u32,
282 messages: Vec<OpenAiWireMessage<'a>>,
283}
284
285#[derive(Debug, Clone)]
291pub struct OpenAiProvider {
292 pub base_url: String,
294 pub http: reqwest::Client,
296}
297
298#[async_trait]
299impl Provider for OpenAiProvider {
300 fn id(&self) -> &str {
301 "openai"
302 }
303
304 async fn complete(
305 &self,
306 req: &ModelRequest,
307 auth: &Auth,
308 ) -> Result<ModelResponse, ProviderError> {
309 let key = auth.openai_key.as_deref().unwrap_or_default();
310 let mut messages = Vec::with_capacity(req.messages.len() + 1);
311 if let Some(system) = req.system.as_deref() {
312 messages.push(OpenAiWireMessage {
313 role: "system",
314 content: system,
315 });
316 }
317 messages.extend(req.messages.iter().map(|m| OpenAiWireMessage {
318 role: &m.role,
319 content: &m.content,
320 }));
321 let body = OpenAiWireRequest {
322 model: wire_model(&req.model),
323 max_tokens: req.max_tokens,
324 messages,
325 };
326
327 let url = format!(
328 "{}/v1/chat/completions",
329 self.base_url.trim_end_matches('/')
330 );
331 let resp = self
332 .http
333 .post(url)
334 .header(axum::http::header::AUTHORIZATION, format!("Bearer {key}"))
335 .json(&body)
336 .send()
337 .await
338 .map_err(|e| ProviderError::Transport(e.to_string()))?;
339
340 let status = resp.status();
341 let bytes = resp
342 .bytes()
343 .await
344 .map_err(|e| ProviderError::Transport(e.to_string()))?;
345
346 if !status.is_success() {
347 return Err(ProviderError::Http {
348 status: status.as_u16(),
349 body: String::from_utf8_lossy(&bytes).into_owned(),
350 });
351 }
352
353 let json: Value =
354 serde_json::from_slice(&bytes).map_err(|e| ProviderError::Decode(e.to_string()))?;
355
356 let text = json
357 .pointer("/choices/0/message/content")
358 .and_then(Value::as_str)
359 .ok_or_else(|| ProviderError::Decode("missing choices[0].message.content".to_owned()))?
360 .to_owned();
361
362 let in_tokens = json
363 .pointer("/usage/prompt_tokens")
364 .and_then(Value::as_u64)
365 .unwrap_or(0);
366 let out_tokens = json
367 .pointer("/usage/completion_tokens")
368 .and_then(Value::as_u64)
369 .unwrap_or(0);
370
371 Ok(ModelResponse {
372 model: req.model.clone(),
373 text,
374 in_tokens,
375 out_tokens,
376 raw: json,
377 })
378 }
379}
380
381#[derive(Clone)]
383pub struct ProviderRegistry {
384 providers: HashMap<String, Arc<dyn Provider>>,
385}
386
387impl std::fmt::Debug for ProviderRegistry {
388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389 f.debug_struct("ProviderRegistry")
390 .field("providers", &self.providers.keys().collect::<Vec<_>>())
391 .finish()
392 }
393}
394
395impl ProviderRegistry {
396 #[must_use]
403 pub fn new(anthropic_base: impl Into<String>, openai_base: impl Into<String>) -> Self {
404 let http = reqwest::Client::builder()
405 .connect_timeout(std::time::Duration::from_secs(10))
406 .timeout(std::time::Duration::from_secs(120))
407 .build()
408 .unwrap_or_else(|_| reqwest::Client::new());
409 let mut providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
410 providers.insert(
411 "anthropic".to_owned(),
412 Arc::new(AnthropicProvider {
413 base_url: anthropic_base.into(),
414 http: http.clone(),
415 }),
416 );
417 providers.insert(
418 "openai".to_owned(),
419 Arc::new(OpenAiProvider {
420 base_url: openai_base.into(),
421 http,
422 }),
423 );
424 Self { providers }
425 }
426
427 #[must_use]
429 pub fn from_map(providers: HashMap<String, Arc<dyn Provider>>) -> Self {
430 Self { providers }
431 }
432
433 #[must_use]
435 pub fn get(&self, provider_id: &str) -> Option<Arc<dyn Provider>> {
436 self.providers.get(provider_id).cloned()
437 }
438}
439
440#[cfg(test)]
442#[derive(Debug, Clone, Default)]
443pub struct MockProvider {
444 id: String,
445 outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
446 calls: Arc<std::sync::Mutex<Vec<String>>>,
449 delay_ms: u64,
452}
453
454#[cfg(test)]
455impl MockProvider {
456 #[must_use]
458 pub fn new(
459 id: impl Into<String>,
460 outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
461 ) -> Self {
462 Self {
463 id: id.into(),
464 outcomes,
465 calls: Arc::default(),
466 delay_ms: 0,
467 }
468 }
469
470 #[must_use]
472 pub fn with_delay(mut self, ms: u64) -> Self {
473 self.delay_ms = ms;
474 self
475 }
476
477 #[must_use]
480 pub fn call_log(&self) -> Arc<std::sync::Mutex<Vec<String>>> {
481 Arc::clone(&self.calls)
482 }
483}
484
485#[cfg(test)]
486#[async_trait]
487impl Provider for MockProvider {
488 fn id(&self) -> &str {
489 &self.id
490 }
491
492 async fn complete(
493 &self,
494 req: &ModelRequest,
495 _auth: &Auth,
496 ) -> Result<ModelResponse, ProviderError> {
497 self.calls.lock().unwrap().push(req.model.clone());
498 if self.delay_ms > 0 {
499 tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
500 }
501 self.outcomes.get(&req.model).cloned().unwrap_or_else(|| {
502 Err(ProviderError::Decode(format!(
503 "no mock outcome configured for {}",
504 req.model
505 )))
506 })
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn wire_model_strips_the_provider_prefix() {
516 assert_eq!(wire_model("anthropic/claude-haiku-4-5"), "claude-haiku-4-5");
518 assert_eq!(wire_model("openai/gpt-5.5"), "gpt-5.5");
519 assert_eq!(wire_model("claude-opus-4-8"), "claude-opus-4-8"); }
521
522 fn resp(model: &str, text: &str) -> ModelResponse {
523 ModelResponse {
524 model: model.to_owned(),
525 text: text.to_owned(),
526 in_tokens: 10,
527 out_tokens: 5,
528 raw: Value::Null,
529 }
530 }
531
532 #[test]
533 fn transport_and_5xx_are_failover_eligible() {
534 assert!(ProviderError::Transport("boom".into()).is_failover_eligible());
535 assert!(
536 ProviderError::Http {
537 status: 503,
538 body: String::new()
539 }
540 .is_failover_eligible()
541 );
542 }
543
544 #[test]
545 fn client_errors_and_decode_failures_are_hard() {
546 assert!(
547 !ProviderError::Http {
548 status: 400,
549 body: String::new()
550 }
551 .is_failover_eligible()
552 );
553 assert!(!ProviderError::Decode("bad json".into()).is_failover_eligible());
554 }
555
556 #[test]
557 fn auth_debug_never_prints_key_material() {
558 let auth = Auth {
559 anthropic_key: Some("sk-ant-super-secret".to_owned()),
560 openai_key: Some("sk-oai-super-secret".to_owned()),
561 };
562 let debug = format!("{auth:?}");
563 assert!(!debug.contains("super-secret"));
564 }
565
566 #[tokio::test]
567 async fn mock_provider_returns_configured_outcome() {
568 let mut outcomes = HashMap::new();
569 outcomes.insert(
570 "anthropic/claude-haiku-4-5".to_owned(),
571 Ok(resp("anthropic/claude-haiku-4-5", "hello")),
572 );
573 let provider = MockProvider::new("anthropic", outcomes);
574 let req = ModelRequest {
575 model: "anthropic/claude-haiku-4-5".to_owned(),
576 system: None,
577 messages: vec![],
578 max_tokens: 100,
579 tools: Value::Null,
580 };
581 let out = provider.complete(&req, &Auth::default()).await.unwrap();
582 assert_eq!(out.text, "hello");
583 }
584}