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}
49
50impl std::fmt::Debug for AppState {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("AppState")
53 .field("config", &self.config)
54 .finish_non_exhaustive()
55 }
56}
57
58fn offer_trace(traces: &store::TraceSender, trace: Trace) {
63 record_trace_metrics(&trace);
64 match traces.try_send(trace) {
65 Ok(()) => {}
66 Err(TrySendError::Full(_)) => {
67 tracing::warn!("trace channel full; dropping trace (writer behind under load)");
68 metrics::counter!("firstpass_traces_dropped_total").increment(1);
69 }
70 Err(TrySendError::Closed(_)) => {
71 tracing::warn!("trace writer is gone; dropping trace");
72 }
73 }
74}
75
76fn record_trace_metrics(trace: &Trace) {
80 if trace.mode == Mode::Enforce {
81 metrics::histogram!("firstpass_enforce_latency_ms")
82 .record(trace.final_.total_latency_ms as f64);
83 if trace.final_.escalations > 0 {
84 metrics::counter!("firstpass_escalations_total")
85 .increment(u64::from(trace.final_.escalations));
86 }
87 }
88 let served_from = match trace.final_.served_from {
89 ServedFrom::Attempt => "attempt",
90 ServedFrom::BestAttempt => "best_attempt",
91 ServedFrom::Error => "error",
92 };
93 metrics::counter!("firstpass_served_total", "served_from" => served_from).increment(1);
94 if trace.final_.served_from == ServedFrom::Error {
95 metrics::counter!("firstpass_upstream_failures_total").increment(1);
96 }
97}
98
99const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
103
104pub fn app(state: AppState) -> Result<Router, ProxyError> {
111 crate::metrics::install()?;
112 let max_concurrency = state.config.max_concurrency;
113 Ok(Router::new()
114 .route("/v1/messages", post(messages))
115 .route("/v1/feedback", post(feedback))
116 .route("/v1/capabilities", get(capabilities))
117 .route("/healthz", get(healthz))
118 .route("/metrics", get(crate::metrics::handler))
119 .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES))
121 .layer(tower::limit::GlobalConcurrencyLimitLayer::new(
124 max_concurrency,
125 ))
126 .with_state(state))
127}
128
129async fn healthz() -> impl IntoResponse {
131 Json(serde_json::json!({ "status": "ok" }))
132}
133
134async fn capabilities(State(state): State<AppState>) -> impl IntoResponse {
137 let (ladder, gates) = state
140 .config
141 .routing
142 .as_ref()
143 .and_then(|c| c.routes.iter().find(|r| r.mode == Mode::Enforce))
144 .map(|r| (r.ladder.clone(), r.gates.clone()))
145 .unwrap_or_default();
146 Json(serde_json::json!({
147 "service": "firstpass",
148 "version": env!("CARGO_PKG_VERSION"),
149 "feature_version": FEATURE_VERSION,
150 "modes": ["observe", "enforce"],
151 "wire_apis": ["anthropic.messages"],
152 "ladder": ladder,
153 "gates": gates,
154 "feedback_api": "POST /v1/feedback",
155 "offboarding": "unset ANTHROPIC_BASE_URL",
156 }))
157}
158
159#[derive(Debug, Deserialize)]
161struct FeedbackRequest {
162 trace_id: String,
164 gate_id: String,
166 verdict: String,
168 #[serde(default)]
170 score: Option<f64>,
171 reporter: String,
173}
174
175async fn feedback(State(state): State<AppState>, body: Bytes) -> Response {
180 let req: FeedbackRequest = match serde_json::from_slice(&body) {
181 Ok(r) => r,
182 Err(e) => {
183 return ProxyError::BadRequest(format!("invalid feedback body: {e}")).into_response();
184 }
185 };
186 let verdict = match req.verdict.as_str() {
187 "pass" => Verdict::Pass,
188 "fail" => Verdict::Fail,
189 "abstain" => Verdict::Abstain,
190 other => {
191 return ProxyError::BadRequest(format!("unknown verdict {other:?}")).into_response();
192 }
193 };
194 let score = match req.score {
195 Some(s) => match Score::new(s) {
196 Ok(sc) => Some(sc),
197 Err(_) => {
198 return ProxyError::BadRequest(format!("score {s} out of range [0,1]"))
199 .into_response();
200 }
201 },
202 None => None,
203 };
204
205 let db = state.config.db_path.clone();
206
207 let (db_check, tid_check) = (db.clone(), req.trace_id.clone());
209 match tokio::task::spawn_blocking(move || store::trace_exists(&db_check, &tid_check)).await {
210 Ok(Ok(true)) => {}
211 Ok(Ok(false)) => {
212 return ProxyError::NotFound(format!("unknown trace_id {:?}", req.trace_id))
213 .into_response();
214 }
215 Ok(Err(e)) => {
216 tracing::error!(%e, "feedback: trace_exists check failed");
217 return ProxyError::Internal(e.to_string()).into_response();
218 }
219 Err(e) => {
220 tracing::error!(%e, "feedback: trace_exists task panicked");
221 return ProxyError::Internal(e.to_string()).into_response();
222 }
223 }
224
225 let dv = DeferredVerdict {
226 gate_id: req.gate_id,
227 verdict,
228 score,
229 reported_at: jiff::Timestamp::now(),
230 reporter: req.reporter,
231 };
232 let trace_id = req.trace_id.clone();
233 match tokio::task::spawn_blocking(move || store::append_deferred(&db, &req.trace_id, &dv)).await
234 {
235 Ok(Ok(())) => (
236 axum::http::StatusCode::ACCEPTED,
237 Json(serde_json::json!({ "status": "recorded", "trace_id": trace_id })),
238 )
239 .into_response(),
240 Ok(Err(e)) => {
241 tracing::error!(%e, "feedback: append_deferred failed");
242 ProxyError::Internal(e.to_string()).into_response()
243 }
244 Err(e) => {
245 tracing::error!(%e, "feedback: append_deferred task panicked");
246 ProxyError::Internal(e.to_string()).into_response()
247 }
248 }
249}
250
251const SESSION_HEADER: &str = "x-firstpass-session";
254
255const AGENT_HEADER: &str = "x-firstpass-agent";
257const SUBAGENT_HEADER: &str = "x-firstpass-subagent";
259
260async fn messages(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
265 let session_header = header_str(&headers, SESSION_HEADER);
266
267 if let Some(routing) = state.config.routing.as_ref() {
270 let features = extract_features(&headers, &body);
271 if let Some(route) = routing
272 .route_for(&features)
273 .filter(|r| r.mode == Mode::Enforce && !r.ladder.is_empty())
274 {
275 let route = route.clone();
278 if enforce_can_handle(&features, &body) {
279 return handle_enforce(&state, &headers, &body, features, &route, session_header)
280 .await;
281 }
282 tracing::info!(
286 "enforce route matched but request has tool/image content; serving via observe passthrough"
287 );
288 }
289 }
290 observe_passthrough(state, headers, body, session_header).await
291}
292
293fn enforce_can_handle(features: &Features, body: &[u8]) -> bool {
301 features.tool_count == 0 && !features.has_images && !messages_have_tool_blocks(body)
302}
303
304fn messages_have_tool_blocks(body: &[u8]) -> bool {
307 serde_json::from_slice::<Value>(body)
308 .ok()
309 .and_then(|json| {
310 json.get("messages")
311 .and_then(Value::as_array)
312 .map(|messages| messages.iter().any(message_has_tool_block))
313 })
314 .unwrap_or(false)
315}
316
317fn message_has_tool_block(message: &Value) -> bool {
319 message
320 .get("content")
321 .and_then(Value::as_array)
322 .is_some_and(|blocks| {
323 blocks.iter().any(|block| {
324 matches!(
325 block.get("type").and_then(Value::as_str),
326 Some("tool_use" | "tool_result")
327 )
328 })
329 })
330}
331
332fn is_stream_request(body: &[u8]) -> bool {
334 serde_json::from_slice::<Value>(body)
335 .ok()
336 .and_then(|json| json.get("stream").and_then(Value::as_bool))
337 .unwrap_or(false)
338}
339
340fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
342 headers
343 .get(name)
344 .and_then(|v| v.to_str().ok())
345 .map(str::to_owned)
346}
347
348fn extract_features(headers: &HeaderMap, body: &[u8]) -> Features {
351 let (_model, tool_count, has_images) = request_features(body);
352 let mut f = Features::new(TaskKind::Other);
353 f.agent = header_str(headers, AGENT_HEADER);
354 f.subagent = header_str(headers, SUBAGENT_HEADER);
355 f.tool_count = tool_count;
356 f.has_images = has_images;
357 f.prompt_token_bucket = token_bucket(body.len() as u64);
360 f.hour_bucket = hour_bucket(jiff::Timestamp::now());
361 f
362}
363
364async fn handle_enforce(
367 state: &AppState,
368 headers: &HeaderMap,
369 body: &Bytes,
370 features: Features,
371 route: &Route,
372 session_header: Option<String>,
373) -> Response {
374 let Some(base_request) = parse_model_request(body) else {
375 return ProxyError::BadRequest(
376 "request body is not a valid Anthropic Messages request".to_owned(),
377 )
378 .into_response();
379 };
380 let auth = Auth::from_headers(headers);
381 let gate_defs = state
382 .config
383 .routing
384 .as_ref()
385 .map_or(&[][..], |cfg| &cfg.gate_defs);
386 let gates = resolve_gates(&route.gates, gate_defs, &state.providers, &auth);
387 let session_id = session_header.unwrap_or_else(|| Uuid::now_v7().to_string());
388 let (budget, max_rungs, speculation, serve_threshold) = match state.config.routing.as_ref() {
389 Some(cfg) => (
390 cfg.budget.per_request_usd,
391 cfg.escalation.max_rungs_per_request,
392 cfg.escalation.speculation,
393 cfg.escalation.serve_threshold,
394 ),
395 None => (None, 3, 0, None),
396 };
397
398 let ctx = EnforceCtx {
399 ladder: &route.ladder,
400 gates: &gates,
401 health: &state.gate_health,
402 base_request: &base_request,
403 providers: &state.providers,
404 auth: &auth,
405 prices: &state.config.prices,
406 budget_per_request_usd: budget,
407 max_rungs,
408 speculation,
409 serve_threshold,
410 features,
411 tenant_id: state.config.tenant_id.clone(),
412 session_id,
413 prompt_hash: prompt_hash(&state.config.prompt_salt, body),
414 api: "anthropic.messages".to_owned(),
415 policy_id: "static-ladder@v0".to_owned(),
416 };
417
418 let (outcome, trace) = route_enforce(ctx).await;
419 offer_trace(&state.traces, trace);
421
422 match outcome {
423 EngineOutcome::Served(resp) => (
424 axum::http::StatusCode::OK,
425 Json(anthropic_response_json(&resp)),
426 )
427 .into_response(),
428 EngineOutcome::Failed(msg) => ProxyError::Engine(msg).into_response(),
429 }
430}
431
432fn parse_model_request(body: &[u8]) -> Option<ModelRequest> {
440 let json: Value = serde_json::from_slice(body).ok()?;
441 let messages_json = json.get("messages")?.as_array()?;
442 let messages = messages_json
443 .iter()
444 .map(|m| ChatMessage {
445 role: m
446 .get("role")
447 .and_then(Value::as_str)
448 .unwrap_or("user")
449 .to_owned(),
450 content: content_to_text(m.get("content")),
451 })
452 .collect();
453 let system = json
454 .get("system")
455 .and_then(Value::as_str)
456 .map(str::to_owned);
457 let max_tokens = json
458 .get("max_tokens")
459 .and_then(Value::as_u64)
460 .and_then(|n| u32::try_from(n).ok())
461 .unwrap_or(1024);
462 let tools = json.get("tools").cloned().unwrap_or(Value::Null);
463 Some(ModelRequest {
464 model: json
465 .get("model")
466 .and_then(Value::as_str)
467 .unwrap_or_default()
468 .to_owned(),
469 system,
470 messages,
471 max_tokens,
472 tools,
473 })
474}
475
476fn content_to_text(content: Option<&Value>) -> String {
478 match content {
479 Some(Value::String(s)) => s.clone(),
480 Some(Value::Array(blocks)) => blocks
481 .iter()
482 .filter_map(|b| b.get("text").and_then(Value::as_str))
483 .collect::<Vec<_>>()
484 .join("\n"),
485 _ => String::new(),
486 }
487}
488
489fn anthropic_response_json(resp: &ModelResponse) -> Value {
492 serde_json::json!({
493 "id": format!("msg_{}", Uuid::now_v7()),
494 "type": "message",
495 "role": "assistant",
496 "model": resp.model,
497 "content": [{ "type": "text", "text": resp.text }],
498 "usage": { "input_tokens": resp.in_tokens, "output_tokens": resp.out_tokens },
499 })
500}
501
502async fn observe_passthrough(
504 state: AppState,
505 headers: HeaderMap,
506 body: Bytes,
507 session_header: Option<String>,
508) -> Response {
509 if is_stream_request(&body) {
511 return observe_stream(state, headers, body, session_header).await;
512 }
513 let start = Instant::now();
514 let result = forward_anthropic(
515 &state.http,
516 &state.config.upstream_anthropic,
517 &headers,
518 body.clone(),
519 )
520 .await;
521 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
522
523 match result {
524 Ok((status, resp_headers, resp_body)) => {
525 spawn_trace(
529 &state,
530 body,
531 Some(resp_body.clone()),
532 latency_ms,
533 session_header,
534 );
535 (status, resp_headers, resp_body).into_response()
536 }
537 Err(err) => {
538 spawn_trace(&state, body, None, latency_ms, session_header);
539 err.into_response()
540 }
541 }
542}
543
544async fn observe_stream(
553 state: AppState,
554 headers: HeaderMap,
555 body: Bytes,
556 session_header: Option<String>,
557) -> Response {
558 let start = Instant::now();
559 let result = forward_anthropic_streaming(
560 &state.http,
561 &state.config.upstream_anthropic,
562 &headers,
563 body.clone(),
564 )
565 .await;
566 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
567
568 match result {
569 Ok((status, resp_headers, response)) => {
570 spawn_stream_trace(&state, body, latency_ms, session_header);
571 let stream_body = Body::from_stream(response.bytes_stream());
572 (status, resp_headers, stream_body).into_response()
573 }
574 Err(err) => {
575 spawn_trace(&state, body, None, latency_ms, session_header);
576 err.into_response()
577 }
578 }
579}
580
581fn spawn_stream_trace(
583 state: &AppState,
584 req_body: Bytes,
585 latency_ms: u64,
586 session_header: Option<String>,
587) {
588 let config = state.config.clone();
589 let traces = state.traces.clone();
590 tokio::spawn(async move {
591 let trace = build_stream_trace(&config, &req_body, latency_ms, session_header.as_deref());
592 offer_trace(&traces, trace);
593 });
594}
595
596fn spawn_trace(
601 state: &AppState,
602 req_body: Bytes,
603 resp_body: Option<Bytes>,
604 latency_ms: u64,
605 session_header: Option<String>,
606) {
607 let config = state.config.clone();
608 let traces = state.traces.clone();
609 tokio::spawn(async move {
610 let trace = match resp_body {
611 Some(resp) => build_trace(
612 &config,
613 &req_body,
614 &resp,
615 latency_ms,
616 session_header.as_deref(),
617 ),
618 None => build_error_trace(&config, &req_body, latency_ms, session_header.as_deref()),
619 };
620 offer_trace(&traces, trace);
621 });
622}
623
624fn session_id(session_header: Option<&str>, trace_id: Uuid) -> String {
626 session_header
627 .map(str::to_owned)
628 .unwrap_or_else(|| trace_id.to_string())
629}
630
631fn prompt_hash(salt: &str, body: &[u8]) -> String {
634 let mut salted = Vec::with_capacity(salt.len() + body.len());
635 salted.extend_from_slice(salt.as_bytes());
636 salted.extend_from_slice(body);
637 sha256_hex(&salted)
638}
639
640fn request_features(body: &[u8]) -> (Option<String>, u32, bool) {
644 let Ok(json) = serde_json::from_slice::<Value>(body) else {
645 return (None, 0, false);
646 };
647 let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
648 let tool_count = json
649 .get("tools")
650 .and_then(Value::as_array)
651 .map_or(0, |tools| u32::try_from(tools.len()).unwrap_or(u32::MAX));
652 let has_images = json
653 .get("messages")
654 .and_then(Value::as_array)
655 .is_some_and(|messages| messages.iter().any(message_has_image));
656 (model, tool_count, has_images)
657}
658
659fn message_has_image(message: &Value) -> bool {
661 message
662 .get("content")
663 .and_then(Value::as_array)
664 .is_some_and(|blocks| {
665 blocks
666 .iter()
667 .any(|block| block.get("type").and_then(Value::as_str) == Some("image"))
668 })
669}
670
671fn response_usage(body: &[u8]) -> (Option<String>, u64, u64) {
674 let Ok(json) = serde_json::from_slice::<Value>(body) else {
675 return (None, 0, 0);
676 };
677 let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
678 let in_tokens = json
679 .pointer("/usage/input_tokens")
680 .and_then(Value::as_u64)
681 .unwrap_or(0);
682 let out_tokens = json
683 .pointer("/usage/output_tokens")
684 .and_then(Value::as_u64)
685 .unwrap_or(0);
686 (model, in_tokens, out_tokens)
687}
688
689fn build_trace(
691 config: &ProxyConfig,
692 req_body: &Bytes,
693 resp_body: &Bytes,
694 latency_ms: u64,
695 session_header: Option<&str>,
696) -> Trace {
697 let (req_model, tool_count, has_images) = request_features(req_body);
698 let (resp_model, in_tokens, out_tokens) = response_usage(resp_body);
699 let model = resp_model
700 .or(req_model)
701 .unwrap_or_else(|| "unknown".to_owned());
702
703 let cost_usd = config
704 .prices
705 .cost_usd(&format!("anthropic/{model}"), in_tokens, out_tokens)
706 .unwrap_or(0.0);
707
708 let attempt = Attempt {
709 rung: 0,
710 model,
711 provider: "anthropic".to_owned(),
712 in_tokens,
713 out_tokens,
714 cost_usd,
715 latency_ms,
716 gates: Vec::new(),
717 verdict: Verdict::Pass,
718 };
719
720 let mut trace = base_trace(config, req_body, latency_ms, session_header);
721 trace.request.features.prompt_token_bucket = token_bucket(in_tokens);
722 trace.request.features.tool_count = tool_count;
723 trace.request.features.has_images = has_images;
724 trace.attempts.push(attempt);
725 trace.final_ = FinalOutcome {
726 served_rung: Some(0),
727 served_from: ServedFrom::Attempt,
728 total_cost_usd: cost_usd,
729 gate_cost_usd: 0.0,
730 total_latency_ms: latency_ms,
731 escalations: 0,
732 counterfactual_baseline_usd: cost_usd,
733 savings_usd: 0.0,
734 };
735 trace.recompute_savings();
736 trace
737}
738
739fn build_stream_trace(
743 config: &ProxyConfig,
744 req_body: &Bytes,
745 latency_ms: u64,
746 session_header: Option<&str>,
747) -> Trace {
748 let (req_model, tool_count, has_images) = request_features(req_body);
749 let model = req_model.unwrap_or_else(|| "unknown".to_owned());
750
751 let attempt = Attempt {
752 rung: 0,
753 model,
754 provider: "anthropic".to_owned(),
755 in_tokens: 0,
756 out_tokens: 0,
757 cost_usd: 0.0,
758 latency_ms,
759 gates: Vec::new(),
760 verdict: Verdict::Pass,
761 };
762
763 let mut trace = base_trace(config, req_body, latency_ms, session_header);
764 trace.request.features.tool_count = tool_count;
765 trace.request.features.has_images = has_images;
766 trace.attempts.push(attempt);
767 trace.final_ = FinalOutcome {
768 served_rung: Some(0),
769 served_from: ServedFrom::Attempt,
770 total_cost_usd: 0.0,
771 gate_cost_usd: 0.0,
772 total_latency_ms: latency_ms,
773 escalations: 0,
774 counterfactual_baseline_usd: 0.0,
775 savings_usd: 0.0,
776 };
777 trace.recompute_savings();
778 trace
779}
780
781fn build_error_trace(
785 config: &ProxyConfig,
786 req_body: &Bytes,
787 latency_ms: u64,
788 session_header: Option<&str>,
789) -> Trace {
790 let (_, tool_count, has_images) = request_features(req_body);
791 let mut trace = base_trace(config, req_body, latency_ms, session_header);
792 trace.request.features.tool_count = tool_count;
793 trace.request.features.has_images = has_images;
794 trace.final_ = FinalOutcome {
795 served_rung: None,
796 served_from: ServedFrom::Error,
797 total_cost_usd: 0.0,
798 gate_cost_usd: 0.0,
799 total_latency_ms: latency_ms,
800 escalations: 0,
801 counterfactual_baseline_usd: 0.0,
802 savings_usd: 0.0,
803 };
804 trace.recompute_savings();
805 trace
806}
807
808fn base_trace(
811 config: &ProxyConfig,
812 req_body: &Bytes,
813 latency_ms: u64,
814 session_header: Option<&str>,
815) -> Trace {
816 let trace_id = Uuid::now_v7();
817 let mut features = Features::new(TaskKind::Other);
818 features.hour_bucket = hour_bucket(jiff::Timestamp::now());
819
820 Trace {
821 trace_id,
822 prev_hash: GENESIS_HASH.to_owned(),
823 tenant_id: config.tenant_id.clone(),
824 session_id: session_id(session_header, trace_id),
825 ts: jiff::Timestamp::now(),
826 mode: Mode::Observe,
827 policy: PolicyRef {
828 id: "observe-passthrough@v0".to_owned(),
829 explore: false,
830 },
831 request: RequestInfo {
832 api: "anthropic.messages".to_owned(),
833 prompt_hash: prompt_hash(&config.prompt_salt, req_body),
834 features,
835 },
836 attempts: Vec::new(),
837 deferred: Vec::new(),
838 final_: FinalOutcome {
839 served_rung: None,
840 served_from: ServedFrom::Error,
841 total_cost_usd: 0.0,
842 gate_cost_usd: 0.0,
843 total_latency_ms: latency_ms,
844 escalations: 0,
845 counterfactual_baseline_usd: 0.0,
846 savings_usd: 0.0,
847 },
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use bytes::Bytes;
854
855 use super::*;
856
857 fn test_config() -> ProxyConfig {
858 ProxyConfig::from_lookup(|_| None).unwrap()
859 }
860
861 #[test]
862 fn build_trace_maps_request_and_response_fields() {
863 let config = test_config();
864 let req = Bytes::from_static(
865 br#"{"model":"claude-haiku-4-5","tools":[{"name":"a"}],"messages":[]}"#,
866 );
867 let resp = Bytes::from_static(
868 br#"{"model":"claude-haiku-4-5","usage":{"input_tokens":1200,"output_tokens":300}}"#,
869 );
870
871 let trace = build_trace(&config, &req, &resp, 42, Some("sess-1"));
872
873 assert_eq!(trace.request.api, "anthropic.messages");
874 assert_eq!(trace.session_id, "sess-1");
875 assert_eq!(trace.attempts.len(), 1);
876 let attempt = &trace.attempts[0];
877 assert_eq!(attempt.model, "claude-haiku-4-5");
878 assert_eq!(attempt.provider, "anthropic");
879 assert_eq!(attempt.in_tokens, 1200);
880 assert_eq!(attempt.out_tokens, 300);
881 assert!(attempt.cost_usd > 0.0);
882 assert_eq!(trace.request.features.tool_count, 1);
883 assert!(!trace.request.features.has_images);
884 assert_eq!(trace.final_.served_rung, Some(0));
885 }
886
887 #[test]
888 fn build_trace_falls_back_to_trace_id_session_when_header_absent() {
889 let config = test_config();
890 let req = Bytes::from_static(b"{}");
891 let resp = Bytes::from_static(b"{}");
892
893 let trace = build_trace(&config, &req, &resp, 1, None);
894
895 assert_eq!(trace.session_id, trace.trace_id.to_string());
896 }
897
898 #[test]
899 fn build_error_trace_has_no_attempts_and_served_from_error() {
900 let config = test_config();
901 let req = Bytes::from_static(br#"{"model":"claude-haiku-4-5"}"#);
902
903 let trace = build_error_trace(&config, &req, 7, None);
904
905 assert!(trace.attempts.is_empty());
906 assert_eq!(trace.final_.served_from, ServedFrom::Error);
907 assert_eq!(trace.final_.served_rung, None);
908 }
909
910 #[test]
911 fn message_with_image_block_sets_has_images() {
912 let req = br#"{"messages":[{"role":"user","content":[{"type":"image"}]}]}"#;
913 let (_, _, has_images) = request_features(req);
914 assert!(has_images);
915 }
916
917 #[test]
918 fn prompt_hash_never_contains_raw_prompt_text() {
919 let hash = prompt_hash("salt", b"super secret prompt");
920 assert!(!hash.contains("secret"));
921 assert_eq!(hash.len(), 64);
922 }
923
924 #[test]
925 fn parse_model_request_flattens_content_blocks() {
926 let body = br#"{"model":"m","system":"sys","max_tokens":50,
927 "messages":[{"role":"user","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]},
928 {"role":"assistant","content":"c"}]}"#;
929 let req = parse_model_request(body).unwrap();
930 assert_eq!(req.system.as_deref(), Some("sys"));
931 assert_eq!(req.max_tokens, 50);
932 assert_eq!(req.messages.len(), 2);
933 assert_eq!(req.messages[0].content, "a\nb");
934 assert_eq!(req.messages[1].content, "c");
935 }
936
937 #[test]
938 fn parse_model_request_rejects_non_message_bodies() {
939 assert!(parse_model_request(b"not json").is_none());
940 assert!(parse_model_request(br#"{"no":"messages"}"#).is_none());
941 }
942
943 use crate::provider::{MockProvider, ModelResponse, Provider, ProviderError, ProviderRegistry};
946 use axum::extract::State;
947 use std::collections::HashMap;
948 use std::sync::Arc;
949 use tokio::sync::mpsc;
950
951 fn model_resp(model: &str, text: &str) -> ModelResponse {
952 ModelResponse {
953 model: model.to_owned(),
954 text: text.to_owned(),
955 in_tokens: 1000,
956 out_tokens: 400,
957 raw: serde_json::Value::Null,
958 }
959 }
960
961 fn enforce_state(
964 ladder: &[&str],
965 gates: &[&str],
966 outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
967 ) -> (AppState, mpsc::Receiver<Trace>) {
968 let toml = format!(
969 "[[route]]\nmatch = {{}}\nmode = \"enforce\"\nladder = [{}]\ngates = [{}]\n",
970 ladder
971 .iter()
972 .map(|m| format!("\"{m}\""))
973 .collect::<Vec<_>>()
974 .join(", "),
975 gates
976 .iter()
977 .map(|g| format!("\"{g}\""))
978 .collect::<Vec<_>>()
979 .join(", "),
980 );
981 let config = ProxyConfig::from_lookup(|k| match k {
982 "FIRSTPASS_CONFIG_TOML" => Some(toml.clone()),
983 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
984 _ => None,
985 })
986 .unwrap();
987
988 let mut outs = HashMap::new();
989 for (model, out) in outcomes {
990 outs.insert(model.to_owned(), out);
991 }
992 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
993 map.insert(
994 "anthropic".to_owned(),
995 Arc::new(MockProvider::new("anthropic", outs)),
996 );
997 let providers = ProviderRegistry::from_map(map);
998
999 let (traces, rx) = mpsc::channel(64);
1000 let state = AppState {
1001 config: Arc::new(config),
1002 http: reqwest::Client::new(),
1003 providers,
1004 gate_health: Arc::new(GateHealthRegistry::new()),
1005 traces,
1006 };
1007 (state, rx)
1008 }
1009
1010 fn user_body() -> Bytes {
1011 Bytes::from_static(
1012 br#"{"model":"ignored","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}"#,
1013 )
1014 }
1015
1016 async fn body_json(resp: Response) -> Value {
1017 let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
1018 .await
1019 .unwrap();
1020 serde_json::from_slice(&bytes).unwrap()
1021 }
1022
1023 #[tokio::test]
1024 async fn enforce_serves_first_pass_and_returns_anthropic_shape() {
1025 let (state, mut rx) = enforce_state(
1026 &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
1027 &["non-empty"],
1028 vec![(
1029 "anthropic/claude-haiku-4-5",
1030 Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
1031 )],
1032 );
1033 let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1034 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1035 let json = body_json(resp).await;
1036 assert_eq!(json["type"], "message");
1037 assert_eq!(json["content"][0]["text"], "hello");
1038 assert_eq!(json["model"], "anthropic/claude-haiku-4-5");
1039
1040 let trace = rx.try_recv().expect("a trace was enqueued");
1041 assert_eq!(trace.mode, Mode::Enforce);
1042 assert_eq!(trace.final_.served_rung, Some(0));
1043 assert_eq!(trace.attempts.len(), 1);
1044 }
1045
1046 #[tokio::test]
1047 async fn enforce_escalates_then_serves_and_traces_two_attempts() {
1048 let (state, mut rx) = enforce_state(
1049 &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
1050 &["non-empty"],
1051 vec![
1052 (
1053 "anthropic/claude-haiku-4-5",
1054 Ok(model_resp("anthropic/claude-haiku-4-5", " ")),
1055 ), (
1057 "anthropic/claude-sonnet-5",
1058 Ok(model_resp("anthropic/claude-sonnet-5", "answer")),
1059 ),
1060 ],
1061 );
1062 let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1063 let json = body_json(resp).await;
1064 assert_eq!(json["content"][0]["text"], "answer");
1065
1066 let trace = rx.try_recv().expect("trace enqueued");
1067 assert_eq!(trace.attempts.len(), 2);
1068 assert_eq!(trace.final_.escalations, 1);
1069 assert_eq!(trace.final_.served_rung, Some(1));
1070 }
1071
1072 #[tokio::test]
1073 async fn enforce_all_rungs_error_returns_502() {
1074 let (state, mut rx) = enforce_state(
1075 &["anthropic/claude-haiku-4-5"],
1076 &["non-empty"],
1077 vec![(
1078 "anthropic/claude-haiku-4-5",
1079 Err(ProviderError::Transport("down".into())),
1080 )],
1081 );
1082 let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1083 assert_eq!(resp.status(), axum::http::StatusCode::BAD_GATEWAY);
1084 assert!(rx.try_recv().is_ok());
1086 }
1087
1088 #[tokio::test]
1089 async fn no_routing_config_falls_through_to_observe_not_enforce() {
1090 let config = ProxyConfig::from_lookup(|k| match k {
1094 "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
1095 _ => None,
1096 })
1097 .unwrap();
1098 let (traces, _rx) = mpsc::channel(64);
1099 let state = AppState {
1100 config: Arc::new(config),
1101 http: reqwest::Client::new(),
1102 providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
1103 gate_health: Arc::new(GateHealthRegistry::new()),
1104 traces,
1105 };
1106 let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1107 assert_ne!(resp.status(), axum::http::StatusCode::OK);
1109 }
1110
1111 #[test]
1112 fn detects_stream_requests() {
1113 assert!(is_stream_request(br#"{"stream": true}"#));
1114 assert!(!is_stream_request(br#"{"stream": false}"#));
1115 assert!(!is_stream_request(br#"{"model":"m"}"#));
1116 assert!(!is_stream_request(b"not json"));
1117 }
1118
1119 #[test]
1120 fn detects_tool_blocks_in_messages() {
1121 let with =
1122 br#"{"messages":[{"role":"user","content":[{"type":"tool_result","content":"42"}]}]}"#;
1123 let without = br#"{"messages":[{"role":"user","content":"hi"}]}"#;
1124 assert!(messages_have_tool_blocks(with));
1125 assert!(!messages_have_tool_blocks(without));
1126 }
1127
1128 #[test]
1129 fn enforce_only_handles_plain_text() {
1130 let plain =
1131 Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
1132 let tools = Bytes::from_static(
1133 br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
1134 );
1135 let f_plain = extract_features(&HeaderMap::new(), &plain);
1136 let f_tools = extract_features(&HeaderMap::new(), &tools);
1137 assert!(enforce_can_handle(&f_plain, &plain));
1138 assert!(!enforce_can_handle(&f_tools, &tools));
1139 }
1140
1141 #[tokio::test]
1145 async fn enforce_falls_back_to_observe_for_tool_requests() {
1146 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n";
1147 let config = ProxyConfig::from_lookup(|k| match k {
1148 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
1149 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
1150 "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
1151 _ => None,
1152 })
1153 .unwrap();
1154 let mut outs = HashMap::new();
1155 outs.insert(
1156 "anthropic/m".to_owned(),
1157 Ok(model_resp("anthropic/m", "hello")),
1158 );
1159 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1160 map.insert(
1161 "anthropic".to_owned(),
1162 Arc::new(MockProvider::new("anthropic", outs)),
1163 );
1164 let (traces, _rx) = mpsc::channel(64);
1165 let state = AppState {
1166 config: Arc::new(config),
1167 http: reqwest::Client::new(),
1168 providers: ProviderRegistry::from_map(map),
1169 gate_health: Arc::new(GateHealthRegistry::new()),
1170 traces,
1171 };
1172
1173 let plain =
1175 Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
1176 let resp = messages(State(state.clone()), HeaderMap::new(), plain).await;
1177 assert_eq!(
1178 resp.status(),
1179 axum::http::StatusCode::OK,
1180 "plain text should enforce"
1181 );
1182
1183 let tools = Bytes::from_static(
1185 br#"{"model":"m","tools":[{"name":"get_weather"}],"messages":[{"role":"user","content":"hi"}]}"#,
1186 );
1187 let resp = messages(State(state.clone()), HeaderMap::new(), tools).await;
1188 assert_ne!(
1189 resp.status(),
1190 axum::http::StatusCode::OK,
1191 "tool request must fall back to observe, not enforce"
1192 );
1193
1194 let toolres = Bytes::from_static(
1196 br#"{"model":"m","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"42"}]}]}"#,
1197 );
1198 let resp = messages(State(state), HeaderMap::new(), toolres).await;
1199 assert_ne!(
1200 resp.status(),
1201 axum::http::StatusCode::OK,
1202 "tool_result request must fall back to observe, not enforce"
1203 );
1204 }
1205
1206 async fn feedback_state() -> (AppState, std::path::PathBuf, String) {
1210 let db = std::env::temp_dir().join(format!("firstpass-feedback-{}.db", Uuid::now_v7()));
1211 let (tx, handle) = crate::store::open(&db).unwrap();
1212
1213 let mut trace = build_error_trace(
1214 &ProxyConfig::from_lookup(|_| None).unwrap(),
1215 &Bytes::from_static(b"{}"),
1216 5,
1217 Some("sess-fb"),
1218 );
1219 trace.attempts.push(Attempt {
1220 rung: 0,
1221 model: "anthropic/claude-haiku-4-5".into(),
1222 provider: "anthropic".into(),
1223 in_tokens: 10,
1224 out_tokens: 5,
1225 cost_usd: 0.001,
1226 latency_ms: 5,
1227 gates: vec![],
1228 verdict: Verdict::Pass,
1229 });
1230 let trace_id = trace.trace_id.to_string();
1231 tx.try_send(trace).unwrap();
1232 drop(tx);
1233 handle.await.unwrap();
1234
1235 let db_str = db.to_string_lossy().into_owned();
1236 let config = ProxyConfig::from_lookup(move |k| match k {
1237 "FIRSTPASS_DB" => Some(db_str.clone()),
1238 _ => None,
1239 })
1240 .unwrap();
1241 let (traces, _rx) = mpsc::channel(64);
1242 let state = AppState {
1243 config: Arc::new(config),
1244 http: reqwest::Client::new(),
1245 providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
1246 gate_health: Arc::new(GateHealthRegistry::new()),
1247 traces,
1248 };
1249 (state, db, trace_id)
1250 }
1251
1252 #[tokio::test]
1253 async fn feedback_records_a_deferred_verdict_without_breaking_the_chain() {
1254 let (state, db, trace_id) = feedback_state().await;
1255 let body = Bytes::from(
1256 serde_json::json!({
1257 "trace_id": trace_id,
1258 "gate_id": "tests",
1259 "verdict": "pass",
1260 "score": 1.0,
1261 "reporter": "ci",
1262 })
1263 .to_string(),
1264 );
1265 let resp = feedback(State(state), body).await;
1266 assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
1267
1268 let view = crate::store::load_trace_view(&db, &trace_id)
1270 .unwrap()
1271 .unwrap();
1272 assert_eq!(view.deferred.len(), 1);
1273 assert_eq!(view.deferred[0].gate_id, "tests");
1274 let traces = crate::store::load_all_traces(&db).unwrap();
1276 firstpass_core::verify_chain(&traces, GENESIS_HASH).unwrap();
1277
1278 let _ = std::fs::remove_file(&db);
1279 }
1280
1281 #[tokio::test]
1282 async fn feedback_for_unknown_trace_is_404() {
1283 let (state, db, _trace_id) = feedback_state().await;
1284 let body = Bytes::from(
1285 serde_json::json!({
1286 "trace_id": "does-not-exist",
1287 "gate_id": "tests",
1288 "verdict": "pass",
1289 "reporter": "ci",
1290 })
1291 .to_string(),
1292 );
1293 let resp = feedback(State(state), body).await;
1294 assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
1295 let _ = std::fs::remove_file(&db);
1296 }
1297
1298 #[tokio::test]
1299 async fn feedback_rejects_bad_verdict_and_score() {
1300 let (state, db, trace_id) = feedback_state().await;
1301 let bad_verdict = Bytes::from(
1302 serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "maybe", "reporter": "x" })
1303 .to_string(),
1304 );
1305 assert_eq!(
1306 feedback(State(state.clone()), bad_verdict).await.status(),
1307 axum::http::StatusCode::BAD_REQUEST
1308 );
1309 let bad_score = Bytes::from(
1310 serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "pass", "score": 9.0, "reporter": "x" })
1311 .to_string(),
1312 );
1313 assert_eq!(
1314 feedback(State(state), bad_score).await.status(),
1315 axum::http::StatusCode::BAD_REQUEST
1316 );
1317 let _ = std::fs::remove_file(&db);
1318 }
1319
1320 #[tokio::test]
1321 async fn metrics_endpoint_renders_after_a_real_request() {
1322 use tower::ServiceExt;
1323
1324 let (state, mut rx) = enforce_state(
1325 &["anthropic/claude-haiku-4-5"],
1326 &["non-empty"],
1327 vec![(
1328 "anthropic/claude-haiku-4-5",
1329 Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
1330 )],
1331 );
1332 let router = app(state).expect("prometheus recorder installs");
1333
1334 let req = axum::http::Request::builder()
1335 .method("POST")
1336 .uri("/v1/messages")
1337 .header("content-type", "application/json")
1338 .body(Body::from(user_body()))
1339 .unwrap();
1340 let resp = router.clone().oneshot(req).await.unwrap();
1341 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1342 rx.try_recv().expect("a trace was enqueued");
1343
1344 let metrics_req = axum::http::Request::builder()
1345 .method("GET")
1346 .uri("/metrics")
1347 .body(Body::empty())
1348 .unwrap();
1349 let metrics_resp = router.oneshot(metrics_req).await.unwrap();
1350 assert_eq!(metrics_resp.status(), axum::http::StatusCode::OK);
1351 let bytes = axum::body::to_bytes(metrics_resp.into_body(), 1 << 20)
1352 .await
1353 .unwrap();
1354 let body = String::from_utf8(bytes.to_vec()).unwrap();
1355 assert!(
1356 body.contains("firstpass_enforce_latency_ms"),
1357 "metrics body missing enforce latency histogram: {body}"
1358 );
1359 assert!(
1360 body.contains("firstpass_served_total"),
1361 "metrics body missing served counter: {body}"
1362 );
1363 }
1364}