Skip to main content

firstpass_proxy/
provider.rs

1//! Normalized model access: a provider-agnostic request/response shape, and the two wire
2//! adapters (Anthropic Messages, OpenAI Chat Completions) that speak it. The router
3//! ([`crate::router`]) only ever talks to [`Provider`]; it never knows which wire format is
4//! behind a given rung.
5
6use 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/// One message in a normalized chat conversation.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ChatMessage {
17    /// `"user"`, `"assistant"`, or `"system"`.
18    pub role: String,
19    /// Message text.
20    pub content: String,
21}
22
23/// A provider-agnostic model request, built once per incoming call and re-used (with
24/// `model` swapped) across every rung of the ladder.
25///
26// ponytail: multimodal content and tool-result blocks are collapsed to plain text for now —
27// enough to route and gate on. A richer content-block model is a later batch.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ModelRequest {
30    /// `provider/model`, e.g. `"anthropic/claude-haiku-4-5"`.
31    pub model: String,
32    /// System prompt, if any.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub system: Option<String>,
35    /// Conversation turns.
36    pub messages: Vec<ChatMessage>,
37    /// Max tokens to generate.
38    pub max_tokens: u32,
39    /// Opaque tool/function-calling passthrough, forwarded as-is to the wire provider.
40    #[serde(default)]
41    pub tools: Value,
42}
43
44/// A provider-agnostic model response.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ModelResponse {
47    /// `provider/model` that produced this response.
48    pub model: String,
49    /// Concatenated text output.
50    pub text: String,
51    /// Input tokens billed.
52    pub in_tokens: u64,
53    /// Output tokens billed.
54    pub out_tokens: u64,
55    /// The raw wire response, kept for debugging/audit — never logged wholesale.
56    pub raw: Value,
57}
58
59/// Failure modes of a provider call.
60#[derive(Debug, Clone, thiserror::Error)]
61pub enum ProviderError {
62    /// The request never got a response (connection failure, timeout).
63    #[error("transport error: {0}")]
64    Transport(String),
65    /// The provider responded with a non-2xx status.
66    #[error("http {status}: {body}")]
67    Http {
68        /// HTTP status code.
69        status: u16,
70        /// Response body (truncated upstream, not by us).
71        body: String,
72    },
73    /// The response body didn't parse into the shape we expected.
74    #[error("decode error: {0}")]
75    Decode(String),
76}
77
78impl ProviderError {
79    /// Whether this failure should trigger cross-rung/cross-provider failover (transport
80    /// errors and 5xx) rather than being treated as a hard, non-retryable error (4xx, decode).
81    #[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/// BYOK credentials for one request, extracted from headers with env-var fallback.
92///
93/// Never logged or persisted — [`std::fmt::Debug`] redacts both fields.
94#[derive(Clone, Default)]
95pub struct Auth {
96    /// Anthropic API key (`x-api-key` header, or `ANTHROPIC_API_KEY`).
97    pub anthropic_key: Option<String>,
98    /// OpenAI API key (`authorization: Bearer ...` header, or `OPENAI_API_KEY`).
99    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    /// Extract BYOK credentials from request headers, falling back to `ANTHROPIC_API_KEY` /
113    /// `OPENAI_API_KEY` environment variables.
114    #[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/// A normalized model backend. Implementations translate [`ModelRequest`]/[`ModelResponse`]
135/// to/from one wire API.
136#[async_trait]
137pub trait Provider: Send + Sync + std::fmt::Debug {
138    /// Call the model and normalize the result.
139    ///
140    /// # Errors
141    /// Returns [`ProviderError`] on transport failure, a non-2xx response, or a response
142    /// that doesn't decode into the expected shape.
143    async fn complete(
144        &self,
145        req: &ModelRequest,
146        auth: &Auth,
147    ) -> Result<ModelResponse, ProviderError>;
148
149    /// Provider identity, e.g. `"anthropic"`.
150    fn id(&self) -> &str;
151}
152
153#[derive(Serialize)]
154struct AnthropicWireMessage<'a> {
155    role: &'a str,
156    content: &'a str,
157}
158
159/// Strip the `provider/` prefix from a ladder model id for the provider's wire API — Anthropic and
160/// OpenAI expect the bare model (`claude-haiku-4-5`), not `anthropic/claude-haiku-4-5`. The full
161/// prefixed id is still what the ladder/trace use; only the wire call is stripped.
162fn 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/// Speaks `POST {base}/v1/messages` (Anthropic Messages API).
176///
177// LIVE-VERIFIED (2026-07-10): exercised against real Anthropic through the running proxy's enforce
178// path — a haiku completion served end-to-end. The `anthropic/` prefix must be stripped for the
179// wire call (see `wire_model`); sending it verbatim 404s.
180#[derive(Debug, Clone)]
181pub struct AnthropicProvider {
182    /// Base URL, e.g. `https://api.anthropic.com`.
183    pub base_url: String,
184    /// Shared, connection-pooled HTTP client.
185    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/// Speaks `POST {base}/v1/chat/completions` (OpenAI Chat Completions API).
286///
287// LIVE-UNVERIFIED: the `wire_model` prefix-strip fix is applied here too, but the OpenAI path has
288// not yet been exercised against a real endpoint (only Anthropic has). Verify against a real key
289// before relying on it in production.
290#[derive(Debug, Clone)]
291pub struct OpenAiProvider {
292    /// Base URL, e.g. `https://api.openai.com`.
293    pub base_url: String,
294    /// Shared, connection-pooled HTTP client.
295    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/// Lookup from provider id (`"anthropic"`, `"openai"`, ...) to the [`Provider`] that serves it.
382#[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    /// Build the standard registry: Anthropic + OpenAI, sharing one HTTP client.
397    ///
398    /// The enforce path is request/response (never streamed through the adapter), so the client
399    /// carries a total request timeout as well as a connect timeout — a hung or slow upstream can't
400    /// pin a routing decision indefinitely. Falls back to a default client if the builder fails
401    /// (only on TLS backend init, which is fatal anyway).
402    #[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    /// Build a registry from arbitrary providers — used to wire up [`MockProvider`]s in tests.
428    #[must_use]
429    pub fn from_map(providers: HashMap<String, Arc<dyn Provider>>) -> Self {
430        Self { providers }
431    }
432
433    /// Look up a provider by id.
434    #[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/// Test-only provider: returns a pre-programmed outcome per model, deterministically.
441#[cfg(test)]
442#[derive(Debug, Clone, Default)]
443pub struct MockProvider {
444    id: String,
445    outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
446    /// Every model string `complete()` was called with — lets speculation tests assert which rungs
447    /// were actually fired. Shared (`Arc`) so a clone taken before boxing still observes the calls.
448    calls: Arc<std::sync::Mutex<Vec<String>>>,
449    /// Simulated per-call latency (0 = respond instantly). Lets a test measure the wall-clock win
450    /// speculation buys by overlapping rung calls that would otherwise run serially.
451    delay_ms: u64,
452}
453
454#[cfg(test)]
455impl MockProvider {
456    /// Build a mock provider that answers `outcomes[model]` for `complete()`.
457    #[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    /// Make `complete()` sleep `ms` before responding, to simulate real per-call latency.
471    #[must_use]
472    pub fn with_delay(mut self, ms: u64) -> Self {
473        self.delay_ms = ms;
474        self
475    }
476
477    /// A handle to the shared call log; clone it before boxing the provider into a registry, then
478    /// inspect the models `complete()` saw after the engine runs.
479    #[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        // Regression: sending "anthropic/claude-haiku-4-5" verbatim 404s at the provider.
517        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"); // no prefix → unchanged
520    }
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}