1use 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#[derive(Clone)]
37pub struct AppState {
38 pub config: Arc<ProxyConfig>,
40 pub http: reqwest::Client,
42 pub providers: ProviderRegistry,
44 pub gate_health: Arc<GateHealthRegistry>,
46 pub traces: store::TraceSender,
48 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
62fn 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
80fn 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
103const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
107
108pub 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 .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES))
125 .layer(tower::limit::GlobalConcurrencyLimitLayer::new(
128 max_concurrency,
129 ))
130 .with_state(state))
131}
132
133async fn healthz() -> impl IntoResponse {
135 Json(serde_json::json!({ "status": "ok" }))
136}
137
138async fn capabilities(State(state): State<AppState>) -> impl IntoResponse {
141 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#[derive(Debug, Deserialize)]
165struct FeedbackRequest {
166 trace_id: String,
168 gate_id: String,
170 verdict: String,
172 #[serde(default)]
174 score: Option<f64>,
175 reporter: String,
177}
178
179async 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 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 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 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
269const SESSION_HEADER: &str = "x-firstpass-session";
272
273const AGENT_HEADER: &str = "x-firstpass-agent";
275const SUBAGENT_HEADER: &str = "x-firstpass-subagent";
277
278async fn messages(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
283 let session_header = header_str(&headers, SESSION_HEADER);
284
285 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 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 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
312fn 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
328fn 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
341fn 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
356fn 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
364fn 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
372fn 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 f.prompt_token_bucket = token_bucket(body.len() as u64);
384 f.hour_bucket = hour_bucket(jiff::Timestamp::now());
385 f
386}
387
388async 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 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 offer_trace(&state.traces, trace);
452
453 match outcome {
454 EngineOutcome::Served(resp) => {
455 let message = anthropic_response_json(&resp);
456 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
477fn 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
525fn 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
551fn 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
560fn anthropic_sse_from_message(message: &Value) -> String {
567 let mut out = String::new();
568
569 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 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 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
646async fn observe_passthrough(
648 state: AppState,
649 headers: HeaderMap,
650 body: Bytes,
651 session_header: Option<String>,
652) -> Response {
653 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 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
688async 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
725fn 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
740fn 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
768fn 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
775fn 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
784fn 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
803fn 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
815fn 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
833fn 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
883fn 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
925fn 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
952fn 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 assert_eq!(
1079 req.messages[0].content,
1080 serde_json::json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])
1081 );
1082 assert_eq!(req.messages[1].content, Value::String("c".to_owned()));
1084 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 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 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 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 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 ), (
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 assert!(rx.try_recv().is_ok());
1272 }
1273
1274 #[tokio::test]
1275 async fn no_routing_config_falls_through_to_observe_not_enforce() {
1276 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 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 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 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 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 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 assert_eq!(frames.first().unwrap()["type"], "message_start");
1371 assert_eq!(frames.last().unwrap()["type"], "message_stop");
1372 assert!(frames.iter().any(|f| f["delta"]["type"] == "text_delta"
1374 && f["delta"]["text"] == "let me check"));
1375 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 #[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 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 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 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 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 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 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 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 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}