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