Skip to main content

firstpass_proxy/
proxy.rs

1//! Axum wiring: routes, request/response shapes, and observe-mode trace construction
2//! (SPEC §7.1, §7.1a — forward unchanged, record asynchronously, zero added latency).
3
4use std::sync::Arc;
5use std::time::Instant;
6
7use axum::body::Body;
8use axum::extract::{Request, State};
9use axum::http::HeaderMap;
10use axum::middleware::Next;
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Extension, Json, Router};
14use bytes::Bytes;
15use firstpass_core::features::{hour_bucket, token_bucket};
16use firstpass_core::hashchain::sha256_hex;
17use firstpass_core::{
18    Attempt, DeferredVerdict, Dialect, FEATURE_VERSION, Features, FinalOutcome, GENESIS_HASH, Mode,
19    PolicyRef, RequestInfo, Score, ServedFrom, TaskKind, Trace, Verdict,
20};
21use serde::Deserialize;
22use serde_json::Value;
23use std::future::Future;
24use std::time::Duration;
25use tokio::sync::mpsc::error::TrySendError;
26use uuid::Uuid;
27
28use crate::config::ProxyConfig;
29use crate::error::ProxyError;
30use crate::gate::{GateHealthRegistry, resolve_gates};
31use crate::provider::{Auth, ChatMessage, ModelRequest, ModelResponse, ProviderRegistry};
32use crate::router::{EnforceCtx, EngineOutcome, route_enforce};
33use crate::store;
34use crate::tenant_auth::{TenantId, auth_middleware};
35use crate::upstream::{
36    forward_anthropic, forward_anthropic_streaming, forward_openai, forward_openai_streaming,
37};
38use firstpass_core::Route;
39
40/// Shared state handed to every request handler. Cheap to clone: an `Arc`ed config, a
41/// pooled HTTP client, and a bounded channel sender.
42#[derive(Clone)]
43pub struct AppState {
44    /// Static proxy configuration.
45    pub config: Arc<ProxyConfig>,
46    /// Shared, connection-pooled HTTP client used to call upstream (observe passthrough).
47    pub http: reqwest::Client,
48    /// Multi-provider registry used by the enforce-mode escalation engine.
49    pub providers: ProviderRegistry,
50    /// Per-gate error budgets (auto-disable), shared across requests.
51    pub gate_health: Arc<GateHealthRegistry>,
52    /// Fire-and-forget sender to the background trace writer.
53    pub traces: store::TraceSender,
54    /// Optional online/adaptive conformal serve threshold (Gibbs-Candès ACI). `None` = fixed
55    /// `serve_threshold` from config (default). When present, `/v1/feedback` nudges it live and the
56    /// enforce path reads its current value per request — the reactive, self-tuning loop.
57    pub adaptive: Option<Arc<std::sync::Mutex<firstpass_core::conformal::AdaptiveConformal>>>,
58    /// Optional UCB1 start-rung bandit (predict-to-start, verify-to-serve). `None` (default) =
59    /// start every request at rung 0, byte-identical to today. When present, `handle_enforce`
60    /// queries it for a predicted start rung per request and feeds back gate verdicts for online
61    /// learning — all in-memory, per-process.
62    pub bandit: Option<Arc<std::sync::Mutex<crate::bandit::StartRungBandit>>>,
63    /// Per-tenant request rate limiter (ADR 0004 §D6). `None` (the default) disables rate
64    /// limiting entirely — set via [`build_tenant_rate_limiter`] from
65    /// [`ProxyConfig::tenant_rate_per_sec`].
66    pub tenant_rate_limiter: Option<Arc<governor::DefaultKeyedRateLimiter<String>>>,
67    /// Durable-receipts spill handle (`FIRSTPASS_RECEIPTS=durable`). `None` in best-effort mode
68    /// (the default) — behavior is byte-identical to before. When `Some`, `offer_trace` appends
69    /// to `<db_path>.spill.jsonl` on channel-full instead of dropping.
70    pub spill: Option<store::SpillHandle>,
71}
72
73/// Build the per-tenant keyed rate limiter from config (ADR 0004 §D6). Returns `None` when
74/// `FIRSTPASS_TENANT_RATE_PER_SEC` is unset (the default) — single-operator and existing
75/// deployments see no limiter and no behavior change.
76#[must_use]
77pub fn build_tenant_rate_limiter(
78    config: &ProxyConfig,
79) -> Option<Arc<governor::DefaultKeyedRateLimiter<String>>> {
80    let per_sec = config.tenant_rate_per_sec?;
81    Some(Arc::new(governor::RateLimiter::keyed(
82        governor::Quota::per_second(per_sec),
83    )))
84}
85
86/// Axum middleware (ADR 0004 §D6): enforce the per-tenant request rate limit. Must run AFTER
87/// [`auth_middleware`] so the resolved [`TenantId`] is already in request extensions. A no-op
88/// (never returns 429) when [`AppState::tenant_rate_limiter`] is `None`.
89pub async fn tenant_rate_limit_middleware(
90    State(state): State<AppState>,
91    Extension(tenant): Extension<TenantId>,
92    req: Request,
93    next: Next,
94) -> Response {
95    if let Some(limiter) = &state.tenant_rate_limiter
96        && limiter.check_key(&tenant.0).is_err()
97    {
98        return ProxyError::RateLimited.into_response();
99    }
100    next.run(req).await
101}
102
103impl std::fmt::Debug for AppState {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("AppState")
106            .field("config", &self.config)
107            .finish_non_exhaustive()
108    }
109}
110
111/// Fire-and-forget a trace at the background writer: non-blocking, and bounded.
112///
113/// **Best-effort mode** (`spill` is `None`): if the writer has fallen behind enough to fill the
114/// buffer, the trace is dropped with a warning rather than blocking the hot path or growing memory
115/// without limit (the audit chain over persisted traces stays valid; a dropped trace is simply
116/// absent).
117///
118/// **Durable mode** (`spill` is `Some`): on `TrySendError::Full` the trace is serialised as a
119/// JSON line and appended (with `sync_data`) to the spill file. This blocks the calling task on a
120/// disk write — the deliberate tradeoff of durable mode; it only fires under sustained
121/// backpressure. The writer drains the spill file at startup and on channel-empty so the chain
122/// stays valid.
123///
124/// ponytail: the spill write holds `Mutex<File>` across a `sync_data` call on the calling tokio
125/// task — fine for the slow backpressure path; use `spawn_blocking` if disk latency at p99 under
126/// sustained overload is measurable.
127fn offer_trace(traces: &store::TraceSender, spill: Option<&store::SpillHandle>, trace: Trace) {
128    record_trace_metrics(&trace);
129    match traces.try_send(trace) {
130        Ok(()) => {}
131        Err(TrySendError::Full(t)) => {
132            if let Some(handle) = spill {
133                match store::append_to_spill(handle, &t) {
134                    Ok(()) => {
135                        metrics::counter!("firstpass_receipts_spilled_total").increment(1);
136                    }
137                    Err(e) => {
138                        tracing::error!(%e, "durable mode: spill write failed; trace lost");
139                        metrics::counter!("firstpass_traces_dropped_total").increment(1);
140                    }
141                }
142            } else {
143                tracing::warn!("trace channel full; dropping trace (writer behind under load)");
144                metrics::counter!("firstpass_traces_dropped_total").increment(1);
145            }
146        }
147        Err(TrySendError::Closed(_)) => {
148            tracing::warn!("trace writer is gone; dropping trace");
149        }
150    }
151}
152
153/// Record the real signals every trace carries: enforce-mode latency/escalations (observe mode
154/// forwards unchanged, so its wall-clock time isn't a routing-decision latency), and what got
155/// served — regardless of mode, since an upstream failure is worth counting either way.
156fn record_trace_metrics(trace: &Trace) {
157    if trace.mode == Mode::Enforce {
158        metrics::histogram!("firstpass_enforce_latency_ms")
159            .record(trace.final_.total_latency_ms as f64);
160        if trace.final_.escalations > 0 {
161            metrics::counter!("firstpass_escalations_total")
162                .increment(u64::from(trace.final_.escalations));
163        }
164    }
165    let served_from = match trace.final_.served_from {
166        ServedFrom::Attempt => "attempt",
167        ServedFrom::BestAttempt => "best_attempt",
168        ServedFrom::Error => "error",
169    };
170    metrics::counter!("firstpass_served_total", "served_from" => served_from).increment(1);
171    if trace.final_.served_from == ServedFrom::Error {
172        metrics::counter!("firstpass_upstream_failures_total").increment(1);
173    }
174    // The value signals: what was spent, what proof cost, and what routing saved vs always-top
175    // (§9.1 counterfactual). Monotonic gauges because `metrics` counters are integer-only and
176    // these are USD floats; scrape-side `rate()`/`increase()` work the same.
177    metrics::gauge!("firstpass_cost_usd_total").increment(trace.final_.total_cost_usd);
178    metrics::gauge!("firstpass_gate_cost_usd_total").increment(trace.final_.gate_cost_usd);
179    metrics::gauge!("firstpass_baseline_usd_total")
180        .increment(trace.final_.counterfactual_baseline_usd);
181    metrics::gauge!("firstpass_savings_usd_total").increment(trace.final_.savings_usd);
182    // Which rung actually served, labeled by model — the shape of the ladder in production.
183    if let Some(rung) = trace.final_.served_rung {
184        let model = trace
185            .attempts
186            .iter()
187            .find(|a| a.rung == rung)
188            .map(|a| a.model.clone())
189            .unwrap_or_else(|| "unknown".to_owned());
190        metrics::counter!(
191            "firstpass_served_rung_total",
192            "rung" => rung.to_string(),
193            "model" => model
194        )
195        .increment(1);
196    }
197}
198
199/// Max accepted request body. Explicit (not axum's ~2 MB default) so it's an intentional ceiling:
200/// generous enough to pass through large multimodal/long-context requests, bounded so a single
201/// oversized body can't exhaust memory.
202const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
203
204// ── Epsilon-greedy helpers ────────────────────────────────────────────────────
205
206/// Map a `u128` seed to a uniform float in `[0, 1)` via two SplitMix64 finalizer rounds.
207///
208/// Used to derive the per-request epsilon-greedy draw from `Uuid::now_v7().as_u128()` — no
209/// new dependencies needed. The two 64-bit halves are finalised separately then XOR-folded
210/// to a single u64 to mix time and random UUID bits.
211///
212/// ponytail: not a general-purpose RNG; replace with `rand` if more draws per request
213/// are ever needed.
214pub(crate) fn u01(seed: u128) -> f64 {
215    let lo = splitmix64_finalise(seed as u64);
216    let hi = splitmix64_finalise((seed >> 64) as u64);
217    // 53-bit mantissa of f64 → uniform on [0, 1).
218    ((lo ^ hi) >> 11) as f64 * (1.0_f64 / (1u64 << 53) as f64)
219}
220
221fn splitmix64_finalise(mut z: u64) -> u64 {
222    z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
223    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
224    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
225    z ^ (z >> 31)
226}
227
228/// Propensity of the logging policy choosing `chosen` under epsilon-greedy over `k` rungs
229/// where `greedy` is the deterministic choice.
230///
231/// `p = (1 − ε) · 𝟙[chosen == greedy] + ε / K`
232///
233/// Both terms apply when the epsilon branch fires and coincidentally lands on the greedy rung.
234#[must_use]
235pub(crate) fn epsilon_propensity(chosen: u32, greedy: u32, epsilon: f64, k: usize) -> f64 {
236    let greedy_term = if chosen == greedy { 1.0 - epsilon } else { 0.0 };
237    greedy_term + epsilon / k as f64
238}
239
240/// Build the axum router: `POST /v1/messages`, `GET /v1/capabilities`, `GET /healthz`,
241/// `GET /metrics`.
242///
243/// # Errors
244/// [`ProxyError::Internal`] if the Prometheus recorder fails to install (see
245/// [`crate::metrics::install`]).
246pub fn app(state: AppState) -> Result<Router, ProxyError> {
247    crate::metrics::install()?;
248    let max_concurrency = state.config.max_concurrency;
249
250    // Tenant-facing business routes: every one runs the auth middleware, which injects the resolved
251    // `TenantId` into request extensions (the authenticated tenant when `require_auth` is on, the
252    // static default when off — ADR 0004 §D1/§D2). Operator routes (`/healthz`, `/metrics`) are
253    // NOT tenant-facing and stay outside the auth layer.
254    // Per-tenant rate limit (ADR 0004 §D6) runs INSIDE (after) the auth layer below — axum layers
255    // wrap outward-in, so a layer added earlier in the chain executes later on the request path —
256    // so the resolved `TenantId` is already in extensions when this middleware checks it. A no-op
257    // when `FIRSTPASS_TENANT_RATE_PER_SEC` is unset.
258    let business = Router::new()
259        .route("/v1/messages", post(messages))
260        .route("/v1/chat/completions", post(chat_completions))
261        .route("/v1/feedback", post(feedback))
262        .route("/v1/capabilities", get(capabilities))
263        .layer(axum::middleware::from_fn_with_state(
264            state.clone(),
265            tenant_rate_limit_middleware,
266        ))
267        .layer(axum::middleware::from_fn_with_state(
268            state.clone(),
269            auth_middleware,
270        ));
271
272    Ok(Router::new()
273        .merge(business)
274        .route("/healthz", get(healthz))
275        .route("/metrics", get(crate::metrics::handler))
276        // Explicit body-size ceiling (DoS/OOM guard) across every route.
277        .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES))
278        // Concurrency load-shed: cap in-flight requests under the cap rather than falling over.
279        // Deliberately NOT a request timeout — that would sever in-flight SSE streams.
280        .layer(tower::limit::GlobalConcurrencyLimitLayer::new(
281            max_concurrency,
282        ))
283        .with_state(state))
284}
285
286/// `GET /healthz` — liveness probe.
287async fn healthz() -> impl IntoResponse {
288    Json(serde_json::json!({ "status": "ok" }))
289}
290
291/// `GET /v1/capabilities` — agent-first discovery (SPEC §0.2, §7.4): what this proxy speaks,
292/// which modes are live, the first enforce route's ladder/gates, and how to turn it off.
293async fn capabilities(State(state): State<AppState>) -> impl IntoResponse {
294    // Report the first enforce route's ladder + gates, so an agent can discover what it's routed
295    // through. Empty when no routing config is loaded (pure observe deployment).
296    let (ladder, gates) = state
297        .config
298        .routing
299        .as_ref()
300        .and_then(|c| c.routes.iter().find(|r| r.mode == Mode::Enforce))
301        .map(|r| (r.ladder.clone(), r.gates.clone()))
302        .unwrap_or_default();
303    Json(serde_json::json!({
304        "service": "firstpass",
305        "version": env!("CARGO_PKG_VERSION"),
306        "feature_version": FEATURE_VERSION,
307        "modes": ["observe", "enforce"],
308        "wire_apis": ["anthropic.messages", "openai.chat_completions"],
309        "ladder": ladder,
310        "gates": gates,
311        "feedback_api": "POST /v1/feedback",
312        "offboarding": "unset ANTHROPIC_BASE_URL (or OPENAI_BASE_URL for OpenAI clients)",
313    }))
314}
315
316/// Body of `POST /v1/feedback`: a downstream outcome reported for a past decision.
317#[derive(Debug, Deserialize)]
318struct FeedbackRequest {
319    /// The `trace_id` of the decision this outcome is about.
320    trace_id: String,
321    /// The gate/source id, e.g. `"tests"` or `"feedback:ci"`.
322    gate_id: String,
323    /// `"pass"` | `"fail"` | `"abstain"`.
324    verdict: String,
325    /// Optional confidence in `[0, 1]`.
326    #[serde(default)]
327    score: Option<f64>,
328    /// Who reported it (a CI system, a human reviewer, a deferred gate).
329    reporter: String,
330}
331
332/// `POST /v1/feedback` — attach a downstream outcome (deferred verdict) to a past trace, closing
333/// the outcome-feedback loop (SPEC §8.3.4). The verdict is stored in a **separate** table keyed
334/// by `trace_id`; the sealed, hashed trace is never mutated, so the audit chain stays verifiable.
335/// Returns `202 Accepted`. This is the signal that later calibrates the gates.
336async fn feedback(
337    State(state): State<AppState>,
338    Extension(TenantId(tenant)): Extension<TenantId>,
339    body: Bytes,
340) -> Response {
341    let req: FeedbackRequest = match serde_json::from_slice(&body) {
342        Ok(r) => r,
343        Err(e) => {
344            return ProxyError::BadRequest(format!("invalid feedback body: {e}")).into_response();
345        }
346    };
347    let verdict = match req.verdict.as_str() {
348        "pass" => Verdict::Pass,
349        "fail" => Verdict::Fail,
350        "abstain" => Verdict::Abstain,
351        other => {
352            return ProxyError::BadRequest(format!("unknown verdict {other:?}")).into_response();
353        }
354    };
355    let score = match req.score {
356        Some(s) => match Score::new(s) {
357            Ok(sc) => Some(sc),
358            Err(_) => {
359                return ProxyError::BadRequest(format!("score {s} out of range [0,1]"))
360                    .into_response();
361            }
362        },
363        None => None,
364    };
365
366    let db = state.config.db_path.clone();
367
368    // Reject feedback for an unknown trace, so orphan outcomes can't accumulate — AND deny
369    // cross-tenant feedback (IDOR, ADR 0004 §D4). `trace_exists` is scoped to the caller's tenant,
370    // so a trace owned by another tenant is indistinguishable from a missing one: both return `404`
371    // (never `403`, which would be an existence oracle).
372    let (db_check, tenant_check, tid_check) = (db.clone(), tenant.clone(), req.trace_id.clone());
373    match tokio::task::spawn_blocking(move || {
374        store::trace_exists(&db_check, &tenant_check, &tid_check)
375    })
376    .await
377    {
378        Ok(Ok(true)) => {}
379        Ok(Ok(false)) => {
380            return ProxyError::NotFound(format!("unknown trace_id {:?}", req.trace_id))
381                .into_response();
382        }
383        Ok(Err(e)) => {
384            tracing::error!(%e, "feedback: trace_exists check failed");
385            return ProxyError::Internal(e.to_string()).into_response();
386        }
387        Err(e) => {
388            tracing::error!(%e, "feedback: trace_exists task panicked");
389            return ProxyError::Internal(e.to_string()).into_response();
390        }
391    }
392
393    // Correctness signal for the online adaptive loop — only a clear Pass/Fail nudges the threshold.
394    let feedback_signal = match verdict {
395        Verdict::Pass => Some(true),
396        Verdict::Fail => Some(false),
397        Verdict::Abstain => None,
398    };
399    let dv = DeferredVerdict {
400        gate_id: req.gate_id,
401        verdict,
402        score,
403        reported_at: jiff::Timestamp::now(),
404        reporter: req.reporter,
405    };
406    let trace_id = req.trace_id.clone();
407    match tokio::task::spawn_blocking(move || store::append_deferred(&db, &req.trace_id, &dv)).await
408    {
409        Ok(Ok(())) => {
410            // Close the reactive loop: nudge the live serve threshold toward the target.
411            if let (Some(a), Some(correct)) = (state.adaptive.as_ref(), feedback_signal)
412                && let Ok(mut g) = a.lock()
413            {
414                g.observe_served(correct);
415                metrics::gauge!("firstpass_serve_threshold").set(g.threshold());
416                metrics::gauge!("firstpass_realized_served_failure")
417                    .set(g.realized_served_failure());
418            }
419            (
420                axum::http::StatusCode::ACCEPTED,
421                Json(serde_json::json!({ "status": "recorded", "trace_id": trace_id })),
422            )
423                .into_response()
424        }
425        Ok(Err(e)) => {
426            tracing::error!(%e, "feedback: append_deferred failed");
427            ProxyError::Internal(e.to_string()).into_response()
428        }
429        Err(e) => {
430            tracing::error!(%e, "feedback: append_deferred task panicked");
431            ProxyError::Internal(e.to_string()).into_response()
432        }
433    }
434}
435
436/// The header a caller may set to group requests into a session for the audit trail. When
437/// absent, each request is its own session (keyed by its own trace id).
438const SESSION_HEADER: &str = "x-firstpass-session";
439
440/// Header carrying the calling agent identity (feature/routing signal).
441const AGENT_HEADER: &str = "x-firstpass-agent";
442/// Header carrying the calling subagent identity.
443const SUBAGENT_HEADER: &str = "x-firstpass-subagent";
444
445/// `POST /v1/messages` — dispatch on the matched route's mode. **Enforce** routes run the
446/// escalation engine (gate + escalate + failover); everything else is an **observe**
447/// passthrough (forward unchanged, trace asynchronously). Either way the trace is recorded
448/// off the response path.
449async fn messages(
450    State(state): State<AppState>,
451    Extension(TenantId(tenant)): Extension<TenantId>,
452    headers: HeaderMap,
453    body: Bytes,
454) -> Response {
455    let session_header = header_str(&headers, SESSION_HEADER);
456
457    // Only parse the request for routing when a routing config is loaded — an observe-only
458    // deployment does zero on-path parsing and keeps its zero-added-latency guarantee.
459    if let Some(routing) = state.config.routing.as_ref() {
460        let features = extract_features(&headers, &body);
461        if let Some(route) = routing
462            .route_for(&features)
463            .filter(|r| r.mode == Mode::Enforce && !r.ladder.is_empty())
464        {
465            // Clone the matched route so no borrow of `state.config` is held across the await;
466            // routes are tiny (a handful of strings).
467            let route = route.clone();
468            if enforce_can_handle(
469                &features,
470                &body,
471                routing.escalation.enforce_structured,
472                &route.ladder,
473                &state.providers,
474                Dialect::Anthropic,
475            ) {
476                return handle_enforce(
477                    &state,
478                    &headers,
479                    &body,
480                    features,
481                    &route,
482                    session_header,
483                    tenant,
484                )
485                .await;
486            }
487            // Structured request that can't be routed faithfully (flag off, or a ladder rung's
488            // dialect doesn't carry structured content verbatim yet): transparent observe
489            // passthrough — correct and un-gated beats routed and corrupted.
490            tracing::info!(
491                "enforce route matched but structured request can't be routed faithfully (flag/ladder); serving via observe passthrough"
492            );
493        }
494    }
495    observe_passthrough(state, headers, body, session_header, tenant).await
496}
497
498/// Whether the enforce path can faithfully handle this request.
499///
500/// **Verbatim-carry path** (ADR 0005 P4): when all ladder rungs carry the inbound dialect
501/// verbatim ([`crate::provider::Provider::carries_structured_verbatim`]), the original request
502/// body is forwarded byte-for-byte with only the model swapped, so every caller field survives.
503///
504/// **Translation path** (OpenAI-inbound → Anthropic ladder): for `Dialect::Openai` inbound
505/// requests hitting an all-Anthropic ladder, we translate the body to Anthropic shape — covers
506/// text, tools, tool_calls, and tool_result messages. `image_url` with http(s) URLs is not
507/// translatable (we can't relay them to Anthropic's vision API without fetching) → fallback.
508///
509/// `enforce_structured == false` restores the pre-ADR-0005 behavior: structured requests always
510/// fall back to transparent observe passthrough.
511fn enforce_can_handle(
512    features: &Features,
513    body: &[u8],
514    enforce_structured: bool,
515    ladder: &[String],
516    providers: &crate::provider::ProviderRegistry,
517    inbound: Dialect,
518) -> bool {
519    let structured = features.tool_count > 0
520        || features.has_images
521        || match inbound {
522            Dialect::Anthropic => messages_have_tool_blocks(body),
523            Dialect::Openai => openai_messages_have_tool_calls(body),
524            Dialect::Gemini => false,
525        };
526    if !structured {
527        return true;
528    }
529    if !enforce_structured {
530        return false;
531    }
532    // Path 1: verbatim carry — every rung speaks the inbound dialect natively.
533    let all_verbatim = ladder.iter().all(|rung| {
534        let provider_id = rung.split('/').next().unwrap_or_default();
535        providers
536            .get(provider_id)
537            .is_some_and(|p| p.carries_structured_verbatim(inbound))
538    });
539    if all_verbatim {
540        return true;
541    }
542    // Path 2: translation — OpenAI inbound → all-Anthropic ladder, when content is translatable.
543    // text/tools/tool_calls/tool_results are covered; http(s) image_url is not (can't relay to
544    // Anthropic's vision API without fetching). Conservative: any http(s) image → observe.
545    if inbound == Dialect::Openai && !openai_has_http_images(body) {
546        let all_anthropic = ladder.iter().all(|rung| {
547            let pid = rung.split('/').next().unwrap_or_default();
548            providers
549                .get(pid)
550                .is_some_and(|p| p.carries_structured_verbatim(Dialect::Anthropic))
551        });
552        if all_anthropic {
553            return true;
554        }
555    }
556    false
557}
558
559/// Whether any message carries a `tool_use` or `tool_result` content block (a multi-turn tool
560/// conversation), which the text-only enforce normalization would drop.
561fn messages_have_tool_blocks(body: &[u8]) -> bool {
562    serde_json::from_slice::<Value>(body)
563        .ok()
564        .and_then(|json| {
565            json.get("messages")
566                .and_then(Value::as_array)
567                .map(|messages| messages.iter().any(message_has_tool_block))
568        })
569        .unwrap_or(false)
570}
571
572/// Whether a single message's content contains a `tool_use` or `tool_result` block.
573fn message_has_tool_block(message: &Value) -> bool {
574    message
575        .get("content")
576        .and_then(Value::as_array)
577        .is_some_and(|blocks| {
578            blocks.iter().any(|block| {
579                matches!(
580                    block.get("type").and_then(Value::as_str),
581                    Some("tool_use" | "tool_result")
582                )
583            })
584        })
585}
586
587/// Whether the request opts into server-sent-events streaming (`"stream": true`).
588fn is_stream_request(body: &[u8]) -> bool {
589    serde_json::from_slice::<Value>(body)
590        .ok()
591        .and_then(|json| json.get("stream").and_then(Value::as_bool))
592        .unwrap_or(false)
593}
594
595/// Read a header as an owned `String`, if present and valid UTF-8.
596fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
597    headers
598        .get(name)
599        .and_then(|v| v.to_str().ok())
600        .map(str::to_owned)
601}
602
603/// Build the routing/telemetry feature vector from request headers + body (best-effort;
604/// malformed fields fall back to safe defaults — this must never fail a request).
605fn extract_features(headers: &HeaderMap, body: &[u8]) -> Features {
606    let (_model, tool_count, has_images) = request_features(body);
607    let mut f = Features::new(TaskKind::Other);
608    f.agent = header_str(headers, AGENT_HEADER);
609    f.subagent = header_str(headers, SUBAGENT_HEADER);
610    f.tool_count = tool_count;
611    f.has_images = has_images;
612    // Pre-call we don't know the token count, so bucket by request byte size — a coarse,
613    // monotonic proxy that never exposes the exact prompt (matches the privacy contract).
614    f.prompt_token_bucket = token_bucket(body.len() as u64);
615    f.hour_bucket = hour_bucket(jiff::Timestamp::now());
616    f
617}
618
619/// Enforce mode (SPEC §7.1): run the escalation engine and serve the first output that clears
620/// the route's gates, escalating on failure with cross-provider failover.
621async fn handle_enforce(
622    state: &AppState,
623    headers: &HeaderMap,
624    body: &Bytes,
625    features: Features,
626    route: &Route,
627    session_header: Option<String>,
628    tenant: String,
629) -> Response {
630    // A streaming client gets its SSE connection opened IMMEDIATELY: the routing pipeline
631    // (model call + gates + possible escalation) runs in a spawned task while the response body
632    // emits standards-compliant SSE comment keepalives (`: firstpass routing`) every few seconds,
633    // so no client or proxy idle-timeout fires during a long escalation. When the pipeline
634    // resolves, the gated result streams out as the usual Anthropic event sequence (ADR 0005 P3);
635    // a pipeline error becomes an SSE `error` event (status is already 200 by then — the SSE
636    // error frame is the in-band channel the protocol defines for exactly this).
637    if is_stream_request(body) {
638        let (tx, rx) = tokio::sync::oneshot::channel::<Result<Value, ProxyError>>();
639        let (state_c, headers_c, body_c, route_c) =
640            (state.clone(), headers.clone(), body.clone(), route.clone());
641        tokio::spawn(async move {
642            let out = enforce_pipeline(
643                &state_c,
644                &headers_c,
645                &body_c,
646                features,
647                &route_c,
648                session_header,
649                tenant,
650            )
651            .await;
652            let _ = tx.send(out);
653        });
654        return sse_keepalive_response(rx, anthropic_sse_from_message);
655    }
656    match enforce_pipeline(
657        state,
658        headers,
659        body,
660        features,
661        route,
662        session_header,
663        tenant,
664    )
665    .await
666    {
667        Ok(message) => (axum::http::StatusCode::OK, Json(message)).into_response(),
668        Err(e) => e.into_response(),
669    }
670}
671
672/// Inner pipeline: resolve gates → route the ladder → bookkeeping (bandit, trace) → the served
673/// [`ModelResponse`]. Shared by both Anthropic and OpenAI enforce paths; callers handle dialect-
674/// specific parsing before and response rendering after.
675#[allow(clippy::too_many_arguments)] // 9 params: all are genuinely distinct, not groupable
676async fn enforce_pipeline_inner(
677    state: &AppState,
678    body: &Bytes,
679    base_request: ModelRequest,
680    auth: Auth,
681    features: Features,
682    route: &Route,
683    session_header: Option<String>,
684    tenant: String,
685    api: &str,
686) -> Result<ModelResponse, ProxyError> {
687    let gate_defs = state
688        .config
689        .routing
690        .as_ref()
691        .map_or(&[][..], |cfg| &cfg.gate_defs);
692    let gates = resolve_gates(
693        &route.gates,
694        gate_defs,
695        &state.providers,
696        &auth,
697        &state.config.prices,
698    );
699    let session_id = session_header.unwrap_or_else(|| Uuid::now_v7().to_string());
700    let (budget, max_rungs, speculation, serve_threshold) = match state.config.routing.as_ref() {
701        Some(cfg) => (
702            cfg.budget.per_request_usd,
703            cfg.escalation.max_rungs_per_request,
704            cfg.escalation.speculation,
705            cfg.escalation.serve_threshold,
706        ),
707        None => (None, 3, 0, None),
708    };
709    // Online adaptive conformal: serve against the LIVE-tracked threshold (updated by /v1/feedback).
710    // Falls back to the fixed config threshold when adaptive is off or its lock is poisoned.
711    let serve_threshold = state
712        .adaptive
713        .as_ref()
714        .and_then(|a| a.lock().ok().map(|g| g.threshold()))
715        .or(serve_threshold);
716
717    // Predict-to-start (bandit): choose where the ladder starts for this context.
718    // The gate still verifies the chosen rung's output before serving — prediction errors cost
719    // money/latency but can never cause a wrong answer to be served.
720    let bandit_ctx = crate::bandit::ContextBucket::from_features(&features);
721
722    // Step 1: greedy start — bandit prediction or rung 0 (cold-start / no bandit).
723    // Thompson sampling returns its own Monte-Carlo selection propensity (the policy is
724    // stochastic by nature); UCB1 returns None and relies on the epsilon overlay as before.
725    let (greedy_rung, base_policy_id, ts_propensity) = {
726        let (chosen, ts_p) = state
727            .bandit
728            .as_ref()
729            .and_then(|b| b.lock().ok())
730            .map(|mut b| {
731                b.choose_start_with_propensity(&bandit_ctx, &route.ladder, &state.config.prices)
732            })
733            .unwrap_or((0, None));
734        let policy = if ts_p.is_some() {
735            "bandit@v2-ts".to_owned()
736        } else if chosen > 0 {
737            "bandit@v1".to_owned()
738        } else {
739            "static-ladder@v0".to_owned()
740        };
741        (chosen, policy, ts_p)
742    };
743
744    // Step 2: epsilon-greedy overlay — randomise a fraction of start-rung choices so the
745    // logging policy is stochastic and IPS/SNIPS off-policy estimates are valid
746    // (Horvitz-Thompson 1952). Propensity p = (1−ε)·𝟙[chosen==greedy] + ε/K is recorded on
747    // every trace; the bandit still observes all gate verdicts (learning is uninterrupted).
748    let exploration_epsilon = state
749        .config
750        .routing
751        .as_ref()
752        .and_then(|cfg| cfg.escalation.exploration.as_ref())
753        .map(|e| e.epsilon);
754
755    let (start_rung, policy_id, explore_flag, propensity) = if ts_propensity.is_some() {
756        // Thompson IS the stochastic logging policy — its MC propensity is logged directly and
757        // the epsilon overlay is redundant (warn once if both are configured).
758        if exploration_epsilon.is_some() {
759            tracing::warn!(
760                "bandit.algorithm = thompson already logs propensities; \
761                 [escalation.exploration] epsilon is ignored"
762            );
763        }
764        (greedy_rung, base_policy_id, false, ts_propensity)
765    } else if let Some(epsilon) = exploration_epsilon {
766        let k = route.ladder.len().max(1);
767        // Derive a per-request uniform draw from a fresh UUIDv7's bits — no new deps.
768        let u = u01(Uuid::now_v7().as_u128());
769        let (chosen, eps_branch) = if u < epsilon {
770            // Epsilon branch: uniform over 0..k
771            let idx = ((u / epsilon) * k as f64) as u32;
772            (idx.min(k as u32 - 1), true)
773        } else {
774            (greedy_rung, false)
775        };
776        let p = epsilon_propensity(chosen, greedy_rung, epsilon, k);
777        (chosen, format!("{base_policy_id}+eps"), eps_branch, Some(p))
778    } else {
779        (greedy_rung, base_policy_id, false, None)
780    };
781
782    // Speculative-deferral band: prefetch only when the bandit's gate-pass estimate for the
783    // chosen start rung is in the configured marginal zone — where the next rung is *probably
784    // but not certainly* needed, the only place parallel spend reliably buys latency
785    // (speculative cascades). Confident-pass or confident-fail contexts run serial and keep
786    // the speculative tokens. No band / no bandit / cold context ⇒ configured behavior.
787    let speculation = match state
788        .config
789        .routing
790        .as_ref()
791        .and_then(|cfg| cfg.escalation.speculation_band)
792    {
793        Some([lo, hi]) if speculation > 0 => {
794            let estimate = state
795                .bandit
796                .as_ref()
797                .and_then(|b| b.lock().ok())
798                .and_then(|b| b.pass_estimate(&bandit_ctx, start_rung));
799            match estimate {
800                Some(p) if p < lo || p > hi => {
801                    metrics::counter!("firstpass_speculation_skipped_total").increment(1);
802                    0
803                }
804                _ => speculation,
805            }
806        }
807        _ => speculation,
808    };
809
810    // Emit metric whenever the bandit is configured (includes cold-start rung-0 choices).
811    if state.bandit.is_some() {
812        metrics::counter!(
813            "firstpass_bandit_start_rung",
814            "rung" => start_rung.to_string()
815        )
816        .increment(1);
817    }
818
819    let ctx = EnforceCtx {
820        ladder: &route.ladder,
821        gates: &gates,
822        health: &state.gate_health,
823        base_request: &base_request,
824        providers: &state.providers,
825        auth: &auth,
826        prices: &state.config.prices,
827        budget_per_request_usd: budget,
828        max_rungs,
829        speculation,
830        serve_threshold,
831        features,
832        start_rung,
833        // The tenant stamped on the enforce trace is the resolved identity from the auth layer
834        // (authenticated key, or the static default when auth is off) — never the request body.
835        tenant_id: tenant,
836        session_id,
837        prompt_hash: prompt_hash(&state.config.prompt_salt, body),
838        api: api.to_owned(),
839        policy_id,
840    };
841
842    let (outcome, mut trace) = route_enforce(ctx).await;
843
844    // Patch explore/propensity onto the trace now that we know whether the epsilon branch fired.
845    // route_enforce leaves these at (false, None); we own the trace before it's hashed+stored.
846    trace.policy.explore = explore_flag;
847    trace.policy.propensity = propensity;
848
849    // Online bandit learning: feed back every gate verdict from this request so the bandit
850    // refines its start-rung estimates. Cheap in-memory update; done before offer_trace so the
851    // trace borrow is still live (we read attempts, then pass trace to offer_trace by value).
852    if let Some(bandit) = state.bandit.as_ref()
853        && let Ok(mut b) = bandit.lock()
854    {
855        for attempt in &trace.attempts {
856            b.observe(&bandit_ctx, attempt.rung, attempt.verdict);
857        }
858    }
859
860    // The trace is already built; enqueue it off-path (non-blocking `try_send`, so no spawn needed).
861    offer_trace(&state.traces, state.spill.as_ref(), trace);
862
863    match outcome {
864        EngineOutcome::Served(resp) => Ok(resp),
865        EngineOutcome::Failed(msg) => Err(ProxyError::Engine(msg)),
866    }
867}
868
869/// Anthropic enforce pipeline: parse → inner pipeline → Anthropic-shaped response JSON.
870/// Shared verbatim by the buffered (non-streaming) and keepalive-streaming paths.
871async fn enforce_pipeline(
872    state: &AppState,
873    headers: &HeaderMap,
874    body: &Bytes,
875    features: Features,
876    route: &Route,
877    session_header: Option<String>,
878    tenant: String,
879) -> Result<Value, ProxyError> {
880    let Some(base_request) = parse_model_request(body) else {
881        return Err(ProxyError::BadRequest(
882            "request body is not a valid Anthropic Messages request".to_owned(),
883        ));
884    };
885    let auth = Auth::from_headers(headers);
886    let resp = enforce_pipeline_inner(
887        state,
888        body,
889        base_request,
890        auth,
891        features,
892        route,
893        session_header,
894        tenant,
895        "anthropic.messages",
896    )
897    .await?;
898    Ok(anthropic_response_json(&resp))
899}
900
901/// OpenAI enforce pipeline: parse (with raw-carry for all-OpenAI ladders, else translation) →
902/// inner pipeline → OpenAI `chat.completion` JSON.
903async fn enforce_pipeline_openai(
904    state: &AppState,
905    headers: &HeaderMap,
906    body: &Bytes,
907    features: Features,
908    route: &Route,
909    session_header: Option<String>,
910    tenant: String,
911) -> Result<Value, ProxyError> {
912    // Decide between verbatim raw-carry (all-OpenAI ladder) and translation (Anthropic ladder).
913    let providers = &state.providers;
914    let all_openai = route.ladder.iter().all(|rung| {
915        let pid = rung.split('/').next().unwrap_or_default();
916        providers
917            .get(pid)
918            .is_some_and(|p| p.carries_structured_verbatim(Dialect::Openai))
919    });
920    let Some(base_request) = parse_openai_request(body, all_openai) else {
921        return Err(ProxyError::BadRequest(
922            "request body is not a valid OpenAI Chat Completions request".to_owned(),
923        ));
924    };
925    let auth = Auth::from_headers(headers);
926    let resp = enforce_pipeline_inner(
927        state,
928        body,
929        base_request,
930        auth,
931        features,
932        route,
933        session_header,
934        tenant,
935        "openai.chat_completions",
936    )
937    .await?;
938    Ok(openai_response_json(&resp))
939}
940
941/// Interval between SSE comment keepalives while the enforce pipeline is still routing.
942const SSE_KEEPALIVE_EVERY: Duration = Duration::from_secs(5);
943
944/// A 200 `text/event-stream` response whose body emits comment keepalives until `rx` resolves,
945/// then the gated result formatted by `format_message` (or an SSE `error` event on failure).
946/// SSE comment lines (leading `:`) are defined by the EventSource spec to be ignored by every
947/// conforming parser — they keep the connection alive without confusing any client.
948///
949/// `format_message` converts the gated result `Value` to the dialect-appropriate SSE frame
950/// string: pass [`anthropic_sse_from_message`] for Anthropic clients,
951/// [`openai_sse_from_message`] for OpenAI clients.
952fn sse_keepalive_response(
953    rx: tokio::sync::oneshot::Receiver<Result<Value, ProxyError>>,
954    format_message: fn(&Value) -> String,
955) -> Response {
956    let mut ticks = tokio::time::interval(SSE_KEEPALIVE_EVERY);
957    ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
958    ticks.reset(); // skip the immediate first tick — the first keepalive fires after one period
959    let stream = KeepaliveStream {
960        rx: Some(rx),
961        ticks,
962        format_message,
963    };
964    (
965        axum::http::StatusCode::OK,
966        [(
967            axum::http::header::CONTENT_TYPE,
968            "text/event-stream; charset=utf-8",
969        )],
970        axum::body::Body::from_stream(stream),
971    )
972        .into_response()
973}
974
975/// Hand-rolled [`futures_core::Stream`]-shaped body (no new dependency): comment keepalives
976/// while the pipeline runs, then the final SSE frame, then end-of-stream.
977struct KeepaliveStream {
978    /// `Some` until the pipeline resolves and the final frame has been emitted.
979    rx: Option<tokio::sync::oneshot::Receiver<Result<Value, ProxyError>>>,
980    ticks: tokio::time::Interval,
981    /// Converts the served result `Value` to the caller's dialect SSE frames.
982    format_message: fn(&Value) -> String,
983}
984
985impl futures_core::Stream for KeepaliveStream {
986    type Item = Result<Bytes, std::convert::Infallible>;
987
988    fn poll_next(
989        mut self: std::pin::Pin<&mut Self>,
990        cx: &mut std::task::Context<'_>,
991    ) -> std::task::Poll<Option<Self::Item>> {
992        use std::task::Poll;
993        let Some(rx) = self.rx.as_mut() else {
994            return Poll::Ready(None); // final frame already emitted
995        };
996        if let Poll::Ready(out) = std::pin::Pin::new(rx).poll(cx) {
997            let fmt = self.format_message;
998            let frame = match out {
999                Ok(Ok(message)) => fmt(&message),
1000                Ok(Err(e)) => sse_error_event(&e),
1001                Err(_) => sse_error_event(&ProxyError::Internal(
1002                    "enforce pipeline task dropped".to_owned(),
1003                )),
1004            };
1005            self.rx = None;
1006            return Poll::Ready(Some(Ok(Bytes::from(frame))));
1007        }
1008        if self.ticks.poll_tick(cx).is_ready() {
1009            return Poll::Ready(Some(Ok(Bytes::from_static(b": firstpass routing\n\n"))));
1010        }
1011        Poll::Pending
1012    }
1013}
1014
1015/// Render a pipeline error as the Anthropic SSE `error` event (client-safe message only —
1016/// internal detail is logged by the error type, never sent).
1017fn sse_error_event(e: &ProxyError) -> String {
1018    let mut out = String::new();
1019    sse_event(
1020        &mut out,
1021        "error",
1022        &serde_json::json!({
1023            "type": "error",
1024            "error": { "type": "api_error", "message": e.client_message() }
1025        }),
1026    );
1027    out
1028}
1029
1030/// Parse an Anthropic Messages request body into the normalized [`ModelRequest`]. Returns
1031/// `None` if the body isn't valid JSON or lacks a `messages` array.
1032///
1033// Message content is preserved **verbatim** (string or array of blocks) — a plain-string content
1034// serializes byte-identical on the wire, and tool_use/tool_result/image blocks survive the round
1035// trip (ADR 0005, invariant I2). Gates operate on `ChatMessage::text_view()`, not the raw content,
1036// so gate behavior is unchanged. Which requests actually enter enforce is still governed by
1037// `enforce_can_handle`; this function only guarantees no fidelity is lost once they do.
1038fn parse_model_request(body: &[u8]) -> Option<ModelRequest> {
1039    let json: Value = serde_json::from_slice(body).ok()?;
1040    let raw = json.clone();
1041    let messages_json = json.get("messages")?.as_array()?;
1042    let messages = messages_json
1043        .iter()
1044        .map(|m| ChatMessage {
1045            role: m
1046                .get("role")
1047                .and_then(Value::as_str)
1048                .unwrap_or("user")
1049                .to_owned(),
1050            content: m
1051                .get("content")
1052                .cloned()
1053                .unwrap_or_else(|| Value::String(String::new())),
1054        })
1055        .collect();
1056    let system = json
1057        .get("system")
1058        .and_then(Value::as_str)
1059        .map(str::to_owned);
1060    let max_tokens = json
1061        .get("max_tokens")
1062        .and_then(Value::as_u64)
1063        .and_then(|n| u32::try_from(n).ok())
1064        .unwrap_or(1024);
1065    let tools = json.get("tools").cloned().unwrap_or(Value::Null);
1066    Some(ModelRequest {
1067        model: json
1068            .get("model")
1069            .and_then(Value::as_str)
1070            .unwrap_or_default()
1071            .to_owned(),
1072        system,
1073        messages,
1074        max_tokens,
1075        tools,
1076        raw,
1077    })
1078}
1079
1080/// Render a served [`ModelResponse`] back into an Anthropic Messages response envelope, so the
1081/// caller sees the same wire shape regardless of which provider actually answered.
1082///
1083/// The `content` blocks come **verbatim** from the upstream response (`resp.raw`) when it is an
1084/// Anthropic message — so `tool_use` / `thinking` / multiple text blocks reach the caller intact
1085/// (ADR 0005 I2). Only when `raw` has no Anthropic `content` array (a synthetic response, or the
1086/// OpenAI adapter, which has `choices` instead) do we fall back to a single reconstructed text
1087/// block. The envelope (`id`, `model`, `usage`) is always normalized so the served model id is the
1088/// prefixed ladder id, not the bare wire id.
1089fn anthropic_response_json(resp: &ModelResponse) -> Value {
1090    let content = resp
1091        .raw
1092        .get("content")
1093        .filter(|c| c.is_array())
1094        .cloned()
1095        .unwrap_or_else(|| serde_json::json!([{ "type": "text", "text": resp.text }]));
1096    serde_json::json!({
1097        "id": format!("msg_{}", Uuid::now_v7()),
1098        "type": "message",
1099        "role": "assistant",
1100        "model": resp.model,
1101        "content": content,
1102        "usage": { "input_tokens": resp.in_tokens, "output_tokens": resp.out_tokens },
1103    })
1104}
1105
1106/// Append one `event: <type>\ndata: <json>\n\n` SSE frame.
1107fn sse_event(out: &mut String, event: &str, data: &Value) {
1108    out.push_str("event: ");
1109    out.push_str(event);
1110    out.push_str("\ndata: ");
1111    out.push_str(&data.to_string());
1112    out.push_str("\n\n");
1113}
1114
1115/// Re-emit a served Anthropic message envelope (from [`anthropic_response_json`]) as an SSE stream
1116/// body, so a `stream: true` client is served even though enforce buffered the response to gate it
1117/// (ADR 0005 P3). The gate needs the full candidate, so this is not token-by-token streaming from
1118/// the model — each content block is emitted as a single delta. `tool_use` blocks are preserved:
1119/// their `input` is streamed as one `input_json_delta` (invariant I2), so the caller reconstructs
1120/// the exact tool call.
1121fn anthropic_sse_from_message(message: &Value) -> String {
1122    let mut out = String::new();
1123
1124    // message_start carries the envelope with content emptied — the blocks stream next.
1125    let mut start_msg = message.clone();
1126    start_msg["content"] = Value::Array(Vec::new());
1127    sse_event(
1128        &mut out,
1129        "message_start",
1130        &serde_json::json!({ "type": "message_start", "message": start_msg }),
1131    );
1132
1133    let empty = Vec::new();
1134    let blocks = message
1135        .get("content")
1136        .and_then(Value::as_array)
1137        .unwrap_or(&empty);
1138    for (i, block) in blocks.iter().enumerate() {
1139        match block.get("type").and_then(Value::as_str) {
1140            Some("tool_use") => {
1141                // Start with an empty input object, then stream the real input as one JSON delta.
1142                let mut shell = block.clone();
1143                shell["input"] = serde_json::json!({});
1144                sse_event(
1145                    &mut out,
1146                    "content_block_start",
1147                    &serde_json::json!({ "type": "content_block_start", "index": i, "content_block": shell }),
1148                );
1149                let input_json = block
1150                    .get("input")
1151                    .map_or_else(|| "{}".to_owned(), std::string::ToString::to_string);
1152                sse_event(
1153                    &mut out,
1154                    "content_block_delta",
1155                    &serde_json::json!({ "type": "content_block_delta", "index": i,
1156                        "delta": { "type": "input_json_delta", "partial_json": input_json } }),
1157                );
1158            }
1159            _ => {
1160                // text (and any other text-bearing block): start empty, stream the text as one delta.
1161                let text = block.get("text").and_then(Value::as_str).unwrap_or("");
1162                sse_event(
1163                    &mut out,
1164                    "content_block_start",
1165                    &serde_json::json!({ "type": "content_block_start", "index": i,
1166                        "content_block": { "type": "text", "text": "" } }),
1167                );
1168                sse_event(
1169                    &mut out,
1170                    "content_block_delta",
1171                    &serde_json::json!({ "type": "content_block_delta", "index": i,
1172                        "delta": { "type": "text_delta", "text": text } }),
1173                );
1174            }
1175        }
1176        sse_event(
1177            &mut out,
1178            "content_block_stop",
1179            &serde_json::json!({ "type": "content_block_stop", "index": i }),
1180        );
1181    }
1182
1183    let out_tokens = message
1184        .pointer("/usage/output_tokens")
1185        .cloned()
1186        .unwrap_or_else(|| Value::from(0));
1187    sse_event(
1188        &mut out,
1189        "message_delta",
1190        &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "end_turn" },
1191            "usage": { "output_tokens": out_tokens } }),
1192    );
1193    sse_event(
1194        &mut out,
1195        "message_stop",
1196        &serde_json::json!({ "type": "message_stop" }),
1197    );
1198    out
1199}
1200
1201/// Observe mode (SPEC §7.1a): forward unchanged, return unchanged, trace asynchronously.
1202async fn observe_passthrough(
1203    state: AppState,
1204    headers: HeaderMap,
1205    body: Bytes,
1206    session_header: Option<String>,
1207    tenant: String,
1208) -> Response {
1209    // Streaming requests are relayed chunk-by-chunk rather than buffered (SPEC §7.4).
1210    if is_stream_request(&body) {
1211        return observe_stream(state, headers, body, session_header, tenant).await;
1212    }
1213    let start = Instant::now();
1214    let result = forward_anthropic(
1215        &state.http,
1216        &state.config.upstream_anthropic,
1217        &headers,
1218        body.clone(),
1219    )
1220    .await;
1221    let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1222
1223    match result {
1224        Ok((status, resp_headers, resp_body)) => {
1225            // Build + record the trace on a detached task so neither JSON parsing nor the
1226            // channel send touches the response path: observe mode adds zero latency to what
1227            // the caller sees (SPEC §7.1a). `Bytes` clones are cheap (refcounted).
1228            spawn_trace(
1229                &state,
1230                body,
1231                Some(resp_body.clone()),
1232                latency_ms,
1233                session_header,
1234                tenant,
1235            );
1236            (status, resp_headers, resp_body).into_response()
1237        }
1238        Err(err) => {
1239            spawn_trace(&state, body, None, latency_ms, session_header, tenant);
1240            err.into_response()
1241        }
1242    }
1243}
1244
1245/// Observe mode for a streaming request (`stream: true`): relay the upstream SSE response
1246/// chunk-by-chunk instead of buffering, so streaming is preserved to the caller and
1247/// time-to-first-byte stays low. `latency_ms` is time-to-response-headers (the added-latency
1248/// figure that matters), recorded off the response path.
1249///
1250// ponytail: streamed-response token usage lives in the SSE `message_start`/`message_delta` events
1251// we don't buffer, so the trace records request-side features + latency now; parsing usage from a
1252// teed SSE stream is the follow-on.
1253async fn observe_stream(
1254    state: AppState,
1255    headers: HeaderMap,
1256    body: Bytes,
1257    session_header: Option<String>,
1258    tenant: String,
1259) -> Response {
1260    let start = Instant::now();
1261    let result = forward_anthropic_streaming(
1262        &state.http,
1263        &state.config.upstream_anthropic,
1264        &headers,
1265        body.clone(),
1266    )
1267    .await;
1268    let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1269
1270    match result {
1271        Ok((status, resp_headers, response)) => {
1272            spawn_stream_trace(&state, body, latency_ms, session_header, tenant);
1273            let stream_body = Body::from_stream(response.bytes_stream());
1274            (status, resp_headers, stream_body).into_response()
1275        }
1276        Err(err) => {
1277            spawn_trace(&state, body, None, latency_ms, session_header, tenant);
1278            err.into_response()
1279        }
1280    }
1281}
1282
1283/// Enqueue a request-side trace for a streamed observe response, off the response path.
1284fn spawn_stream_trace(
1285    state: &AppState,
1286    req_body: Bytes,
1287    latency_ms: u64,
1288    session_header: Option<String>,
1289    tenant: String,
1290) {
1291    let config = state.config.clone();
1292    let traces = state.traces.clone();
1293    let spill = state.spill.clone();
1294    tokio::spawn(async move {
1295        let mut trace =
1296            build_stream_trace(&config, &req_body, latency_ms, session_header.as_deref());
1297        // Stamp the resolved tenant identity — never the config default nor anything request-borne.
1298        trace.tenant_id = tenant;
1299        offer_trace(&traces, spill.as_ref(), trace);
1300    });
1301}
1302
1303/// Construct the trace and enqueue it for the background writer, entirely off the response
1304/// path. Fire-and-forget: if the writer has shut down we log rather than propagate — recording
1305/// must never affect what the caller sees. `resp_body` is `Some` for a forwarded response and
1306/// `None` when the upstream call failed outright.
1307fn spawn_trace(
1308    state: &AppState,
1309    req_body: Bytes,
1310    resp_body: Option<Bytes>,
1311    latency_ms: u64,
1312    session_header: Option<String>,
1313    tenant: String,
1314) {
1315    let config = state.config.clone();
1316    let traces = state.traces.clone();
1317    let spill = state.spill.clone();
1318    tokio::spawn(async move {
1319        let mut trace = match resp_body {
1320            Some(resp) => build_trace(
1321                &config,
1322                &req_body,
1323                &resp,
1324                latency_ms,
1325                session_header.as_deref(),
1326            ),
1327            None => build_error_trace(&config, &req_body, latency_ms, session_header.as_deref()),
1328        };
1329        // Stamp the resolved tenant identity — never the config default nor anything request-borne.
1330        trace.tenant_id = tenant;
1331        offer_trace(&traces, spill.as_ref(), trace);
1332    });
1333}
1334
1335/// Session id for the trace: the caller-supplied header, or the trace's own id when absent.
1336fn session_id(session_header: Option<&str>, trace_id: Uuid) -> String {
1337    session_header
1338        .map(str::to_owned)
1339        .unwrap_or_else(|| trace_id.to_string())
1340}
1341
1342/// Salted hash of the raw request body — the only trace of the prompt that ever touches
1343/// storage (SPEC: never log or persist raw prompt text).
1344fn prompt_hash(salt: &str, body: &[u8]) -> String {
1345    let mut salted = Vec::with_capacity(salt.len() + body.len());
1346    salted.extend_from_slice(salt.as_bytes());
1347    salted.extend_from_slice(body);
1348    sha256_hex(&salted)
1349}
1350
1351/// Best-effort request-side feature extraction: model name, tool count, and whether any
1352/// message carries image content. Malformed/absent fields fall back to safe defaults rather
1353/// than failing the request — this is telemetry, not the served response.
1354fn request_features(body: &[u8]) -> (Option<String>, u32, bool) {
1355    let Ok(json) = serde_json::from_slice::<Value>(body) else {
1356        return (None, 0, false);
1357    };
1358    let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
1359    let tool_count = json
1360        .get("tools")
1361        .and_then(Value::as_array)
1362        .map_or(0, |tools| u32::try_from(tools.len()).unwrap_or(u32::MAX));
1363    let has_images = json
1364        .get("messages")
1365        .and_then(Value::as_array)
1366        .is_some_and(|messages| messages.iter().any(message_has_image));
1367    (model, tool_count, has_images)
1368}
1369
1370/// Whether a single message's content contains an image block (`{"type": "image", ...}`).
1371fn message_has_image(message: &Value) -> bool {
1372    message
1373        .get("content")
1374        .and_then(Value::as_array)
1375        .is_some_and(|blocks| {
1376            blocks
1377                .iter()
1378                .any(|block| block.get("type").and_then(Value::as_str) == Some("image"))
1379        })
1380}
1381
1382/// Response-side usage extraction: model name and token counts, defaulting to `0` when the
1383/// upstream response doesn't carry them (e.g. an error body).
1384fn response_usage(body: &[u8]) -> (Option<String>, u64, u64) {
1385    let Ok(json) = serde_json::from_slice::<Value>(body) else {
1386        return (None, 0, 0);
1387    };
1388    let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
1389    let in_tokens = json
1390        .pointer("/usage/input_tokens")
1391        .and_then(Value::as_u64)
1392        .unwrap_or(0);
1393    let out_tokens = json
1394        .pointer("/usage/output_tokens")
1395        .and_then(Value::as_u64)
1396        .unwrap_or(0);
1397    (model, in_tokens, out_tokens)
1398}
1399
1400/// Build the observe-mode trace for a request that was successfully forwarded and answered.
1401fn build_trace(
1402    config: &ProxyConfig,
1403    req_body: &Bytes,
1404    resp_body: &Bytes,
1405    latency_ms: u64,
1406    session_header: Option<&str>,
1407) -> Trace {
1408    let (req_model, tool_count, has_images) = request_features(req_body);
1409    let (resp_model, in_tokens, out_tokens) = response_usage(resp_body);
1410    let model = resp_model
1411        .or(req_model)
1412        .unwrap_or_else(|| "unknown".to_owned());
1413
1414    let cost_usd = config
1415        .prices
1416        .cost_usd(&format!("anthropic/{model}"), in_tokens, out_tokens)
1417        .unwrap_or(0.0);
1418
1419    let attempt = Attempt {
1420        rung: 0,
1421        model,
1422        provider: "anthropic".to_owned(),
1423        in_tokens,
1424        out_tokens,
1425        cost_usd,
1426        latency_ms,
1427        gates: Vec::new(),
1428        verdict: Verdict::Pass,
1429    };
1430
1431    let mut trace = base_trace(config, req_body, latency_ms, session_header);
1432    trace.request.features.prompt_token_bucket = token_bucket(in_tokens);
1433    trace.request.features.tool_count = tool_count;
1434    trace.request.features.has_images = has_images;
1435    trace.attempts.push(attempt);
1436    trace.final_ = FinalOutcome {
1437        served_rung: Some(0),
1438        served_from: ServedFrom::Attempt,
1439        total_cost_usd: cost_usd,
1440        gate_cost_usd: 0.0,
1441        total_latency_ms: latency_ms,
1442        escalations: 0,
1443        counterfactual_baseline_usd: cost_usd,
1444        savings_usd: 0.0,
1445    };
1446    trace.recompute_savings();
1447    trace
1448}
1449
1450/// Build the observe-mode trace for a **streamed** response: we relayed real bytes to the caller,
1451/// but the token usage lives in the SSE events we didn't buffer, so it's recorded as served with
1452/// unknown (zero) usage — honest about what we served without inventing token counts.
1453fn build_stream_trace(
1454    config: &ProxyConfig,
1455    req_body: &Bytes,
1456    latency_ms: u64,
1457    session_header: Option<&str>,
1458) -> Trace {
1459    let (req_model, tool_count, has_images) = request_features(req_body);
1460    let model = req_model.unwrap_or_else(|| "unknown".to_owned());
1461
1462    let attempt = Attempt {
1463        rung: 0,
1464        model,
1465        provider: "anthropic".to_owned(),
1466        in_tokens: 0,
1467        out_tokens: 0,
1468        cost_usd: 0.0,
1469        latency_ms,
1470        gates: Vec::new(),
1471        verdict: Verdict::Pass,
1472    };
1473
1474    let mut trace = base_trace(config, req_body, latency_ms, session_header);
1475    trace.request.features.tool_count = tool_count;
1476    trace.request.features.has_images = has_images;
1477    trace.attempts.push(attempt);
1478    trace.final_ = FinalOutcome {
1479        served_rung: Some(0),
1480        served_from: ServedFrom::Attempt,
1481        total_cost_usd: 0.0,
1482        gate_cost_usd: 0.0,
1483        total_latency_ms: latency_ms,
1484        escalations: 0,
1485        counterfactual_baseline_usd: 0.0,
1486        savings_usd: 0.0,
1487    };
1488    trace.recompute_savings();
1489    trace
1490}
1491
1492/// Build the observe-mode trace for a request whose upstream call failed outright (no
1493/// response to report usage from). Recorded with `served_from: Error` and no attempts —
1494/// keep the audit trail honest that nothing was served.
1495fn build_error_trace(
1496    config: &ProxyConfig,
1497    req_body: &Bytes,
1498    latency_ms: u64,
1499    session_header: Option<&str>,
1500) -> Trace {
1501    let (_, tool_count, has_images) = request_features(req_body);
1502    let mut trace = base_trace(config, req_body, latency_ms, session_header);
1503    trace.request.features.tool_count = tool_count;
1504    trace.request.features.has_images = has_images;
1505    trace.final_ = FinalOutcome {
1506        served_rung: None,
1507        served_from: ServedFrom::Error,
1508        total_cost_usd: 0.0,
1509        gate_cost_usd: 0.0,
1510        total_latency_ms: latency_ms,
1511        escalations: 0,
1512        counterfactual_baseline_usd: 0.0,
1513        savings_usd: 0.0,
1514    };
1515    trace.recompute_savings();
1516    trace
1517}
1518
1519/// The parts of a trace that don't depend on whether the call succeeded: identity, policy,
1520/// and the request-side feature vector minus token bucket (which needs response usage).
1521fn base_trace(
1522    config: &ProxyConfig,
1523    req_body: &Bytes,
1524    latency_ms: u64,
1525    session_header: Option<&str>,
1526) -> Trace {
1527    let trace_id = Uuid::now_v7();
1528    let mut features = Features::new(TaskKind::Other);
1529    features.hour_bucket = hour_bucket(jiff::Timestamp::now());
1530
1531    Trace {
1532        trace_id,
1533        prev_hash: GENESIS_HASH.to_owned(),
1534        tenant_id: config.tenant_id.clone(),
1535        session_id: session_id(session_header, trace_id),
1536        ts: jiff::Timestamp::now(),
1537        mode: Mode::Observe,
1538        policy: PolicyRef {
1539            id: "observe-passthrough@v0".to_owned(),
1540            explore: false,
1541            propensity: None,
1542        },
1543        request: RequestInfo {
1544            api: "anthropic.messages".to_owned(),
1545            prompt_hash: prompt_hash(&config.prompt_salt, req_body),
1546            features,
1547        },
1548        attempts: Vec::new(),
1549        deferred: Vec::new(),
1550        final_: FinalOutcome {
1551            served_rung: None,
1552            served_from: ServedFrom::Error,
1553            total_cost_usd: 0.0,
1554            gate_cost_usd: 0.0,
1555            total_latency_ms: latency_ms,
1556            escalations: 0,
1557            counterfactual_baseline_usd: 0.0,
1558            savings_usd: 0.0,
1559        },
1560    }
1561}
1562
1563// ── OpenAI-inbound detection helpers ─────────────────────────────────────────
1564
1565/// Whether any OpenAI-format message has `tool_calls` on an assistant turn, or a `role:"tool"`
1566/// message (a multi-turn tool conversation that would need translation).
1567fn openai_messages_have_tool_calls(body: &[u8]) -> bool {
1568    serde_json::from_slice::<Value>(body)
1569        .ok()
1570        .and_then(|json| {
1571            json.get("messages").and_then(Value::as_array).map(|msgs| {
1572                msgs.iter().any(|m| {
1573                    m.get("tool_calls").is_some()
1574                        || m.get("role").and_then(Value::as_str) == Some("tool")
1575                })
1576            })
1577        })
1578        .unwrap_or(false)
1579}
1580
1581/// Whether any OpenAI-format message has an `image_url` content part whose URL is an
1582/// http(s) URL (not a data: URI). These cannot be forwarded to Anthropic's vision API without
1583/// fetching, so they are treated as non-translatable → observe fallback.
1584fn openai_has_http_images(body: &[u8]) -> bool {
1585    serde_json::from_slice::<Value>(body)
1586        .ok()
1587        .and_then(|json| {
1588            json.get("messages").and_then(Value::as_array).map(|msgs| {
1589                msgs.iter().any(|m| {
1590                    m.get("content")
1591                        .and_then(Value::as_array)
1592                        .is_some_and(|parts| {
1593                            parts.iter().any(|p| {
1594                                p.get("type").and_then(Value::as_str) == Some("image_url")
1595                                    && p.pointer("/image_url/url")
1596                                        .and_then(Value::as_str)
1597                                        .is_some_and(|u| {
1598                                            u.starts_with("http://") || u.starts_with("https://")
1599                                        })
1600                            })
1601                        })
1602                })
1603            })
1604        })
1605        .unwrap_or(false)
1606}
1607
1608/// Whether any OpenAI-format message has an `image_url` content part (data: or http(s)).
1609/// Used by `extract_openai_features` to set `has_images`.
1610fn openai_messages_have_images(body: &[u8]) -> bool {
1611    serde_json::from_slice::<Value>(body)
1612        .ok()
1613        .and_then(|json| {
1614            json.get("messages").and_then(Value::as_array).map(|msgs| {
1615                msgs.iter().any(|m| {
1616                    m.get("content")
1617                        .and_then(Value::as_array)
1618                        .is_some_and(|parts| {
1619                            parts
1620                                .iter()
1621                                .any(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
1622                        })
1623                })
1624            })
1625        })
1626        .unwrap_or(false)
1627}
1628
1629/// Build the routing/telemetry feature vector from an OpenAI Chat Completions request body.
1630/// Parallel to [`extract_features`] but understands OpenAI format (image_url vs image blocks).
1631fn extract_openai_features(headers: &HeaderMap, body: &[u8]) -> Features {
1632    let Ok(json) = serde_json::from_slice::<Value>(body) else {
1633        let mut f = Features::new(TaskKind::Other);
1634        f.hour_bucket = hour_bucket(jiff::Timestamp::now());
1635        return f;
1636    };
1637    let tool_count = json
1638        .get("tools")
1639        .and_then(Value::as_array)
1640        .map_or(0, |tools| u32::try_from(tools.len()).unwrap_or(u32::MAX));
1641    let has_images = openai_messages_have_images(body);
1642    let mut f = Features::new(TaskKind::Other);
1643    f.agent = header_str(headers, AGENT_HEADER);
1644    f.subagent = header_str(headers, SUBAGENT_HEADER);
1645    f.tool_count = tool_count;
1646    f.has_images = has_images;
1647    f.prompt_token_bucket = token_bucket(body.len() as u64);
1648    f.hour_bucket = hour_bucket(jiff::Timestamp::now());
1649    f
1650}
1651
1652// ── OpenAI → internal translation ────────────────────────────────────────────
1653
1654/// Parse a `data:image/<type>;base64,<data>` URL into `(media_type, base64_data)`.
1655fn parse_data_url(url: &str) -> Option<(&str, &str)> {
1656    let rest = url.strip_prefix("data:")?;
1657    let (meta, data) = rest.split_once(',')?;
1658    let media_type = meta.strip_suffix(";base64")?;
1659    Some((media_type, data))
1660}
1661
1662/// Translate an OpenAI user content value to Anthropic content blocks.
1663/// Returns `None` for any `image_url` part with an http(s) URL (non-translatable).
1664fn translate_openai_user_content(content: &Value) -> Option<Value> {
1665    match content {
1666        // Plain string → keep as-is (most common path)
1667        Value::String(_) => Some(content.clone()),
1668        Value::Array(parts) => {
1669            let mut blocks: Vec<Value> = Vec::with_capacity(parts.len());
1670            for part in parts {
1671                match part.get("type").and_then(Value::as_str) {
1672                    Some("text") => {
1673                        let text = part.get("text").and_then(Value::as_str).unwrap_or("");
1674                        blocks.push(serde_json::json!({ "type": "text", "text": text }));
1675                    }
1676                    Some("image_url") => {
1677                        let url = part.pointer("/image_url/url").and_then(Value::as_str)?;
1678                        if url.starts_with("http://") || url.starts_with("https://") {
1679                            return None; // not translatable — caller falls back to observe
1680                        }
1681                        // data: URI → Anthropic base64 image block
1682                        let (media_type, data) = parse_data_url(url)?;
1683                        blocks.push(serde_json::json!({
1684                            "type": "image",
1685                            "source": { "type": "base64", "media_type": media_type, "data": data }
1686                        }));
1687                    }
1688                    _ => {} // skip unknown content part types conservatively
1689                }
1690            }
1691            Some(Value::Array(blocks))
1692        }
1693        _ => Some(Value::String(String::new())),
1694    }
1695}
1696
1697/// Translate OpenAI `tools` array to Anthropic tools format.
1698/// OpenAI: `[{"type":"function","function":{"name":"...","description":"...","parameters":{...}}}]`
1699/// Anthropic: `[{"name":"...","description":"...","input_schema":{...}}]`
1700fn translate_openai_tools(tools: &Value) -> Value {
1701    let Some(arr) = tools.as_array() else {
1702        return Value::Null;
1703    };
1704    let converted: Vec<Value> = arr
1705        .iter()
1706        .map(|tool| {
1707            let func = tool.get("function").unwrap_or(&Value::Null);
1708            let mut out = serde_json::json!({
1709                "name": func.get("name").cloned().unwrap_or(Value::String(String::new())),
1710                "input_schema": func.get("parameters").cloned()
1711                    .unwrap_or_else(|| serde_json::json!({ "type": "object" })),
1712            });
1713            if let Some(desc) = func.get("description") {
1714                out["description"] = desc.clone();
1715            }
1716            out
1717        })
1718        .collect();
1719    Value::Array(converted)
1720}
1721
1722/// Translate OpenAI `tool_choice` to Anthropic `tool_choice`. Best-effort.
1723fn translate_openai_tool_choice(tc: &Value) -> Value {
1724    match tc {
1725        Value::String(s) => match s.as_str() {
1726            "auto" => serde_json::json!({ "type": "auto" }),
1727            "required" => serde_json::json!({ "type": "any" }),
1728            // ponytail: "none" has no direct Anthropic equivalent; omit = no constraint
1729            _ => serde_json::json!({ "type": "auto" }),
1730        },
1731        Value::Object(_) => {
1732            // {"type":"function","function":{"name":"foo"}} → {"type":"tool","name":"foo"}
1733            if tc.get("type").and_then(Value::as_str) == Some("function") {
1734                let name = tc.pointer("/function/name").cloned().unwrap_or(Value::Null);
1735                serde_json::json!({ "type": "tool", "name": name })
1736            } else {
1737                serde_json::json!({ "type": "auto" })
1738            }
1739        }
1740        _ => serde_json::json!({ "type": "auto" }),
1741    }
1742}
1743
1744/// Parse an OpenAI Chat Completions request body into the normalized [`ModelRequest`].
1745///
1746/// `carry_raw`: when `true` (all-OpenAI-dialect ladder), the original JSON is stored in
1747/// `raw` for verbatim carry — only the model is swapped, every other field survives intact.
1748/// When `false` (translation path to Anthropic ladder), `raw` is `Null` so
1749/// `anthropic_wire_body` reconstructs from the translated normalized fields.
1750///
1751/// Returns `None` if:
1752/// - the body isn't valid JSON or lacks a `messages` array, OR
1753/// - a user message contains an `image_url` with an http(s) URL (non-translatable; caller
1754///   should have already fallen back via `enforce_can_handle` but this is a defense-in-depth
1755///   guard — `None` → `BadRequest` rather than silently dropping the image).
1756pub fn parse_openai_request(body: &[u8], carry_raw: bool) -> Option<ModelRequest> {
1757    let json: Value = serde_json::from_slice(body).ok()?;
1758    let raw = if carry_raw { json.clone() } else { Value::Null };
1759
1760    let messages_json = json.get("messages")?.as_array()?;
1761
1762    let mut system: Option<String> = None;
1763    let mut messages: Vec<ChatMessage> = Vec::with_capacity(messages_json.len());
1764    let mut tools = Value::Null;
1765    let mut tool_choice_override: Option<Value> = None;
1766
1767    for msg in messages_json {
1768        let role = msg.get("role").and_then(Value::as_str).unwrap_or("user");
1769        match role {
1770            "system" => {
1771                // First system message wins; subsequent ones are appended as user blocks.
1772                // ponytail: Anthropic doesn't support multiple system messages inline;
1773                // we take the last one here. A proper multi-system-message implementation
1774                // would concatenate them, but that's rare in practice.
1775                if let Some(s) = msg.get("content").and_then(Value::as_str) {
1776                    system = Some(s.to_owned());
1777                }
1778            }
1779            "user" => {
1780                let content_val = msg.get("content").unwrap_or(&Value::Null);
1781                let translated = translate_openai_user_content(content_val)?;
1782                messages.push(ChatMessage {
1783                    role: "user".to_owned(),
1784                    content: translated,
1785                });
1786            }
1787            "assistant" => {
1788                if let Some(tc_arr) = msg.get("tool_calls").and_then(Value::as_array) {
1789                    // Tool-call turn: translate tool_calls to Anthropic tool_use blocks.
1790                    let mut blocks: Vec<Value> = Vec::new();
1791                    // Text before tool calls (may be null or absent)
1792                    if let Some(text) = msg.get("content").and_then(Value::as_str)
1793                        && !text.is_empty()
1794                    {
1795                        blocks.push(serde_json::json!({ "type": "text", "text": text }));
1796                    }
1797                    for tc in tc_arr {
1798                        let id = tc.get("id").and_then(Value::as_str).unwrap_or("");
1799                        let func = tc.get("function").unwrap_or(&Value::Null);
1800                        let name = func.get("name").and_then(Value::as_str).unwrap_or("");
1801                        let args_str = func
1802                            .get("arguments")
1803                            .and_then(Value::as_str)
1804                            .unwrap_or("{}");
1805                        let input: Value = serde_json::from_str(args_str)
1806                            .unwrap_or_else(|_| serde_json::json!({}));
1807                        blocks.push(serde_json::json!({
1808                            "type": "tool_use",
1809                            "id": id,
1810                            "name": name,
1811                            "input": input,
1812                        }));
1813                    }
1814                    messages.push(ChatMessage {
1815                        role: "assistant".to_owned(),
1816                        content: Value::Array(blocks),
1817                    });
1818                } else {
1819                    // Regular text assistant message
1820                    let content = msg
1821                        .get("content")
1822                        .cloned()
1823                        .unwrap_or_else(|| Value::String(String::new()));
1824                    messages.push(ChatMessage {
1825                        role: "assistant".to_owned(),
1826                        content,
1827                    });
1828                }
1829            }
1830            "tool" => {
1831                // role:"tool" → Anthropic tool_result block (wrapped in user turn)
1832                let tool_call_id = msg
1833                    .get("tool_call_id")
1834                    .and_then(Value::as_str)
1835                    .unwrap_or("");
1836                let content = msg
1837                    .get("content")
1838                    .cloned()
1839                    .unwrap_or_else(|| Value::String(String::new()));
1840                let result_block = serde_json::json!({
1841                    "type": "tool_result",
1842                    "tool_use_id": tool_call_id,
1843                    "content": content,
1844                });
1845                messages.push(ChatMessage {
1846                    role: "user".to_owned(),
1847                    content: Value::Array(vec![result_block]),
1848                });
1849            }
1850            _ => {} // skip unknown roles
1851        }
1852    }
1853
1854    // Translate tools and tool_choice (only when NOT raw-carry; raw-carry forwards them as-is).
1855    if !carry_raw {
1856        if let Some(t) = json.get("tools") {
1857            tools = translate_openai_tools(t);
1858        }
1859        if let Some(tc) = json.get("tool_choice") {
1860            tool_choice_override = Some(translate_openai_tool_choice(tc));
1861        }
1862    } else {
1863        tools = json.get("tools").cloned().unwrap_or(Value::Null);
1864    }
1865
1866    let max_tokens = json
1867        .get("max_tokens")
1868        .and_then(Value::as_u64)
1869        .and_then(|n| u32::try_from(n).ok())
1870        .unwrap_or(1024);
1871
1872    let model = json
1873        .get("model")
1874        .and_then(Value::as_str)
1875        .unwrap_or_default()
1876        .to_owned();
1877
1878    // For translation path, embed tool_choice into tools value so it's available downstream.
1879    // ponytail: this stuffs tool_choice into the Anthropic body via the raw=Null path in
1880    // anthropic_wire_body, which rebuilds from normalized fields. Tool_choice isn't a
1881    // ModelRequest field, so we carry it via a synthetic tools wrapper... actually we don't
1882    // need this — anthropic_wire_body rebuilds from normalized fields that include `tools`
1883    // but not `tool_choice`. The translation path loses tool_choice for non-raw-carry. This
1884    // is the known ceiling; full fidelity on mixed ladders requires adding tool_choice to
1885    // ModelRequest or always using raw carry.
1886    let _ = tool_choice_override; // accepted limitation on translation path
1887
1888    Some(ModelRequest {
1889        model,
1890        system,
1891        messages,
1892        max_tokens,
1893        tools,
1894        raw,
1895    })
1896}
1897
1898// ── Internal → OpenAI response rendering ─────────────────────────────────────
1899
1900/// Extract `(content_text, tool_calls)` from a served [`ModelResponse`]'s raw value.
1901///
1902/// Handles both Anthropic-format raw (has `content` array → translate to OpenAI shape)
1903/// and OpenAI-format raw (has `choices` → pass through content/tool_calls from the wire).
1904fn extract_openai_content_and_tools(raw: &Value, text: &str) -> (Value, Option<Value>) {
1905    // Anthropic-format: content array with text and/or tool_use blocks
1906    if let Some(blocks) = raw.get("content").and_then(Value::as_array) {
1907        let mut text_parts: Vec<&str> = Vec::new();
1908        let mut tool_calls: Vec<Value> = Vec::new();
1909        for block in blocks {
1910            match block.get("type").and_then(Value::as_str) {
1911                Some("text") => {
1912                    if let Some(t) = block.get("text").and_then(Value::as_str) {
1913                        text_parts.push(t);
1914                    }
1915                }
1916                Some("tool_use") => {
1917                    let id = block.get("id").and_then(Value::as_str).unwrap_or("");
1918                    let name = block.get("name").and_then(Value::as_str).unwrap_or("");
1919                    let input_str = block
1920                        .get("input")
1921                        .map_or_else(|| "{}".to_owned(), std::string::ToString::to_string);
1922                    tool_calls.push(serde_json::json!({
1923                        "id": id,
1924                        "type": "function",
1925                        "function": { "name": name, "arguments": input_str },
1926                    }));
1927                }
1928                _ => {}
1929            }
1930        }
1931        let content_text = if tool_calls.is_empty() || !text_parts.is_empty() {
1932            Value::String(text_parts.join(""))
1933        } else {
1934            Value::Null // tool-only response: null content per OpenAI spec
1935        };
1936        let tc = if tool_calls.is_empty() {
1937            None
1938        } else {
1939            Some(Value::Array(tool_calls))
1940        };
1941        return (content_text, tc);
1942    }
1943
1944    // OpenAI-format raw (all-OpenAI-ladder path): extract from choices
1945    if let Some(msg) = raw.pointer("/choices/0/message") {
1946        let content = msg
1947            .get("content")
1948            .cloned()
1949            .unwrap_or(Value::String(text.to_owned()));
1950        let tc = msg.get("tool_calls").cloned();
1951        return (content, tc);
1952    }
1953
1954    // Fallback: use the text projection
1955    (Value::String(text.to_owned()), None)
1956}
1957
1958/// Render a served [`ModelResponse`] back as an OpenAI `chat.completion` JSON envelope,
1959/// so an OpenAI-client caller sees the standard wire shape regardless of which rung answered.
1960fn openai_response_json(resp: &ModelResponse) -> Value {
1961    let (content_text, tool_calls) = extract_openai_content_and_tools(&resp.raw, &resp.text);
1962    let finish_reason = if tool_calls.is_some() {
1963        "tool_calls"
1964    } else {
1965        "stop"
1966    };
1967    let mut message = serde_json::json!({
1968        "role": "assistant",
1969        "content": content_text,
1970    });
1971    if let Some(tc) = tool_calls {
1972        message["tool_calls"] = tc;
1973    }
1974    serde_json::json!({
1975        "id": format!("chatcmpl-{}", Uuid::now_v7()),
1976        "object": "chat.completion",
1977        "created": jiff::Timestamp::now().as_second(),
1978        "model": resp.model,
1979        "choices": [{
1980            "index": 0,
1981            "message": message,
1982            "finish_reason": finish_reason,
1983        }],
1984        "usage": {
1985            "prompt_tokens": resp.in_tokens,
1986            "completion_tokens": resp.out_tokens,
1987            "total_tokens": resp.in_tokens + resp.out_tokens,
1988        }
1989    })
1990}
1991
1992/// Re-emit a served OpenAI `chat.completion` envelope as an SSE stream body
1993/// (`data: chat.completion.chunk` frames ending with `data: [DONE]`), so a `stream: true`
1994/// OpenAI client is served even though enforce buffered the full response to gate it.
1995fn openai_sse_from_message(message: &Value) -> String {
1996    let id = message
1997        .get("id")
1998        .and_then(Value::as_str)
1999        .unwrap_or("chatcmpl-unknown")
2000        .to_owned();
2001    let created = message.get("created").cloned().unwrap_or(Value::from(0));
2002    let model = message
2003        .get("model")
2004        .and_then(Value::as_str)
2005        .unwrap_or("unknown");
2006
2007    let choices = message.get("choices").and_then(Value::as_array);
2008    let msg = choices
2009        .and_then(|c| c.first())
2010        .and_then(|c| c.get("message"));
2011    let content = msg
2012        .and_then(|m| m.get("content"))
2013        .cloned()
2014        .unwrap_or(Value::Null);
2015    let tool_calls = msg.and_then(|m| m.get("tool_calls")).cloned();
2016    let finish_reason = choices
2017        .and_then(|c| c.first())
2018        .and_then(|c| c.get("finish_reason"))
2019        .cloned()
2020        .unwrap_or_else(|| Value::String("stop".to_owned()));
2021
2022    let mut out = String::new();
2023    let chunk = |delta: Value| {
2024        serde_json::json!({
2025            "id": id,
2026            "object": "chat.completion.chunk",
2027            "created": created,
2028            "model": model,
2029            "choices": [{ "index": 0, "delta": delta, "finish_reason": Value::Null }]
2030        })
2031    };
2032
2033    // Role delta
2034    let role_chunk = chunk(serde_json::json!({ "role": "assistant", "content": "" }));
2035    out.push_str("data: ");
2036    out.push_str(&role_chunk.to_string());
2037    out.push_str("\n\n");
2038
2039    // Content delta (if any)
2040    if let Value::String(text) = &content
2041        && !text.is_empty()
2042    {
2043        let content_chunk = chunk(serde_json::json!({ "content": text }));
2044        out.push_str("data: ");
2045        out.push_str(&content_chunk.to_string());
2046        out.push_str("\n\n");
2047    }
2048
2049    // Tool calls delta (if any)
2050    if let Some(tc) = tool_calls {
2051        let tc_chunk = chunk(serde_json::json!({ "tool_calls": tc }));
2052        out.push_str("data: ");
2053        out.push_str(&tc_chunk.to_string());
2054        out.push_str("\n\n");
2055    }
2056
2057    // Finish chunk
2058    let finish_chunk = serde_json::json!({
2059        "id": id,
2060        "object": "chat.completion.chunk",
2061        "created": created,
2062        "model": model,
2063        "choices": [{ "index": 0, "delta": {}, "finish_reason": finish_reason }]
2064    });
2065    out.push_str("data: ");
2066    out.push_str(&finish_chunk.to_string());
2067    out.push_str("\n\n");
2068
2069    out.push_str("data: [DONE]\n\n");
2070    out
2071}
2072
2073// ── OpenAI handler path ───────────────────────────────────────────────────────
2074
2075/// Enforce mode for an OpenAI-inbound request: run the escalation engine and serve the
2076/// first output that clears the route's gates, rendered as an OpenAI `chat.completion`.
2077async fn handle_enforce_openai(
2078    state: &AppState,
2079    headers: &HeaderMap,
2080    body: &Bytes,
2081    features: Features,
2082    route: &Route,
2083    session_header: Option<String>,
2084    tenant: String,
2085) -> Response {
2086    if is_stream_request(body) {
2087        let (tx, rx) = tokio::sync::oneshot::channel::<Result<Value, ProxyError>>();
2088        let (state_c, headers_c, body_c, route_c) =
2089            (state.clone(), headers.clone(), body.clone(), route.clone());
2090        tokio::spawn(async move {
2091            let out = enforce_pipeline_openai(
2092                &state_c,
2093                &headers_c,
2094                &body_c,
2095                features,
2096                &route_c,
2097                session_header,
2098                tenant,
2099            )
2100            .await;
2101            let _ = tx.send(out);
2102        });
2103        return sse_keepalive_response(rx, openai_sse_from_message);
2104    }
2105    match enforce_pipeline_openai(
2106        state,
2107        headers,
2108        body,
2109        features,
2110        route,
2111        session_header,
2112        tenant,
2113    )
2114    .await
2115    {
2116        Ok(message) => (axum::http::StatusCode::OK, Json(message)).into_response(),
2117        Err(e) => e.into_response(),
2118    }
2119}
2120
2121/// Observe mode for `POST /v1/chat/completions`: forward unchanged to the OpenAI upstream,
2122/// return unchanged, trace asynchronously.
2123///
2124// ponytail: observe trace stamps api="anthropic.messages" (base_trace default); update
2125// base_trace to accept an api param if the distinction matters for audit consumers.
2126async fn observe_passthrough_openai(
2127    state: AppState,
2128    headers: HeaderMap,
2129    body: Bytes,
2130    session_header: Option<String>,
2131    tenant: String,
2132) -> Response {
2133    if is_stream_request(&body) {
2134        return observe_stream_openai(state, headers, body, session_header, tenant).await;
2135    }
2136    let start = Instant::now();
2137    let result = forward_openai(
2138        &state.http,
2139        &state.config.upstream_openai,
2140        &headers,
2141        body.clone(),
2142    )
2143    .await;
2144    let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2145    match result {
2146        Ok((status, resp_headers, resp_body)) => {
2147            spawn_trace(
2148                &state,
2149                body,
2150                Some(resp_body.clone()),
2151                latency_ms,
2152                session_header,
2153                tenant,
2154            );
2155            (status, resp_headers, resp_body).into_response()
2156        }
2157        Err(err) => {
2158            spawn_trace(&state, body, None, latency_ms, session_header, tenant);
2159            err.into_response()
2160        }
2161    }
2162}
2163
2164/// Observe streaming for `POST /v1/chat/completions`.
2165async fn observe_stream_openai(
2166    state: AppState,
2167    headers: HeaderMap,
2168    body: Bytes,
2169    session_header: Option<String>,
2170    tenant: String,
2171) -> Response {
2172    let start = Instant::now();
2173    let result = forward_openai_streaming(
2174        &state.http,
2175        &state.config.upstream_openai,
2176        &headers,
2177        body.clone(),
2178    )
2179    .await;
2180    let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2181    match result {
2182        Ok((status, resp_headers, response)) => {
2183            spawn_stream_trace(&state, body, latency_ms, session_header, tenant);
2184            let stream_body = Body::from_stream(response.bytes_stream());
2185            (status, resp_headers, stream_body).into_response()
2186        }
2187        Err(err) => {
2188            spawn_trace(&state, body, None, latency_ms, session_header, tenant);
2189            err.into_response()
2190        }
2191    }
2192}
2193
2194/// `POST /v1/chat/completions` — OpenAI-compatible inbound endpoint (SPEC §M1).
2195/// Observe mode: transparent passthrough to the OpenAI upstream base URL.
2196/// Enforce mode: translate to the internal `ModelRequest`, run the escalation engine,
2197/// render the result as an OpenAI `chat.completion` (or `chat.completion.chunk` SSE stream).
2198async fn chat_completions(
2199    State(state): State<AppState>,
2200    Extension(TenantId(tenant)): Extension<TenantId>,
2201    headers: HeaderMap,
2202    body: Bytes,
2203) -> Response {
2204    let session_header = header_str(&headers, SESSION_HEADER);
2205
2206    if let Some(routing) = state.config.routing.as_ref() {
2207        let features = extract_openai_features(&headers, &body);
2208        if let Some(route) = routing
2209            .route_for(&features)
2210            .filter(|r| r.mode == Mode::Enforce && !r.ladder.is_empty())
2211        {
2212            let route = route.clone();
2213            if enforce_can_handle(
2214                &features,
2215                &body,
2216                routing.escalation.enforce_structured,
2217                &route.ladder,
2218                &state.providers,
2219                Dialect::Openai,
2220            ) {
2221                return handle_enforce_openai(
2222                    &state,
2223                    &headers,
2224                    &body,
2225                    features,
2226                    &route,
2227                    session_header,
2228                    tenant,
2229                )
2230                .await;
2231            }
2232            tracing::info!(
2233                "enforce route matched but OpenAI structured request can't be routed faithfully (flag/ladder); serving via observe passthrough"
2234            );
2235        }
2236    }
2237    observe_passthrough_openai(state, headers, body, session_header, tenant).await
2238}
2239
2240#[cfg(test)]
2241mod tests {
2242    use bytes::Bytes;
2243
2244    use super::*;
2245
2246    fn test_config() -> ProxyConfig {
2247        ProxyConfig::from_lookup(|_| None).unwrap()
2248    }
2249
2250    #[test]
2251    fn build_trace_maps_request_and_response_fields() {
2252        let config = test_config();
2253        let req = Bytes::from_static(
2254            br#"{"model":"claude-haiku-4-5","tools":[{"name":"a"}],"messages":[]}"#,
2255        );
2256        let resp = Bytes::from_static(
2257            br#"{"model":"claude-haiku-4-5","usage":{"input_tokens":1200,"output_tokens":300}}"#,
2258        );
2259
2260        let trace = build_trace(&config, &req, &resp, 42, Some("sess-1"));
2261
2262        assert_eq!(trace.request.api, "anthropic.messages");
2263        assert_eq!(trace.session_id, "sess-1");
2264        assert_eq!(trace.attempts.len(), 1);
2265        let attempt = &trace.attempts[0];
2266        assert_eq!(attempt.model, "claude-haiku-4-5");
2267        assert_eq!(attempt.provider, "anthropic");
2268        assert_eq!(attempt.in_tokens, 1200);
2269        assert_eq!(attempt.out_tokens, 300);
2270        assert!(attempt.cost_usd > 0.0);
2271        assert_eq!(trace.request.features.tool_count, 1);
2272        assert!(!trace.request.features.has_images);
2273        assert_eq!(trace.final_.served_rung, Some(0));
2274    }
2275
2276    #[test]
2277    fn build_trace_falls_back_to_trace_id_session_when_header_absent() {
2278        let config = test_config();
2279        let req = Bytes::from_static(b"{}");
2280        let resp = Bytes::from_static(b"{}");
2281
2282        let trace = build_trace(&config, &req, &resp, 1, None);
2283
2284        assert_eq!(trace.session_id, trace.trace_id.to_string());
2285    }
2286
2287    #[test]
2288    fn build_error_trace_has_no_attempts_and_served_from_error() {
2289        let config = test_config();
2290        let req = Bytes::from_static(br#"{"model":"claude-haiku-4-5"}"#);
2291
2292        let trace = build_error_trace(&config, &req, 7, None);
2293
2294        assert!(trace.attempts.is_empty());
2295        assert_eq!(trace.final_.served_from, ServedFrom::Error);
2296        assert_eq!(trace.final_.served_rung, None);
2297    }
2298
2299    #[test]
2300    fn message_with_image_block_sets_has_images() {
2301        let req = br#"{"messages":[{"role":"user","content":[{"type":"image"}]}]}"#;
2302        let (_, _, has_images) = request_features(req);
2303        assert!(has_images);
2304    }
2305
2306    #[test]
2307    fn prompt_hash_never_contains_raw_prompt_text() {
2308        let hash = prompt_hash("salt", b"super secret prompt");
2309        assert!(!hash.contains("secret"));
2310        assert_eq!(hash.len(), 64);
2311    }
2312
2313    #[test]
2314    fn parse_model_request_preserves_content_verbatim_and_projects_text() {
2315        let body = br#"{"model":"m","system":"sys","max_tokens":50,
2316            "messages":[{"role":"user","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]},
2317                        {"role":"assistant","content":"c"}]}"#;
2318        let req = parse_model_request(body).unwrap();
2319        assert_eq!(req.system.as_deref(), Some("sys"));
2320        assert_eq!(req.max_tokens, 50);
2321        assert_eq!(req.messages.len(), 2);
2322        // I2: the block array is carried verbatim, not flattened away...
2323        assert_eq!(
2324            req.messages[0].content,
2325            serde_json::json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])
2326        );
2327        // ...and a plain string stays a plain string (I1: byte-identical on the wire).
2328        assert_eq!(req.messages[1].content, Value::String("c".to_owned()));
2329        // Gates see the same text they always did.
2330        assert_eq!(req.messages[0].text_view(), "a\nb");
2331        assert_eq!(req.messages[1].text_view(), "c");
2332    }
2333
2334    #[test]
2335    fn tool_and_image_blocks_survive_the_request_round_trip() {
2336        // ADR 0005 I2: tool_use / tool_result / image blocks are never dropped on the request side.
2337        let body = br#"{"model":"m","max_tokens":50,"messages":[
2338            {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}}]},
2339            {"role":"user","content":[
2340                {"type":"tool_result","tool_use_id":"t1","content":"2"},
2341                {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
2342            ]}]}"#;
2343        let req = parse_model_request(body).unwrap();
2344        let round_tripped = serde_json::to_value(&req.messages).unwrap();
2345        assert_eq!(
2346            round_tripped,
2347            serde_json::json!([
2348                {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}}]},
2349                {"role":"user","content":[
2350                    {"type":"tool_result","tool_use_id":"t1","content":"2"},
2351                    {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
2352                ]}
2353            ])
2354        );
2355    }
2356
2357    #[test]
2358    fn text_message_serializes_byte_identical_to_a_plain_string() {
2359        // I1: a string-content message must not gain array wrapping on the wire.
2360        let m = ChatMessage::text("user", "hello");
2361        assert_eq!(
2362            serde_json::to_string(&m).unwrap(),
2363            r#"{"role":"user","content":"hello"}"#
2364        );
2365    }
2366
2367    #[test]
2368    fn parse_model_request_rejects_non_message_bodies() {
2369        assert!(parse_model_request(b"not json").is_none());
2370        assert!(parse_model_request(br#"{"no":"messages"}"#).is_none());
2371    }
2372
2373    // --- Enforce-path handler tests (drive `messages` end-to-end with mock providers) ---
2374
2375    use crate::provider::{MockProvider, ModelResponse, Provider, ProviderError, ProviderRegistry};
2376    use axum::extract::State;
2377    use std::collections::HashMap;
2378    use std::sync::Arc;
2379    use tokio::sync::mpsc;
2380
2381    fn model_resp(model: &str, text: &str) -> ModelResponse {
2382        ModelResponse {
2383            model: model.to_owned(),
2384            text: text.to_owned(),
2385            in_tokens: 1000,
2386            out_tokens: 400,
2387            raw: serde_json::Value::Null,
2388        }
2389    }
2390
2391    /// Build an `AppState` whose anthropic provider answers the given per-model outcomes, with an
2392    /// enforce route over `ladder`/`gates`. Returns the state and the trace receiver.
2393    fn enforce_state(
2394        ladder: &[&str],
2395        gates: &[&str],
2396        outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
2397    ) -> (AppState, mpsc::Receiver<Trace>) {
2398        let toml = format!(
2399            "[[route]]\nmatch = {{}}\nmode = \"enforce\"\nladder = [{}]\ngates = [{}]\n",
2400            ladder
2401                .iter()
2402                .map(|m| format!("\"{m}\""))
2403                .collect::<Vec<_>>()
2404                .join(", "),
2405            gates
2406                .iter()
2407                .map(|g| format!("\"{g}\""))
2408                .collect::<Vec<_>>()
2409                .join(", "),
2410        );
2411        let config = ProxyConfig::from_lookup(|k| match k {
2412            "FIRSTPASS_CONFIG_TOML" => Some(toml.clone()),
2413            "FIRSTPASS_MODE" => Some("enforce".to_owned()),
2414            _ => None,
2415        })
2416        .unwrap();
2417
2418        let mut outs = HashMap::new();
2419        for (model, out) in outcomes {
2420            outs.insert(model.to_owned(), out);
2421        }
2422        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
2423        map.insert(
2424            "anthropic".to_owned(),
2425            Arc::new(MockProvider::new("anthropic", outs)),
2426        );
2427        let providers = ProviderRegistry::from_map(map);
2428
2429        let (traces, rx) = mpsc::channel(64);
2430        let state = AppState {
2431            config: Arc::new(config),
2432            http: reqwest::Client::new(),
2433            providers,
2434            gate_health: Arc::new(GateHealthRegistry::new()),
2435            traces,
2436            adaptive: None,
2437            bandit: None,
2438            tenant_rate_limiter: None,
2439            spill: None,
2440        };
2441        (state, rx)
2442    }
2443
2444    fn user_body() -> Bytes {
2445        Bytes::from_static(
2446            br#"{"model":"ignored","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}"#,
2447        )
2448    }
2449
2450    async fn body_json(resp: Response) -> Value {
2451        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
2452            .await
2453            .unwrap();
2454        serde_json::from_slice(&bytes).unwrap()
2455    }
2456
2457    #[tokio::test]
2458    async fn enforce_serves_first_pass_and_returns_anthropic_shape() {
2459        let (state, mut rx) = enforce_state(
2460            &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
2461            &["non-empty"],
2462            vec![(
2463                "anthropic/claude-haiku-4-5",
2464                Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
2465            )],
2466        );
2467        let resp = messages(
2468            State(state),
2469            Extension(TenantId("default".to_owned())),
2470            HeaderMap::new(),
2471            user_body(),
2472        )
2473        .await;
2474        assert_eq!(resp.status(), axum::http::StatusCode::OK);
2475        let json = body_json(resp).await;
2476        assert_eq!(json["type"], "message");
2477        assert_eq!(json["content"][0]["text"], "hello");
2478        assert_eq!(json["model"], "anthropic/claude-haiku-4-5");
2479
2480        let trace = rx.try_recv().expect("a trace was enqueued");
2481        assert_eq!(trace.mode, Mode::Enforce);
2482        assert_eq!(trace.final_.served_rung, Some(0));
2483        assert_eq!(trace.attempts.len(), 1);
2484    }
2485
2486    #[tokio::test]
2487    async fn enforce_escalates_then_serves_and_traces_two_attempts() {
2488        let (state, mut rx) = enforce_state(
2489            &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
2490            &["non-empty"],
2491            vec![
2492                (
2493                    "anthropic/claude-haiku-4-5",
2494                    Ok(model_resp("anthropic/claude-haiku-4-5", "   ")),
2495                ), // fails
2496                (
2497                    "anthropic/claude-sonnet-5",
2498                    Ok(model_resp("anthropic/claude-sonnet-5", "answer")),
2499                ),
2500            ],
2501        );
2502        let resp = messages(
2503            State(state),
2504            Extension(TenantId("default".to_owned())),
2505            HeaderMap::new(),
2506            user_body(),
2507        )
2508        .await;
2509        let json = body_json(resp).await;
2510        assert_eq!(json["content"][0]["text"], "answer");
2511
2512        let trace = rx.try_recv().expect("trace enqueued");
2513        assert_eq!(trace.attempts.len(), 2);
2514        assert_eq!(trace.final_.escalations, 1);
2515        assert_eq!(trace.final_.served_rung, Some(1));
2516    }
2517
2518    #[tokio::test]
2519    async fn enforce_all_rungs_error_returns_502() {
2520        let (state, mut rx) = enforce_state(
2521            &["anthropic/claude-haiku-4-5"],
2522            &["non-empty"],
2523            vec![(
2524                "anthropic/claude-haiku-4-5",
2525                Err(ProviderError::Transport("down".into())),
2526            )],
2527        );
2528        let resp = messages(
2529            State(state),
2530            Extension(TenantId("default".to_owned())),
2531            HeaderMap::new(),
2532            user_body(),
2533        )
2534        .await;
2535        assert_eq!(resp.status(), axum::http::StatusCode::BAD_GATEWAY);
2536        // A trace is still recorded for the failed decision.
2537        assert!(rx.try_recv().is_ok());
2538    }
2539
2540    #[tokio::test]
2541    async fn no_routing_config_falls_through_to_observe_not_enforce() {
2542        // config with no routing => enforce path never runs; observe attempts a real upstream
2543        // call which fails fast against an unroutable host. We only assert it did NOT take the
2544        // enforce branch (which would have used the mock and returned 200 with our text).
2545        let config = ProxyConfig::from_lookup(|k| match k {
2546            "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
2547            _ => None,
2548        })
2549        .unwrap();
2550        let (traces, _rx) = mpsc::channel(64);
2551        let state = AppState {
2552            config: Arc::new(config),
2553            http: reqwest::Client::new(),
2554            providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
2555            gate_health: Arc::new(GateHealthRegistry::new()),
2556            traces,
2557            adaptive: None,
2558            bandit: None,
2559            tenant_rate_limiter: None,
2560            spill: None,
2561        };
2562        let resp = messages(
2563            State(state),
2564            Extension(TenantId("default".to_owned())),
2565            HeaderMap::new(),
2566            user_body(),
2567        )
2568        .await;
2569        // Observe path forwards upstream; the bogus host yields a gateway error, not our 200.
2570        assert_ne!(resp.status(), axum::http::StatusCode::OK);
2571    }
2572
2573    #[test]
2574    fn detects_stream_requests() {
2575        assert!(is_stream_request(br#"{"stream": true}"#));
2576        assert!(!is_stream_request(br#"{"stream": false}"#));
2577        assert!(!is_stream_request(br#"{"model":"m"}"#));
2578        assert!(!is_stream_request(b"not json"));
2579    }
2580
2581    #[test]
2582    fn detects_tool_blocks_in_messages() {
2583        let with =
2584            br#"{"messages":[{"role":"user","content":[{"type":"tool_result","content":"42"}]}]}"#;
2585        let without = br#"{"messages":[{"role":"user","content":"hi"}]}"#;
2586        assert!(messages_have_tool_blocks(with));
2587        assert!(!messages_have_tool_blocks(without));
2588    }
2589
2590    #[test]
2591    fn enforce_only_handles_plain_text() {
2592        let plain =
2593            Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
2594        let tools = Bytes::from_static(
2595            br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
2596        );
2597        let f_plain = extract_features(&HeaderMap::new(), &plain);
2598        let f_tools = extract_features(&HeaderMap::new(), &tools);
2599        let anthropic_ladder = vec!["anthropic/claude-haiku-4-5".to_owned()];
2600        let providers = test_registry();
2601        // Opted out (enforce_structured = false): plain text routes, tools fall back to observe.
2602        assert!(enforce_can_handle(
2603            &f_plain,
2604            &plain,
2605            false,
2606            &anthropic_ladder,
2607            &providers,
2608            Dialect::Anthropic,
2609        ));
2610        assert!(!enforce_can_handle(
2611            &f_tools,
2612            &tools,
2613            false,
2614            &anthropic_ladder,
2615            &providers,
2616            Dialect::Anthropic,
2617        ));
2618    }
2619
2620    #[test]
2621    fn structured_enforce_routes_tools_and_streaming() {
2622        // ADR 0005 P2+P3: with the opt-in flag on, tool and streaming requests both route through
2623        // enforce (streaming is served as the gated result re-emitted as SSE).
2624        let tools = Bytes::from_static(
2625            br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
2626        );
2627        let streaming_tools = Bytes::from_static(
2628            br#"{"model":"m","stream":true,"tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
2629        );
2630        let f = extract_features(&HeaderMap::new(), &tools);
2631        let anthropic_ladder = vec![
2632            "anthropic/claude-haiku-4-5".to_owned(),
2633            "anthropic/claude-sonnet-5".to_owned(),
2634        ];
2635        let providers = test_registry();
2636        assert!(enforce_can_handle(
2637            &f,
2638            &tools,
2639            true,
2640            &anthropic_ladder,
2641            &providers,
2642            Dialect::Anthropic,
2643        ));
2644        assert!(enforce_can_handle(
2645            &f,
2646            &streaming_tools,
2647            true,
2648            &anthropic_ladder,
2649            &providers,
2650            Dialect::Anthropic,
2651        ));
2652    }
2653
2654    /// Registry with the built-in `anthropic` (verbatim carrier) + `openai` (not yet) providers.
2655    fn test_registry() -> crate::provider::ProviderRegistry {
2656        crate::provider::ProviderRegistry::new("http://localhost", "http://localhost")
2657    }
2658
2659    #[test]
2660    fn fidelity_guard_blocks_structured_on_non_verbatim_ladder() {
2661        // The default-on guard: a tool request routes through an all-Anthropic ladder, but a
2662        // ladder containing an OpenAI-dialect rung (structured translation not built) falls back
2663        // to observe — un-gated is safe, corrupted is not. Plain text routes on any ladder.
2664        let tools = Bytes::from_static(
2665            br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
2666        );
2667        let plain =
2668            Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
2669        let f_tools = extract_features(&HeaderMap::new(), &tools);
2670        let f_plain = extract_features(&HeaderMap::new(), &plain);
2671        let providers = test_registry();
2672        let mixed_ladder = vec![
2673            "openai/gpt-4.1-mini".to_owned(),
2674            "anthropic/claude-sonnet-5".to_owned(),
2675        ];
2676        assert!(!enforce_can_handle(
2677            &f_tools,
2678            &tools,
2679            true,
2680            &mixed_ladder,
2681            &providers,
2682            Dialect::Anthropic,
2683        ));
2684        assert!(enforce_can_handle(
2685            &f_plain,
2686            &plain,
2687            true,
2688            &mixed_ladder,
2689            &providers,
2690            Dialect::Anthropic,
2691        ));
2692    }
2693
2694    #[test]
2695    fn enforce_sse_reemission_preserves_text_and_tool_use() {
2696        // ADR 0005 P3 + I2: a served response with a text block AND a tool_use block round-trips
2697        // through the SSE re-emitter — the tool call's input survives as an input_json_delta.
2698        let resp = ModelResponse {
2699            model: "anthropic/claude-haiku-4-5".to_owned(),
2700            text: "let me check".to_owned(),
2701            in_tokens: 5,
2702            out_tokens: 7,
2703            raw: serde_json::json!({
2704                "content": [
2705                    { "type": "text", "text": "let me check" },
2706                    { "type": "tool_use", "id": "tu_1", "name": "get_weather", "input": { "city": "Paris" } }
2707                ]
2708            }),
2709        };
2710        let sse = anthropic_sse_from_message(&anthropic_response_json(&resp));
2711
2712        // Parse every data frame structurally (key order is not part of the contract).
2713        let frames: Vec<Value> = sse
2714            .lines()
2715            .filter_map(|l| l.strip_prefix("data: "))
2716            .map(|d| serde_json::from_str::<Value>(d).expect("each SSE data frame is valid JSON"))
2717            .collect();
2718
2719        // Full lifecycle, in order.
2720        assert_eq!(frames.first().unwrap()["type"], "message_start");
2721        assert_eq!(frames.last().unwrap()["type"], "message_stop");
2722        // The text block streams its text as a text_delta.
2723        assert!(frames.iter().any(|f| f["delta"]["type"] == "text_delta"
2724            && f["delta"]["text"] == "let me check"));
2725        // The tool_use block is present with its id/name, and its input streams as one JSON delta —
2726        // not dropped (ADR 0005 I2).
2727        assert!(
2728            frames
2729                .iter()
2730                .any(|f| f["content_block"]["type"] == "tool_use"
2731                    && f["content_block"]["name"] == "get_weather"
2732                    && f["content_block"]["id"] == "tu_1")
2733        );
2734        assert!(
2735            frames
2736                .iter()
2737                .any(|f| f["delta"]["type"] == "input_json_delta"
2738                    && f["delta"]["partial_json"] == r#"{"city":"Paris"}"#)
2739        );
2740    }
2741
2742    /// ADR 0005, default-on: an enforce route now serves BOTH plain text and tool requests (the
2743    /// mock ladder carries structured content verbatim). Setting `enforce_structured = false`
2744    /// restores the old behavior: tool requests fall back to transparent observe passthrough —
2745    /// proven by the tool request hitting the (bogus) upstream instead of the enforcing mock.
2746    #[tokio::test]
2747    async fn enforce_falls_back_to_observe_for_tool_requests() {
2748        let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n";
2749        let config = ProxyConfig::from_lookup(|k| match k {
2750            "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
2751            "FIRSTPASS_MODE" => Some("enforce".to_owned()),
2752            "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
2753            _ => None,
2754        })
2755        .unwrap();
2756        let mut outs = HashMap::new();
2757        outs.insert(
2758            "anthropic/m".to_owned(),
2759            Ok(model_resp("anthropic/m", "hello")),
2760        );
2761        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
2762        map.insert(
2763            "anthropic".to_owned(),
2764            Arc::new(MockProvider::new("anthropic", outs)),
2765        );
2766        let (traces, _rx) = mpsc::channel(64);
2767        let state = AppState {
2768            config: Arc::new(config),
2769            http: reqwest::Client::new(),
2770            providers: ProviderRegistry::from_map(map),
2771            gate_health: Arc::new(GateHealthRegistry::new()),
2772            traces,
2773            adaptive: None,
2774            bandit: None,
2775            tenant_rate_limiter: None,
2776            spill: None,
2777        };
2778
2779        // Plain text enforces: the mock serves 200.
2780        let plain =
2781            Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
2782        let resp = messages(
2783            State(state.clone()),
2784            Extension(TenantId("default".to_owned())),
2785            HeaderMap::new(),
2786            plain,
2787        )
2788        .await;
2789        assert_eq!(
2790            resp.status(),
2791            axum::http::StatusCode::OK,
2792            "plain text should enforce"
2793        );
2794
2795        // Default-on (enforce_structured = true) + verbatim-carrying ladder: tools now ENFORCE —
2796        // the mock serves 200 and the tool request never touches the bogus upstream.
2797        let tools = Bytes::from_static(
2798            br#"{"model":"m","tools":[{"name":"get_weather"}],"messages":[{"role":"user","content":"hi"}]}"#,
2799        );
2800        let resp = messages(
2801            State(state.clone()),
2802            Extension(TenantId("default".to_owned())),
2803            HeaderMap::new(),
2804            tools.clone(),
2805        )
2806        .await;
2807        assert_eq!(
2808            resp.status(),
2809            axum::http::StatusCode::OK,
2810            "tool request must route through enforce by default (ADR 0005 default-on)"
2811        );
2812
2813        // Opt-out (enforce_structured = false): the same tool request falls back to observe —
2814        // it hits the bogus upstream and is not 200.
2815        let toml_off = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n[escalation]\nenforce_structured = false\n";
2816        let config_off = ProxyConfig::from_lookup(|k| match k {
2817            "FIRSTPASS_CONFIG_TOML" => Some(toml_off.to_owned()),
2818            "FIRSTPASS_MODE" => Some("enforce".to_owned()),
2819            "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
2820            _ => None,
2821        })
2822        .unwrap();
2823        let state_off = AppState {
2824            config: Arc::new(config_off),
2825            ..state.clone()
2826        };
2827        let resp = messages(
2828            State(state_off),
2829            Extension(TenantId("default".to_owned())),
2830            HeaderMap::new(),
2831            tools,
2832        )
2833        .await;
2834        assert_ne!(
2835            resp.status(),
2836            axum::http::StatusCode::OK,
2837            "with enforce_structured = false a tool request must fall back to observe"
2838        );
2839
2840        // tool_result block in a message => same fallback.
2841        let toolres = Bytes::from_static(
2842            br#"{"model":"m","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"42"}]}]}"#,
2843        );
2844        let resp = messages(
2845            State(state),
2846            Extension(TenantId("default".to_owned())),
2847            HeaderMap::new(),
2848            toolres,
2849        )
2850        .await;
2851        assert_eq!(
2852            resp.status(),
2853            axum::http::StatusCode::OK,
2854            "tool_result blocks route through enforce by default too (verbatim carry)"
2855        );
2856    }
2857
2858    // --- Feedback API tests (drive `feedback` against a real temp trace store) ---
2859
2860    /// Persist one trace to a fresh temp DB and return (state, db_path, trace_id).
2861    async fn feedback_state() -> (AppState, std::path::PathBuf, String) {
2862        let db = std::env::temp_dir().join(format!("firstpass-feedback-{}.db", Uuid::now_v7()));
2863        let (tx, handle) = crate::store::open(&db).unwrap();
2864
2865        let mut trace = build_error_trace(
2866            &ProxyConfig::from_lookup(|_| None).unwrap(),
2867            &Bytes::from_static(b"{}"),
2868            5,
2869            Some("sess-fb"),
2870        );
2871        trace.attempts.push(Attempt {
2872            rung: 0,
2873            model: "anthropic/claude-haiku-4-5".into(),
2874            provider: "anthropic".into(),
2875            in_tokens: 10,
2876            out_tokens: 5,
2877            cost_usd: 0.001,
2878            latency_ms: 5,
2879            gates: vec![],
2880            verdict: Verdict::Pass,
2881        });
2882        let trace_id = trace.trace_id.to_string();
2883        tx.try_send(trace).unwrap();
2884        drop(tx);
2885        handle.await.unwrap();
2886
2887        let db_str = db.to_string_lossy().into_owned();
2888        let config = ProxyConfig::from_lookup(move |k| match k {
2889            "FIRSTPASS_DB" => Some(db_str.clone()),
2890            _ => None,
2891        })
2892        .unwrap();
2893        let (traces, _rx) = mpsc::channel(64);
2894        let state = AppState {
2895            config: Arc::new(config),
2896            http: reqwest::Client::new(),
2897            providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
2898            gate_health: Arc::new(GateHealthRegistry::new()),
2899            traces,
2900            adaptive: None,
2901            bandit: None,
2902            tenant_rate_limiter: None,
2903            spill: None,
2904        };
2905        (state, db, trace_id)
2906    }
2907
2908    #[tokio::test]
2909    async fn feedback_nudges_the_adaptive_threshold() {
2910        use firstpass_core::conformal::AdaptiveConformal;
2911        let (mut state, _db, trace_id) = feedback_state().await;
2912        let aci = Arc::new(std::sync::Mutex::new(AdaptiveConformal::new(0.1, 0.2, 0.5)));
2913        state.adaptive = Some(aci.clone());
2914        let before = aci.lock().unwrap().threshold();
2915
2916        // A served FAILURE raises the threshold (serve more conservatively).
2917        let fail = Bytes::from(
2918            serde_json::json!({ "trace_id": trace_id, "gate_id": "tests", "verdict": "fail", "reporter": "ci" })
2919                .to_string(),
2920        );
2921        assert_eq!(
2922            feedback(
2923                State(state.clone()),
2924                Extension(TenantId("default".to_owned())),
2925                fail
2926            )
2927            .await
2928            .status(),
2929            axum::http::StatusCode::ACCEPTED
2930        );
2931        let after_fail = aci.lock().unwrap().threshold();
2932        assert!(
2933            after_fail > before,
2934            "served fail should raise the live threshold: {before} -> {after_fail}"
2935        );
2936
2937        // A served PASS nudges it back down — the loop is reactive both ways.
2938        let pass = Bytes::from(
2939            serde_json::json!({ "trace_id": trace_id, "gate_id": "tests", "verdict": "pass", "reporter": "ci" })
2940                .to_string(),
2941        );
2942        let _ = feedback(
2943            State(state),
2944            Extension(TenantId("default".to_owned())),
2945            pass,
2946        )
2947        .await;
2948        assert!(aci.lock().unwrap().threshold() < after_fail);
2949    }
2950
2951    #[tokio::test]
2952    async fn feedback_records_a_deferred_verdict_without_breaking_the_chain() {
2953        let (state, db, trace_id) = feedback_state().await;
2954        let body = Bytes::from(
2955            serde_json::json!({
2956                "trace_id": trace_id,
2957                "gate_id": "tests",
2958                "verdict": "pass",
2959                "score": 1.0,
2960                "reporter": "ci",
2961            })
2962            .to_string(),
2963        );
2964        let resp = feedback(
2965            State(state),
2966            Extension(TenantId("default".to_owned())),
2967            body,
2968        )
2969        .await;
2970        assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
2971
2972        // The deferred verdict is visible on the trace view...
2973        let view = crate::store::load_trace_view(&db, "default", &trace_id)
2974            .unwrap()
2975            .unwrap();
2976        assert_eq!(view.deferred.len(), 1);
2977        assert_eq!(view.deferred[0].gate_id, "tests");
2978        // ...and the sealed chain still verifies (the outcome didn't mutate the trace).
2979        let traces = crate::store::load_all_traces(&db).unwrap();
2980        firstpass_core::verify_chain(&traces, GENESIS_HASH).unwrap();
2981
2982        let _ = std::fs::remove_file(&db);
2983    }
2984
2985    #[tokio::test]
2986    async fn feedback_for_unknown_trace_is_404() {
2987        let (state, db, _trace_id) = feedback_state().await;
2988        let body = Bytes::from(
2989            serde_json::json!({
2990                "trace_id": "does-not-exist",
2991                "gate_id": "tests",
2992                "verdict": "pass",
2993                "reporter": "ci",
2994            })
2995            .to_string(),
2996        );
2997        let resp = feedback(
2998            State(state),
2999            Extension(TenantId("default".to_owned())),
3000            body,
3001        )
3002        .await;
3003        assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
3004        let _ = std::fs::remove_file(&db);
3005    }
3006
3007    /// D4 IDOR: a real trace owned by "default" cannot receive feedback from another tenant. The
3008    /// caller gets a `404` (not `403`), so there is no existence oracle across the boundary.
3009    #[tokio::test]
3010    async fn feedback_across_tenants_is_404_not_403() {
3011        let (state, db, trace_id) = feedback_state().await;
3012        let body = Bytes::from(
3013            serde_json::json!({
3014                "trace_id": trace_id,
3015                "gate_id": "tests",
3016                "verdict": "pass",
3017                "score": 1.0,
3018                "reporter": "attacker",
3019            })
3020            .to_string(),
3021        );
3022        // Caller authenticated as a *different* tenant than the trace's owner.
3023        let resp = feedback(
3024            State(state),
3025            Extension(TenantId("tenant-b".to_owned())),
3026            body,
3027        )
3028        .await;
3029        assert_eq!(
3030            resp.status(),
3031            axum::http::StatusCode::NOT_FOUND,
3032            "cross-tenant feedback must look exactly like a missing trace"
3033        );
3034        let _ = std::fs::remove_file(&db);
3035    }
3036
3037    #[tokio::test]
3038    async fn feedback_rejects_bad_verdict_and_score() {
3039        let (state, db, trace_id) = feedback_state().await;
3040        let bad_verdict = Bytes::from(
3041            serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "maybe", "reporter": "x" })
3042                .to_string(),
3043        );
3044        assert_eq!(
3045            feedback(
3046                State(state.clone()),
3047                Extension(TenantId("default".to_owned())),
3048                bad_verdict
3049            )
3050            .await
3051            .status(),
3052            axum::http::StatusCode::BAD_REQUEST
3053        );
3054        let bad_score = Bytes::from(
3055            serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "pass", "score": 9.0, "reporter": "x" })
3056                .to_string(),
3057        );
3058        assert_eq!(
3059            feedback(
3060                State(state),
3061                Extension(TenantId("default".to_owned())),
3062                bad_score
3063            )
3064            .await
3065            .status(),
3066            axum::http::StatusCode::BAD_REQUEST
3067        );
3068        let _ = std::fs::remove_file(&db);
3069    }
3070
3071    #[tokio::test]
3072    async fn metrics_endpoint_renders_after_a_real_request() {
3073        use tower::ServiceExt;
3074
3075        let (state, mut rx) = enforce_state(
3076            &["anthropic/claude-haiku-4-5"],
3077            &["non-empty"],
3078            vec![(
3079                "anthropic/claude-haiku-4-5",
3080                Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
3081            )],
3082        );
3083        let router = app(state).expect("prometheus recorder installs");
3084
3085        let req = axum::http::Request::builder()
3086            .method("POST")
3087            .uri("/v1/messages")
3088            .header("content-type", "application/json")
3089            .body(Body::from(user_body()))
3090            .unwrap();
3091        let resp = router.clone().oneshot(req).await.unwrap();
3092        assert_eq!(resp.status(), axum::http::StatusCode::OK);
3093        rx.try_recv().expect("a trace was enqueued");
3094
3095        let metrics_req = axum::http::Request::builder()
3096            .method("GET")
3097            .uri("/metrics")
3098            .body(Body::empty())
3099            .unwrap();
3100        let metrics_resp = router.oneshot(metrics_req).await.unwrap();
3101        assert_eq!(metrics_resp.status(), axum::http::StatusCode::OK);
3102        let bytes = axum::body::to_bytes(metrics_resp.into_body(), 1 << 20)
3103            .await
3104            .unwrap();
3105        let body = String::from_utf8(bytes.to_vec()).unwrap();
3106        assert!(
3107            body.contains("firstpass_enforce_latency_ms"),
3108            "metrics body missing enforce latency histogram: {body}"
3109        );
3110        assert!(
3111            body.contains("firstpass_served_total"),
3112            "metrics body missing served counter: {body}"
3113        );
3114    }
3115
3116    // --- Multi-tenant auth (ADR 0004 §D1) integration tests, driven through the real router ---
3117
3118    /// Build an `AppState` whose config toggles auth and (optionally) carries a tenant-keys JSON.
3119    fn auth_state(require_auth: bool, keys_json: Option<String>) -> AppState {
3120        auth_state_rated(require_auth, keys_json, None)
3121    }
3122
3123    /// Like [`auth_state`], but also wires `FIRSTPASS_TENANT_RATE_PER_SEC` (ADR 0004 §D6) when
3124    /// `rate_per_sec` is `Some`.
3125    fn auth_state_rated(
3126        require_auth: bool,
3127        keys_json: Option<String>,
3128        rate_per_sec: Option<u32>,
3129    ) -> AppState {
3130        let config = ProxyConfig::from_lookup(|k| match k {
3131            "FIRSTPASS_REQUIRE_AUTH" => require_auth.then(|| "true".to_owned()),
3132            "FIRSTPASS_TENANT_KEYS_JSON" => keys_json.clone(),
3133            "FIRSTPASS_TENANT_RATE_PER_SEC" => rate_per_sec.map(|n| n.to_string()),
3134            _ => None,
3135        })
3136        .unwrap();
3137        let (traces, _rx) = mpsc::channel(64);
3138        // Deliberately leak the receiver for the test's lifetime so the sender never reports the
3139        // channel closed (the auth tests exercise `/v1/capabilities`, which enqueues no trace).
3140        std::mem::forget(_rx);
3141        let providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
3142        let tenant_rate_limiter = build_tenant_rate_limiter(&config);
3143        AppState {
3144            config: Arc::new(config),
3145            http: reqwest::Client::new(),
3146            providers: ProviderRegistry::from_map(providers),
3147            gate_health: Arc::new(GateHealthRegistry::new()),
3148            traces,
3149            adaptive: None,
3150            bandit: None,
3151            tenant_rate_limiter,
3152            spill: None,
3153        }
3154    }
3155
3156    fn cap_request(auth_header: Option<&str>) -> axum::http::Request<Body> {
3157        let mut b = axum::http::Request::builder()
3158            .method("GET")
3159            .uri("/v1/capabilities");
3160        if let Some(h) = auth_header {
3161            b = b.header("authorization", h);
3162        }
3163        b.body(Body::empty()).unwrap()
3164    }
3165
3166    #[tokio::test]
3167    async fn auth_off_allows_unauthenticated_request() {
3168        use tower::ServiceExt;
3169        let router = app(auth_state(false, None)).expect("router");
3170        let resp = router.oneshot(cap_request(None)).await.unwrap();
3171        // Default-off: no key required, request proceeds to the handler.
3172        assert_eq!(resp.status(), axum::http::StatusCode::OK);
3173    }
3174
3175    #[tokio::test]
3176    async fn auth_on_missing_key_is_401_opaque() {
3177        use tower::ServiceExt;
3178        let hash = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3179        let keys = format!("{{\"tenant-a\": {hash:?}}}");
3180        let router = app(auth_state(true, Some(keys))).expect("router");
3181
3182        let resp = router.oneshot(cap_request(None)).await.unwrap();
3183        assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
3184        let json = body_json(resp).await;
3185        assert_eq!(json["error"]["type"], "unauthorized");
3186        // Opaque: the body must not name tenants or hint which key would work.
3187        let msg = json["error"]["message"].as_str().unwrap();
3188        assert!(!msg.contains("tenant"), "no tenant oracle in body: {msg}");
3189    }
3190
3191    #[tokio::test]
3192    async fn auth_on_invalid_key_is_401() {
3193        use tower::ServiceExt;
3194        let hash = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3195        let keys = format!("{{\"tenant-a\": {hash:?}}}");
3196        let router = app(auth_state(true, Some(keys))).expect("router");
3197
3198        let resp = router
3199            .oneshot(cap_request(Some("Bearer wrong-key")))
3200            .await
3201            .unwrap();
3202        assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
3203    }
3204
3205    #[tokio::test]
3206    async fn auth_on_valid_key_proceeds() {
3207        use tower::ServiceExt;
3208        let hash = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3209        let keys = format!("{{\"tenant-a\": {hash:?}}}");
3210        let router = app(auth_state(true, Some(keys))).expect("router");
3211
3212        // Keyed format: `<tenant_id>.<secret>`.
3213        let resp = router
3214            .oneshot(cap_request(Some("Bearer tenant-a.key-a")))
3215            .await
3216            .unwrap();
3217        // A valid key clears the middleware and reaches the handler.
3218        assert_eq!(resp.status(), axum::http::StatusCode::OK);
3219    }
3220
3221    /// Two tenants, keyed so requests carry a real, distinct `TenantId` (ADR 0004 §D6).
3222    fn two_tenant_state(rate_per_sec: Option<u32>) -> AppState {
3223        let hash_a = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3224        let hash_b = crate::tenant_auth::TenantKeys::hash_key("key-b").unwrap();
3225        let keys = format!("{{\"tenant-a\": {hash_a:?}, \"tenant-b\": {hash_b:?}}}");
3226        auth_state_rated(true, Some(keys), rate_per_sec)
3227    }
3228
3229    #[tokio::test]
3230    async fn tenant_exceeding_rate_limit_gets_429_opaque() {
3231        use tower::ServiceExt;
3232        // Burst capacity == rate for `Quota::per_second`, so 1 req/sec allows one request through
3233        // and rejects the rest of a burst. The requests are fired CONCURRENTLY so they reach the
3234        // limiter in one tight window — a serial sequence is wall-clock sensitive (each request
3235        // pays a deliberately slow Argon2 verify, and on a loaded CI runner >1s can elapse between
3236        // limiter checks, refilling the bucket and turning the expected 429 into a legit 200).
3237        let router = app(two_tenant_state(Some(1))).expect("router");
3238
3239        let (r1, r2, r3, r4) = tokio::join!(
3240            router
3241                .clone()
3242                .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3243            router
3244                .clone()
3245                .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3246            router
3247                .clone()
3248                .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3249            router
3250                .clone()
3251                .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3252        );
3253        let responses = [r1.unwrap(), r2.unwrap(), r3.unwrap(), r4.unwrap()];
3254        let ok = responses
3255            .iter()
3256            .filter(|r| r.status() == axum::http::StatusCode::OK)
3257            .count();
3258        assert!(ok >= 1, "the burst's first request must pass");
3259        let limited: Vec<_> = responses
3260            .into_iter()
3261            .filter(|r| r.status() == axum::http::StatusCode::TOO_MANY_REQUESTS)
3262            .collect();
3263        assert!(
3264            !limited.is_empty(),
3265            "a 4-request burst against 1 req/sec must trip the limiter"
3266        );
3267
3268        let json = body_json(limited.into_iter().next().unwrap()).await;
3269        assert_eq!(json["error"]["type"], "rate_limited");
3270        // Opaque: no bucket state or limit value leaked to the caller.
3271        let msg = json["error"]["message"].as_str().unwrap();
3272        assert!(!msg.contains('1'), "no limit value in body: {msg}");
3273    }
3274
3275    #[tokio::test]
3276    async fn rate_limit_buckets_are_independent_per_tenant() {
3277        use tower::ServiceExt;
3278        let router = app(two_tenant_state(Some(1))).expect("router");
3279
3280        // Tenant A bursts past its 1 req/sec budget (concurrent — see the sibling test for why a
3281        // serial sequence would be wall-clock flaky)...
3282        let (a1, a2, a3) = tokio::join!(
3283            router
3284                .clone()
3285                .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3286            router
3287                .clone()
3288                .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3289            router
3290                .clone()
3291                .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3292        );
3293        let a_limited = [a1.unwrap(), a2.unwrap(), a3.unwrap()]
3294            .iter()
3295            .filter(|r| r.status() == axum::http::StatusCode::TOO_MANY_REQUESTS)
3296            .count();
3297        assert!(a_limited >= 1, "tenant A's burst must trip its limiter");
3298
3299        // ...but tenant B, on the same gate/route, is unaffected (independent bucket).
3300        let b1 = router
3301            .clone()
3302            .oneshot(cap_request(Some("Bearer tenant-b.key-b")))
3303            .await
3304            .unwrap();
3305        assert_eq!(b1.status(), axum::http::StatusCode::OK);
3306    }
3307
3308    #[tokio::test]
3309    async fn rate_limit_unset_never_429s() {
3310        use tower::ServiceExt;
3311        // Backward-compat: with no FIRSTPASS_TENANT_RATE_PER_SEC, drive many requests through and
3312        // confirm none are ever rate-limited (default-off).
3313        let router = app(two_tenant_state(None)).expect("router");
3314        for _ in 0..20 {
3315            let resp = router
3316                .clone()
3317                .oneshot(cap_request(Some("Bearer tenant-a.key-a")))
3318                .await
3319                .unwrap();
3320            assert_eq!(resp.status(), axum::http::StatusCode::OK);
3321        }
3322    }
3323
3324    // ── epsilon-greedy helper unit tests ──────────────────────────────────────
3325
3326    #[test]
3327    fn u01_is_deterministic_and_in_range() {
3328        let s1 = u01(0xDEAD_BEEF_CAFE_1234_u128);
3329        let s2 = u01(0xDEAD_BEEF_CAFE_1234_u128);
3330        assert_eq!(s1, s2, "u01 must be deterministic for the same seed");
3331        assert!((0.0..1.0).contains(&s1), "u01 must return [0, 1), got {s1}");
3332
3333        // Different seeds produce different values (highly likely with SM64).
3334        let s3 = u01(0x1234_5678_9ABC_DEF0_u128);
3335        assert_ne!(s1, s3, "different seeds should give different values");
3336
3337        // Verify range across a spread of seeds.
3338        for i in 0u64..256 {
3339            let v = u01(i as u128);
3340            assert!((0.0..1.0).contains(&v), "seed {i}: u01={v} out of [0,1)");
3341        }
3342    }
3343
3344    #[test]
3345    fn epsilon_propensity_formula() {
3346        let epsilon = 0.2_f64;
3347        let k = 3_usize;
3348        let greedy = 1_u32;
3349
3350        // Greedy rung chosen: both (1-ε) and ε/K terms apply.
3351        let p_greedy = epsilon_propensity(greedy, greedy, epsilon, k);
3352        let expected_greedy = (1.0 - epsilon) + epsilon / k as f64;
3353        assert!(
3354            (p_greedy - expected_greedy).abs() < 1e-12,
3355            "{p_greedy} != {expected_greedy}"
3356        );
3357
3358        // Non-greedy rung: only ε/K term.
3359        let p_other = epsilon_propensity(0, greedy, epsilon, k);
3360        let expected_other = epsilon / k as f64;
3361        assert!(
3362            (p_other - expected_other).abs() < 1e-12,
3363            "{p_other} != {expected_other}"
3364        );
3365
3366        // All propensities are in (0, 1].
3367        for chosen in 0..k as u32 {
3368            let p = epsilon_propensity(chosen, greedy, epsilon, k);
3369            assert!(
3370                p > 0.0 && p <= 1.0,
3371                "propensity {p} out of (0,1] for chosen={chosen}"
3372            );
3373        }
3374    }
3375
3376    #[test]
3377    fn epsilon_branch_and_greedy_branch_both_occur_over_many_seeds() {
3378        // With epsilon=0.3 over 200 sequential seeds, both branches must fire.
3379        let epsilon = 0.3_f64;
3380        let mut saw_explore = false;
3381        let mut saw_greedy = false;
3382        for i in 0u64..200 {
3383            let u = u01(i as u128);
3384            if u < epsilon {
3385                saw_explore = true;
3386            } else {
3387                saw_greedy = true;
3388            }
3389            if saw_explore && saw_greedy {
3390                break;
3391            }
3392        }
3393        assert!(
3394            saw_explore,
3395            "epsilon branch must fire with epsilon=0.3 over 200 seeds"
3396        );
3397        assert!(
3398            saw_greedy,
3399            "greedy branch must occur with epsilon=0.3 over 200 seeds"
3400        );
3401    }
3402
3403    #[test]
3404    fn epsilon_propensity_sums_to_one_over_all_rungs() {
3405        // Sum of propensities over all K rungs equals 1 (it is a valid probability distribution).
3406        let epsilon = 0.15_f64;
3407        let k = 4_usize;
3408        let greedy = 2_u32;
3409        let total: f64 = (0..k as u32)
3410            .map(|r| epsilon_propensity(r, greedy, epsilon, k))
3411            .sum();
3412        assert!(
3413            (total - 1.0).abs() < 1e-12,
3414            "propensities must sum to 1, got {total}"
3415        );
3416    }
3417
3418    /// Poll the hand-rolled keepalive stream directly under paused tokio time: one keepalive
3419    /// per idle interval while the pipeline runs, then the final SSE frame, then end-of-stream.
3420    #[tokio::test(start_paused = true)]
3421    async fn keepalive_stream_ticks_then_emits_final_frame() {
3422        let (tx, rx) = tokio::sync::oneshot::channel::<Result<Value, ProxyError>>();
3423        let mut ticks = tokio::time::interval(SSE_KEEPALIVE_EVERY);
3424        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
3425        ticks.reset();
3426        let mut stream = KeepaliveStream {
3427            rx: Some(rx),
3428            ticks,
3429            format_message: anthropic_sse_from_message,
3430        };
3431        /// One poll of the stream: `Some(item)` if ready, `None` if pending right now.
3432        async fn next(
3433            stream: &mut KeepaliveStream,
3434        ) -> Option<Option<Result<Bytes, std::convert::Infallible>>> {
3435            std::future::poll_fn(|cx| {
3436                std::task::Poll::Ready(
3437                    match futures_core::Stream::poll_next(std::pin::Pin::new(&mut *stream), cx) {
3438                        std::task::Poll::Ready(item) => Some(item),
3439                        std::task::Poll::Pending => None,
3440                    },
3441                )
3442            })
3443            .await
3444        }
3445
3446        // Pipeline still running, no interval elapsed: nothing to emit yet.
3447        assert!(
3448            next(&mut stream).await.is_none(),
3449            "no frame before an interval"
3450        );
3451
3452        // Advance past one keepalive interval: a comment frame is emitted.
3453        tokio::time::advance(SSE_KEEPALIVE_EVERY + Duration::from_millis(1)).await;
3454        let frame = next(&mut stream)
3455            .await
3456            .expect("keepalive due")
3457            .unwrap()
3458            .unwrap();
3459        assert!(
3460            frame.starts_with(b": "),
3461            "keepalive must be an SSE comment (ignored by every conforming parser)"
3462        );
3463
3464        // Pipeline resolves: the final frame is the full Anthropic event sequence, then EOS.
3465        let message = serde_json::json!({
3466            "id": "msg_1", "type": "message", "role": "assistant", "model": "m",
3467            "content": [{ "type": "text", "text": "done" }],
3468            "usage": { "input_tokens": 1, "output_tokens": 1 }
3469        });
3470        tx.send(Ok(message)).unwrap();
3471        let frame = next(&mut stream)
3472            .await
3473            .expect("final frame")
3474            .unwrap()
3475            .unwrap();
3476        let text = String::from_utf8(frame.to_vec()).unwrap();
3477        assert!(text.contains("event: message_start"));
3478        assert!(text.contains("event: message_stop"));
3479        let eos = next(&mut stream)
3480            .await
3481            .expect("stream must end after the final frame");
3482        assert!(eos.is_none(), "end-of-stream after the final frame");
3483    }
3484
3485    /// E2E: a `stream: true` enforce request (default-on structured) is answered 200
3486    /// `text/event-stream` whose body carries the full gated event sequence.
3487    #[tokio::test]
3488    async fn streaming_enforce_serves_full_sse_sequence() {
3489        let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n";
3490        let config = ProxyConfig::from_lookup(|k| match k {
3491            "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
3492            "FIRSTPASS_MODE" => Some("enforce".to_owned()),
3493            "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
3494            _ => None,
3495        })
3496        .unwrap();
3497        let mut outs = HashMap::new();
3498        outs.insert(
3499            "anthropic/m".to_owned(),
3500            Ok(model_resp("anthropic/m", "gated answer")),
3501        );
3502        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
3503        map.insert(
3504            "anthropic".to_owned(),
3505            Arc::new(MockProvider::new("anthropic", outs)),
3506        );
3507        let (traces, _rx) = mpsc::channel(64);
3508        let state = AppState {
3509            config: Arc::new(config),
3510            http: reqwest::Client::new(),
3511            providers: ProviderRegistry::from_map(map),
3512            gate_health: Arc::new(GateHealthRegistry::new()),
3513            traces,
3514            adaptive: None,
3515            bandit: None,
3516            tenant_rate_limiter: None,
3517            spill: None,
3518        };
3519        let body = Bytes::from_static(
3520            br#"{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}"#,
3521        );
3522        let resp = messages(
3523            State(state),
3524            Extension(TenantId("default".to_owned())),
3525            HeaderMap::new(),
3526            body,
3527        )
3528        .await;
3529        assert_eq!(resp.status(), axum::http::StatusCode::OK);
3530        assert!(
3531            resp.headers()
3532                .get(axum::http::header::CONTENT_TYPE)
3533                .and_then(|v| v.to_str().ok())
3534                .is_some_and(|ct| ct.starts_with("text/event-stream")),
3535            "streaming client must get SSE"
3536        );
3537        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
3538            .await
3539            .unwrap();
3540        let text = String::from_utf8(bytes.to_vec()).unwrap();
3541        assert!(text.contains("event: message_start"));
3542        assert!(text.contains("gated answer"));
3543        assert!(text.contains("event: message_stop"));
3544    }
3545
3546    // ── OpenAI inbound (SPEC §M1) ──────────────────────────────────────────────
3547
3548    // --- Golden translation tests ---
3549
3550    #[test]
3551    fn parse_openai_request_plain_text() {
3552        // Simple user message → normalized ModelRequest (translation path, carry_raw=false)
3553        let body = br#"{"model":"gpt-4o","max_tokens":256,"messages":[{"role":"user","content":"hello"}]}"#;
3554        let req = parse_openai_request(body, false).expect("must parse");
3555        assert_eq!(req.model, "gpt-4o");
3556        assert_eq!(req.max_tokens, 256);
3557        assert_eq!(req.messages.len(), 1);
3558        assert_eq!(req.messages[0].role, "user");
3559        assert_eq!(req.messages[0].content, Value::String("hello".to_owned()));
3560        assert!(req.system.is_none());
3561        // Translation path: raw must be Null so anthropic_wire_body rebuilds from fields
3562        assert_eq!(req.raw, Value::Null);
3563    }
3564
3565    #[test]
3566    fn parse_openai_request_system_message() {
3567        let body = br#"{"model":"gpt-4o","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"hi"}]}"#;
3568        let req = parse_openai_request(body, false).expect("must parse");
3569        assert_eq!(req.system.as_deref(), Some("be concise"));
3570        assert_eq!(req.messages.len(), 1);
3571        assert_eq!(req.messages[0].role, "user");
3572    }
3573
3574    #[test]
3575    fn parse_openai_request_tool_calls_translate_to_tool_use() {
3576        let body = br#"{
3577            "model":"gpt-4o",
3578            "messages":[
3579                {"role":"user","content":"what's the weather?"},
3580                {"role":"assistant","content":null,"tool_calls":[
3581                    {"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}
3582                ]},
3583                {"role":"tool","tool_call_id":"call_1","content":"15C, cloudy"}
3584            ]
3585        }"#;
3586        let req = parse_openai_request(body, false).expect("must parse");
3587        // 3 messages → user + assistant (tool_use blocks) + user (tool_result)
3588        assert_eq!(req.messages.len(), 3);
3589        // Assistant turn: Anthropic tool_use block
3590        let asst = &req.messages[1];
3591        assert_eq!(asst.role, "assistant");
3592        let blocks = asst.content.as_array().expect("content array");
3593        assert_eq!(blocks[0]["type"], "tool_use");
3594        assert_eq!(blocks[0]["name"], "get_weather");
3595        assert_eq!(blocks[0]["id"], "call_1");
3596        assert_eq!(blocks[0]["input"]["city"], "Paris");
3597        // Tool result turn: role becomes "user", tool_result block
3598        let tool_msg = &req.messages[2];
3599        assert_eq!(tool_msg.role, "user");
3600        let result_blocks = tool_msg.content.as_array().expect("result blocks");
3601        assert_eq!(result_blocks[0]["type"], "tool_result");
3602        assert_eq!(result_blocks[0]["tool_use_id"], "call_1");
3603    }
3604
3605    #[test]
3606    fn parse_openai_request_tools_translate_to_anthropic_format() {
3607        let body = br#"{
3608            "model":"gpt-4o",
3609            "messages":[{"role":"user","content":"use a tool"}],
3610            "tools":[{"type":"function","function":{"name":"search","description":"web search","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}}]
3611        }"#;
3612        let req = parse_openai_request(body, false).expect("must parse");
3613        let tools = req.tools.as_array().expect("tools array");
3614        assert_eq!(tools.len(), 1);
3615        assert_eq!(tools[0]["name"], "search");
3616        assert_eq!(tools[0]["description"], "web search");
3617        assert_eq!(tools[0]["input_schema"]["type"], "object");
3618    }
3619
3620    #[test]
3621    fn parse_openai_request_raw_carry_preserves_full_body() {
3622        // All-OpenAI ladder path: raw = original JSON, no translation
3623        let body =
3624            br#"{"model":"gpt-4o","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}"#;
3625        let req = parse_openai_request(body, true).expect("must parse");
3626        assert!(req.raw.is_object(), "raw must be the full JSON object");
3627        assert_eq!(req.raw["model"], "gpt-4o");
3628        assert_eq!(req.raw["max_tokens"], 100);
3629        // Tools remain in OpenAI shape (not translated) on the raw-carry path
3630        assert!(req.tools.is_null(), "no tools in this request");
3631    }
3632
3633    #[test]
3634    fn parse_openai_request_http_image_returns_none() {
3635        // Non-translatable: http image URL → caller must use observe fallback
3636        let body = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[
3637            {"type":"text","text":"describe this"},
3638            {"type":"image_url","image_url":{"url":"https://example.com/cat.png"}}
3639        ]}]}"#;
3640        let result = parse_openai_request(body, false);
3641        assert!(result.is_none(), "http image URL must fail translation");
3642    }
3643
3644    #[test]
3645    fn parse_openai_request_data_url_image_translates_to_anthropic_base64() {
3646        let body = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[
3647            {"type":"text","text":"describe"},
3648            {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
3649        ]}]}"#;
3650        let req = parse_openai_request(body, false).expect("data URL must parse");
3651        let blocks = req.messages[0].content.as_array().expect("blocks");
3652        let img = blocks
3653            .iter()
3654            .find(|b| b["type"] == "image")
3655            .expect("image block");
3656        assert_eq!(img["source"]["type"], "base64");
3657        assert_eq!(img["source"]["media_type"], "image/png");
3658        assert_eq!(img["source"]["data"], "iVBORw0KGgo=");
3659    }
3660
3661    // --- Response rendering tests ---
3662
3663    #[test]
3664    fn openai_response_json_renders_text_response() {
3665        let resp = ModelResponse {
3666            model: "gpt-4o".to_owned(),
3667            text: "Hello!".to_owned(),
3668            in_tokens: 10,
3669            out_tokens: 5,
3670            raw: serde_json::json!({
3671                "content": [{ "type": "text", "text": "Hello!" }]
3672            }),
3673        };
3674        let json = openai_response_json(&resp);
3675        assert_eq!(json["object"], "chat.completion");
3676        assert_eq!(json["model"], "gpt-4o");
3677        assert_eq!(json["choices"][0]["message"]["role"], "assistant");
3678        assert_eq!(json["choices"][0]["message"]["content"], "Hello!");
3679        assert_eq!(json["choices"][0]["finish_reason"], "stop");
3680        assert_eq!(json["usage"]["prompt_tokens"], 10);
3681        assert_eq!(json["usage"]["completion_tokens"], 5);
3682    }
3683
3684    #[test]
3685    fn openai_response_json_renders_tool_call() {
3686        let resp = ModelResponse {
3687            model: "gpt-4o".to_owned(),
3688            text: String::new(),
3689            in_tokens: 20,
3690            out_tokens: 15,
3691            raw: serde_json::json!({
3692                "content": [{
3693                    "type": "tool_use",
3694                    "id": "call_abc",
3695                    "name": "search",
3696                    "input": {"q": "Rust async"}
3697                }]
3698            }),
3699        };
3700        let json = openai_response_json(&resp);
3701        assert_eq!(json["choices"][0]["finish_reason"], "tool_calls");
3702        let tc = &json["choices"][0]["message"]["tool_calls"][0];
3703        assert_eq!(tc["id"], "call_abc");
3704        assert_eq!(tc["type"], "function");
3705        assert_eq!(tc["function"]["name"], "search");
3706        // content is null for tool-only responses (OpenAI spec)
3707        assert_eq!(json["choices"][0]["message"]["content"], Value::Null);
3708    }
3709
3710    #[test]
3711    fn openai_sse_from_message_plain_text_has_role_then_content_then_stop() {
3712        let resp = ModelResponse {
3713            model: "gpt-4o".to_owned(),
3714            text: "Hi there!".to_owned(),
3715            in_tokens: 5,
3716            out_tokens: 3,
3717            raw: serde_json::json!({
3718                "content": [{ "type": "text", "text": "Hi there!" }]
3719            }),
3720        };
3721        let sse = openai_sse_from_message(&openai_response_json(&resp));
3722        // Every line is either blank or starts with "data: "
3723        for line in sse.lines() {
3724            assert!(
3725                line.is_empty() || line.starts_with("data: "),
3726                "bad SSE line: {line:?}"
3727            );
3728        }
3729        let frames: Vec<&str> = sse
3730            .lines()
3731            .filter_map(|l| l.strip_prefix("data: "))
3732            .collect();
3733        // Last frame must be [DONE]
3734        assert_eq!(*frames.last().unwrap(), "[DONE]");
3735        // Role delta must appear
3736        let role_frame: Value = serde_json::from_str(frames[0]).unwrap();
3737        assert_eq!(role_frame["choices"][0]["delta"]["role"], "assistant");
3738        // Content delta must appear somewhere
3739        assert!(frames.iter().any(|f| {
3740            if *f == "[DONE]" {
3741                return false;
3742            }
3743            serde_json::from_str::<Value>(f)
3744                .ok()
3745                .is_some_and(|v| v["choices"][0]["delta"]["content"] == "Hi there!")
3746        }));
3747        // Finish reason must appear in a non-[DONE] frame
3748        assert!(frames.iter().any(|f| {
3749            if *f == "[DONE]" {
3750                return false;
3751            }
3752            serde_json::from_str::<Value>(f)
3753                .ok()
3754                .is_some_and(|v| v["choices"][0]["finish_reason"] == "stop")
3755        }));
3756    }
3757
3758    // --- Detection helper tests ---
3759
3760    #[test]
3761    fn detects_openai_tool_calls() {
3762        let with_tool_calls = Bytes::from_static(br#"{"messages":[
3763            {"role":"user","content":"hi"},
3764            {"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}
3765        ]}"#);
3766        let without = Bytes::from_static(br#"{"messages":[{"role":"user","content":"hi"}]}"#);
3767        let with_tool_msg = Bytes::from_static(
3768            br#"{"messages":[
3769            {"role":"tool","tool_call_id":"c1","content":"result"}
3770        ]}"#,
3771        );
3772        assert!(openai_messages_have_tool_calls(&with_tool_calls));
3773        assert!(!openai_messages_have_tool_calls(&without));
3774        assert!(openai_messages_have_tool_calls(&with_tool_msg));
3775    }
3776
3777    #[test]
3778    fn detects_openai_http_images() {
3779        let http_img = Bytes::from_static(
3780            br#"{"messages":[{"role":"user","content":[
3781            {"type":"image_url","image_url":{"url":"https://example.com/img.png"}}
3782        ]}]}"#,
3783        );
3784        let data_img = Bytes::from_static(
3785            br#"{"messages":[{"role":"user","content":[
3786            {"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}
3787        ]}]}"#,
3788        );
3789        let no_img = Bytes::from_static(br#"{"messages":[{"role":"user","content":"hi"}]}"#);
3790        assert!(openai_has_http_images(&http_img));
3791        assert!(!openai_has_http_images(&data_img));
3792        assert!(!openai_has_http_images(&no_img));
3793    }
3794
3795    #[test]
3796    fn enforce_can_handle_openai_inbound_all_openai_ladder() {
3797        // All-OpenAI ladder: verbatim carry, no translation needed → enforce allowed.
3798        let tools_body = Bytes::from_static(br#"{"model":"gpt-4o","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"c","type":"function","function":{"name":"f","arguments":"{}"}}]}]}"#);
3799        let f = extract_openai_features(&HeaderMap::new(), &tools_body);
3800        let ladder = vec!["openai/gpt-4o-mini".to_owned(), "openai/gpt-4o".to_owned()];
3801        let providers = crate::provider::ProviderRegistry::new("http://x", "http://x");
3802        assert!(enforce_can_handle(
3803            &f,
3804            &tools_body,
3805            true,
3806            &ladder,
3807            &providers,
3808            Dialect::Openai
3809        ));
3810    }
3811
3812    #[test]
3813    fn enforce_can_handle_openai_inbound_all_anthropic_ladder_no_http_image() {
3814        // Translation path: OpenAI inbound + all-Anthropic ladder + no http images → allowed.
3815        let tools_body = Bytes::from_static(br#"{"model":"gpt-4o","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"c","type":"function","function":{"name":"f","arguments":"{}"}}]}]}"#);
3816        let f = extract_openai_features(&HeaderMap::new(), &tools_body);
3817        let ladder = vec!["anthropic/claude-haiku-4-5".to_owned()];
3818        let providers = crate::provider::ProviderRegistry::new("http://x", "http://x");
3819        assert!(enforce_can_handle(
3820            &f,
3821            &tools_body,
3822            true,
3823            &ladder,
3824            &providers,
3825            Dialect::Openai,
3826        ));
3827    }
3828
3829    #[test]
3830    fn enforce_can_handle_openai_inbound_http_image_falls_back() {
3831        // Non-translatable: http image URL → enforce not possible, observe fallback.
3832        let img_body = Bytes::from_static(
3833            br#"{"model":"gpt-4o","messages":[{"role":"user","content":[
3834            {"type":"image_url","image_url":{"url":"https://example.com/img.png"}}
3835        ]}]}"#,
3836        );
3837        let f = extract_openai_features(&HeaderMap::new(), &img_body);
3838        let ladder = vec!["anthropic/claude-haiku-4-5".to_owned()];
3839        let providers = crate::provider::ProviderRegistry::new("http://x", "http://x");
3840        assert!(!enforce_can_handle(
3841            &f,
3842            &img_body,
3843            true,
3844            &ladder,
3845            &providers,
3846            Dialect::Openai,
3847        ));
3848    }
3849
3850    // --- E2E handler tests ---
3851
3852    /// Build a minimal enforce AppState backed by MockProvider for OpenAI-inbound tests.
3853    fn openai_enforce_state(mock_resp: ModelResponse) -> AppState {
3854        let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"mock/m\"]\ngates = [\"non-empty\"]\n";
3855        let config = ProxyConfig::from_lookup(|k| match k {
3856            "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
3857            "FIRSTPASS_MODE" => Some("enforce".to_owned()),
3858            _ => None,
3859        })
3860        .unwrap();
3861        let mut outs = HashMap::new();
3862        outs.insert("mock/m".to_owned(), Ok(mock_resp));
3863        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
3864        map.insert("mock".to_owned(), Arc::new(MockProvider::new("mock", outs)));
3865        let (traces, _rx) = mpsc::channel(64);
3866        std::mem::forget(_rx);
3867        let tenant_rate_limiter = build_tenant_rate_limiter(&config);
3868        AppState {
3869            config: Arc::new(config),
3870            http: reqwest::Client::new(),
3871            providers: ProviderRegistry::from_map(map),
3872            gate_health: Arc::new(GateHealthRegistry::new()),
3873            traces,
3874            adaptive: None,
3875            bandit: None,
3876            tenant_rate_limiter,
3877            spill: None,
3878        }
3879    }
3880
3881    #[tokio::test]
3882    async fn chat_completions_plain_text_enforce_returns_openai_shape() {
3883        let mock = model_resp("mock/m", "gated answer");
3884        let state = openai_enforce_state(mock);
3885        let body = Bytes::from_static(
3886            br#"{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}"#,
3887        );
3888        let resp = chat_completions(
3889            State(state),
3890            Extension(TenantId("default".to_owned())),
3891            HeaderMap::new(),
3892            body,
3893        )
3894        .await;
3895        assert_eq!(resp.status(), axum::http::StatusCode::OK);
3896        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
3897            .await
3898            .unwrap();
3899        let json: Value = serde_json::from_slice(&bytes).expect("must be JSON");
3900        assert_eq!(json["object"], "chat.completion");
3901        assert_eq!(json["choices"][0]["message"]["role"], "assistant");
3902        assert_eq!(json["choices"][0]["message"]["content"], "gated answer");
3903        assert_eq!(json["choices"][0]["finish_reason"], "stop");
3904        assert!(
3905            json["id"]
3906                .as_str()
3907                .is_some_and(|id| id.starts_with("chatcmpl-"))
3908        );
3909    }
3910
3911    #[tokio::test]
3912    async fn chat_completions_stream_true_returns_sse_with_openai_chunks() {
3913        let mock = model_resp("mock/m", "gated answer");
3914        let state = openai_enforce_state(mock);
3915        let body = Bytes::from_static(
3916            br#"{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hello"}]}"#,
3917        );
3918        let resp = chat_completions(
3919            State(state),
3920            Extension(TenantId("default".to_owned())),
3921            HeaderMap::new(),
3922            body,
3923        )
3924        .await;
3925        assert_eq!(resp.status(), axum::http::StatusCode::OK);
3926        assert!(
3927            resp.headers()
3928                .get(axum::http::header::CONTENT_TYPE)
3929                .and_then(|v| v.to_str().ok())
3930                .is_some_and(|ct| ct.starts_with("text/event-stream")),
3931            "stream:true must return SSE"
3932        );
3933        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
3934            .await
3935            .unwrap();
3936        let text = String::from_utf8(bytes.to_vec()).unwrap();
3937        // Must contain OpenAI chunk object type, not Anthropic
3938        assert!(
3939            text.contains("chat.completion.chunk"),
3940            "must have OpenAI chunk frames"
3941        );
3942        assert!(text.contains("[DONE]"), "must end with [DONE]");
3943        assert!(
3944            text.contains("gated answer"),
3945            "content must be in the stream"
3946        );
3947        // Must NOT contain Anthropic SSE event types
3948        assert!(
3949            !text.contains("message_start"),
3950            "must not have Anthropic event types"
3951        );
3952    }
3953}