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. `content` is either a plain string (the common
15/// case — serializes byte-identically to a text message) OR the original array of content blocks
16/// (`text` / `tool_use` / `tool_result` / `image`), forwarded verbatim so tool and multimodal turns
17/// survive the enforce path (ADR 0005). Use [`ChatMessage::text_view`] to read a text projection for
18/// gating.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ChatMessage {
21    /// `"user"`, `"assistant"`, or `"system"`.
22    pub role: String,
23    /// Message content — a string, or an array of content blocks, forwarded as-is to the provider.
24    pub content: Value,
25}
26
27impl ChatMessage {
28    /// A text-only message (the common path).
29    #[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    /// Concatenate the text this message carries, for gating. A string is itself; an array yields the
38    /// joined `text` blocks (tool_use/tool_result/image blocks contribute no gating text).
39    #[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/// A provider-agnostic model request, built once per incoming call and re-used (with
54/// `model` swapped) across every rung of the ladder.
55///
56// Message content is carried verbatim (string or content-block array, ADR 0005); gates read a text
57// projection via `ChatMessage::text_view`. `tools` is opaque passthrough.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ModelRequest {
60    /// `provider/model`, e.g. `"anthropic/claude-haiku-4-5"`.
61    pub model: String,
62    /// System prompt, if any.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub system: Option<String>,
65    /// Conversation turns.
66    pub messages: Vec<ChatMessage>,
67    /// Max tokens to generate.
68    pub max_tokens: u32,
69    /// Opaque tool/function-calling passthrough, forwarded as-is to the wire provider.
70    #[serde(default)]
71    pub tools: Value,
72}
73
74/// A provider-agnostic model response.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ModelResponse {
77    /// `provider/model` that produced this response.
78    pub model: String,
79    /// Concatenated text output.
80    pub text: String,
81    /// Input tokens billed.
82    pub in_tokens: u64,
83    /// Output tokens billed.
84    pub out_tokens: u64,
85    /// The raw wire response, kept for debugging/audit — never logged wholesale.
86    pub raw: Value,
87}
88
89/// Failure modes of a provider call.
90#[derive(Debug, Clone, thiserror::Error)]
91pub enum ProviderError {
92    /// The request never got a response (connection failure, timeout).
93    #[error("transport error: {0}")]
94    Transport(String),
95    /// The provider responded with a non-2xx status.
96    #[error("http {status}: {body}")]
97    Http {
98        /// HTTP status code.
99        status: u16,
100        /// Response body (truncated upstream, not by us).
101        body: String,
102    },
103    /// The response body didn't parse into the shape we expected.
104    #[error("decode error: {0}")]
105    Decode(String),
106}
107
108impl ProviderError {
109    /// Whether this failure should trigger cross-rung/cross-provider failover (transport
110    /// errors and 5xx) rather than being treated as a hard, non-retryable error (4xx, decode).
111    #[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/// BYOK credentials for one request, extracted from headers with env-var fallback.
122///
123/// Never logged or persisted — [`std::fmt::Debug`] redacts both fields.
124#[derive(Clone, Default)]
125pub struct Auth {
126    /// Anthropic API key (`x-api-key` header, or `ANTHROPIC_API_KEY`).
127    pub anthropic_key: Option<String>,
128    /// OpenAI API key (`authorization: Bearer ...` header, or `OPENAI_API_KEY`).
129    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    /// Extract BYOK credentials from request headers, falling back to `ANTHROPIC_API_KEY` /
143    /// `OPENAI_API_KEY` environment variables.
144    #[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/// A normalized model backend. Implementations translate [`ModelRequest`]/[`ModelResponse`]
165/// to/from one wire API.
166#[async_trait]
167pub trait Provider: Send + Sync + std::fmt::Debug {
168    /// Call the model and normalize the result.
169    ///
170    /// # Errors
171    /// Returns [`ProviderError`] on transport failure, a non-2xx response, or a response
172    /// that doesn't decode into the expected shape.
173    async fn complete(
174        &self,
175        req: &ModelRequest,
176        auth: &Auth,
177    ) -> Result<ModelResponse, ProviderError>;
178
179    /// Provider identity, e.g. `"anthropic"`.
180    fn id(&self) -> &str;
181}
182
183#[derive(Serialize)]
184struct AnthropicWireMessage<'a> {
185    role: &'a str,
186    content: &'a Value,
187}
188
189/// Strip the `provider/` prefix from a ladder model id for the provider's wire API — Anthropic and
190/// OpenAI expect the bare model (`claude-haiku-4-5`), not `anthropic/claude-haiku-4-5`. The full
191/// prefixed id is still what the ladder/trace use; only the wire call is stripped.
192fn wire_model(model: &str) -> &str {
193    model.split_once('/').map_or(model, |(_, m)| m)
194}
195
196/// Resolve the API key for a provider call. A configured `api_key_env` wins (that env var is *this*
197/// provider's key — e.g. `GROQ_API_KEY` for a Groq rung); otherwise fall back to the per-request
198/// BYOK override from headers/env (the built-in `anthropic`/`openai` path). Empty string when
199/// neither is set (a keyless local endpoint).
200fn 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/// Speaks `POST {base}/v1/messages` (Anthropic Messages API).
217///
218// LIVE-VERIFIED (2026-07-10): exercised against real Anthropic through the running proxy's enforce
219// path — a haiku completion served end-to-end. The `anthropic/` prefix must be stripped for the
220// wire call (see `wire_model`); sending it verbatim 404s.
221#[derive(Debug, Clone)]
222pub struct AnthropicProvider {
223    /// Ladder prefix / trace label for this provider (usually `"anthropic"`).
224    pub id: String,
225    /// Base URL, e.g. `https://api.anthropic.com`.
226    pub base_url: String,
227    /// Env var the API key is read from when no per-request BYOK header is present. `None` for the
228    /// built-in provider, which resolves the key via [`Auth`] (`x-api-key` header or env).
229    pub api_key_env: Option<String>,
230    /// Shared, connection-pooled HTTP client.
231    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/// Speaks `POST {base}/v1/chat/completions` (OpenAI Chat Completions API).
332///
333// LIVE-UNVERIFIED: the `wire_model` prefix-strip fix is applied here too, but the OpenAI path has
334// not yet been exercised against a real endpoint (only Anthropic has). Verify against a real key
335// before relying on it in production.
336#[derive(Debug, Clone)]
337pub struct OpenAiProvider {
338    /// Ladder prefix / trace label for this provider (`"openai"`, `"groq"`, `"together"`, …).
339    pub id: String,
340    /// Base URL, e.g. `https://api.openai.com` or `https://api.groq.com/openai`.
341    pub base_url: String,
342    /// Env var the API key is read from, e.g. `"GROQ_API_KEY"`. `None` for the built-in `openai`
343    /// provider (resolves via [`Auth`]: `authorization` header or `OPENAI_API_KEY`) and for keyless
344    /// local endpoints (Ollama / vLLM).
345    pub api_key_env: Option<String>,
346    /// Shared, connection-pooled HTTP client.
347    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/// Lookup from provider id (`"anthropic"`, `"openai"`, ...) to the [`Provider`] that serves it.
434#[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    /// One HTTP client shared by every provider. The enforce path is request/response (never
449    /// streamed through the adapter), so it carries a total request timeout as well as a connect
450    /// timeout — a hung or slow upstream can't pin a routing decision indefinitely. Falls back to a
451    /// default client if the builder fails (only on TLS backend init, which is fatal anyway).
452    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    /// Build the standard registry: the built-in `anthropic` + `openai` providers.
461    #[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    /// Build the registry with the built-in `anthropic` / `openai` providers plus every configured
467    /// `[[provider]]` entry — so a ladder can route to any OpenAI-compatible or Anthropic-compatible
468    /// endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI, OpenRouter, Ollama, vLLM, Azure,
469    /// …) by id. A `[[provider]]` whose id is `anthropic` or `openai` overrides the built-in default
470    /// (e.g. to point `openai` at Azure). Shares one HTTP client across all of them.
471    #[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    /// Build a registry from arbitrary providers — used to wire up [`MockProvider`]s in tests.
518    #[must_use]
519    pub fn from_map(providers: HashMap<String, Arc<dyn Provider>>) -> Self {
520        Self { providers }
521    }
522
523    /// Look up a provider by id.
524    #[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/// Test-only provider: returns a pre-programmed outcome per model, deterministically.
531#[cfg(test)]
532#[derive(Debug, Clone, Default)]
533pub struct MockProvider {
534    id: String,
535    outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
536    /// Every model string `complete()` was called with — lets speculation tests assert which rungs
537    /// were actually fired. Shared (`Arc`) so a clone taken before boxing still observes the calls.
538    calls: Arc<std::sync::Mutex<Vec<String>>>,
539    /// Simulated per-call latency (0 = respond instantly). Lets a test measure the wall-clock win
540    /// speculation buys by overlapping rung calls that would otherwise run serially.
541    delay_ms: u64,
542}
543
544#[cfg(test)]
545impl MockProvider {
546    /// Build a mock provider that answers `outcomes[model]` for `complete()`.
547    #[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    /// Make `complete()` sleep `ms` before responding, to simulate real per-call latency.
561    #[must_use]
562    pub fn with_delay(mut self, ms: u64) -> Self {
563        self.delay_ms = ms;
564        self
565    }
566
567    /// A handle to the shared call log; clone it before boxing the provider into a registry, then
568    /// inspect the models `complete()` saw after the engine runs.
569    #[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        // Regression: sending "anthropic/claude-haiku-4-5" verbatim 404s at the provider.
607        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"); // no prefix → unchanged
610    }
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            // A custom provider may override a built-in id (e.g. point `openai` at Azure).
622            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        // Built-in anthropic is still present; the custom groq resolves and labels itself "groq".
635        assert_eq!(reg.get("anthropic").unwrap().id(), "anthropic");
636        assert_eq!(reg.get("groq").unwrap().id(), "groq");
637        // Unknown provider → None (router fails over rather than guessing).
638        assert!(reg.get("does-not-exist").is_none());
639    }
640
641    #[test]
642    fn resolve_api_key_prefers_configured_env_then_byok() {
643        // Use PATH (always present) to exercise the env branch without mutating process env.
644        let path = std::env::var("PATH").expect("PATH is set");
645        // Configured env wins over any BYOK override (the env var is *this* provider's key).
646        assert_eq!(resolve_api_key(Some("PATH"), Some("byok")), path);
647        // An unset configured env → fall back to the per-request BYOK override.
648        assert_eq!(
649            resolve_api_key(Some("FIRSTPASS_DEFINITELY_UNSET_KEY"), Some("byok")),
650            "byok"
651        );
652        // No configured env → fall back to BYOK.
653        assert_eq!(resolve_api_key(None, Some("byok")), "byok");
654        // Neither → empty (keyless local endpoint).
655        assert_eq!(resolve_api_key(None, None), "");
656    }
657
658    #[test]
659    fn anthropic_wire_forwards_tool_and_image_content_verbatim() {
660        // ADR 0005 I3 (request side): the Anthropic adapter serializes tool_use / tool_result /
661        // image content blocks byte-for-byte into the wire body — enforce forwards them, it does not
662        // flatten them. A plain-string message still serializes as a bare string (I1).
663        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}