Skip to main content

firstpass_proxy/
provider.rs

1//! Normalized model access: a provider-agnostic request/response shape, and the wire adapters
2//! (Anthropic Messages, OpenAI Chat Completions, Google Gemini) 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;
8use std::time::SystemTime;
9
10use async_trait::async_trait;
11use axum::http::HeaderMap;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// One message in a normalized chat conversation. `content` is either a plain string (the common
16/// case — serializes byte-identically to a text message) OR the original array of content blocks
17/// (`text` / `tool_use` / `tool_result` / `image`), forwarded verbatim so tool and multimodal turns
18/// survive the enforce path (ADR 0005). Use [`ChatMessage::text_view`] to read a text projection for
19/// gating.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ChatMessage {
22    /// `"user"`, `"assistant"`, or `"system"`.
23    pub role: String,
24    /// Message content — a string, or an array of content blocks, forwarded as-is to the provider.
25    pub content: Value,
26}
27
28impl ChatMessage {
29    /// A text-only message (the common path).
30    #[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    /// Concatenate the text this message carries, for gating. A string is itself; an array yields the
39    /// joined `text` blocks (tool_use/tool_result/image blocks contribute no gating text).
40    #[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/// A provider-agnostic model request, built once per incoming call and re-used (with
55/// `model` swapped) across every rung of the ladder.
56///
57// Message content is carried verbatim (string or content-block array, ADR 0005); gates read a text
58// projection via `ChatMessage::text_view`. `tools` is opaque passthrough.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ModelRequest {
61    /// `provider/model`, e.g. `"anthropic/claude-haiku-4-5"`.
62    pub model: String,
63    /// System prompt, if any.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub system: Option<String>,
66    /// Conversation turns.
67    pub messages: Vec<ChatMessage>,
68    /// Max tokens to generate.
69    pub max_tokens: u32,
70    /// Opaque tool/function-calling passthrough, forwarded as-is to the wire provider.
71    #[serde(default)]
72    pub tools: Value,
73    /// The **full original inbound request JSON**, when this request came off the proxy's wire
74    /// (ADR 0005). Anthropic-dialect providers send it verbatim with only `model` swapped (and
75    /// `stream` stripped), so every field — `tools`, `tool_choice`, `temperature`, `thinking`,
76    /// `stop_sequences`, … — survives the rung exactly as the caller wrote it. `Null` for
77    /// synthesized requests (judge / consistency samples), which use the normalized fields.
78    #[serde(default, skip_serializing_if = "Value::is_null")]
79    pub raw: Value,
80}
81
82/// A provider-agnostic model response.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ModelResponse {
85    /// `provider/model` that produced this response.
86    pub model: String,
87    /// Concatenated text output.
88    pub text: String,
89    /// Input tokens billed.
90    pub in_tokens: u64,
91    /// Output tokens billed.
92    pub out_tokens: u64,
93    /// The raw wire response, kept for debugging/audit — never logged wholesale.
94    pub raw: Value,
95}
96
97/// Failure modes of a provider call.
98#[derive(Debug, Clone, thiserror::Error)]
99pub enum ProviderError {
100    /// The request never got a response (connection failure, timeout).
101    #[error("transport error: {0}")]
102    Transport(String),
103    /// The provider responded with a non-2xx status.
104    #[error("http {status}: {body}")]
105    Http {
106        /// HTTP status code.
107        status: u16,
108        /// Response body (truncated upstream, not by us).
109        body: String,
110    },
111    /// The response body didn't parse into the shape we expected.
112    #[error("decode error: {0}")]
113    Decode(String),
114}
115
116impl ProviderError {
117    /// Whether this failure should trigger cross-rung/cross-provider failover (transport
118    /// errors and 5xx) rather than being treated as a hard, non-retryable error (4xx, decode).
119    #[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/// BYOK credentials for one request, extracted from headers with env-var fallback.
130///
131/// Never logged or persisted — [`std::fmt::Debug`] redacts both fields.
132#[derive(Clone, Default)]
133pub struct Auth {
134    /// Anthropic API key (`x-api-key` header, or `ANTHROPIC_API_KEY`).
135    pub anthropic_key: Option<String>,
136    /// OpenAI API key (`authorization: Bearer ...` header, or `OPENAI_API_KEY`).
137    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    /// Extract BYOK credentials from request headers, falling back to `ANTHROPIC_API_KEY` /
151    /// `OPENAI_API_KEY` environment variables.
152    #[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/// A normalized model backend. Implementations translate [`ModelRequest`]/[`ModelResponse`]
173/// to/from one wire API.
174#[async_trait]
175pub trait Provider: Send + Sync + std::fmt::Debug {
176    /// Call the model and normalize the result.
177    ///
178    /// # Errors
179    /// Returns [`ProviderError`] on transport failure, a non-2xx response, or a response
180    /// that doesn't decode into the expected shape.
181    async fn complete(
182        &self,
183        req: &ModelRequest,
184        auth: &Auth,
185    ) -> Result<ModelResponse, ProviderError>;
186
187    /// Provider identity, e.g. `"anthropic"`.
188    fn id(&self) -> &str;
189
190    /// Whether this provider carries structured content (tool_use / tool_result / image blocks
191    /// and top-level `tools`) **verbatim** on the wire for the given *inbound* dialect.
192    ///
193    /// - Anthropic-dialect providers (Anthropic / Bedrock / Vertex) return `true` iff
194    ///   `inbound == Dialect::Anthropic`: the raw inbound body is forwarded with only the model
195    ///   swapped, so every caller field — `tools`, `tool_choice`, `thinking`, … — survives
196    ///   intact (ADR 0005 P4).
197    /// - `OpenAiProvider` returns `true` iff `inbound == Dialect::Openai`: same verbatim-carry
198    ///   rule for all-OpenAI-dialect ladders.
199    /// - All other providers (Gemini, unknown) return `false` — no verbatim-carry built yet.
200    ///
201    /// The enforce path's fidelity guard uses this to decide: verbatim-carry path (all rungs
202    /// return `true` for the inbound dialect), translation path (OpenAI → Anthropic when
203    /// content is translatable), or transparent observe passthrough (safe fallback).
204    fn carries_structured_verbatim(&self, _inbound: firstpass_core::Dialect) -> bool {
205        false
206    }
207}
208
209/// Strip the `provider/` prefix from a ladder model id for the provider's wire API — Anthropic and
210/// OpenAI expect the bare model (`claude-haiku-4-5`), not `anthropic/claude-haiku-4-5`. The full
211/// prefixed id is still what the ladder/trace use; only the wire call is stripped.
212fn wire_model(model: &str) -> &str {
213    model.split_once('/').map_or(model, |(_, m)| m)
214}
215
216/// Resolve the API key for a provider call. A configured `api_key_env` wins (that env var is *this*
217/// provider's key — e.g. `GROQ_API_KEY` for a Groq rung); otherwise fall back to the per-request
218/// BYOK override from headers/env (the built-in `anthropic`/`openai` path). Empty string when
219/// neither is set (a keyless local endpoint).
220fn 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/// Build the Anthropic Messages wire body for `req`: the original inbound JSON **verbatim** with
228/// only `model` swapped to the rung's wire id and `stream` stripped (`complete` is the buffered
229/// call — the gate needs the whole candidate). Falls back to the normalized fields when there is
230/// no raw body (synthesized judge/consistency requests). Pure, so fidelity is unit-testable.
231#[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/// Speaks `POST {base}/v1/messages` (Anthropic Messages API).
262///
263// LIVE-VERIFIED (2026-07-10): exercised against real Anthropic through the running proxy's enforce
264// path — a haiku completion served end-to-end. The `anthropic/` prefix must be stripped for the
265// wire call (see `wire_model`); sending it verbatim 404s.
266#[derive(Debug, Clone)]
267pub struct AnthropicProvider {
268    /// Ladder prefix / trace label for this provider (usually `"anthropic"`).
269    pub id: String,
270    /// Base URL, e.g. `https://api.anthropic.com`.
271    pub base_url: String,
272    /// Env var the API key is read from when no per-request BYOK header is present. `None` for the
273    /// built-in provider, which resolves the key via [`Auth`] (`x-api-key` header or env).
274    pub api_key_env: Option<String>,
275    /// Shared, connection-pooled HTTP client.
276    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
335/// Extract `(text, in_tokens, out_tokens)` from an Anthropic Messages API response. Shared by
336/// [`AnthropicProvider`] and every auth scheme that wraps the Anthropic body shape (Bedrock,
337/// Vertex — ADR 0006): they all get the same response back, only the request's auth/URL differ.
338fn 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
363/// Build an Anthropic Messages-shaped request body with the model **omitted** — Bedrock and Vertex
364/// both put the model in the URL, not the body (ADR 0006 P2/P3), unlike direct Anthropic API calls
365/// which need `model` in the body ([`AnthropicWireRequest`]). `anthropic_version` is the dialect
366/// version string each host expects (`bedrock-2023-05-31` / `vertex-2023-10-16`).
367fn anthropic_messages_body(req: &ModelRequest, anthropic_version: &str) -> Value {
368    // Same verbatim-carry rule as `anthropic_wire_body`, adapted to hosts that put the model in
369    // the URL: original inbound JSON minus `model`/`stream`, plus the host's version string.
370    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/// Build the OpenAI Chat Completions wire body for `req`: the original inbound JSON **verbatim**
400/// with only `model` swapped to the rung's wire id and `stream` stripped (analogous to
401/// [`anthropic_wire_body`] for all-OpenAI-dialect ladders). Falls back to the normalized fields
402/// when there is no raw body (synthesized judge/consistency requests, or translation path).
403/// Pure, so fidelity is unit-testable.
404#[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    // Fallback: reconstruct from normalized fields (translation path or synthesized requests).
416    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/// Speaks `POST {base}/v1/chat/completions` (OpenAI Chat Completions API).
437///
438// LIVE-UNVERIFIED: the `wire_model` prefix-strip fix is applied here too, but the OpenAI path has
439// not yet been exercised against a real endpoint (only Anthropic has). Verify against a real key
440// before relying on it in production.
441#[derive(Debug, Clone)]
442pub struct OpenAiProvider {
443    /// Ladder prefix / trace label for this provider (`"openai"`, `"groq"`, `"together"`, …).
444    pub id: String,
445    /// Base URL, e.g. `https://api.openai.com` or `https://api.groq.com/openai`.
446    pub base_url: String,
447    /// Env var the API key is read from, e.g. `"GROQ_API_KEY"`. `None` for the built-in `openai`
448    /// provider (resolves via [`Auth`]: `authorization` header or `OPENAI_API_KEY`) and for keyless
449    /// local endpoints (Ollama / vLLM).
450    pub api_key_env: Option<String>,
451    /// Shared, connection-pooled HTTP client.
452    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        // `content` is null for tool-call-only responses; treat as empty text rather than an error.
503        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
528/// Build the Gemini `generateContent` request body from a normalized [`ModelRequest`]. Split out
529/// (pure, no I/O) so the translation is unit-tested offline. Gemini uses `contents` with roles
530/// `user` / `model` (not `assistant`) and a separate `system_instruction`; the system prompt and the
531/// per-message text projection ([`ChatMessage::text_view`]) are what we send. Tool/multimodal blocks
532/// for Gemini (its `functionCall` / `functionResponse` / `inlineData` shapes) are a follow-on — this
533/// adapter routes text, matching the OpenAI adapter's current scope.
534fn 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
557/// Extract `(text, in_tokens, out_tokens)` from a Gemini `generateContent` response. `text` is the
558/// concatenation of the first candidate's text parts; token counts come from `usageMetadata`.
559fn 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/// Speaks `POST {base}/v1beta/models/{model}:generateContent` (Google Gemini Generative Language
581/// API). The API key goes in the `x-goog-api-key` header — never the URL query string, so it stays
582/// out of logs and proxies.
583///
584// LIVE-UNVERIFIED: the request/response translation is unit-tested offline; it has not yet been
585// exercised against a real Gemini endpoint. Verify against a real key before relying on it.
586#[derive(Debug, Clone)]
587pub struct GeminiProvider {
588    /// Ladder prefix / trace label (usually `"gemini"` or `"google"`).
589    pub id: String,
590    /// Base URL, e.g. `https://generativelanguage.googleapis.com`.
591    pub base_url: String,
592    /// Env var the API key is read from, e.g. `"GEMINI_API_KEY"`.
593    pub api_key_env: Option<String>,
594    /// Shared, connection-pooled HTTP client.
595    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
650/// AWS credentials read fresh from the standard env vars at call time — never cached, never
651/// logged (ADR 0006 P2). `AWS_SESSION_TOKEN` is optional (long-lived IAM user keys have none;
652/// STS-issued temporary credentials do).
653struct 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
674/// Build the Bedrock `invoke` URL for a region + wire model id.
675fn bedrock_url(region: &str, model: &str) -> String {
676    format!("https://bedrock-runtime.{region}.amazonaws.com/model/{model}/invoke")
677}
678
679/// SigV4-sign a Bedrock `InvokeModel` request, returning the fully-built (headers + body) HTTP
680/// request ready to hand to reqwest. Delegates all canonical-request construction and HMAC signing
681/// to `aws-sigv4` (ADR 0006 I3) — this function only wires host/service/region into that crate's
682/// API and applies the resulting signature. Split out from [`BedrockProvider::complete`] so the
683/// signing shape (not its cryptographic validity, which only a real AWS call can prove) is
684/// unit-testable offline with dummy credentials.
685///
686// LIVE-UNVERIFIED: tests assert the produced request carries an `AWS4-HMAC-SHA256` authorization
687// header and the expected host, against dummy credentials. Signature validity is only provable
688// against a real Bedrock endpoint.
689fn 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/// Speaks `POST https://bedrock-runtime.{region}.amazonaws.com/model/{model}/invoke` (Claude on AWS
750/// Bedrock) — an Anthropic-shaped body ([`anthropic_messages_body`]) authenticated with AWS SigV4
751/// request signing rather than an API key (ADR 0006 P2). Credentials come from the standard
752/// `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` env vars, read fresh per
753/// call — never logged, never put in the URL (I2).
754///
755// LIVE-UNVERIFIED: the request/response translation and signing call shape are unit-tested
756// offline; this has not been exercised against a real Bedrock endpoint. Verify against real AWS
757// credentials before relying on it in production.
758#[derive(Debug, Clone)]
759pub struct BedrockProvider {
760    /// Ladder prefix / trace label (usually `"bedrock"`).
761    pub id: String,
762    /// AWS region Bedrock is called in, e.g. `"us-east-1"`.
763    pub region: Option<String>,
764    /// Shared, connection-pooled HTTP client.
765    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
828/// Build the Vertex AI `rawPredict` URL for a region + project + wire model id (Claude on Vertex).
829fn 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/// Speaks `POST https://{region}-aiplatform.googleapis.com/.../publishers/anthropic/models/{model}:rawPredict`
836/// (Claude on Google Vertex AI) — an Anthropic-shaped body ([`anthropic_messages_body`])
837/// authenticated with a GCP OAuth2 bearer token rather than an API key (ADR 0006 P3). The token is
838/// minted and cached by `gcp_auth` from `GOOGLE_APPLICATION_CREDENTIALS` or the ambient GCP
839/// environment — this adapter never caches or logs it, and it never goes in the URL (I2).
840///
841// LIVE-UNVERIFIED: the request/response translation is unit-tested offline; this has not been
842// exercised against a real Vertex endpoint. Verify against a real service account before relying
843// on it in production.
844#[derive(Debug, Clone)]
845pub struct VertexProvider {
846    /// Ladder prefix / trace label (usually `"vertex"`).
847    pub id: String,
848    /// GCP region Vertex is called in, e.g. `"us-central1"`.
849    pub region: Option<String>,
850    /// GCP project id.
851    pub project: Option<String>,
852    /// Shared, connection-pooled HTTP client.
853    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        // ponytail: gcp_auth re-detects the auth method per call (the token itself is cached inside
882        // the provider). Cache the provider in a OnceCell if Vertex ever becomes a hot path.
883        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/// Lookup from provider id (`"anthropic"`, `"openai"`, ...) to the [`Provider`] that serves it.
929#[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    /// One HTTP client shared by every provider. The enforce path is request/response (never
944    /// streamed through the adapter), so it carries a total request timeout as well as a connect
945    /// timeout — a hung or slow upstream can't pin a routing decision indefinitely. Falls back to a
946    /// default client if the builder fails (only on TLS backend init, which is fatal anyway).
947    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    /// Build the standard registry: the built-in `anthropic` + `openai` providers.
956    #[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    /// Build the registry with the built-in `anthropic` / `openai` providers plus every configured
962    /// `[[provider]]` entry — so a ladder can route to any OpenAI-compatible or Anthropic-compatible
963    /// endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI, OpenRouter, Ollama, vLLM, Azure,
964    /// …) by id. A `[[provider]]` whose id is `anthropic` or `openai` overrides the built-in default
965    /// (e.g. to point `openai` at Azure). Shares one HTTP client across all of them.
966    #[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            // Auth scheme comes first (ADR 0006): `aws_sigv4`/`gcp_oauth` are bespoke-auth
994            // backends that wrap the Anthropic body shape regardless of `dialect`; `api_key`
995            // (the default) is today's dialect-driven dispatch, unchanged.
996            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    /// Build a registry from arbitrary providers — used to wire up [`MockProvider`]s in tests.
1035    #[must_use]
1036    pub fn from_map(providers: HashMap<String, Arc<dyn Provider>>) -> Self {
1037        Self { providers }
1038    }
1039
1040    /// Look up a provider by id.
1041    #[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/// Test-only provider: returns a pre-programmed outcome per model, deterministically.
1048#[cfg(test)]
1049#[derive(Debug, Clone, Default)]
1050pub struct MockProvider {
1051    id: String,
1052    outcomes: HashMap<String, Result<ModelResponse, ProviderError>>,
1053    /// Every model string `complete()` was called with — lets speculation tests assert which rungs
1054    /// were actually fired. Shared (`Arc`) so a clone taken before boxing still observes the calls.
1055    calls: Arc<std::sync::Mutex<Vec<String>>>,
1056    /// Simulated per-call latency (0 = respond instantly). Lets a test measure the wall-clock win
1057    /// speculation buys by overlapping rung calls that would otherwise run serially.
1058    delay_ms: u64,
1059}
1060
1061#[cfg(test)]
1062impl MockProvider {
1063    /// Build a mock provider that answers `outcomes[model]` for `complete()`.
1064    #[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    /// Make `complete()` sleep `ms` before responding, to simulate real per-call latency.
1078    #[must_use]
1079    pub fn with_delay(mut self, ms: u64) -> Self {
1080        self.delay_ms = ms;
1081        self
1082    }
1083
1084    /// A handle to the shared call log; clone it before boxing the provider into a registry, then
1085    /// inspect the models `complete()` saw after the engine runs.
1086    #[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    // ponytail: returns true for all dialects — test-only mock needs to pass the fidelity guard
1100    // for both Anthropic and OpenAI inbound tests without a real dialect-split implementation.
1101    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        // Regression: sending "anthropic/claude-haiku-4-5" verbatim 404s at the provider.
1130        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"); // no prefix → unchanged
1133    }
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        // System prompt goes in system_instruction, not contents.
1150        assert_eq!(body["system_instruction"]["parts"][0]["text"], "be terse");
1151        assert_eq!(body["generationConfig"]["maxOutputTokens"], 256);
1152        // Anthropic's "assistant" role becomes Gemini's "model"; "user" stays "user".
1153        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        // A response with no candidates is a decode error, not a fabricated empty answer.
1172        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            // A custom provider may override a built-in id (e.g. point `openai` at Azure).
1207            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        // Built-in anthropic is still present; the custom groq resolves and labels itself "groq".
1223        assert_eq!(reg.get("anthropic").unwrap().id(), "anthropic");
1224        assert_eq!(reg.get("groq").unwrap().id(), "groq");
1225        // Unknown provider → None (router fails over rather than guessing).
1226        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        // Model goes in the URL for Bedrock/Vertex, never the body.
1247        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        // Content-block arrays forward verbatim, same as the direct Anthropic adapter (ADR 0005).
1253        assert_eq!(
1254            body["messages"][1]["content"],
1255            serde_json::json!([{ "type": "text", "text": "hello" }])
1256        );
1257
1258        // No system prompt => no `system` key at all (not `null`).
1259        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        // Dummy, non-production credentials — this only proves the *shape* of the signed request
1297        // (algorithm header + host), not cryptographic validity against real AWS (LIVE-UNVERIFIED).
1298        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        // Use PATH (always present) to exercise the env branch without mutating process env.
1384        let path = std::env::var("PATH").expect("PATH is set");
1385        // Configured env wins over any BYOK override (the env var is *this* provider's key).
1386        assert_eq!(resolve_api_key(Some("PATH"), Some("byok")), path);
1387        // An unset configured env → fall back to the per-request BYOK override.
1388        assert_eq!(
1389            resolve_api_key(Some("FIRSTPASS_DEFINITELY_UNSET_KEY"), Some("byok")),
1390            "byok"
1391        );
1392        // No configured env → fall back to BYOK.
1393        assert_eq!(resolve_api_key(None, Some("byok")), "byok");
1394        // Neither → empty (keyless local endpoint).
1395        assert_eq!(resolve_api_key(None, None), "");
1396    }
1397
1398    #[test]
1399    fn anthropic_wire_forwards_tool_and_image_content_verbatim() {
1400        // ADR 0005 I3 (request side): the Anthropic adapter serializes tool_use / tool_result /
1401        // image content blocks byte-for-byte into the wire body — enforce forwards them, it does not
1402        // flatten them. A plain-string message still serializes as a bare string (I1).
1403        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        // ADR 0005: with a raw inbound body present, the wire request IS that body — every field
1508        // the caller set (tools, tool_choice, temperature, thinking, ...) survives; only `model`
1509        // is swapped to the rung's wire id and `stream` is stripped (complete() buffers to gate).
1510        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        // Anthropic provider: verbatim for Anthropic inbound, not for OpenAI
1576        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        // OpenAI provider: verbatim for OpenAI inbound, not for Anthropic
1587        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        // All-OpenAI-ladder raw carry: same semantics as anthropic_wire_body_carries_raw_request_verbatim.
1602        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        // Translation path (raw = Null): reconstruct from normalized fields.
1641        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        // System → injected as first message with role="system"
1655        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}