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::State;
9use axum::http::HeaderMap;
10use axum::response::{IntoResponse, Response};
11use axum::routing::{get, post};
12use axum::{Json, Router};
13use bytes::Bytes;
14use firstpass_core::features::{hour_bucket, token_bucket};
15use firstpass_core::hashchain::sha256_hex;
16use firstpass_core::{
17    Attempt, DeferredVerdict, FEATURE_VERSION, Features, FinalOutcome, GENESIS_HASH, Mode,
18    PolicyRef, RequestInfo, Score, ServedFrom, TaskKind, Trace, Verdict,
19};
20use serde::Deserialize;
21use serde_json::Value;
22use tokio::sync::mpsc::error::TrySendError;
23use uuid::Uuid;
24
25use crate::config::ProxyConfig;
26use crate::error::ProxyError;
27use crate::gate::{GateHealthRegistry, resolve_gates};
28use crate::provider::{Auth, ChatMessage, ModelRequest, ModelResponse, ProviderRegistry};
29use crate::router::{EnforceCtx, EngineOutcome, route_enforce};
30use crate::store;
31use crate::upstream::{forward_anthropic, forward_anthropic_streaming};
32use firstpass_core::Route;
33
34/// Shared state handed to every request handler. Cheap to clone: an `Arc`ed config, a
35/// pooled HTTP client, and a bounded channel sender.
36#[derive(Clone)]
37pub struct AppState {
38    /// Static proxy configuration.
39    pub config: Arc<ProxyConfig>,
40    /// Shared, connection-pooled HTTP client used to call upstream (observe passthrough).
41    pub http: reqwest::Client,
42    /// Multi-provider registry used by the enforce-mode escalation engine.
43    pub providers: ProviderRegistry,
44    /// Per-gate error budgets (auto-disable), shared across requests.
45    pub gate_health: Arc<GateHealthRegistry>,
46    /// Fire-and-forget sender to the background trace writer.
47    pub traces: store::TraceSender,
48}
49
50impl std::fmt::Debug for AppState {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("AppState")
53            .field("config", &self.config)
54            .finish_non_exhaustive()
55    }
56}
57
58/// Fire-and-forget a trace at the background writer: non-blocking, and bounded. If the writer has
59/// fallen behind enough to fill the buffer, or is gone, the trace is dropped with a warning rather
60/// than blocking the hot path or growing memory without limit (the audit chain over persisted
61/// traces stays valid; a dropped trace is simply absent).
62fn offer_trace(traces: &store::TraceSender, trace: Trace) {
63    record_trace_metrics(&trace);
64    match traces.try_send(trace) {
65        Ok(()) => {}
66        Err(TrySendError::Full(_)) => {
67            tracing::warn!("trace channel full; dropping trace (writer behind under load)");
68            metrics::counter!("firstpass_traces_dropped_total").increment(1);
69        }
70        Err(TrySendError::Closed(_)) => {
71            tracing::warn!("trace writer is gone; dropping trace");
72        }
73    }
74}
75
76/// Record the real signals every trace carries: enforce-mode latency/escalations (observe mode
77/// forwards unchanged, so its wall-clock time isn't a routing-decision latency), and what got
78/// served — regardless of mode, since an upstream failure is worth counting either way.
79fn record_trace_metrics(trace: &Trace) {
80    if trace.mode == Mode::Enforce {
81        metrics::histogram!("firstpass_enforce_latency_ms")
82            .record(trace.final_.total_latency_ms as f64);
83        if trace.final_.escalations > 0 {
84            metrics::counter!("firstpass_escalations_total")
85                .increment(u64::from(trace.final_.escalations));
86        }
87    }
88    let served_from = match trace.final_.served_from {
89        ServedFrom::Attempt => "attempt",
90        ServedFrom::BestAttempt => "best_attempt",
91        ServedFrom::Error => "error",
92    };
93    metrics::counter!("firstpass_served_total", "served_from" => served_from).increment(1);
94    if trace.final_.served_from == ServedFrom::Error {
95        metrics::counter!("firstpass_upstream_failures_total").increment(1);
96    }
97}
98
99/// Max accepted request body. Explicit (not axum's ~2 MB default) so it's an intentional ceiling:
100/// generous enough to pass through large multimodal/long-context requests, bounded so a single
101/// oversized body can't exhaust memory.
102const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
103
104/// Build the axum router: `POST /v1/messages`, `GET /v1/capabilities`, `GET /healthz`,
105/// `GET /metrics`.
106///
107/// # Errors
108/// [`ProxyError::Internal`] if the Prometheus recorder fails to install (see
109/// [`crate::metrics::install`]).
110pub fn app(state: AppState) -> Result<Router, ProxyError> {
111    crate::metrics::install()?;
112    let max_concurrency = state.config.max_concurrency;
113    Ok(Router::new()
114        .route("/v1/messages", post(messages))
115        .route("/v1/feedback", post(feedback))
116        .route("/v1/capabilities", get(capabilities))
117        .route("/healthz", get(healthz))
118        .route("/metrics", get(crate::metrics::handler))
119        // Explicit body-size ceiling (DoS/OOM guard) across every route.
120        .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES))
121        // Concurrency load-shed: cap in-flight requests under the cap rather than falling over.
122        // Deliberately NOT a request timeout — that would sever in-flight SSE streams.
123        .layer(tower::limit::GlobalConcurrencyLimitLayer::new(
124            max_concurrency,
125        ))
126        .with_state(state))
127}
128
129/// `GET /healthz` — liveness probe.
130async fn healthz() -> impl IntoResponse {
131    Json(serde_json::json!({ "status": "ok" }))
132}
133
134/// `GET /v1/capabilities` — agent-first discovery (SPEC §0.2, §7.4): what this proxy speaks,
135/// which modes are live, the first enforce route's ladder/gates, and how to turn it off.
136async fn capabilities(State(state): State<AppState>) -> impl IntoResponse {
137    // Report the first enforce route's ladder + gates, so an agent can discover what it's routed
138    // through. Empty when no routing config is loaded (pure observe deployment).
139    let (ladder, gates) = state
140        .config
141        .routing
142        .as_ref()
143        .and_then(|c| c.routes.iter().find(|r| r.mode == Mode::Enforce))
144        .map(|r| (r.ladder.clone(), r.gates.clone()))
145        .unwrap_or_default();
146    Json(serde_json::json!({
147        "service": "firstpass",
148        "version": env!("CARGO_PKG_VERSION"),
149        "feature_version": FEATURE_VERSION,
150        "modes": ["observe", "enforce"],
151        "wire_apis": ["anthropic.messages"],
152        "ladder": ladder,
153        "gates": gates,
154        "feedback_api": "POST /v1/feedback",
155        "offboarding": "unset ANTHROPIC_BASE_URL",
156    }))
157}
158
159/// Body of `POST /v1/feedback`: a downstream outcome reported for a past decision.
160#[derive(Debug, Deserialize)]
161struct FeedbackRequest {
162    /// The `trace_id` of the decision this outcome is about.
163    trace_id: String,
164    /// The gate/source id, e.g. `"tests"` or `"feedback:ci"`.
165    gate_id: String,
166    /// `"pass"` | `"fail"` | `"abstain"`.
167    verdict: String,
168    /// Optional confidence in `[0, 1]`.
169    #[serde(default)]
170    score: Option<f64>,
171    /// Who reported it (a CI system, a human reviewer, a deferred gate).
172    reporter: String,
173}
174
175/// `POST /v1/feedback` — attach a downstream outcome (deferred verdict) to a past trace, closing
176/// the outcome-feedback loop (SPEC §8.3.4). The verdict is stored in a **separate** table keyed
177/// by `trace_id`; the sealed, hashed trace is never mutated, so the audit chain stays verifiable.
178/// Returns `202 Accepted`. This is the signal that later calibrates the gates.
179async fn feedback(State(state): State<AppState>, body: Bytes) -> Response {
180    let req: FeedbackRequest = match serde_json::from_slice(&body) {
181        Ok(r) => r,
182        Err(e) => {
183            return ProxyError::BadRequest(format!("invalid feedback body: {e}")).into_response();
184        }
185    };
186    let verdict = match req.verdict.as_str() {
187        "pass" => Verdict::Pass,
188        "fail" => Verdict::Fail,
189        "abstain" => Verdict::Abstain,
190        other => {
191            return ProxyError::BadRequest(format!("unknown verdict {other:?}")).into_response();
192        }
193    };
194    let score = match req.score {
195        Some(s) => match Score::new(s) {
196            Ok(sc) => Some(sc),
197            Err(_) => {
198                return ProxyError::BadRequest(format!("score {s} out of range [0,1]"))
199                    .into_response();
200            }
201        },
202        None => None,
203    };
204
205    let db = state.config.db_path.clone();
206
207    // Reject feedback for an unknown trace, so orphan outcomes can't accumulate.
208    let (db_check, tid_check) = (db.clone(), req.trace_id.clone());
209    match tokio::task::spawn_blocking(move || store::trace_exists(&db_check, &tid_check)).await {
210        Ok(Ok(true)) => {}
211        Ok(Ok(false)) => {
212            return ProxyError::NotFound(format!("unknown trace_id {:?}", req.trace_id))
213                .into_response();
214        }
215        Ok(Err(e)) => {
216            tracing::error!(%e, "feedback: trace_exists check failed");
217            return ProxyError::Internal(e.to_string()).into_response();
218        }
219        Err(e) => {
220            tracing::error!(%e, "feedback: trace_exists task panicked");
221            return ProxyError::Internal(e.to_string()).into_response();
222        }
223    }
224
225    let dv = DeferredVerdict {
226        gate_id: req.gate_id,
227        verdict,
228        score,
229        reported_at: jiff::Timestamp::now(),
230        reporter: req.reporter,
231    };
232    let trace_id = req.trace_id.clone();
233    match tokio::task::spawn_blocking(move || store::append_deferred(&db, &req.trace_id, &dv)).await
234    {
235        Ok(Ok(())) => (
236            axum::http::StatusCode::ACCEPTED,
237            Json(serde_json::json!({ "status": "recorded", "trace_id": trace_id })),
238        )
239            .into_response(),
240        Ok(Err(e)) => {
241            tracing::error!(%e, "feedback: append_deferred failed");
242            ProxyError::Internal(e.to_string()).into_response()
243        }
244        Err(e) => {
245            tracing::error!(%e, "feedback: append_deferred task panicked");
246            ProxyError::Internal(e.to_string()).into_response()
247        }
248    }
249}
250
251/// The header a caller may set to group requests into a session for the audit trail. When
252/// absent, each request is its own session (keyed by its own trace id).
253const SESSION_HEADER: &str = "x-firstpass-session";
254
255/// Header carrying the calling agent identity (feature/routing signal).
256const AGENT_HEADER: &str = "x-firstpass-agent";
257/// Header carrying the calling subagent identity.
258const SUBAGENT_HEADER: &str = "x-firstpass-subagent";
259
260/// `POST /v1/messages` — dispatch on the matched route's mode. **Enforce** routes run the
261/// escalation engine (gate + escalate + failover); everything else is an **observe**
262/// passthrough (forward unchanged, trace asynchronously). Either way the trace is recorded
263/// off the response path.
264async fn messages(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
265    let session_header = header_str(&headers, SESSION_HEADER);
266
267    // Only parse the request for routing when a routing config is loaded — an observe-only
268    // deployment does zero on-path parsing and keeps its zero-added-latency guarantee.
269    if let Some(routing) = state.config.routing.as_ref() {
270        let features = extract_features(&headers, &body);
271        if let Some(route) = routing
272            .route_for(&features)
273            .filter(|r| r.mode == Mode::Enforce && !r.ladder.is_empty())
274        {
275            // Clone the matched route so no borrow of `state.config` is held across the await;
276            // routes are tiny (a handful of strings).
277            let route = route.clone();
278            if enforce_can_handle(&features, &body) {
279                return handle_enforce(&state, &headers, &body, features, &route, session_header)
280                    .await;
281            }
282            // The request carries tools / images / tool-result blocks the text-only enforce path
283            // can't round-trip faithfully yet. Rather than drop them and serve corrupted output,
284            // fall through to transparent observe passthrough (correct, un-gated) for this request.
285            tracing::info!(
286                "enforce route matched but request has tool/image content; serving via observe passthrough"
287            );
288        }
289    }
290    observe_passthrough(state, headers, body, session_header).await
291}
292
293/// Whether the enforce path can faithfully handle this request. Enforce normalizes content to
294/// text and re-synthesizes a text response, so it cannot round-trip tool calls or images — a
295/// request that declares tools, carries images, or contains tool_use/tool_result blocks is served
296/// via transparent observe passthrough instead of being silently corrupted.
297///
298// ponytail: full tool/multimodal round-tripping through the ladder is the follow-on (needs
299// provider-adapter work + live verification); this guard just refuses to corrupt in the meantime.
300fn enforce_can_handle(features: &Features, body: &[u8]) -> bool {
301    features.tool_count == 0 && !features.has_images && !messages_have_tool_blocks(body)
302}
303
304/// Whether any message carries a `tool_use` or `tool_result` content block (a multi-turn tool
305/// conversation), which the text-only enforce normalization would drop.
306fn messages_have_tool_blocks(body: &[u8]) -> bool {
307    serde_json::from_slice::<Value>(body)
308        .ok()
309        .and_then(|json| {
310            json.get("messages")
311                .and_then(Value::as_array)
312                .map(|messages| messages.iter().any(message_has_tool_block))
313        })
314        .unwrap_or(false)
315}
316
317/// Whether a single message's content contains a `tool_use` or `tool_result` block.
318fn message_has_tool_block(message: &Value) -> bool {
319    message
320        .get("content")
321        .and_then(Value::as_array)
322        .is_some_and(|blocks| {
323            blocks.iter().any(|block| {
324                matches!(
325                    block.get("type").and_then(Value::as_str),
326                    Some("tool_use" | "tool_result")
327                )
328            })
329        })
330}
331
332/// Whether the request opts into server-sent-events streaming (`"stream": true`).
333fn is_stream_request(body: &[u8]) -> bool {
334    serde_json::from_slice::<Value>(body)
335        .ok()
336        .and_then(|json| json.get("stream").and_then(Value::as_bool))
337        .unwrap_or(false)
338}
339
340/// Read a header as an owned `String`, if present and valid UTF-8.
341fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
342    headers
343        .get(name)
344        .and_then(|v| v.to_str().ok())
345        .map(str::to_owned)
346}
347
348/// Build the routing/telemetry feature vector from request headers + body (best-effort;
349/// malformed fields fall back to safe defaults — this must never fail a request).
350fn extract_features(headers: &HeaderMap, body: &[u8]) -> Features {
351    let (_model, tool_count, has_images) = request_features(body);
352    let mut f = Features::new(TaskKind::Other);
353    f.agent = header_str(headers, AGENT_HEADER);
354    f.subagent = header_str(headers, SUBAGENT_HEADER);
355    f.tool_count = tool_count;
356    f.has_images = has_images;
357    // Pre-call we don't know the token count, so bucket by request byte size — a coarse,
358    // monotonic proxy that never exposes the exact prompt (matches the privacy contract).
359    f.prompt_token_bucket = token_bucket(body.len() as u64);
360    f.hour_bucket = hour_bucket(jiff::Timestamp::now());
361    f
362}
363
364/// Enforce mode (SPEC §7.1): run the escalation engine and serve the first output that clears
365/// the route's gates, escalating on failure with cross-provider failover.
366async fn handle_enforce(
367    state: &AppState,
368    headers: &HeaderMap,
369    body: &Bytes,
370    features: Features,
371    route: &Route,
372    session_header: Option<String>,
373) -> Response {
374    let Some(base_request) = parse_model_request(body) else {
375        return ProxyError::BadRequest(
376            "request body is not a valid Anthropic Messages request".to_owned(),
377        )
378        .into_response();
379    };
380    let auth = Auth::from_headers(headers);
381    let gate_defs = state
382        .config
383        .routing
384        .as_ref()
385        .map_or(&[][..], |cfg| &cfg.gate_defs);
386    let gates = resolve_gates(&route.gates, gate_defs, &state.providers, &auth);
387    let session_id = session_header.unwrap_or_else(|| Uuid::now_v7().to_string());
388    let (budget, max_rungs, speculation, serve_threshold) = match state.config.routing.as_ref() {
389        Some(cfg) => (
390            cfg.budget.per_request_usd,
391            cfg.escalation.max_rungs_per_request,
392            cfg.escalation.speculation,
393            cfg.escalation.serve_threshold,
394        ),
395        None => (None, 3, 0, None),
396    };
397
398    let ctx = EnforceCtx {
399        ladder: &route.ladder,
400        gates: &gates,
401        health: &state.gate_health,
402        base_request: &base_request,
403        providers: &state.providers,
404        auth: &auth,
405        prices: &state.config.prices,
406        budget_per_request_usd: budget,
407        max_rungs,
408        speculation,
409        serve_threshold,
410        features,
411        tenant_id: state.config.tenant_id.clone(),
412        session_id,
413        prompt_hash: prompt_hash(&state.config.prompt_salt, body),
414        api: "anthropic.messages".to_owned(),
415        policy_id: "static-ladder@v0".to_owned(),
416    };
417
418    let (outcome, trace) = route_enforce(ctx).await;
419    // The trace is already built; enqueue it off-path (non-blocking `try_send`, so no spawn needed).
420    offer_trace(&state.traces, trace);
421
422    match outcome {
423        EngineOutcome::Served(resp) => (
424            axum::http::StatusCode::OK,
425            Json(anthropic_response_json(&resp)),
426        )
427            .into_response(),
428        EngineOutcome::Failed(msg) => ProxyError::Engine(msg).into_response(),
429    }
430}
431
432/// Parse an Anthropic Messages request body into the normalized [`ModelRequest`]. Returns
433/// `None` if the body isn't valid JSON or lacks a `messages` array.
434///
435// Content blocks are collapsed to their concatenated text. Requests carrying tool_use/tool_result
436// or image blocks never reach this function — `enforce_can_handle` routes them to transparent
437// observe passthrough instead, so nothing is silently dropped here. Full multimodal round-tripping
438// through the ladder is the follow-up; the enforce beachhead is text/code.
439fn parse_model_request(body: &[u8]) -> Option<ModelRequest> {
440    let json: Value = serde_json::from_slice(body).ok()?;
441    let messages_json = json.get("messages")?.as_array()?;
442    let messages = messages_json
443        .iter()
444        .map(|m| ChatMessage {
445            role: m
446                .get("role")
447                .and_then(Value::as_str)
448                .unwrap_or("user")
449                .to_owned(),
450            content: content_to_text(m.get("content")),
451        })
452        .collect();
453    let system = json
454        .get("system")
455        .and_then(Value::as_str)
456        .map(str::to_owned);
457    let max_tokens = json
458        .get("max_tokens")
459        .and_then(Value::as_u64)
460        .and_then(|n| u32::try_from(n).ok())
461        .unwrap_or(1024);
462    let tools = json.get("tools").cloned().unwrap_or(Value::Null);
463    Some(ModelRequest {
464        model: json
465            .get("model")
466            .and_then(Value::as_str)
467            .unwrap_or_default()
468            .to_owned(),
469        system,
470        messages,
471        max_tokens,
472        tools,
473    })
474}
475
476/// Flatten Anthropic message content (a string, or an array of `{type,text}` blocks) to text.
477fn content_to_text(content: Option<&Value>) -> String {
478    match content {
479        Some(Value::String(s)) => s.clone(),
480        Some(Value::Array(blocks)) => blocks
481            .iter()
482            .filter_map(|b| b.get("text").and_then(Value::as_str))
483            .collect::<Vec<_>>()
484            .join("\n"),
485        _ => String::new(),
486    }
487}
488
489/// Render a served [`ModelResponse`] back into an Anthropic Messages response envelope, so the
490/// caller sees the same wire shape regardless of which provider actually answered.
491fn anthropic_response_json(resp: &ModelResponse) -> Value {
492    serde_json::json!({
493        "id": format!("msg_{}", Uuid::now_v7()),
494        "type": "message",
495        "role": "assistant",
496        "model": resp.model,
497        "content": [{ "type": "text", "text": resp.text }],
498        "usage": { "input_tokens": resp.in_tokens, "output_tokens": resp.out_tokens },
499    })
500}
501
502/// Observe mode (SPEC §7.1a): forward unchanged, return unchanged, trace asynchronously.
503async fn observe_passthrough(
504    state: AppState,
505    headers: HeaderMap,
506    body: Bytes,
507    session_header: Option<String>,
508) -> Response {
509    // Streaming requests are relayed chunk-by-chunk rather than buffered (SPEC §7.4).
510    if is_stream_request(&body) {
511        return observe_stream(state, headers, body, session_header).await;
512    }
513    let start = Instant::now();
514    let result = forward_anthropic(
515        &state.http,
516        &state.config.upstream_anthropic,
517        &headers,
518        body.clone(),
519    )
520    .await;
521    let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
522
523    match result {
524        Ok((status, resp_headers, resp_body)) => {
525            // Build + record the trace on a detached task so neither JSON parsing nor the
526            // channel send touches the response path: observe mode adds zero latency to what
527            // the caller sees (SPEC §7.1a). `Bytes` clones are cheap (refcounted).
528            spawn_trace(
529                &state,
530                body,
531                Some(resp_body.clone()),
532                latency_ms,
533                session_header,
534            );
535            (status, resp_headers, resp_body).into_response()
536        }
537        Err(err) => {
538            spawn_trace(&state, body, None, latency_ms, session_header);
539            err.into_response()
540        }
541    }
542}
543
544/// Observe mode for a streaming request (`stream: true`): relay the upstream SSE response
545/// chunk-by-chunk instead of buffering, so streaming is preserved to the caller and
546/// time-to-first-byte stays low. `latency_ms` is time-to-response-headers (the added-latency
547/// figure that matters), recorded off the response path.
548///
549// ponytail: streamed-response token usage lives in the SSE `message_start`/`message_delta` events
550// we don't buffer, so the trace records request-side features + latency now; parsing usage from a
551// teed SSE stream is the follow-on.
552async fn observe_stream(
553    state: AppState,
554    headers: HeaderMap,
555    body: Bytes,
556    session_header: Option<String>,
557) -> Response {
558    let start = Instant::now();
559    let result = forward_anthropic_streaming(
560        &state.http,
561        &state.config.upstream_anthropic,
562        &headers,
563        body.clone(),
564    )
565    .await;
566    let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
567
568    match result {
569        Ok((status, resp_headers, response)) => {
570            spawn_stream_trace(&state, body, latency_ms, session_header);
571            let stream_body = Body::from_stream(response.bytes_stream());
572            (status, resp_headers, stream_body).into_response()
573        }
574        Err(err) => {
575            spawn_trace(&state, body, None, latency_ms, session_header);
576            err.into_response()
577        }
578    }
579}
580
581/// Enqueue a request-side trace for a streamed observe response, off the response path.
582fn spawn_stream_trace(
583    state: &AppState,
584    req_body: Bytes,
585    latency_ms: u64,
586    session_header: Option<String>,
587) {
588    let config = state.config.clone();
589    let traces = state.traces.clone();
590    tokio::spawn(async move {
591        let trace = build_stream_trace(&config, &req_body, latency_ms, session_header.as_deref());
592        offer_trace(&traces, trace);
593    });
594}
595
596/// Construct the trace and enqueue it for the background writer, entirely off the response
597/// path. Fire-and-forget: if the writer has shut down we log rather than propagate — recording
598/// must never affect what the caller sees. `resp_body` is `Some` for a forwarded response and
599/// `None` when the upstream call failed outright.
600fn spawn_trace(
601    state: &AppState,
602    req_body: Bytes,
603    resp_body: Option<Bytes>,
604    latency_ms: u64,
605    session_header: Option<String>,
606) {
607    let config = state.config.clone();
608    let traces = state.traces.clone();
609    tokio::spawn(async move {
610        let trace = match resp_body {
611            Some(resp) => build_trace(
612                &config,
613                &req_body,
614                &resp,
615                latency_ms,
616                session_header.as_deref(),
617            ),
618            None => build_error_trace(&config, &req_body, latency_ms, session_header.as_deref()),
619        };
620        offer_trace(&traces, trace);
621    });
622}
623
624/// Session id for the trace: the caller-supplied header, or the trace's own id when absent.
625fn session_id(session_header: Option<&str>, trace_id: Uuid) -> String {
626    session_header
627        .map(str::to_owned)
628        .unwrap_or_else(|| trace_id.to_string())
629}
630
631/// Salted hash of the raw request body — the only trace of the prompt that ever touches
632/// storage (SPEC: never log or persist raw prompt text).
633fn prompt_hash(salt: &str, body: &[u8]) -> String {
634    let mut salted = Vec::with_capacity(salt.len() + body.len());
635    salted.extend_from_slice(salt.as_bytes());
636    salted.extend_from_slice(body);
637    sha256_hex(&salted)
638}
639
640/// Best-effort request-side feature extraction: model name, tool count, and whether any
641/// message carries image content. Malformed/absent fields fall back to safe defaults rather
642/// than failing the request — this is telemetry, not the served response.
643fn request_features(body: &[u8]) -> (Option<String>, u32, bool) {
644    let Ok(json) = serde_json::from_slice::<Value>(body) else {
645        return (None, 0, false);
646    };
647    let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
648    let tool_count = json
649        .get("tools")
650        .and_then(Value::as_array)
651        .map_or(0, |tools| u32::try_from(tools.len()).unwrap_or(u32::MAX));
652    let has_images = json
653        .get("messages")
654        .and_then(Value::as_array)
655        .is_some_and(|messages| messages.iter().any(message_has_image));
656    (model, tool_count, has_images)
657}
658
659/// Whether a single message's content contains an image block (`{"type": "image", ...}`).
660fn message_has_image(message: &Value) -> bool {
661    message
662        .get("content")
663        .and_then(Value::as_array)
664        .is_some_and(|blocks| {
665            blocks
666                .iter()
667                .any(|block| block.get("type").and_then(Value::as_str) == Some("image"))
668        })
669}
670
671/// Response-side usage extraction: model name and token counts, defaulting to `0` when the
672/// upstream response doesn't carry them (e.g. an error body).
673fn response_usage(body: &[u8]) -> (Option<String>, u64, u64) {
674    let Ok(json) = serde_json::from_slice::<Value>(body) else {
675        return (None, 0, 0);
676    };
677    let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
678    let in_tokens = json
679        .pointer("/usage/input_tokens")
680        .and_then(Value::as_u64)
681        .unwrap_or(0);
682    let out_tokens = json
683        .pointer("/usage/output_tokens")
684        .and_then(Value::as_u64)
685        .unwrap_or(0);
686    (model, in_tokens, out_tokens)
687}
688
689/// Build the observe-mode trace for a request that was successfully forwarded and answered.
690fn build_trace(
691    config: &ProxyConfig,
692    req_body: &Bytes,
693    resp_body: &Bytes,
694    latency_ms: u64,
695    session_header: Option<&str>,
696) -> Trace {
697    let (req_model, tool_count, has_images) = request_features(req_body);
698    let (resp_model, in_tokens, out_tokens) = response_usage(resp_body);
699    let model = resp_model
700        .or(req_model)
701        .unwrap_or_else(|| "unknown".to_owned());
702
703    let cost_usd = config
704        .prices
705        .cost_usd(&format!("anthropic/{model}"), in_tokens, out_tokens)
706        .unwrap_or(0.0);
707
708    let attempt = Attempt {
709        rung: 0,
710        model,
711        provider: "anthropic".to_owned(),
712        in_tokens,
713        out_tokens,
714        cost_usd,
715        latency_ms,
716        gates: Vec::new(),
717        verdict: Verdict::Pass,
718    };
719
720    let mut trace = base_trace(config, req_body, latency_ms, session_header);
721    trace.request.features.prompt_token_bucket = token_bucket(in_tokens);
722    trace.request.features.tool_count = tool_count;
723    trace.request.features.has_images = has_images;
724    trace.attempts.push(attempt);
725    trace.final_ = FinalOutcome {
726        served_rung: Some(0),
727        served_from: ServedFrom::Attempt,
728        total_cost_usd: cost_usd,
729        gate_cost_usd: 0.0,
730        total_latency_ms: latency_ms,
731        escalations: 0,
732        counterfactual_baseline_usd: cost_usd,
733        savings_usd: 0.0,
734    };
735    trace.recompute_savings();
736    trace
737}
738
739/// Build the observe-mode trace for a **streamed** response: we relayed real bytes to the caller,
740/// but the token usage lives in the SSE events we didn't buffer, so it's recorded as served with
741/// unknown (zero) usage — honest about what we served without inventing token counts.
742fn build_stream_trace(
743    config: &ProxyConfig,
744    req_body: &Bytes,
745    latency_ms: u64,
746    session_header: Option<&str>,
747) -> Trace {
748    let (req_model, tool_count, has_images) = request_features(req_body);
749    let model = req_model.unwrap_or_else(|| "unknown".to_owned());
750
751    let attempt = Attempt {
752        rung: 0,
753        model,
754        provider: "anthropic".to_owned(),
755        in_tokens: 0,
756        out_tokens: 0,
757        cost_usd: 0.0,
758        latency_ms,
759        gates: Vec::new(),
760        verdict: Verdict::Pass,
761    };
762
763    let mut trace = base_trace(config, req_body, latency_ms, session_header);
764    trace.request.features.tool_count = tool_count;
765    trace.request.features.has_images = has_images;
766    trace.attempts.push(attempt);
767    trace.final_ = FinalOutcome {
768        served_rung: Some(0),
769        served_from: ServedFrom::Attempt,
770        total_cost_usd: 0.0,
771        gate_cost_usd: 0.0,
772        total_latency_ms: latency_ms,
773        escalations: 0,
774        counterfactual_baseline_usd: 0.0,
775        savings_usd: 0.0,
776    };
777    trace.recompute_savings();
778    trace
779}
780
781/// Build the observe-mode trace for a request whose upstream call failed outright (no
782/// response to report usage from). Recorded with `served_from: Error` and no attempts —
783/// keep the audit trail honest that nothing was served.
784fn build_error_trace(
785    config: &ProxyConfig,
786    req_body: &Bytes,
787    latency_ms: u64,
788    session_header: Option<&str>,
789) -> Trace {
790    let (_, tool_count, has_images) = request_features(req_body);
791    let mut trace = base_trace(config, req_body, latency_ms, session_header);
792    trace.request.features.tool_count = tool_count;
793    trace.request.features.has_images = has_images;
794    trace.final_ = FinalOutcome {
795        served_rung: None,
796        served_from: ServedFrom::Error,
797        total_cost_usd: 0.0,
798        gate_cost_usd: 0.0,
799        total_latency_ms: latency_ms,
800        escalations: 0,
801        counterfactual_baseline_usd: 0.0,
802        savings_usd: 0.0,
803    };
804    trace.recompute_savings();
805    trace
806}
807
808/// The parts of a trace that don't depend on whether the call succeeded: identity, policy,
809/// and the request-side feature vector minus token bucket (which needs response usage).
810fn base_trace(
811    config: &ProxyConfig,
812    req_body: &Bytes,
813    latency_ms: u64,
814    session_header: Option<&str>,
815) -> Trace {
816    let trace_id = Uuid::now_v7();
817    let mut features = Features::new(TaskKind::Other);
818    features.hour_bucket = hour_bucket(jiff::Timestamp::now());
819
820    Trace {
821        trace_id,
822        prev_hash: GENESIS_HASH.to_owned(),
823        tenant_id: config.tenant_id.clone(),
824        session_id: session_id(session_header, trace_id),
825        ts: jiff::Timestamp::now(),
826        mode: Mode::Observe,
827        policy: PolicyRef {
828            id: "observe-passthrough@v0".to_owned(),
829            explore: false,
830        },
831        request: RequestInfo {
832            api: "anthropic.messages".to_owned(),
833            prompt_hash: prompt_hash(&config.prompt_salt, req_body),
834            features,
835        },
836        attempts: Vec::new(),
837        deferred: Vec::new(),
838        final_: FinalOutcome {
839            served_rung: None,
840            served_from: ServedFrom::Error,
841            total_cost_usd: 0.0,
842            gate_cost_usd: 0.0,
843            total_latency_ms: latency_ms,
844            escalations: 0,
845            counterfactual_baseline_usd: 0.0,
846            savings_usd: 0.0,
847        },
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use bytes::Bytes;
854
855    use super::*;
856
857    fn test_config() -> ProxyConfig {
858        ProxyConfig::from_lookup(|_| None).unwrap()
859    }
860
861    #[test]
862    fn build_trace_maps_request_and_response_fields() {
863        let config = test_config();
864        let req = Bytes::from_static(
865            br#"{"model":"claude-haiku-4-5","tools":[{"name":"a"}],"messages":[]}"#,
866        );
867        let resp = Bytes::from_static(
868            br#"{"model":"claude-haiku-4-5","usage":{"input_tokens":1200,"output_tokens":300}}"#,
869        );
870
871        let trace = build_trace(&config, &req, &resp, 42, Some("sess-1"));
872
873        assert_eq!(trace.request.api, "anthropic.messages");
874        assert_eq!(trace.session_id, "sess-1");
875        assert_eq!(trace.attempts.len(), 1);
876        let attempt = &trace.attempts[0];
877        assert_eq!(attempt.model, "claude-haiku-4-5");
878        assert_eq!(attempt.provider, "anthropic");
879        assert_eq!(attempt.in_tokens, 1200);
880        assert_eq!(attempt.out_tokens, 300);
881        assert!(attempt.cost_usd > 0.0);
882        assert_eq!(trace.request.features.tool_count, 1);
883        assert!(!trace.request.features.has_images);
884        assert_eq!(trace.final_.served_rung, Some(0));
885    }
886
887    #[test]
888    fn build_trace_falls_back_to_trace_id_session_when_header_absent() {
889        let config = test_config();
890        let req = Bytes::from_static(b"{}");
891        let resp = Bytes::from_static(b"{}");
892
893        let trace = build_trace(&config, &req, &resp, 1, None);
894
895        assert_eq!(trace.session_id, trace.trace_id.to_string());
896    }
897
898    #[test]
899    fn build_error_trace_has_no_attempts_and_served_from_error() {
900        let config = test_config();
901        let req = Bytes::from_static(br#"{"model":"claude-haiku-4-5"}"#);
902
903        let trace = build_error_trace(&config, &req, 7, None);
904
905        assert!(trace.attempts.is_empty());
906        assert_eq!(trace.final_.served_from, ServedFrom::Error);
907        assert_eq!(trace.final_.served_rung, None);
908    }
909
910    #[test]
911    fn message_with_image_block_sets_has_images() {
912        let req = br#"{"messages":[{"role":"user","content":[{"type":"image"}]}]}"#;
913        let (_, _, has_images) = request_features(req);
914        assert!(has_images);
915    }
916
917    #[test]
918    fn prompt_hash_never_contains_raw_prompt_text() {
919        let hash = prompt_hash("salt", b"super secret prompt");
920        assert!(!hash.contains("secret"));
921        assert_eq!(hash.len(), 64);
922    }
923
924    #[test]
925    fn parse_model_request_flattens_content_blocks() {
926        let body = br#"{"model":"m","system":"sys","max_tokens":50,
927            "messages":[{"role":"user","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]},
928                        {"role":"assistant","content":"c"}]}"#;
929        let req = parse_model_request(body).unwrap();
930        assert_eq!(req.system.as_deref(), Some("sys"));
931        assert_eq!(req.max_tokens, 50);
932        assert_eq!(req.messages.len(), 2);
933        assert_eq!(req.messages[0].content, "a\nb");
934        assert_eq!(req.messages[1].content, "c");
935    }
936
937    #[test]
938    fn parse_model_request_rejects_non_message_bodies() {
939        assert!(parse_model_request(b"not json").is_none());
940        assert!(parse_model_request(br#"{"no":"messages"}"#).is_none());
941    }
942
943    // --- Enforce-path handler tests (drive `messages` end-to-end with mock providers) ---
944
945    use crate::provider::{MockProvider, ModelResponse, Provider, ProviderError, ProviderRegistry};
946    use axum::extract::State;
947    use std::collections::HashMap;
948    use std::sync::Arc;
949    use tokio::sync::mpsc;
950
951    fn model_resp(model: &str, text: &str) -> ModelResponse {
952        ModelResponse {
953            model: model.to_owned(),
954            text: text.to_owned(),
955            in_tokens: 1000,
956            out_tokens: 400,
957            raw: serde_json::Value::Null,
958        }
959    }
960
961    /// Build an `AppState` whose anthropic provider answers the given per-model outcomes, with an
962    /// enforce route over `ladder`/`gates`. Returns the state and the trace receiver.
963    fn enforce_state(
964        ladder: &[&str],
965        gates: &[&str],
966        outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
967    ) -> (AppState, mpsc::Receiver<Trace>) {
968        let toml = format!(
969            "[[route]]\nmatch = {{}}\nmode = \"enforce\"\nladder = [{}]\ngates = [{}]\n",
970            ladder
971                .iter()
972                .map(|m| format!("\"{m}\""))
973                .collect::<Vec<_>>()
974                .join(", "),
975            gates
976                .iter()
977                .map(|g| format!("\"{g}\""))
978                .collect::<Vec<_>>()
979                .join(", "),
980        );
981        let config = ProxyConfig::from_lookup(|k| match k {
982            "FIRSTPASS_CONFIG_TOML" => Some(toml.clone()),
983            "FIRSTPASS_MODE" => Some("enforce".to_owned()),
984            _ => None,
985        })
986        .unwrap();
987
988        let mut outs = HashMap::new();
989        for (model, out) in outcomes {
990            outs.insert(model.to_owned(), out);
991        }
992        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
993        map.insert(
994            "anthropic".to_owned(),
995            Arc::new(MockProvider::new("anthropic", outs)),
996        );
997        let providers = ProviderRegistry::from_map(map);
998
999        let (traces, rx) = mpsc::channel(64);
1000        let state = AppState {
1001            config: Arc::new(config),
1002            http: reqwest::Client::new(),
1003            providers,
1004            gate_health: Arc::new(GateHealthRegistry::new()),
1005            traces,
1006        };
1007        (state, rx)
1008    }
1009
1010    fn user_body() -> Bytes {
1011        Bytes::from_static(
1012            br#"{"model":"ignored","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}"#,
1013        )
1014    }
1015
1016    async fn body_json(resp: Response) -> Value {
1017        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
1018            .await
1019            .unwrap();
1020        serde_json::from_slice(&bytes).unwrap()
1021    }
1022
1023    #[tokio::test]
1024    async fn enforce_serves_first_pass_and_returns_anthropic_shape() {
1025        let (state, mut rx) = enforce_state(
1026            &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
1027            &["non-empty"],
1028            vec![(
1029                "anthropic/claude-haiku-4-5",
1030                Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
1031            )],
1032        );
1033        let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1034        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1035        let json = body_json(resp).await;
1036        assert_eq!(json["type"], "message");
1037        assert_eq!(json["content"][0]["text"], "hello");
1038        assert_eq!(json["model"], "anthropic/claude-haiku-4-5");
1039
1040        let trace = rx.try_recv().expect("a trace was enqueued");
1041        assert_eq!(trace.mode, Mode::Enforce);
1042        assert_eq!(trace.final_.served_rung, Some(0));
1043        assert_eq!(trace.attempts.len(), 1);
1044    }
1045
1046    #[tokio::test]
1047    async fn enforce_escalates_then_serves_and_traces_two_attempts() {
1048        let (state, mut rx) = enforce_state(
1049            &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
1050            &["non-empty"],
1051            vec![
1052                (
1053                    "anthropic/claude-haiku-4-5",
1054                    Ok(model_resp("anthropic/claude-haiku-4-5", "   ")),
1055                ), // fails
1056                (
1057                    "anthropic/claude-sonnet-5",
1058                    Ok(model_resp("anthropic/claude-sonnet-5", "answer")),
1059                ),
1060            ],
1061        );
1062        let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1063        let json = body_json(resp).await;
1064        assert_eq!(json["content"][0]["text"], "answer");
1065
1066        let trace = rx.try_recv().expect("trace enqueued");
1067        assert_eq!(trace.attempts.len(), 2);
1068        assert_eq!(trace.final_.escalations, 1);
1069        assert_eq!(trace.final_.served_rung, Some(1));
1070    }
1071
1072    #[tokio::test]
1073    async fn enforce_all_rungs_error_returns_502() {
1074        let (state, mut rx) = enforce_state(
1075            &["anthropic/claude-haiku-4-5"],
1076            &["non-empty"],
1077            vec![(
1078                "anthropic/claude-haiku-4-5",
1079                Err(ProviderError::Transport("down".into())),
1080            )],
1081        );
1082        let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1083        assert_eq!(resp.status(), axum::http::StatusCode::BAD_GATEWAY);
1084        // A trace is still recorded for the failed decision.
1085        assert!(rx.try_recv().is_ok());
1086    }
1087
1088    #[tokio::test]
1089    async fn no_routing_config_falls_through_to_observe_not_enforce() {
1090        // config with no routing => enforce path never runs; observe attempts a real upstream
1091        // call which fails fast against an unroutable host. We only assert it did NOT take the
1092        // enforce branch (which would have used the mock and returned 200 with our text).
1093        let config = ProxyConfig::from_lookup(|k| match k {
1094            "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
1095            _ => None,
1096        })
1097        .unwrap();
1098        let (traces, _rx) = mpsc::channel(64);
1099        let state = AppState {
1100            config: Arc::new(config),
1101            http: reqwest::Client::new(),
1102            providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
1103            gate_health: Arc::new(GateHealthRegistry::new()),
1104            traces,
1105        };
1106        let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1107        // Observe path forwards upstream; the bogus host yields a gateway error, not our 200.
1108        assert_ne!(resp.status(), axum::http::StatusCode::OK);
1109    }
1110
1111    #[test]
1112    fn detects_stream_requests() {
1113        assert!(is_stream_request(br#"{"stream": true}"#));
1114        assert!(!is_stream_request(br#"{"stream": false}"#));
1115        assert!(!is_stream_request(br#"{"model":"m"}"#));
1116        assert!(!is_stream_request(b"not json"));
1117    }
1118
1119    #[test]
1120    fn detects_tool_blocks_in_messages() {
1121        let with =
1122            br#"{"messages":[{"role":"user","content":[{"type":"tool_result","content":"42"}]}]}"#;
1123        let without = br#"{"messages":[{"role":"user","content":"hi"}]}"#;
1124        assert!(messages_have_tool_blocks(with));
1125        assert!(!messages_have_tool_blocks(without));
1126    }
1127
1128    #[test]
1129    fn enforce_only_handles_plain_text() {
1130        let plain =
1131            Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
1132        let tools = Bytes::from_static(
1133            br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
1134        );
1135        let f_plain = extract_features(&HeaderMap::new(), &plain);
1136        let f_tools = extract_features(&HeaderMap::new(), &tools);
1137        assert!(enforce_can_handle(&f_plain, &plain));
1138        assert!(!enforce_can_handle(&f_tools, &tools));
1139    }
1140
1141    /// B2: an enforce route serves plain text (200 from the mock) but falls back to transparent
1142    /// observe passthrough for tool/image requests rather than dropping blocks — proven by the
1143    /// tool request hitting the (bogus) upstream instead of the enforcing mock.
1144    #[tokio::test]
1145    async fn enforce_falls_back_to_observe_for_tool_requests() {
1146        let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n";
1147        let config = ProxyConfig::from_lookup(|k| match k {
1148            "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
1149            "FIRSTPASS_MODE" => Some("enforce".to_owned()),
1150            "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
1151            _ => None,
1152        })
1153        .unwrap();
1154        let mut outs = HashMap::new();
1155        outs.insert(
1156            "anthropic/m".to_owned(),
1157            Ok(model_resp("anthropic/m", "hello")),
1158        );
1159        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1160        map.insert(
1161            "anthropic".to_owned(),
1162            Arc::new(MockProvider::new("anthropic", outs)),
1163        );
1164        let (traces, _rx) = mpsc::channel(64);
1165        let state = AppState {
1166            config: Arc::new(config),
1167            http: reqwest::Client::new(),
1168            providers: ProviderRegistry::from_map(map),
1169            gate_health: Arc::new(GateHealthRegistry::new()),
1170            traces,
1171        };
1172
1173        // Plain text enforces: the mock serves 200.
1174        let plain =
1175            Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
1176        let resp = messages(State(state.clone()), HeaderMap::new(), plain).await;
1177        assert_eq!(
1178            resp.status(),
1179            axum::http::StatusCode::OK,
1180            "plain text should enforce"
1181        );
1182
1183        // Declares tools => cannot enforce faithfully => observe fallback => bogus upstream => not 200.
1184        let tools = Bytes::from_static(
1185            br#"{"model":"m","tools":[{"name":"get_weather"}],"messages":[{"role":"user","content":"hi"}]}"#,
1186        );
1187        let resp = messages(State(state.clone()), HeaderMap::new(), tools).await;
1188        assert_ne!(
1189            resp.status(),
1190            axum::http::StatusCode::OK,
1191            "tool request must fall back to observe, not enforce"
1192        );
1193
1194        // tool_result block in a message => same fallback.
1195        let toolres = Bytes::from_static(
1196            br#"{"model":"m","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"42"}]}]}"#,
1197        );
1198        let resp = messages(State(state), HeaderMap::new(), toolres).await;
1199        assert_ne!(
1200            resp.status(),
1201            axum::http::StatusCode::OK,
1202            "tool_result request must fall back to observe, not enforce"
1203        );
1204    }
1205
1206    // --- Feedback API tests (drive `feedback` against a real temp trace store) ---
1207
1208    /// Persist one trace to a fresh temp DB and return (state, db_path, trace_id).
1209    async fn feedback_state() -> (AppState, std::path::PathBuf, String) {
1210        let db = std::env::temp_dir().join(format!("firstpass-feedback-{}.db", Uuid::now_v7()));
1211        let (tx, handle) = crate::store::open(&db).unwrap();
1212
1213        let mut trace = build_error_trace(
1214            &ProxyConfig::from_lookup(|_| None).unwrap(),
1215            &Bytes::from_static(b"{}"),
1216            5,
1217            Some("sess-fb"),
1218        );
1219        trace.attempts.push(Attempt {
1220            rung: 0,
1221            model: "anthropic/claude-haiku-4-5".into(),
1222            provider: "anthropic".into(),
1223            in_tokens: 10,
1224            out_tokens: 5,
1225            cost_usd: 0.001,
1226            latency_ms: 5,
1227            gates: vec![],
1228            verdict: Verdict::Pass,
1229        });
1230        let trace_id = trace.trace_id.to_string();
1231        tx.try_send(trace).unwrap();
1232        drop(tx);
1233        handle.await.unwrap();
1234
1235        let db_str = db.to_string_lossy().into_owned();
1236        let config = ProxyConfig::from_lookup(move |k| match k {
1237            "FIRSTPASS_DB" => Some(db_str.clone()),
1238            _ => None,
1239        })
1240        .unwrap();
1241        let (traces, _rx) = mpsc::channel(64);
1242        let state = AppState {
1243            config: Arc::new(config),
1244            http: reqwest::Client::new(),
1245            providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
1246            gate_health: Arc::new(GateHealthRegistry::new()),
1247            traces,
1248        };
1249        (state, db, trace_id)
1250    }
1251
1252    #[tokio::test]
1253    async fn feedback_records_a_deferred_verdict_without_breaking_the_chain() {
1254        let (state, db, trace_id) = feedback_state().await;
1255        let body = Bytes::from(
1256            serde_json::json!({
1257                "trace_id": trace_id,
1258                "gate_id": "tests",
1259                "verdict": "pass",
1260                "score": 1.0,
1261                "reporter": "ci",
1262            })
1263            .to_string(),
1264        );
1265        let resp = feedback(State(state), body).await;
1266        assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
1267
1268        // The deferred verdict is visible on the trace view...
1269        let view = crate::store::load_trace_view(&db, &trace_id)
1270            .unwrap()
1271            .unwrap();
1272        assert_eq!(view.deferred.len(), 1);
1273        assert_eq!(view.deferred[0].gate_id, "tests");
1274        // ...and the sealed chain still verifies (the outcome didn't mutate the trace).
1275        let traces = crate::store::load_all_traces(&db).unwrap();
1276        firstpass_core::verify_chain(&traces, GENESIS_HASH).unwrap();
1277
1278        let _ = std::fs::remove_file(&db);
1279    }
1280
1281    #[tokio::test]
1282    async fn feedback_for_unknown_trace_is_404() {
1283        let (state, db, _trace_id) = feedback_state().await;
1284        let body = Bytes::from(
1285            serde_json::json!({
1286                "trace_id": "does-not-exist",
1287                "gate_id": "tests",
1288                "verdict": "pass",
1289                "reporter": "ci",
1290            })
1291            .to_string(),
1292        );
1293        let resp = feedback(State(state), body).await;
1294        assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
1295        let _ = std::fs::remove_file(&db);
1296    }
1297
1298    #[tokio::test]
1299    async fn feedback_rejects_bad_verdict_and_score() {
1300        let (state, db, trace_id) = feedback_state().await;
1301        let bad_verdict = Bytes::from(
1302            serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "maybe", "reporter": "x" })
1303                .to_string(),
1304        );
1305        assert_eq!(
1306            feedback(State(state.clone()), bad_verdict).await.status(),
1307            axum::http::StatusCode::BAD_REQUEST
1308        );
1309        let bad_score = Bytes::from(
1310            serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "pass", "score": 9.0, "reporter": "x" })
1311                .to_string(),
1312        );
1313        assert_eq!(
1314            feedback(State(state), bad_score).await.status(),
1315            axum::http::StatusCode::BAD_REQUEST
1316        );
1317        let _ = std::fs::remove_file(&db);
1318    }
1319
1320    #[tokio::test]
1321    async fn metrics_endpoint_renders_after_a_real_request() {
1322        use tower::ServiceExt;
1323
1324        let (state, mut rx) = enforce_state(
1325            &["anthropic/claude-haiku-4-5"],
1326            &["non-empty"],
1327            vec![(
1328                "anthropic/claude-haiku-4-5",
1329                Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
1330            )],
1331        );
1332        let router = app(state).expect("prometheus recorder installs");
1333
1334        let req = axum::http::Request::builder()
1335            .method("POST")
1336            .uri("/v1/messages")
1337            .header("content-type", "application/json")
1338            .body(Body::from(user_body()))
1339            .unwrap();
1340        let resp = router.clone().oneshot(req).await.unwrap();
1341        assert_eq!(resp.status(), axum::http::StatusCode::OK);
1342        rx.try_recv().expect("a trace was enqueued");
1343
1344        let metrics_req = axum::http::Request::builder()
1345            .method("GET")
1346            .uri("/metrics")
1347            .body(Body::empty())
1348            .unwrap();
1349        let metrics_resp = router.oneshot(metrics_req).await.unwrap();
1350        assert_eq!(metrics_resp.status(), axum::http::StatusCode::OK);
1351        let bytes = axum::body::to_bytes(metrics_resp.into_body(), 1 << 20)
1352            .await
1353            .unwrap();
1354        let body = String::from_utf8(bytes.to_vec()).unwrap();
1355        assert!(
1356            body.contains("firstpass_enforce_latency_ms"),
1357            "metrics body missing enforce latency histogram: {body}"
1358        );
1359        assert!(
1360            body.contains("firstpass_served_total"),
1361            "metrics body missing served counter: {body}"
1362        );
1363    }
1364}