1use std::sync::Arc;
5use std::time::Instant;
6
7use axum::body::Body;
8use axum::extract::{Request, State};
9use axum::http::HeaderMap;
10use axum::middleware::Next;
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Extension, Json, Router};
14use bytes::Bytes;
15use firstpass_core::features::{hour_bucket, token_bucket};
16use firstpass_core::hashchain::sha256_hex;
17use firstpass_core::{
18 Attempt, DeferredVerdict, Dialect, FEATURE_VERSION, Features, FinalOutcome, GENESIS_HASH, Mode,
19 ModelRef, PolicyRef, ProbeRegime, ProbeSignal, RequestInfo, RoutingMode, Score, ServedFrom,
20 TaskKind, Trace, Verdict,
21};
22use serde::Deserialize;
23use serde_json::Value;
24use std::future::Future;
25use std::time::Duration;
26use tokio::sync::mpsc::error::TrySendError;
27use uuid::Uuid;
28
29use crate::config::ProxyConfig;
30use crate::error::ProxyError;
31use crate::gate::{GateHealthRegistry, aggregate_with_policy, resolve_gates};
32use crate::provider::{Auth, ChatMessage, ModelRequest, ModelResponse, ProviderRegistry};
33use crate::router::{EnforceCtx, EngineOutcome, route_enforce};
34use crate::store;
35use crate::tenant_auth::{TenantId, auth_middleware};
36use crate::upstream::{
37 forward_anthropic, forward_anthropic_streaming, forward_openai, forward_openai_streaming,
38};
39use firstpass_core::Route;
40
41#[derive(Clone)]
44pub struct AppState {
45 pub config: Arc<ProxyConfig>,
47 pub http: reqwest::Client,
49 pub providers: ProviderRegistry,
51 pub gate_health: Arc<GateHealthRegistry>,
53 pub traces: store::TraceSender,
55 pub adaptive: Option<Arc<std::sync::Mutex<firstpass_core::conformal::AdaptiveConformal>>>,
59 pub bandit: Option<Arc<std::sync::Mutex<crate::bandit::StartRungBandit>>>,
64 pub predictor: Option<Arc<std::sync::Mutex<firstpass_core::PassPredictor>>>,
70 pub tenant_rate_limiter: Option<Arc<governor::DefaultKeyedRateLimiter<String>>>,
74 pub spill: Option<store::SpillHandle>,
78}
79
80#[must_use]
84pub fn build_tenant_rate_limiter(
85 config: &ProxyConfig,
86) -> Option<Arc<governor::DefaultKeyedRateLimiter<String>>> {
87 let per_sec = config.tenant_rate_per_sec?;
88 Some(Arc::new(governor::RateLimiter::keyed(
89 governor::Quota::per_second(per_sec),
90 )))
91}
92
93pub async fn tenant_rate_limit_middleware(
97 State(state): State<AppState>,
98 Extension(tenant): Extension<TenantId>,
99 req: Request,
100 next: Next,
101) -> Response {
102 if let Some(limiter) = &state.tenant_rate_limiter
103 && limiter.check_key(&tenant.0).is_err()
104 {
105 return ProxyError::RateLimited.into_response();
106 }
107 next.run(req).await
108}
109
110impl std::fmt::Debug for AppState {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("AppState")
113 .field("config", &self.config)
114 .finish_non_exhaustive()
115 }
116}
117
118fn offer_trace(traces: &store::TraceSender, spill: Option<&store::SpillHandle>, trace: Trace) {
135 record_trace_metrics(&trace);
136 match traces.try_send(trace) {
137 Ok(()) => {}
138 Err(TrySendError::Full(t)) => {
139 if let Some(handle) = spill {
140 match store::append_to_spill(handle, &t) {
141 Ok(()) => {
142 metrics::counter!("firstpass_receipts_spilled_total").increment(1);
143 }
144 Err(e) => {
145 tracing::error!(%e, "durable mode: spill write failed; trace lost");
146 metrics::counter!("firstpass_traces_dropped_total").increment(1);
147 }
148 }
149 } else {
150 tracing::warn!("trace channel full; dropping trace (writer behind under load)");
151 metrics::counter!("firstpass_traces_dropped_total").increment(1);
152 }
153 }
154 Err(TrySendError::Closed(_)) => {
155 tracing::warn!("trace writer is gone; dropping trace");
156 }
157 }
158}
159
160fn record_trace_metrics(trace: &Trace) {
164 if trace.mode == Mode::Enforce {
165 metrics::histogram!("firstpass_enforce_latency_ms")
166 .record(trace.final_.total_latency_ms as f64);
167 if trace.final_.escalations > 0 {
168 metrics::counter!("firstpass_escalations_total")
169 .increment(u64::from(trace.final_.escalations));
170 }
171 }
172 let served_from = match trace.final_.served_from {
173 ServedFrom::Attempt => "attempt",
174 ServedFrom::BestAttempt => "best_attempt",
175 ServedFrom::Error => "error",
176 };
177 metrics::counter!("firstpass_served_total", "served_from" => served_from).increment(1);
178 if trace.final_.served_from == ServedFrom::Error {
179 metrics::counter!("firstpass_upstream_failures_total").increment(1);
180 }
181 metrics::gauge!("firstpass_cost_usd_total").increment(trace.final_.total_cost_usd);
185 metrics::gauge!("firstpass_gate_cost_usd_total").increment(trace.final_.gate_cost_usd);
186 metrics::gauge!("firstpass_baseline_usd_total")
187 .increment(trace.final_.counterfactual_baseline_usd);
188 metrics::gauge!("firstpass_savings_usd_total").increment(trace.final_.savings_usd);
189 if let Some(rung) = trace.final_.served_rung {
191 let model = trace
192 .attempts
193 .iter()
194 .find(|a| a.rung == rung)
195 .map(|a| a.model.clone())
196 .unwrap_or_else(|| "unknown".to_owned());
197 metrics::counter!(
198 "firstpass_served_rung_total",
199 "rung" => rung.to_string(),
200 "model" => model
201 )
202 .increment(1);
203 }
204}
205
206const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
210
211pub(crate) fn u01(seed: u128) -> f64 {
222 let lo = splitmix64_finalise(seed as u64);
223 let hi = splitmix64_finalise((seed >> 64) as u64);
224 ((lo ^ hi) >> 11) as f64 * (1.0_f64 / (1u64 << 53) as f64)
226}
227
228fn splitmix64_finalise(mut z: u64) -> u64 {
229 z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
230 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
231 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
232 z ^ (z >> 31)
233}
234
235#[must_use]
242pub(crate) fn epsilon_propensity(chosen: u32, greedy: u32, epsilon: f64, k: usize) -> f64 {
243 let greedy_term = if chosen == greedy { 1.0 - epsilon } else { 0.0 };
244 greedy_term + epsilon / k as f64
245}
246
247pub fn app(state: AppState) -> Result<Router, ProxyError> {
254 crate::metrics::install()?;
255 let max_concurrency = state.config.max_concurrency;
256
257 let business = Router::new()
266 .route("/v1/messages", post(messages))
267 .route("/v1/chat/completions", post(chat_completions))
268 .route("/v1/feedback", post(feedback))
269 .route("/v1/capabilities", get(capabilities))
270 .layer(axum::middleware::from_fn_with_state(
271 state.clone(),
272 tenant_rate_limit_middleware,
273 ))
274 .layer(axum::middleware::from_fn_with_state(
275 state.clone(),
276 auth_middleware,
277 ));
278
279 Ok(Router::new()
280 .merge(business)
281 .route("/healthz", get(healthz))
282 .route("/metrics", get(crate::metrics::handler))
283 .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES))
285 .layer(tower::limit::GlobalConcurrencyLimitLayer::new(
288 max_concurrency,
289 ))
290 .with_state(state))
291}
292
293async fn healthz() -> impl IntoResponse {
295 Json(serde_json::json!({ "status": "ok" }))
296}
297
298async fn capabilities(State(state): State<AppState>) -> impl IntoResponse {
301 let (ladder, gates) = state
304 .config
305 .routing
306 .as_ref()
307 .and_then(|c| c.routes.iter().find(|r| r.mode == Mode::Enforce))
308 .map(|r| (r.ladder.clone(), r.gates.clone()))
309 .unwrap_or_default();
310 let routing_modes: Vec<serde_json::Value> = RoutingMode::ALL
311 .iter()
312 .map(|m| {
313 let p = m.preset();
314 serde_json::json!({
315 "name": m.as_str(),
316 "description": p.description,
317 "tradeoff": p.tradeoff,
318 })
319 })
320 .collect();
321 Json(serde_json::json!({
322 "service": "firstpass",
323 "version": env!("CARGO_PKG_VERSION"),
324 "feature_version": FEATURE_VERSION,
325 "modes": ["observe", "enforce"],
326 "routing_modes": routing_modes,
327 "wire_apis": ["anthropic.messages", "openai.chat_completions"],
328 "ladder": ladder,
329 "gates": gates,
330 "feedback_api": "POST /v1/feedback",
331 "offboarding": "unset ANTHROPIC_BASE_URL (or OPENAI_BASE_URL for OpenAI clients)",
332 }))
333}
334
335#[derive(Debug, Deserialize)]
337struct FeedbackRequest {
338 trace_id: String,
340 gate_id: String,
342 verdict: String,
344 #[serde(default)]
346 score: Option<f64>,
347 reporter: String,
349}
350
351async fn feedback(
356 State(state): State<AppState>,
357 Extension(TenantId(tenant)): Extension<TenantId>,
358 body: Bytes,
359) -> Response {
360 let req: FeedbackRequest = match serde_json::from_slice(&body) {
361 Ok(r) => r,
362 Err(e) => {
363 return ProxyError::BadRequest(format!("invalid feedback body: {e}")).into_response();
364 }
365 };
366 let verdict = match req.verdict.as_str() {
367 "pass" => Verdict::Pass,
368 "fail" => Verdict::Fail,
369 "abstain" => Verdict::Abstain,
370 other => {
371 return ProxyError::BadRequest(format!("unknown verdict {other:?}")).into_response();
372 }
373 };
374 let score = match req.score {
375 Some(s) => match Score::new(s) {
376 Ok(sc) => Some(sc),
377 Err(_) => {
378 return ProxyError::BadRequest(format!("score {s} out of range [0,1]"))
379 .into_response();
380 }
381 },
382 None => None,
383 };
384
385 let db = state.config.db_path.clone();
386
387 let (db_check, tenant_check, tid_check) = (db.clone(), tenant.clone(), req.trace_id.clone());
392 match tokio::task::spawn_blocking(move || {
393 store::trace_exists(&db_check, &tenant_check, &tid_check)
394 })
395 .await
396 {
397 Ok(Ok(true)) => {}
398 Ok(Ok(false)) => {
399 return ProxyError::NotFound(format!("unknown trace_id {:?}", req.trace_id))
400 .into_response();
401 }
402 Ok(Err(e)) => {
403 tracing::error!(%e, "feedback: trace_exists check failed");
404 return ProxyError::Internal(e.to_string()).into_response();
405 }
406 Err(e) => {
407 tracing::error!(%e, "feedback: trace_exists task panicked");
408 return ProxyError::Internal(e.to_string()).into_response();
409 }
410 }
411
412 let feedback_signal = match verdict {
414 Verdict::Pass => Some(true),
415 Verdict::Fail => Some(false),
416 Verdict::Abstain => None,
417 };
418 let dv = DeferredVerdict {
419 gate_id: req.gate_id,
420 verdict,
421 score,
422 reported_at: jiff::Timestamp::now(),
423 reporter: req.reporter,
424 };
425 let trace_id = req.trace_id.clone();
426 match tokio::task::spawn_blocking(move || store::append_deferred(&db, &req.trace_id, &dv)).await
427 {
428 Ok(Ok(())) => {
429 if let (Some(a), Some(correct)) = (state.adaptive.as_ref(), feedback_signal)
431 && let Ok(mut g) = a.lock()
432 {
433 g.observe_served(correct);
434 metrics::gauge!("firstpass_serve_threshold").set(g.threshold());
435 metrics::gauge!("firstpass_realized_served_failure")
436 .set(g.realized_served_failure());
437 }
438 (
439 axum::http::StatusCode::ACCEPTED,
440 Json(serde_json::json!({ "status": "recorded", "trace_id": trace_id })),
441 )
442 .into_response()
443 }
444 Ok(Err(e)) => {
445 tracing::error!(%e, "feedback: append_deferred failed");
446 ProxyError::Internal(e.to_string()).into_response()
447 }
448 Err(e) => {
449 tracing::error!(%e, "feedback: append_deferred task panicked");
450 ProxyError::Internal(e.to_string()).into_response()
451 }
452 }
453}
454
455const SESSION_HEADER: &str = "x-firstpass-session";
458
459const AGENT_HEADER: &str = "x-firstpass-agent";
461const SUBAGENT_HEADER: &str = "x-firstpass-subagent";
463const MODE_PROFILE_HEADER: &str = "x-firstpass-mode";
466
467fn resolve_mode(headers: &HeaderMap, route: &Route, config: &ProxyConfig) -> RoutingMode {
476 if let Some(val) = header_str(headers, MODE_PROFILE_HEADER) {
478 match val.trim().to_ascii_lowercase().as_str() {
479 "observe" => return RoutingMode::Observe,
480 "cost" => return RoutingMode::Cost,
481 "balanced" => return RoutingMode::Balanced,
482 "quality" => return RoutingMode::Quality,
483 "latency" => return RoutingMode::Latency,
484 "max" => return RoutingMode::Max,
485 other => {
486 tracing::warn!(
487 value = other,
488 "unknown x-firstpass-mode value; ignoring \
489 (valid: observe|cost|balanced|quality|latency|max)"
490 );
491 }
492 }
493 }
494 if let Some(m) = route.routing_mode {
496 return m;
497 }
498 config.default_routing_mode
500}
501
502async fn messages(
507 State(state): State<AppState>,
508 Extension(TenantId(tenant)): Extension<TenantId>,
509 headers: HeaderMap,
510 body: Bytes,
511) -> Response {
512 let session_header = header_str(&headers, SESSION_HEADER);
513
514 if let Some(routing) = state.config.routing.as_ref() {
517 let features = extract_features(&headers, &body);
518 if let Some(route) = routing
519 .route_for(&features)
520 .filter(|r| r.mode == Mode::Enforce && !r.ladder.is_empty())
521 {
522 let route = route.clone();
525 let routing_mode = resolve_mode(&headers, &route, &state.config);
527 if routing_mode == RoutingMode::Observe {
529 return observe_passthrough(state, headers, body, session_header, tenant).await;
530 }
531 if enforce_can_handle(
532 &features,
533 &body,
534 routing.escalation.enforce_structured,
535 &route.ladder,
536 &state.providers,
537 Dialect::Anthropic,
538 ) {
539 return handle_enforce(
540 &state,
541 &headers,
542 &body,
543 features,
544 &route,
545 session_header,
546 tenant,
547 routing_mode,
548 )
549 .await;
550 }
551 tracing::info!(
555 "enforce route matched but structured request can't be routed faithfully (flag/ladder); serving via observe passthrough"
556 );
557 }
558 }
559 observe_passthrough(state, headers, body, session_header, tenant).await
560}
561
562fn enforce_can_handle(
576 features: &Features,
577 body: &[u8],
578 enforce_structured: bool,
579 ladder: &[String],
580 providers: &crate::provider::ProviderRegistry,
581 inbound: Dialect,
582) -> bool {
583 let structured = features.tool_count > 0
584 || features.has_images
585 || match inbound {
586 Dialect::Anthropic => messages_have_tool_blocks(body),
587 Dialect::Openai => openai_messages_have_tool_calls(body),
588 Dialect::Gemini => false,
589 };
590 if !structured {
591 return true;
592 }
593 if !enforce_structured {
594 return false;
595 }
596 let all_verbatim = ladder.iter().all(|rung| {
598 let provider_id = rung.split('/').next().unwrap_or_default();
599 providers
600 .get(provider_id)
601 .is_some_and(|p| p.carries_structured_verbatim(inbound))
602 });
603 if all_verbatim {
604 return true;
605 }
606 if inbound == Dialect::Openai && !openai_has_http_images(body) {
610 let all_anthropic = ladder.iter().all(|rung| {
611 let pid = rung.split('/').next().unwrap_or_default();
612 providers
613 .get(pid)
614 .is_some_and(|p| p.carries_structured_verbatim(Dialect::Anthropic))
615 });
616 if all_anthropic {
617 return true;
618 }
619 }
620 false
621}
622
623fn messages_have_tool_blocks(body: &[u8]) -> bool {
626 serde_json::from_slice::<Value>(body)
627 .ok()
628 .and_then(|json| {
629 json.get("messages")
630 .and_then(Value::as_array)
631 .map(|messages| messages.iter().any(message_has_tool_block))
632 })
633 .unwrap_or(false)
634}
635
636fn message_has_tool_block(message: &Value) -> bool {
638 message
639 .get("content")
640 .and_then(Value::as_array)
641 .is_some_and(|blocks| {
642 blocks.iter().any(|block| {
643 matches!(
644 block.get("type").and_then(Value::as_str),
645 Some("tool_use" | "tool_result")
646 )
647 })
648 })
649}
650
651fn is_stream_request(body: &[u8]) -> bool {
653 serde_json::from_slice::<Value>(body)
654 .ok()
655 .and_then(|json| json.get("stream").and_then(Value::as_bool))
656 .unwrap_or(false)
657}
658
659fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
661 headers
662 .get(name)
663 .and_then(|v| v.to_str().ok())
664 .map(str::to_owned)
665}
666
667fn extract_features(headers: &HeaderMap, body: &[u8]) -> Features {
670 let (_model, tool_count, has_images) = request_features(body);
671 let mut f = Features::new(TaskKind::Other);
672 f.agent = header_str(headers, AGENT_HEADER);
673 f.subagent = header_str(headers, SUBAGENT_HEADER);
674 f.tool_count = tool_count;
675 f.has_images = has_images;
676 f.prompt_token_bucket = token_bucket(body.len() as u64);
679 f.hour_bucket = hour_bucket(jiff::Timestamp::now());
680 f
681}
682
683#[allow(clippy::too_many_arguments)]
686async fn handle_enforce(
687 state: &AppState,
688 headers: &HeaderMap,
689 body: &Bytes,
690 features: Features,
691 route: &Route,
692 session_header: Option<String>,
693 tenant: String,
694 routing_mode: RoutingMode,
695) -> Response {
696 if is_stream_request(body) {
704 let (tx, rx) = tokio::sync::oneshot::channel::<Result<Value, ProxyError>>();
705 let (state_c, headers_c, body_c, route_c) =
706 (state.clone(), headers.clone(), body.clone(), route.clone());
707 tokio::spawn(async move {
708 let out = enforce_pipeline(
709 &state_c,
710 &headers_c,
711 &body_c,
712 features,
713 &route_c,
714 session_header,
715 tenant,
716 routing_mode,
717 )
718 .await;
719 let _ = tx.send(out);
720 });
721 return sse_keepalive_response(rx, anthropic_sse_from_message);
722 }
723 match enforce_pipeline(
724 state,
725 headers,
726 body,
727 features,
728 route,
729 session_header,
730 tenant,
731 routing_mode,
732 )
733 .await
734 {
735 Ok(message) => (axum::http::StatusCode::OK, Json(message)).into_response(),
736 Err(e) => e.into_response(),
737 }
738}
739
740#[allow(clippy::too_many_arguments)] async fn enforce_pipeline_inner(
745 state: &AppState,
746 body: &Bytes,
747 base_request: ModelRequest,
748 auth: Auth,
749 features: Features,
750 route: &Route,
751 session_header: Option<String>,
752 tenant: String,
753 api: &str,
754 routing_mode: RoutingMode,
755) -> Result<ModelResponse, ProxyError> {
756 let gate_defs = state
757 .config
758 .routing
759 .as_ref()
760 .map_or(&[][..], |cfg| &cfg.gate_defs);
761 let gates = resolve_gates(
762 &route.gates,
763 gate_defs,
764 &state.providers,
765 &auth,
766 &state.config.prices,
767 );
768 let session_id = session_header.unwrap_or_else(|| Uuid::now_v7().to_string());
769 let (budget, max_rungs, speculation, serve_threshold, elastic) =
770 match state.config.routing.as_ref() {
771 Some(cfg) => (
772 cfg.budget.per_request_usd,
773 cfg.escalation.max_rungs_per_request,
774 cfg.escalation.speculation,
775 cfg.escalation.serve_threshold,
776 cfg.escalation.elastic.as_ref(),
777 ),
778 None => (None, 3, 0, None, None),
779 };
780 let serve_threshold = state
783 .adaptive
784 .as_ref()
785 .and_then(|a| a.lock().ok().map(|g| g.threshold()))
786 .or(serve_threshold);
787
788 let preset = routing_mode.preset();
791 let max_rungs = if let Some(delta) = preset.max_rungs_delta {
792 (max_rungs as i32 + delta).max(1) as u32
793 } else {
794 max_rungs
795 };
796 let speculation = preset.speculation.unwrap_or(speculation);
797
798 let bandit_ctx = crate::bandit::ContextBucket::from_features(&features);
802
803 let (greedy_rung, base_policy_id, ts_propensity) = {
807 let (chosen, ts_p) = state
808 .bandit
809 .as_ref()
810 .and_then(|b| b.lock().ok())
811 .map(|mut b| {
812 b.choose_start_with_propensity(&bandit_ctx, &route.ladder, &state.config.prices)
813 })
814 .unwrap_or((0, None));
815 let policy = if ts_p.is_some() {
816 "bandit@v2-ts".to_owned()
817 } else if chosen > 0 {
818 "bandit@v1".to_owned()
819 } else {
820 "static-ladder@v0".to_owned()
821 };
822 (chosen, policy, ts_p)
823 };
824
825 let exploration_epsilon = state
830 .config
831 .routing
832 .as_ref()
833 .and_then(|cfg| cfg.escalation.exploration.as_ref())
834 .map(|e| e.epsilon);
835
836 let (start_rung, policy_id, explore_flag, propensity) = if ts_propensity.is_some() {
837 if exploration_epsilon.is_some() {
840 tracing::warn!(
841 "bandit.algorithm = thompson already logs propensities; \
842 [escalation.exploration] epsilon is ignored"
843 );
844 }
845 (greedy_rung, base_policy_id, false, ts_propensity)
846 } else if let Some(epsilon) = exploration_epsilon {
847 let k = route.ladder.len().max(1);
848 let u = u01(Uuid::now_v7().as_u128());
850 let (chosen, eps_branch) = if u < epsilon {
851 let idx = ((u / epsilon) * k as f64) as u32;
853 (idx.min(k as u32 - 1), true)
854 } else {
855 (greedy_rung, false)
856 };
857 let p = epsilon_propensity(chosen, greedy_rung, epsilon, k);
858 (chosen, format!("{base_policy_id}+eps"), eps_branch, Some(p))
859 } else {
860 (greedy_rung, base_policy_id, false, None)
861 };
862
863 let start_rung = if preset.start_at_top {
866 route.ladder.len().saturating_sub(1) as u32
867 } else {
868 start_rung
869 };
870
871 let speculation = match state
877 .config
878 .routing
879 .as_ref()
880 .and_then(|cfg| cfg.escalation.speculation_band)
881 {
882 Some([lo, hi]) if speculation > 0 => {
883 let estimate = state
884 .bandit
885 .as_ref()
886 .and_then(|b| b.lock().ok())
887 .and_then(|b| b.pass_estimate(&bandit_ctx, start_rung));
888 match estimate {
889 Some(p) if p < lo || p > hi => {
890 metrics::counter!("firstpass_speculation_skipped_total").increment(1);
891 0
892 }
893 _ => speculation,
894 }
895 }
896 _ => speculation,
897 };
898
899 if state.bandit.is_some() {
901 metrics::counter!(
902 "firstpass_bandit_start_rung",
903 "rung" => start_rung.to_string()
904 )
905 .increment(1);
906 }
907
908 let ctx = EnforceCtx {
909 ladder: &route.ladder,
910 gates: &gates,
911 health: &state.gate_health,
912 base_request: &base_request,
913 providers: &state.providers,
914 auth: &auth,
915 prices: &state.config.prices,
916 budget_per_request_usd: budget,
917 max_rungs,
918 speculation,
919 serve_threshold,
920 elastic,
921 features,
922 start_rung,
923 tenant_id: tenant,
926 session_id,
927 prompt_hash: prompt_hash(&state.config.prompt_salt, body),
928 api: api.to_owned(),
929 policy_id,
930 };
931
932 let (outcome, mut trace) = route_enforce(ctx).await;
933
934 trace.policy.explore = explore_flag;
937 trace.policy.propensity = propensity;
938 if routing_mode != RoutingMode::Balanced {
941 trace.policy.mode_profile = Some(routing_mode.as_str().to_owned());
942 }
943
944 if let Some(bandit) = state.bandit.as_ref()
948 && let Ok(mut b) = bandit.lock()
949 {
950 for attempt in &trace.attempts {
951 b.observe(&bandit_ctx, attempt.rung, attempt.verdict);
952 }
953 }
954
955 if let Some(predictor) = state.predictor.as_ref()
961 && let Ok(mut p) = predictor.lock()
962 {
963 let predicted = p.predict(&trace.request.features, start_rung);
965 for attempt in &trace.attempts {
966 match attempt.verdict {
967 Verdict::Pass => p.update(&trace.request.features, attempt.rung, true),
968 Verdict::Fail => p.update(&trace.request.features, attempt.rung, false),
969 Verdict::Abstain => {} }
971 }
972 trace.predicted_pass = Some(predicted);
973 metrics::histogram!("firstpass_predictor_pass_prob").record(predicted);
974 }
975
976 if let Some(probe_cfg) = state
983 .config
984 .routing
985 .as_ref()
986 .and_then(|c| c.escalation.probe)
987 && u01(Uuid::now_v7().as_u128()) < probe_cfg.sample_rate
988 {
989 let probe_rung = (start_rung as usize).min(route.ladder.len().saturating_sub(1));
991 if let Some(probe_model_str) = route.ladder.get(probe_rung).cloned() {
992 let probe_provider = ModelRef::parse(&probe_model_str)
993 .ok()
994 .and_then(|m| state.providers.get(&m.provider));
995
996 if let Some(probe_provider) = probe_provider {
997 let mut join_set = tokio::task::JoinSet::new();
1000 for _ in 0..probe_cfg.k {
1001 let mut probe_req = base_request.clone();
1002 probe_req.model = probe_model_str.clone();
1003 let probe_auth = auth.clone();
1004 let prov = probe_provider.clone();
1005 join_set.spawn(async move { prov.complete(&probe_req, &probe_auth).await });
1006 }
1007
1008 let fail_closed_owned: std::collections::HashSet<String> = gates
1011 .iter()
1012 .filter(|g| g.abstain_fails_closed())
1013 .map(|g| g.id().to_owned())
1014 .collect();
1015
1016 let mut gate_pass_count = 0u32;
1017 let mut probe_cost_usd = 0.0f64;
1018
1019 while let Some(task_result) = join_set.join_next().await {
1020 let Ok(Ok(probe_resp)) = task_result else {
1021 continue; };
1023 probe_cost_usd += state
1024 .config
1025 .prices
1026 .cost_usd(
1027 &probe_model_str,
1028 probe_resp.in_tokens,
1029 probe_resp.out_tokens,
1030 )
1031 .unwrap_or(0.0);
1032
1033 let mut probe_gate_req = base_request.clone();
1034 probe_gate_req.model = probe_model_str.clone();
1035
1036 let mut probe_gate_results = Vec::with_capacity(gates.len());
1039 for g in &gates {
1040 if !state.gate_health.enabled(&trace.tenant_id, g.id()) {
1042 continue;
1043 }
1044 let r = g.evaluate(&probe_gate_req, &probe_resp).await;
1045 probe_gate_results.push(r);
1047 }
1048
1049 let fail_closed_refs: std::collections::HashSet<&str> =
1050 fail_closed_owned.iter().map(|s| s.as_str()).collect();
1051 let verdict = aggregate_with_policy(&probe_gate_results, &fail_closed_refs);
1052 let passes = match serve_threshold {
1055 None => verdict == Verdict::Pass,
1056 Some(t) => crate::calibrate::gate_score(&probe_gate_results, verdict) >= t,
1057 };
1058 if passes {
1059 gate_pass_count += 1;
1060 }
1061 }
1062
1063 let regime = ProbeRegime::classify(gate_pass_count, probe_cfg.k);
1064 let regime_label = match regime {
1065 ProbeRegime::ConfidentPass => "confident_pass",
1066 ProbeRegime::ConfidentFail => "confident_fail",
1067 ProbeRegime::Ambiguous => "ambiguous",
1068 };
1069 metrics::counter!(
1070 "firstpass_probe_regime_total",
1071 "regime" => regime_label
1072 )
1073 .increment(1);
1074 metrics::gauge!("firstpass_probe_cost_usd_total").increment(probe_cost_usd);
1075 trace.probe = Some(ProbeSignal {
1076 k: probe_cfg.k,
1077 gate_pass_count,
1078 regime,
1079 probe_cost_usd,
1080 });
1081 }
1082 }
1083 }
1084 offer_trace(&state.traces, state.spill.as_ref(), trace);
1088
1089 match outcome {
1090 EngineOutcome::Served(resp) => Ok(resp),
1091 EngineOutcome::Failed(msg) => Err(ProxyError::Engine(msg)),
1092 }
1093}
1094
1095#[allow(clippy::too_many_arguments)]
1098async fn enforce_pipeline(
1099 state: &AppState,
1100 headers: &HeaderMap,
1101 body: &Bytes,
1102 features: Features,
1103 route: &Route,
1104 session_header: Option<String>,
1105 tenant: String,
1106 routing_mode: RoutingMode,
1107) -> Result<Value, ProxyError> {
1108 let Some(base_request) = parse_model_request(body) else {
1109 return Err(ProxyError::BadRequest(
1110 "request body is not a valid Anthropic Messages request".to_owned(),
1111 ));
1112 };
1113 let auth = Auth::from_headers(headers);
1114 let resp = enforce_pipeline_inner(
1115 state,
1116 body,
1117 base_request,
1118 auth,
1119 features,
1120 route,
1121 session_header,
1122 tenant,
1123 "anthropic.messages",
1124 routing_mode,
1125 )
1126 .await?;
1127 Ok(anthropic_response_json(&resp))
1128}
1129
1130#[allow(clippy::too_many_arguments)]
1133async fn enforce_pipeline_openai(
1134 state: &AppState,
1135 headers: &HeaderMap,
1136 body: &Bytes,
1137 features: Features,
1138 route: &Route,
1139 session_header: Option<String>,
1140 tenant: String,
1141 routing_mode: RoutingMode,
1142) -> Result<Value, ProxyError> {
1143 let providers = &state.providers;
1145 let all_openai = route.ladder.iter().all(|rung| {
1146 let pid = rung.split('/').next().unwrap_or_default();
1147 providers
1148 .get(pid)
1149 .is_some_and(|p| p.carries_structured_verbatim(Dialect::Openai))
1150 });
1151 let Some(base_request) = parse_openai_request(body, all_openai) else {
1152 return Err(ProxyError::BadRequest(
1153 "request body is not a valid OpenAI Chat Completions request".to_owned(),
1154 ));
1155 };
1156 let auth = Auth::from_headers(headers);
1157 let resp = enforce_pipeline_inner(
1158 state,
1159 body,
1160 base_request,
1161 auth,
1162 features,
1163 route,
1164 session_header,
1165 tenant,
1166 "openai.chat_completions",
1167 routing_mode,
1168 )
1169 .await?;
1170 Ok(openai_response_json(&resp))
1171}
1172
1173const SSE_KEEPALIVE_EVERY: Duration = Duration::from_secs(5);
1175
1176fn sse_keepalive_response(
1185 rx: tokio::sync::oneshot::Receiver<Result<Value, ProxyError>>,
1186 format_message: fn(&Value) -> String,
1187) -> Response {
1188 let mut ticks = tokio::time::interval(SSE_KEEPALIVE_EVERY);
1189 ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1190 ticks.reset(); let stream = KeepaliveStream {
1192 rx: Some(rx),
1193 ticks,
1194 format_message,
1195 };
1196 (
1197 axum::http::StatusCode::OK,
1198 [(
1199 axum::http::header::CONTENT_TYPE,
1200 "text/event-stream; charset=utf-8",
1201 )],
1202 axum::body::Body::from_stream(stream),
1203 )
1204 .into_response()
1205}
1206
1207struct KeepaliveStream {
1210 rx: Option<tokio::sync::oneshot::Receiver<Result<Value, ProxyError>>>,
1212 ticks: tokio::time::Interval,
1213 format_message: fn(&Value) -> String,
1215}
1216
1217impl futures_core::Stream for KeepaliveStream {
1218 type Item = Result<Bytes, std::convert::Infallible>;
1219
1220 fn poll_next(
1221 mut self: std::pin::Pin<&mut Self>,
1222 cx: &mut std::task::Context<'_>,
1223 ) -> std::task::Poll<Option<Self::Item>> {
1224 use std::task::Poll;
1225 let Some(rx) = self.rx.as_mut() else {
1226 return Poll::Ready(None); };
1228 if let Poll::Ready(out) = std::pin::Pin::new(rx).poll(cx) {
1229 let fmt = self.format_message;
1230 let frame = match out {
1231 Ok(Ok(message)) => fmt(&message),
1232 Ok(Err(e)) => sse_error_event(&e),
1233 Err(_) => sse_error_event(&ProxyError::Internal(
1234 "enforce pipeline task dropped".to_owned(),
1235 )),
1236 };
1237 self.rx = None;
1238 return Poll::Ready(Some(Ok(Bytes::from(frame))));
1239 }
1240 if self.ticks.poll_tick(cx).is_ready() {
1241 return Poll::Ready(Some(Ok(Bytes::from_static(b": firstpass routing\n\n"))));
1242 }
1243 Poll::Pending
1244 }
1245}
1246
1247fn sse_error_event(e: &ProxyError) -> String {
1250 let mut out = String::new();
1251 sse_event(
1252 &mut out,
1253 "error",
1254 &serde_json::json!({
1255 "type": "error",
1256 "error": { "type": "api_error", "message": e.client_message() }
1257 }),
1258 );
1259 out
1260}
1261
1262fn parse_model_request(body: &[u8]) -> Option<ModelRequest> {
1271 let json: Value = serde_json::from_slice(body).ok()?;
1272 let raw = json.clone();
1273 let messages_json = json.get("messages")?.as_array()?;
1274 let messages = messages_json
1275 .iter()
1276 .map(|m| ChatMessage {
1277 role: m
1278 .get("role")
1279 .and_then(Value::as_str)
1280 .unwrap_or("user")
1281 .to_owned(),
1282 content: m
1283 .get("content")
1284 .cloned()
1285 .unwrap_or_else(|| Value::String(String::new())),
1286 })
1287 .collect();
1288 let system = json
1289 .get("system")
1290 .and_then(Value::as_str)
1291 .map(str::to_owned);
1292 let max_tokens = json
1293 .get("max_tokens")
1294 .and_then(Value::as_u64)
1295 .and_then(|n| u32::try_from(n).ok())
1296 .unwrap_or(1024);
1297 let tools = json.get("tools").cloned().unwrap_or(Value::Null);
1298 Some(ModelRequest {
1299 model: json
1300 .get("model")
1301 .and_then(Value::as_str)
1302 .unwrap_or_default()
1303 .to_owned(),
1304 system,
1305 messages,
1306 max_tokens,
1307 tools,
1308 raw,
1309 })
1310}
1311
1312fn anthropic_response_json(resp: &ModelResponse) -> Value {
1322 let content = resp
1323 .raw
1324 .get("content")
1325 .filter(|c| c.is_array())
1326 .cloned()
1327 .unwrap_or_else(|| serde_json::json!([{ "type": "text", "text": resp.text }]));
1328 serde_json::json!({
1329 "id": format!("msg_{}", Uuid::now_v7()),
1330 "type": "message",
1331 "role": "assistant",
1332 "model": resp.model,
1333 "content": content,
1334 "usage": { "input_tokens": resp.in_tokens, "output_tokens": resp.out_tokens },
1335 })
1336}
1337
1338fn sse_event(out: &mut String, event: &str, data: &Value) {
1340 out.push_str("event: ");
1341 out.push_str(event);
1342 out.push_str("\ndata: ");
1343 out.push_str(&data.to_string());
1344 out.push_str("\n\n");
1345}
1346
1347fn anthropic_sse_from_message(message: &Value) -> String {
1354 let mut out = String::new();
1355
1356 let mut start_msg = message.clone();
1358 start_msg["content"] = Value::Array(Vec::new());
1359 sse_event(
1360 &mut out,
1361 "message_start",
1362 &serde_json::json!({ "type": "message_start", "message": start_msg }),
1363 );
1364
1365 let empty = Vec::new();
1366 let blocks = message
1367 .get("content")
1368 .and_then(Value::as_array)
1369 .unwrap_or(&empty);
1370 for (i, block) in blocks.iter().enumerate() {
1371 match block.get("type").and_then(Value::as_str) {
1372 Some("tool_use") => {
1373 let mut shell = block.clone();
1375 shell["input"] = serde_json::json!({});
1376 sse_event(
1377 &mut out,
1378 "content_block_start",
1379 &serde_json::json!({ "type": "content_block_start", "index": i, "content_block": shell }),
1380 );
1381 let input_json = block
1382 .get("input")
1383 .map_or_else(|| "{}".to_owned(), std::string::ToString::to_string);
1384 sse_event(
1385 &mut out,
1386 "content_block_delta",
1387 &serde_json::json!({ "type": "content_block_delta", "index": i,
1388 "delta": { "type": "input_json_delta", "partial_json": input_json } }),
1389 );
1390 }
1391 _ => {
1392 let text = block.get("text").and_then(Value::as_str).unwrap_or("");
1394 sse_event(
1395 &mut out,
1396 "content_block_start",
1397 &serde_json::json!({ "type": "content_block_start", "index": i,
1398 "content_block": { "type": "text", "text": "" } }),
1399 );
1400 sse_event(
1401 &mut out,
1402 "content_block_delta",
1403 &serde_json::json!({ "type": "content_block_delta", "index": i,
1404 "delta": { "type": "text_delta", "text": text } }),
1405 );
1406 }
1407 }
1408 sse_event(
1409 &mut out,
1410 "content_block_stop",
1411 &serde_json::json!({ "type": "content_block_stop", "index": i }),
1412 );
1413 }
1414
1415 let out_tokens = message
1416 .pointer("/usage/output_tokens")
1417 .cloned()
1418 .unwrap_or_else(|| Value::from(0));
1419 sse_event(
1420 &mut out,
1421 "message_delta",
1422 &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "end_turn" },
1423 "usage": { "output_tokens": out_tokens } }),
1424 );
1425 sse_event(
1426 &mut out,
1427 "message_stop",
1428 &serde_json::json!({ "type": "message_stop" }),
1429 );
1430 out
1431}
1432
1433async fn observe_passthrough(
1435 state: AppState,
1436 headers: HeaderMap,
1437 body: Bytes,
1438 session_header: Option<String>,
1439 tenant: String,
1440) -> Response {
1441 if is_stream_request(&body) {
1443 return observe_stream(state, headers, body, session_header, tenant).await;
1444 }
1445 let start = Instant::now();
1446 let result = forward_anthropic(
1447 &state.http,
1448 &state.config.upstream_anthropic,
1449 &headers,
1450 body.clone(),
1451 )
1452 .await;
1453 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1454
1455 match result {
1456 Ok((status, resp_headers, resp_body)) => {
1457 spawn_trace(
1461 &state,
1462 body,
1463 Some(resp_body.clone()),
1464 latency_ms,
1465 session_header,
1466 tenant,
1467 );
1468 (status, resp_headers, resp_body).into_response()
1469 }
1470 Err(err) => {
1471 spawn_trace(&state, body, None, latency_ms, session_header, tenant);
1472 err.into_response()
1473 }
1474 }
1475}
1476
1477async fn observe_stream(
1486 state: AppState,
1487 headers: HeaderMap,
1488 body: Bytes,
1489 session_header: Option<String>,
1490 tenant: String,
1491) -> Response {
1492 let start = Instant::now();
1493 let result = forward_anthropic_streaming(
1494 &state.http,
1495 &state.config.upstream_anthropic,
1496 &headers,
1497 body.clone(),
1498 )
1499 .await;
1500 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1501
1502 match result {
1503 Ok((status, resp_headers, response)) => {
1504 spawn_stream_trace(&state, body, latency_ms, session_header, tenant);
1505 let stream_body = Body::from_stream(response.bytes_stream());
1506 (status, resp_headers, stream_body).into_response()
1507 }
1508 Err(err) => {
1509 spawn_trace(&state, body, None, latency_ms, session_header, tenant);
1510 err.into_response()
1511 }
1512 }
1513}
1514
1515fn spawn_stream_trace(
1517 state: &AppState,
1518 req_body: Bytes,
1519 latency_ms: u64,
1520 session_header: Option<String>,
1521 tenant: String,
1522) {
1523 let config = state.config.clone();
1524 let traces = state.traces.clone();
1525 let spill = state.spill.clone();
1526 tokio::spawn(async move {
1527 let mut trace =
1528 build_stream_trace(&config, &req_body, latency_ms, session_header.as_deref());
1529 trace.tenant_id = tenant;
1531 offer_trace(&traces, spill.as_ref(), trace);
1532 });
1533}
1534
1535fn spawn_trace(
1540 state: &AppState,
1541 req_body: Bytes,
1542 resp_body: Option<Bytes>,
1543 latency_ms: u64,
1544 session_header: Option<String>,
1545 tenant: String,
1546) {
1547 let config = state.config.clone();
1548 let traces = state.traces.clone();
1549 let spill = state.spill.clone();
1550 tokio::spawn(async move {
1551 let mut trace = match resp_body {
1552 Some(resp) => build_trace(
1553 &config,
1554 &req_body,
1555 &resp,
1556 latency_ms,
1557 session_header.as_deref(),
1558 ),
1559 None => build_error_trace(&config, &req_body, latency_ms, session_header.as_deref()),
1560 };
1561 trace.tenant_id = tenant;
1563 offer_trace(&traces, spill.as_ref(), trace);
1564 });
1565}
1566
1567fn session_id(session_header: Option<&str>, trace_id: Uuid) -> String {
1569 session_header
1570 .map(str::to_owned)
1571 .unwrap_or_else(|| trace_id.to_string())
1572}
1573
1574fn prompt_hash(salt: &str, body: &[u8]) -> String {
1577 let mut salted = Vec::with_capacity(salt.len() + body.len());
1578 salted.extend_from_slice(salt.as_bytes());
1579 salted.extend_from_slice(body);
1580 sha256_hex(&salted)
1581}
1582
1583fn request_features(body: &[u8]) -> (Option<String>, u32, bool) {
1587 let Ok(json) = serde_json::from_slice::<Value>(body) else {
1588 return (None, 0, false);
1589 };
1590 let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
1591 let tool_count = json
1592 .get("tools")
1593 .and_then(Value::as_array)
1594 .map_or(0, |tools| u32::try_from(tools.len()).unwrap_or(u32::MAX));
1595 let has_images = json
1596 .get("messages")
1597 .and_then(Value::as_array)
1598 .is_some_and(|messages| messages.iter().any(message_has_image));
1599 (model, tool_count, has_images)
1600}
1601
1602fn message_has_image(message: &Value) -> bool {
1604 message
1605 .get("content")
1606 .and_then(Value::as_array)
1607 .is_some_and(|blocks| {
1608 blocks
1609 .iter()
1610 .any(|block| block.get("type").and_then(Value::as_str) == Some("image"))
1611 })
1612}
1613
1614fn response_usage(body: &[u8]) -> (Option<String>, u64, u64) {
1617 let Ok(json) = serde_json::from_slice::<Value>(body) else {
1618 return (None, 0, 0);
1619 };
1620 let model = json.get("model").and_then(Value::as_str).map(str::to_owned);
1621 let in_tokens = json
1622 .pointer("/usage/input_tokens")
1623 .and_then(Value::as_u64)
1624 .unwrap_or(0);
1625 let out_tokens = json
1626 .pointer("/usage/output_tokens")
1627 .and_then(Value::as_u64)
1628 .unwrap_or(0);
1629 (model, in_tokens, out_tokens)
1630}
1631
1632fn build_trace(
1634 config: &ProxyConfig,
1635 req_body: &Bytes,
1636 resp_body: &Bytes,
1637 latency_ms: u64,
1638 session_header: Option<&str>,
1639) -> Trace {
1640 let (req_model, tool_count, has_images) = request_features(req_body);
1641 let (resp_model, in_tokens, out_tokens) = response_usage(resp_body);
1642 let model = resp_model
1643 .or(req_model)
1644 .unwrap_or_else(|| "unknown".to_owned());
1645
1646 let cost_usd = config
1647 .prices
1648 .cost_usd(&format!("anthropic/{model}"), in_tokens, out_tokens)
1649 .unwrap_or(0.0);
1650
1651 let attempt = Attempt {
1652 rung: 0,
1653 model,
1654 provider: "anthropic".to_owned(),
1655 in_tokens,
1656 out_tokens,
1657 cost_usd,
1658 latency_ms,
1659 gates: Vec::new(),
1660 verdict: Verdict::Pass,
1661 };
1662
1663 let mut trace = base_trace(config, req_body, latency_ms, session_header);
1664 trace.request.features.prompt_token_bucket = token_bucket(in_tokens);
1665 trace.request.features.tool_count = tool_count;
1666 trace.request.features.has_images = has_images;
1667 trace.attempts.push(attempt);
1668 trace.final_ = FinalOutcome {
1669 served_rung: Some(0),
1670 served_from: ServedFrom::Attempt,
1671 total_cost_usd: cost_usd,
1672 gate_cost_usd: 0.0,
1673 total_latency_ms: latency_ms,
1674 escalations: 0,
1675 counterfactual_baseline_usd: cost_usd,
1676 savings_usd: 0.0,
1677 };
1678 trace.recompute_savings();
1679 trace
1680}
1681
1682fn build_stream_trace(
1686 config: &ProxyConfig,
1687 req_body: &Bytes,
1688 latency_ms: u64,
1689 session_header: Option<&str>,
1690) -> Trace {
1691 let (req_model, tool_count, has_images) = request_features(req_body);
1692 let model = req_model.unwrap_or_else(|| "unknown".to_owned());
1693
1694 let attempt = Attempt {
1695 rung: 0,
1696 model,
1697 provider: "anthropic".to_owned(),
1698 in_tokens: 0,
1699 out_tokens: 0,
1700 cost_usd: 0.0,
1701 latency_ms,
1702 gates: Vec::new(),
1703 verdict: Verdict::Pass,
1704 };
1705
1706 let mut trace = base_trace(config, req_body, latency_ms, session_header);
1707 trace.request.features.tool_count = tool_count;
1708 trace.request.features.has_images = has_images;
1709 trace.attempts.push(attempt);
1710 trace.final_ = FinalOutcome {
1711 served_rung: Some(0),
1712 served_from: ServedFrom::Attempt,
1713 total_cost_usd: 0.0,
1714 gate_cost_usd: 0.0,
1715 total_latency_ms: latency_ms,
1716 escalations: 0,
1717 counterfactual_baseline_usd: 0.0,
1718 savings_usd: 0.0,
1719 };
1720 trace.recompute_savings();
1721 trace
1722}
1723
1724fn build_error_trace(
1728 config: &ProxyConfig,
1729 req_body: &Bytes,
1730 latency_ms: u64,
1731 session_header: Option<&str>,
1732) -> Trace {
1733 let (_, tool_count, has_images) = request_features(req_body);
1734 let mut trace = base_trace(config, req_body, latency_ms, session_header);
1735 trace.request.features.tool_count = tool_count;
1736 trace.request.features.has_images = has_images;
1737 trace.final_ = FinalOutcome {
1738 served_rung: None,
1739 served_from: ServedFrom::Error,
1740 total_cost_usd: 0.0,
1741 gate_cost_usd: 0.0,
1742 total_latency_ms: latency_ms,
1743 escalations: 0,
1744 counterfactual_baseline_usd: 0.0,
1745 savings_usd: 0.0,
1746 };
1747 trace.recompute_savings();
1748 trace
1749}
1750
1751fn base_trace(
1754 config: &ProxyConfig,
1755 req_body: &Bytes,
1756 latency_ms: u64,
1757 session_header: Option<&str>,
1758) -> Trace {
1759 let trace_id = Uuid::now_v7();
1760 let mut features = Features::new(TaskKind::Other);
1761 features.hour_bucket = hour_bucket(jiff::Timestamp::now());
1762
1763 Trace {
1764 trace_id,
1765 prev_hash: GENESIS_HASH.to_owned(),
1766 tenant_id: config.tenant_id.clone(),
1767 session_id: session_id(session_header, trace_id),
1768 ts: jiff::Timestamp::now(),
1769 mode: Mode::Observe,
1770 policy: PolicyRef {
1771 id: "observe-passthrough@v0".to_owned(),
1772 explore: false,
1773 propensity: None,
1774 mode_profile: None,
1775 },
1776 request: RequestInfo {
1777 api: "anthropic.messages".to_owned(),
1778 prompt_hash: prompt_hash(&config.prompt_salt, req_body),
1779 features,
1780 },
1781 attempts: Vec::new(),
1782 deferred: Vec::new(),
1783 final_: FinalOutcome {
1784 served_rung: None,
1785 served_from: ServedFrom::Error,
1786 total_cost_usd: 0.0,
1787 gate_cost_usd: 0.0,
1788 total_latency_ms: latency_ms,
1789 escalations: 0,
1790 counterfactual_baseline_usd: 0.0,
1791 savings_usd: 0.0,
1792 },
1793 probe: None,
1794 predicted_pass: None,
1795 elastic: None,
1796 }
1797}
1798
1799fn openai_messages_have_tool_calls(body: &[u8]) -> bool {
1804 serde_json::from_slice::<Value>(body)
1805 .ok()
1806 .and_then(|json| {
1807 json.get("messages").and_then(Value::as_array).map(|msgs| {
1808 msgs.iter().any(|m| {
1809 m.get("tool_calls").is_some()
1810 || m.get("role").and_then(Value::as_str) == Some("tool")
1811 })
1812 })
1813 })
1814 .unwrap_or(false)
1815}
1816
1817fn openai_has_http_images(body: &[u8]) -> bool {
1821 serde_json::from_slice::<Value>(body)
1822 .ok()
1823 .and_then(|json| {
1824 json.get("messages").and_then(Value::as_array).map(|msgs| {
1825 msgs.iter().any(|m| {
1826 m.get("content")
1827 .and_then(Value::as_array)
1828 .is_some_and(|parts| {
1829 parts.iter().any(|p| {
1830 p.get("type").and_then(Value::as_str) == Some("image_url")
1831 && p.pointer("/image_url/url")
1832 .and_then(Value::as_str)
1833 .is_some_and(|u| {
1834 u.starts_with("http://") || u.starts_with("https://")
1835 })
1836 })
1837 })
1838 })
1839 })
1840 })
1841 .unwrap_or(false)
1842}
1843
1844fn openai_messages_have_images(body: &[u8]) -> bool {
1847 serde_json::from_slice::<Value>(body)
1848 .ok()
1849 .and_then(|json| {
1850 json.get("messages").and_then(Value::as_array).map(|msgs| {
1851 msgs.iter().any(|m| {
1852 m.get("content")
1853 .and_then(Value::as_array)
1854 .is_some_and(|parts| {
1855 parts
1856 .iter()
1857 .any(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
1858 })
1859 })
1860 })
1861 })
1862 .unwrap_or(false)
1863}
1864
1865fn extract_openai_features(headers: &HeaderMap, body: &[u8]) -> Features {
1868 let Ok(json) = serde_json::from_slice::<Value>(body) else {
1869 let mut f = Features::new(TaskKind::Other);
1870 f.hour_bucket = hour_bucket(jiff::Timestamp::now());
1871 return f;
1872 };
1873 let tool_count = json
1874 .get("tools")
1875 .and_then(Value::as_array)
1876 .map_or(0, |tools| u32::try_from(tools.len()).unwrap_or(u32::MAX));
1877 let has_images = openai_messages_have_images(body);
1878 let mut f = Features::new(TaskKind::Other);
1879 f.agent = header_str(headers, AGENT_HEADER);
1880 f.subagent = header_str(headers, SUBAGENT_HEADER);
1881 f.tool_count = tool_count;
1882 f.has_images = has_images;
1883 f.prompt_token_bucket = token_bucket(body.len() as u64);
1884 f.hour_bucket = hour_bucket(jiff::Timestamp::now());
1885 f
1886}
1887
1888fn parse_data_url(url: &str) -> Option<(&str, &str)> {
1892 let rest = url.strip_prefix("data:")?;
1893 let (meta, data) = rest.split_once(',')?;
1894 let media_type = meta.strip_suffix(";base64")?;
1895 Some((media_type, data))
1896}
1897
1898fn translate_openai_user_content(content: &Value) -> Option<Value> {
1901 match content {
1902 Value::String(_) => Some(content.clone()),
1904 Value::Array(parts) => {
1905 let mut blocks: Vec<Value> = Vec::with_capacity(parts.len());
1906 for part in parts {
1907 match part.get("type").and_then(Value::as_str) {
1908 Some("text") => {
1909 let text = part.get("text").and_then(Value::as_str).unwrap_or("");
1910 blocks.push(serde_json::json!({ "type": "text", "text": text }));
1911 }
1912 Some("image_url") => {
1913 let url = part.pointer("/image_url/url").and_then(Value::as_str)?;
1914 if url.starts_with("http://") || url.starts_with("https://") {
1915 return None; }
1917 let (media_type, data) = parse_data_url(url)?;
1919 blocks.push(serde_json::json!({
1920 "type": "image",
1921 "source": { "type": "base64", "media_type": media_type, "data": data }
1922 }));
1923 }
1924 _ => {} }
1926 }
1927 Some(Value::Array(blocks))
1928 }
1929 _ => Some(Value::String(String::new())),
1930 }
1931}
1932
1933fn translate_openai_tools(tools: &Value) -> Value {
1937 let Some(arr) = tools.as_array() else {
1938 return Value::Null;
1939 };
1940 let converted: Vec<Value> = arr
1941 .iter()
1942 .map(|tool| {
1943 let func = tool.get("function").unwrap_or(&Value::Null);
1944 let mut out = serde_json::json!({
1945 "name": func.get("name").cloned().unwrap_or(Value::String(String::new())),
1946 "input_schema": func.get("parameters").cloned()
1947 .unwrap_or_else(|| serde_json::json!({ "type": "object" })),
1948 });
1949 if let Some(desc) = func.get("description") {
1950 out["description"] = desc.clone();
1951 }
1952 out
1953 })
1954 .collect();
1955 Value::Array(converted)
1956}
1957
1958fn translate_openai_tool_choice(tc: &Value) -> Value {
1960 match tc {
1961 Value::String(s) => match s.as_str() {
1962 "auto" => serde_json::json!({ "type": "auto" }),
1963 "required" => serde_json::json!({ "type": "any" }),
1964 _ => serde_json::json!({ "type": "auto" }),
1966 },
1967 Value::Object(_) => {
1968 if tc.get("type").and_then(Value::as_str) == Some("function") {
1970 let name = tc.pointer("/function/name").cloned().unwrap_or(Value::Null);
1971 serde_json::json!({ "type": "tool", "name": name })
1972 } else {
1973 serde_json::json!({ "type": "auto" })
1974 }
1975 }
1976 _ => serde_json::json!({ "type": "auto" }),
1977 }
1978}
1979
1980pub fn parse_openai_request(body: &[u8], carry_raw: bool) -> Option<ModelRequest> {
1993 let json: Value = serde_json::from_slice(body).ok()?;
1994 let raw = if carry_raw { json.clone() } else { Value::Null };
1995
1996 let messages_json = json.get("messages")?.as_array()?;
1997
1998 let mut system: Option<String> = None;
1999 let mut messages: Vec<ChatMessage> = Vec::with_capacity(messages_json.len());
2000 let mut tools = Value::Null;
2001 let mut tool_choice_override: Option<Value> = None;
2002
2003 for msg in messages_json {
2004 let role = msg.get("role").and_then(Value::as_str).unwrap_or("user");
2005 match role {
2006 "system" => {
2007 if let Some(s) = msg.get("content").and_then(Value::as_str) {
2012 system = Some(s.to_owned());
2013 }
2014 }
2015 "user" => {
2016 let content_val = msg.get("content").unwrap_or(&Value::Null);
2017 let translated = translate_openai_user_content(content_val)?;
2018 messages.push(ChatMessage {
2019 role: "user".to_owned(),
2020 content: translated,
2021 });
2022 }
2023 "assistant" => {
2024 if let Some(tc_arr) = msg.get("tool_calls").and_then(Value::as_array) {
2025 let mut blocks: Vec<Value> = Vec::new();
2027 if let Some(text) = msg.get("content").and_then(Value::as_str)
2029 && !text.is_empty()
2030 {
2031 blocks.push(serde_json::json!({ "type": "text", "text": text }));
2032 }
2033 for tc in tc_arr {
2034 let id = tc.get("id").and_then(Value::as_str).unwrap_or("");
2035 let func = tc.get("function").unwrap_or(&Value::Null);
2036 let name = func.get("name").and_then(Value::as_str).unwrap_or("");
2037 let args_str = func
2038 .get("arguments")
2039 .and_then(Value::as_str)
2040 .unwrap_or("{}");
2041 let input: Value = serde_json::from_str(args_str)
2042 .unwrap_or_else(|_| serde_json::json!({}));
2043 blocks.push(serde_json::json!({
2044 "type": "tool_use",
2045 "id": id,
2046 "name": name,
2047 "input": input,
2048 }));
2049 }
2050 messages.push(ChatMessage {
2051 role: "assistant".to_owned(),
2052 content: Value::Array(blocks),
2053 });
2054 } else {
2055 let content = msg
2057 .get("content")
2058 .cloned()
2059 .unwrap_or_else(|| Value::String(String::new()));
2060 messages.push(ChatMessage {
2061 role: "assistant".to_owned(),
2062 content,
2063 });
2064 }
2065 }
2066 "tool" => {
2067 let tool_call_id = msg
2069 .get("tool_call_id")
2070 .and_then(Value::as_str)
2071 .unwrap_or("");
2072 let content = msg
2073 .get("content")
2074 .cloned()
2075 .unwrap_or_else(|| Value::String(String::new()));
2076 let result_block = serde_json::json!({
2077 "type": "tool_result",
2078 "tool_use_id": tool_call_id,
2079 "content": content,
2080 });
2081 messages.push(ChatMessage {
2082 role: "user".to_owned(),
2083 content: Value::Array(vec![result_block]),
2084 });
2085 }
2086 _ => {} }
2088 }
2089
2090 if !carry_raw {
2092 if let Some(t) = json.get("tools") {
2093 tools = translate_openai_tools(t);
2094 }
2095 if let Some(tc) = json.get("tool_choice") {
2096 tool_choice_override = Some(translate_openai_tool_choice(tc));
2097 }
2098 } else {
2099 tools = json.get("tools").cloned().unwrap_or(Value::Null);
2100 }
2101
2102 let max_tokens = json
2103 .get("max_tokens")
2104 .and_then(Value::as_u64)
2105 .and_then(|n| u32::try_from(n).ok())
2106 .unwrap_or(1024);
2107
2108 let model = json
2109 .get("model")
2110 .and_then(Value::as_str)
2111 .unwrap_or_default()
2112 .to_owned();
2113
2114 let _ = tool_choice_override; Some(ModelRequest {
2125 model,
2126 system,
2127 messages,
2128 max_tokens,
2129 tools,
2130 raw,
2131 })
2132}
2133
2134fn extract_openai_content_and_tools(raw: &Value, text: &str) -> (Value, Option<Value>) {
2141 if let Some(blocks) = raw.get("content").and_then(Value::as_array) {
2143 let mut text_parts: Vec<&str> = Vec::new();
2144 let mut tool_calls: Vec<Value> = Vec::new();
2145 for block in blocks {
2146 match block.get("type").and_then(Value::as_str) {
2147 Some("text") => {
2148 if let Some(t) = block.get("text").and_then(Value::as_str) {
2149 text_parts.push(t);
2150 }
2151 }
2152 Some("tool_use") => {
2153 let id = block.get("id").and_then(Value::as_str).unwrap_or("");
2154 let name = block.get("name").and_then(Value::as_str).unwrap_or("");
2155 let input_str = block
2156 .get("input")
2157 .map_or_else(|| "{}".to_owned(), std::string::ToString::to_string);
2158 tool_calls.push(serde_json::json!({
2159 "id": id,
2160 "type": "function",
2161 "function": { "name": name, "arguments": input_str },
2162 }));
2163 }
2164 _ => {}
2165 }
2166 }
2167 let content_text = if tool_calls.is_empty() || !text_parts.is_empty() {
2168 Value::String(text_parts.join(""))
2169 } else {
2170 Value::Null };
2172 let tc = if tool_calls.is_empty() {
2173 None
2174 } else {
2175 Some(Value::Array(tool_calls))
2176 };
2177 return (content_text, tc);
2178 }
2179
2180 if let Some(msg) = raw.pointer("/choices/0/message") {
2182 let content = msg
2183 .get("content")
2184 .cloned()
2185 .unwrap_or(Value::String(text.to_owned()));
2186 let tc = msg.get("tool_calls").cloned();
2187 return (content, tc);
2188 }
2189
2190 (Value::String(text.to_owned()), None)
2192}
2193
2194fn openai_response_json(resp: &ModelResponse) -> Value {
2197 let (content_text, tool_calls) = extract_openai_content_and_tools(&resp.raw, &resp.text);
2198 let finish_reason = if tool_calls.is_some() {
2199 "tool_calls"
2200 } else {
2201 "stop"
2202 };
2203 let mut message = serde_json::json!({
2204 "role": "assistant",
2205 "content": content_text,
2206 });
2207 if let Some(tc) = tool_calls {
2208 message["tool_calls"] = tc;
2209 }
2210 serde_json::json!({
2211 "id": format!("chatcmpl-{}", Uuid::now_v7()),
2212 "object": "chat.completion",
2213 "created": jiff::Timestamp::now().as_second(),
2214 "model": resp.model,
2215 "choices": [{
2216 "index": 0,
2217 "message": message,
2218 "finish_reason": finish_reason,
2219 }],
2220 "usage": {
2221 "prompt_tokens": resp.in_tokens,
2222 "completion_tokens": resp.out_tokens,
2223 "total_tokens": resp.in_tokens + resp.out_tokens,
2224 }
2225 })
2226}
2227
2228fn openai_sse_from_message(message: &Value) -> String {
2232 let id = message
2233 .get("id")
2234 .and_then(Value::as_str)
2235 .unwrap_or("chatcmpl-unknown")
2236 .to_owned();
2237 let created = message.get("created").cloned().unwrap_or(Value::from(0));
2238 let model = message
2239 .get("model")
2240 .and_then(Value::as_str)
2241 .unwrap_or("unknown");
2242
2243 let choices = message.get("choices").and_then(Value::as_array);
2244 let msg = choices
2245 .and_then(|c| c.first())
2246 .and_then(|c| c.get("message"));
2247 let content = msg
2248 .and_then(|m| m.get("content"))
2249 .cloned()
2250 .unwrap_or(Value::Null);
2251 let tool_calls = msg.and_then(|m| m.get("tool_calls")).cloned();
2252 let finish_reason = choices
2253 .and_then(|c| c.first())
2254 .and_then(|c| c.get("finish_reason"))
2255 .cloned()
2256 .unwrap_or_else(|| Value::String("stop".to_owned()));
2257
2258 let mut out = String::new();
2259 let chunk = |delta: Value| {
2260 serde_json::json!({
2261 "id": id,
2262 "object": "chat.completion.chunk",
2263 "created": created,
2264 "model": model,
2265 "choices": [{ "index": 0, "delta": delta, "finish_reason": Value::Null }]
2266 })
2267 };
2268
2269 let role_chunk = chunk(serde_json::json!({ "role": "assistant", "content": "" }));
2271 out.push_str("data: ");
2272 out.push_str(&role_chunk.to_string());
2273 out.push_str("\n\n");
2274
2275 if let Value::String(text) = &content
2277 && !text.is_empty()
2278 {
2279 let content_chunk = chunk(serde_json::json!({ "content": text }));
2280 out.push_str("data: ");
2281 out.push_str(&content_chunk.to_string());
2282 out.push_str("\n\n");
2283 }
2284
2285 if let Some(tc) = tool_calls {
2287 let tc_chunk = chunk(serde_json::json!({ "tool_calls": tc }));
2288 out.push_str("data: ");
2289 out.push_str(&tc_chunk.to_string());
2290 out.push_str("\n\n");
2291 }
2292
2293 let finish_chunk = serde_json::json!({
2295 "id": id,
2296 "object": "chat.completion.chunk",
2297 "created": created,
2298 "model": model,
2299 "choices": [{ "index": 0, "delta": {}, "finish_reason": finish_reason }]
2300 });
2301 out.push_str("data: ");
2302 out.push_str(&finish_chunk.to_string());
2303 out.push_str("\n\n");
2304
2305 out.push_str("data: [DONE]\n\n");
2306 out
2307}
2308
2309#[allow(clippy::too_many_arguments)]
2314async fn handle_enforce_openai(
2315 state: &AppState,
2316 headers: &HeaderMap,
2317 body: &Bytes,
2318 features: Features,
2319 route: &Route,
2320 session_header: Option<String>,
2321 tenant: String,
2322 routing_mode: RoutingMode,
2323) -> Response {
2324 if is_stream_request(body) {
2325 let (tx, rx) = tokio::sync::oneshot::channel::<Result<Value, ProxyError>>();
2326 let (state_c, headers_c, body_c, route_c) =
2327 (state.clone(), headers.clone(), body.clone(), route.clone());
2328 tokio::spawn(async move {
2329 let out = enforce_pipeline_openai(
2330 &state_c,
2331 &headers_c,
2332 &body_c,
2333 features,
2334 &route_c,
2335 session_header,
2336 tenant,
2337 routing_mode,
2338 )
2339 .await;
2340 let _ = tx.send(out);
2341 });
2342 return sse_keepalive_response(rx, openai_sse_from_message);
2343 }
2344 match enforce_pipeline_openai(
2345 state,
2346 headers,
2347 body,
2348 features,
2349 route,
2350 session_header,
2351 tenant,
2352 routing_mode,
2353 )
2354 .await
2355 {
2356 Ok(message) => (axum::http::StatusCode::OK, Json(message)).into_response(),
2357 Err(e) => e.into_response(),
2358 }
2359}
2360
2361async fn observe_passthrough_openai(
2367 state: AppState,
2368 headers: HeaderMap,
2369 body: Bytes,
2370 session_header: Option<String>,
2371 tenant: String,
2372) -> Response {
2373 if is_stream_request(&body) {
2374 return observe_stream_openai(state, headers, body, session_header, tenant).await;
2375 }
2376 let start = Instant::now();
2377 let result = forward_openai(
2378 &state.http,
2379 &state.config.upstream_openai,
2380 &headers,
2381 body.clone(),
2382 )
2383 .await;
2384 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2385 match result {
2386 Ok((status, resp_headers, resp_body)) => {
2387 spawn_trace(
2388 &state,
2389 body,
2390 Some(resp_body.clone()),
2391 latency_ms,
2392 session_header,
2393 tenant,
2394 );
2395 (status, resp_headers, resp_body).into_response()
2396 }
2397 Err(err) => {
2398 spawn_trace(&state, body, None, latency_ms, session_header, tenant);
2399 err.into_response()
2400 }
2401 }
2402}
2403
2404async fn observe_stream_openai(
2406 state: AppState,
2407 headers: HeaderMap,
2408 body: Bytes,
2409 session_header: Option<String>,
2410 tenant: String,
2411) -> Response {
2412 let start = Instant::now();
2413 let result = forward_openai_streaming(
2414 &state.http,
2415 &state.config.upstream_openai,
2416 &headers,
2417 body.clone(),
2418 )
2419 .await;
2420 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2421 match result {
2422 Ok((status, resp_headers, response)) => {
2423 spawn_stream_trace(&state, body, latency_ms, session_header, tenant);
2424 let stream_body = Body::from_stream(response.bytes_stream());
2425 (status, resp_headers, stream_body).into_response()
2426 }
2427 Err(err) => {
2428 spawn_trace(&state, body, None, latency_ms, session_header, tenant);
2429 err.into_response()
2430 }
2431 }
2432}
2433
2434async fn chat_completions(
2439 State(state): State<AppState>,
2440 Extension(TenantId(tenant)): Extension<TenantId>,
2441 headers: HeaderMap,
2442 body: Bytes,
2443) -> Response {
2444 let session_header = header_str(&headers, SESSION_HEADER);
2445
2446 if let Some(routing) = state.config.routing.as_ref() {
2447 let features = extract_openai_features(&headers, &body);
2448 if let Some(route) = routing
2449 .route_for(&features)
2450 .filter(|r| r.mode == Mode::Enforce && !r.ladder.is_empty())
2451 {
2452 let route = route.clone();
2453 let routing_mode = resolve_mode(&headers, &route, &state.config);
2455 if routing_mode == RoutingMode::Observe {
2457 return observe_passthrough_openai(state, headers, body, session_header, tenant)
2458 .await;
2459 }
2460 if enforce_can_handle(
2461 &features,
2462 &body,
2463 routing.escalation.enforce_structured,
2464 &route.ladder,
2465 &state.providers,
2466 Dialect::Openai,
2467 ) {
2468 return handle_enforce_openai(
2469 &state,
2470 &headers,
2471 &body,
2472 features,
2473 &route,
2474 session_header,
2475 tenant,
2476 routing_mode,
2477 )
2478 .await;
2479 }
2480 tracing::info!(
2481 "enforce route matched but OpenAI structured request can't be routed faithfully (flag/ladder); serving via observe passthrough"
2482 );
2483 }
2484 }
2485 observe_passthrough_openai(state, headers, body, session_header, tenant).await
2486}
2487
2488#[cfg(test)]
2489mod tests {
2490 use bytes::Bytes;
2491
2492 use super::*;
2493
2494 fn test_config() -> ProxyConfig {
2495 ProxyConfig::from_lookup(|_| None).unwrap()
2496 }
2497
2498 #[test]
2499 fn build_trace_maps_request_and_response_fields() {
2500 let config = test_config();
2501 let req = Bytes::from_static(
2502 br#"{"model":"claude-haiku-4-5","tools":[{"name":"a"}],"messages":[]}"#,
2503 );
2504 let resp = Bytes::from_static(
2505 br#"{"model":"claude-haiku-4-5","usage":{"input_tokens":1200,"output_tokens":300}}"#,
2506 );
2507
2508 let trace = build_trace(&config, &req, &resp, 42, Some("sess-1"));
2509
2510 assert_eq!(trace.request.api, "anthropic.messages");
2511 assert_eq!(trace.session_id, "sess-1");
2512 assert_eq!(trace.attempts.len(), 1);
2513 let attempt = &trace.attempts[0];
2514 assert_eq!(attempt.model, "claude-haiku-4-5");
2515 assert_eq!(attempt.provider, "anthropic");
2516 assert_eq!(attempt.in_tokens, 1200);
2517 assert_eq!(attempt.out_tokens, 300);
2518 assert!(attempt.cost_usd > 0.0);
2519 assert_eq!(trace.request.features.tool_count, 1);
2520 assert!(!trace.request.features.has_images);
2521 assert_eq!(trace.final_.served_rung, Some(0));
2522 }
2523
2524 #[test]
2525 fn build_trace_falls_back_to_trace_id_session_when_header_absent() {
2526 let config = test_config();
2527 let req = Bytes::from_static(b"{}");
2528 let resp = Bytes::from_static(b"{}");
2529
2530 let trace = build_trace(&config, &req, &resp, 1, None);
2531
2532 assert_eq!(trace.session_id, trace.trace_id.to_string());
2533 }
2534
2535 #[test]
2536 fn build_error_trace_has_no_attempts_and_served_from_error() {
2537 let config = test_config();
2538 let req = Bytes::from_static(br#"{"model":"claude-haiku-4-5"}"#);
2539
2540 let trace = build_error_trace(&config, &req, 7, None);
2541
2542 assert!(trace.attempts.is_empty());
2543 assert_eq!(trace.final_.served_from, ServedFrom::Error);
2544 assert_eq!(trace.final_.served_rung, None);
2545 }
2546
2547 #[test]
2548 fn message_with_image_block_sets_has_images() {
2549 let req = br#"{"messages":[{"role":"user","content":[{"type":"image"}]}]}"#;
2550 let (_, _, has_images) = request_features(req);
2551 assert!(has_images);
2552 }
2553
2554 #[test]
2555 fn prompt_hash_never_contains_raw_prompt_text() {
2556 let hash = prompt_hash("salt", b"super secret prompt");
2557 assert!(!hash.contains("secret"));
2558 assert_eq!(hash.len(), 64);
2559 }
2560
2561 #[test]
2562 fn parse_model_request_preserves_content_verbatim_and_projects_text() {
2563 let body = br#"{"model":"m","system":"sys","max_tokens":50,
2564 "messages":[{"role":"user","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]},
2565 {"role":"assistant","content":"c"}]}"#;
2566 let req = parse_model_request(body).unwrap();
2567 assert_eq!(req.system.as_deref(), Some("sys"));
2568 assert_eq!(req.max_tokens, 50);
2569 assert_eq!(req.messages.len(), 2);
2570 assert_eq!(
2572 req.messages[0].content,
2573 serde_json::json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])
2574 );
2575 assert_eq!(req.messages[1].content, Value::String("c".to_owned()));
2577 assert_eq!(req.messages[0].text_view(), "a\nb");
2579 assert_eq!(req.messages[1].text_view(), "c");
2580 }
2581
2582 #[test]
2583 fn tool_and_image_blocks_survive_the_request_round_trip() {
2584 let body = br#"{"model":"m","max_tokens":50,"messages":[
2586 {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}}]},
2587 {"role":"user","content":[
2588 {"type":"tool_result","tool_use_id":"t1","content":"2"},
2589 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
2590 ]}]}"#;
2591 let req = parse_model_request(body).unwrap();
2592 let round_tripped = serde_json::to_value(&req.messages).unwrap();
2593 assert_eq!(
2594 round_tripped,
2595 serde_json::json!([
2596 {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}}]},
2597 {"role":"user","content":[
2598 {"type":"tool_result","tool_use_id":"t1","content":"2"},
2599 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
2600 ]}
2601 ])
2602 );
2603 }
2604
2605 #[test]
2606 fn text_message_serializes_byte_identical_to_a_plain_string() {
2607 let m = ChatMessage::text("user", "hello");
2609 assert_eq!(
2610 serde_json::to_string(&m).unwrap(),
2611 r#"{"role":"user","content":"hello"}"#
2612 );
2613 }
2614
2615 #[test]
2616 fn parse_model_request_rejects_non_message_bodies() {
2617 assert!(parse_model_request(b"not json").is_none());
2618 assert!(parse_model_request(br#"{"no":"messages"}"#).is_none());
2619 }
2620
2621 use crate::provider::{MockProvider, ModelResponse, Provider, ProviderError, ProviderRegistry};
2624 use axum::extract::State;
2625 use std::collections::HashMap;
2626 use std::sync::Arc;
2627 use tokio::sync::mpsc;
2628
2629 fn model_resp(model: &str, text: &str) -> ModelResponse {
2630 ModelResponse {
2631 model: model.to_owned(),
2632 text: text.to_owned(),
2633 in_tokens: 1000,
2634 out_tokens: 400,
2635 raw: serde_json::Value::Null,
2636 }
2637 }
2638
2639 fn enforce_state(
2642 ladder: &[&str],
2643 gates: &[&str],
2644 outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
2645 ) -> (AppState, mpsc::Receiver<Trace>) {
2646 let toml = format!(
2647 "[[route]]\nmatch = {{}}\nmode = \"enforce\"\nladder = [{}]\ngates = [{}]\n",
2648 ladder
2649 .iter()
2650 .map(|m| format!("\"{m}\""))
2651 .collect::<Vec<_>>()
2652 .join(", "),
2653 gates
2654 .iter()
2655 .map(|g| format!("\"{g}\""))
2656 .collect::<Vec<_>>()
2657 .join(", "),
2658 );
2659 let config = ProxyConfig::from_lookup(|k| match k {
2660 "FIRSTPASS_CONFIG_TOML" => Some(toml.clone()),
2661 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
2662 _ => None,
2663 })
2664 .unwrap();
2665
2666 let mut outs = HashMap::new();
2667 for (model, out) in outcomes {
2668 outs.insert(model.to_owned(), out);
2669 }
2670 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
2671 map.insert(
2672 "anthropic".to_owned(),
2673 Arc::new(MockProvider::new("anthropic", outs)),
2674 );
2675 let providers = ProviderRegistry::from_map(map);
2676
2677 let (traces, rx) = mpsc::channel(64);
2678 let state = AppState {
2679 config: Arc::new(config),
2680 http: reqwest::Client::new(),
2681 providers,
2682 gate_health: Arc::new(GateHealthRegistry::new()),
2683 traces,
2684 adaptive: None,
2685 bandit: None,
2686 predictor: None,
2687 tenant_rate_limiter: None,
2688 spill: None,
2689 };
2690 (state, rx)
2691 }
2692
2693 fn user_body() -> Bytes {
2694 Bytes::from_static(
2695 br#"{"model":"ignored","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}"#,
2696 )
2697 }
2698
2699 async fn body_json(resp: Response) -> Value {
2700 let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
2701 .await
2702 .unwrap();
2703 serde_json::from_slice(&bytes).unwrap()
2704 }
2705
2706 #[tokio::test]
2707 async fn mode_profile_stamped_on_trace_when_non_balanced() {
2708 let (state, mut rx) = enforce_state(
2709 &["anthropic/claude-haiku-4-5"],
2710 &["non-empty"],
2711 vec![(
2712 "anthropic/claude-haiku-4-5",
2713 Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
2714 )],
2715 );
2716 let mut headers = HeaderMap::new();
2717 headers.insert("x-firstpass-mode", "quality".parse().unwrap());
2718 let resp = messages(
2719 State(state),
2720 Extension(TenantId("default".to_owned())),
2721 headers,
2722 user_body(),
2723 )
2724 .await;
2725 assert_eq!(resp.status(), axum::http::StatusCode::OK);
2726 let trace = rx.try_recv().expect("trace enqueued");
2727 assert_eq!(
2728 trace.policy.mode_profile.as_deref(),
2729 Some("quality"),
2730 "mode_profile must be stamped when quality mode is active"
2731 );
2732 }
2733
2734 #[tokio::test]
2735 async fn mode_profile_absent_from_trace_when_balanced() {
2736 let (state, mut rx) = enforce_state(
2737 &["anthropic/claude-haiku-4-5"],
2738 &["non-empty"],
2739 vec![(
2740 "anthropic/claude-haiku-4-5",
2741 Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
2742 )],
2743 );
2744 let resp = messages(
2746 State(state),
2747 Extension(TenantId("default".to_owned())),
2748 HeaderMap::new(),
2749 user_body(),
2750 )
2751 .await;
2752 assert_eq!(resp.status(), axum::http::StatusCode::OK);
2753 let trace = rx.try_recv().expect("trace enqueued");
2754 assert!(
2755 trace.policy.mode_profile.is_none(),
2756 "mode_profile must be absent when Balanced (byte-identical invariant)"
2757 );
2758 }
2759
2760 #[tokio::test]
2761 async fn enforce_serves_first_pass_and_returns_anthropic_shape() {
2762 let (state, mut rx) = enforce_state(
2763 &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
2764 &["non-empty"],
2765 vec![(
2766 "anthropic/claude-haiku-4-5",
2767 Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
2768 )],
2769 );
2770 let resp = messages(
2771 State(state),
2772 Extension(TenantId("default".to_owned())),
2773 HeaderMap::new(),
2774 user_body(),
2775 )
2776 .await;
2777 assert_eq!(resp.status(), axum::http::StatusCode::OK);
2778 let json = body_json(resp).await;
2779 assert_eq!(json["type"], "message");
2780 assert_eq!(json["content"][0]["text"], "hello");
2781 assert_eq!(json["model"], "anthropic/claude-haiku-4-5");
2782
2783 let trace = rx.try_recv().expect("a trace was enqueued");
2784 assert_eq!(trace.mode, Mode::Enforce);
2785 assert_eq!(trace.final_.served_rung, Some(0));
2786 assert_eq!(trace.attempts.len(), 1);
2787 }
2788
2789 #[tokio::test]
2790 async fn enforce_escalates_then_serves_and_traces_two_attempts() {
2791 let (state, mut rx) = enforce_state(
2792 &["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"],
2793 &["non-empty"],
2794 vec![
2795 (
2796 "anthropic/claude-haiku-4-5",
2797 Ok(model_resp("anthropic/claude-haiku-4-5", " ")),
2798 ), (
2800 "anthropic/claude-sonnet-5",
2801 Ok(model_resp("anthropic/claude-sonnet-5", "answer")),
2802 ),
2803 ],
2804 );
2805 let resp = messages(
2806 State(state),
2807 Extension(TenantId("default".to_owned())),
2808 HeaderMap::new(),
2809 user_body(),
2810 )
2811 .await;
2812 let json = body_json(resp).await;
2813 assert_eq!(json["content"][0]["text"], "answer");
2814
2815 let trace = rx.try_recv().expect("trace enqueued");
2816 assert_eq!(trace.attempts.len(), 2);
2817 assert_eq!(trace.final_.escalations, 1);
2818 assert_eq!(trace.final_.served_rung, Some(1));
2819 }
2820
2821 #[tokio::test]
2822 async fn enforce_all_rungs_error_returns_502() {
2823 let (state, mut rx) = enforce_state(
2824 &["anthropic/claude-haiku-4-5"],
2825 &["non-empty"],
2826 vec![(
2827 "anthropic/claude-haiku-4-5",
2828 Err(ProviderError::Transport("down".into())),
2829 )],
2830 );
2831 let resp = messages(
2832 State(state),
2833 Extension(TenantId("default".to_owned())),
2834 HeaderMap::new(),
2835 user_body(),
2836 )
2837 .await;
2838 assert_eq!(resp.status(), axum::http::StatusCode::BAD_GATEWAY);
2839 assert!(rx.try_recv().is_ok());
2841 }
2842
2843 #[tokio::test]
2844 async fn no_routing_config_falls_through_to_observe_not_enforce() {
2845 let config = ProxyConfig::from_lookup(|k| match k {
2849 "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
2850 _ => None,
2851 })
2852 .unwrap();
2853 let (traces, _rx) = mpsc::channel(64);
2854 let state = AppState {
2855 config: Arc::new(config),
2856 http: reqwest::Client::new(),
2857 providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
2858 gate_health: Arc::new(GateHealthRegistry::new()),
2859 traces,
2860 adaptive: None,
2861 bandit: None,
2862 predictor: None,
2863 tenant_rate_limiter: None,
2864 spill: None,
2865 };
2866 let resp = messages(
2867 State(state),
2868 Extension(TenantId("default".to_owned())),
2869 HeaderMap::new(),
2870 user_body(),
2871 )
2872 .await;
2873 assert_ne!(resp.status(), axum::http::StatusCode::OK);
2875 }
2876
2877 fn bare_enforce_route() -> Route {
2880 use firstpass_core::config::{Match, Mode};
2881 Route {
2882 match_: Match::default(),
2883 mode: Mode::Enforce,
2884 ladder: vec!["anthropic/claude-haiku-4-5".to_owned()],
2885 gates: vec![],
2886 deferred_gates: vec![],
2887 routing_mode: None,
2888 }
2889 }
2890
2891 #[test]
2892 fn balanced_preset_application_is_noop() {
2893 let base_max_rungs = 3u32;
2895 let base_speculation = 2u32;
2896 let preset = RoutingMode::Balanced.preset();
2897 let max_rungs = if let Some(d) = preset.max_rungs_delta {
2898 (base_max_rungs as i32 + d).max(1) as u32
2899 } else {
2900 base_max_rungs
2901 };
2902 let speculation = preset.speculation.unwrap_or(base_speculation);
2903 let start_at_top = preset.start_at_top;
2904 assert_eq!(max_rungs, 3, "Balanced must not change max_rungs");
2905 assert_eq!(speculation, 2, "Balanced must not change speculation");
2906 assert!(!start_at_top, "Balanced must not set start_at_top");
2907 }
2908
2909 #[test]
2910 fn resolve_mode_header_wins_over_route_and_global() {
2911 let mut headers = HeaderMap::new();
2912 headers.insert("x-firstpass-mode", "cost".parse().unwrap());
2913 let mut route = bare_enforce_route();
2914 route.routing_mode = Some(RoutingMode::Quality); let mut config = test_config();
2916 config.default_routing_mode = RoutingMode::Max; assert_eq!(
2918 resolve_mode(&headers, &route, &config),
2919 RoutingMode::Cost,
2920 "header must win"
2921 );
2922 }
2923
2924 #[test]
2925 fn resolve_mode_route_wins_over_global_when_no_header() {
2926 let mut route = bare_enforce_route();
2927 route.routing_mode = Some(RoutingMode::Latency);
2928 let mut config = test_config();
2929 config.default_routing_mode = RoutingMode::Max;
2930 assert_eq!(
2931 resolve_mode(&HeaderMap::new(), &route, &config),
2932 RoutingMode::Latency,
2933 "route must beat global default"
2934 );
2935 }
2936
2937 #[test]
2938 fn resolve_mode_global_when_header_and_route_absent() {
2939 let mut config = test_config();
2940 config.default_routing_mode = RoutingMode::Quality;
2941 assert_eq!(
2942 resolve_mode(&HeaderMap::new(), &bare_enforce_route(), &config),
2943 RoutingMode::Quality
2944 );
2945 }
2946
2947 #[test]
2948 fn resolve_mode_unknown_header_falls_through_to_route() {
2949 let mut headers = HeaderMap::new();
2950 headers.insert("x-firstpass-mode", "turbo-mode".parse().unwrap());
2952 let mut route = bare_enforce_route();
2953 route.routing_mode = Some(RoutingMode::Cost);
2954 let config = test_config();
2955 assert_eq!(
2956 resolve_mode(&headers, &route, &config),
2957 RoutingMode::Cost,
2958 "unknown header value must fall through to route"
2959 );
2960 }
2961
2962 #[test]
2963 fn resolve_mode_header_case_insensitive() {
2964 let mut headers = HeaderMap::new();
2965 headers.insert("x-firstpass-mode", "QUALITY".parse().unwrap());
2966 assert_eq!(
2967 resolve_mode(&headers, &bare_enforce_route(), &test_config()),
2968 RoutingMode::Quality
2969 );
2970 }
2971
2972 #[test]
2973 fn resolve_mode_no_mode_set_returns_balanced() {
2974 assert_eq!(
2976 resolve_mode(&HeaderMap::new(), &bare_enforce_route(), &test_config()),
2977 RoutingMode::Balanced
2978 );
2979 }
2980
2981 #[test]
2982 fn capabilities_json_includes_routing_modes() {
2983 let modes: Vec<&'static str> = RoutingMode::ALL.iter().map(|m| m.as_str()).collect();
2984 assert!(modes.contains(&"balanced"));
2985 assert!(modes.contains(&"cost"));
2986 assert!(modes.contains(&"quality"));
2987 assert!(modes.contains(&"latency"));
2988 assert!(modes.contains(&"max"));
2989 assert!(modes.contains(&"observe"));
2990 }
2991
2992 #[test]
2993 fn detects_stream_requests() {
2994 assert!(is_stream_request(br#"{"stream": true}"#));
2995 assert!(!is_stream_request(br#"{"stream": false}"#));
2996 assert!(!is_stream_request(br#"{"model":"m"}"#));
2997 assert!(!is_stream_request(b"not json"));
2998 }
2999
3000 #[test]
3001 fn detects_tool_blocks_in_messages() {
3002 let with =
3003 br#"{"messages":[{"role":"user","content":[{"type":"tool_result","content":"42"}]}]}"#;
3004 let without = br#"{"messages":[{"role":"user","content":"hi"}]}"#;
3005 assert!(messages_have_tool_blocks(with));
3006 assert!(!messages_have_tool_blocks(without));
3007 }
3008
3009 #[test]
3010 fn enforce_only_handles_plain_text() {
3011 let plain =
3012 Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
3013 let tools = Bytes::from_static(
3014 br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
3015 );
3016 let f_plain = extract_features(&HeaderMap::new(), &plain);
3017 let f_tools = extract_features(&HeaderMap::new(), &tools);
3018 let anthropic_ladder = vec!["anthropic/claude-haiku-4-5".to_owned()];
3019 let providers = test_registry();
3020 assert!(enforce_can_handle(
3022 &f_plain,
3023 &plain,
3024 false,
3025 &anthropic_ladder,
3026 &providers,
3027 Dialect::Anthropic,
3028 ));
3029 assert!(!enforce_can_handle(
3030 &f_tools,
3031 &tools,
3032 false,
3033 &anthropic_ladder,
3034 &providers,
3035 Dialect::Anthropic,
3036 ));
3037 }
3038
3039 #[test]
3040 fn structured_enforce_routes_tools_and_streaming() {
3041 let tools = Bytes::from_static(
3044 br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
3045 );
3046 let streaming_tools = Bytes::from_static(
3047 br#"{"model":"m","stream":true,"tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
3048 );
3049 let f = extract_features(&HeaderMap::new(), &tools);
3050 let anthropic_ladder = vec![
3051 "anthropic/claude-haiku-4-5".to_owned(),
3052 "anthropic/claude-sonnet-5".to_owned(),
3053 ];
3054 let providers = test_registry();
3055 assert!(enforce_can_handle(
3056 &f,
3057 &tools,
3058 true,
3059 &anthropic_ladder,
3060 &providers,
3061 Dialect::Anthropic,
3062 ));
3063 assert!(enforce_can_handle(
3064 &f,
3065 &streaming_tools,
3066 true,
3067 &anthropic_ladder,
3068 &providers,
3069 Dialect::Anthropic,
3070 ));
3071 }
3072
3073 fn test_registry() -> crate::provider::ProviderRegistry {
3075 crate::provider::ProviderRegistry::new("http://localhost", "http://localhost")
3076 }
3077
3078 #[test]
3079 fn fidelity_guard_blocks_structured_on_non_verbatim_ladder() {
3080 let tools = Bytes::from_static(
3084 br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
3085 );
3086 let plain =
3087 Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
3088 let f_tools = extract_features(&HeaderMap::new(), &tools);
3089 let f_plain = extract_features(&HeaderMap::new(), &plain);
3090 let providers = test_registry();
3091 let mixed_ladder = vec![
3092 "openai/gpt-4.1-mini".to_owned(),
3093 "anthropic/claude-sonnet-5".to_owned(),
3094 ];
3095 assert!(!enforce_can_handle(
3096 &f_tools,
3097 &tools,
3098 true,
3099 &mixed_ladder,
3100 &providers,
3101 Dialect::Anthropic,
3102 ));
3103 assert!(enforce_can_handle(
3104 &f_plain,
3105 &plain,
3106 true,
3107 &mixed_ladder,
3108 &providers,
3109 Dialect::Anthropic,
3110 ));
3111 }
3112
3113 #[test]
3114 fn enforce_sse_reemission_preserves_text_and_tool_use() {
3115 let resp = ModelResponse {
3118 model: "anthropic/claude-haiku-4-5".to_owned(),
3119 text: "let me check".to_owned(),
3120 in_tokens: 5,
3121 out_tokens: 7,
3122 raw: serde_json::json!({
3123 "content": [
3124 { "type": "text", "text": "let me check" },
3125 { "type": "tool_use", "id": "tu_1", "name": "get_weather", "input": { "city": "Paris" } }
3126 ]
3127 }),
3128 };
3129 let sse = anthropic_sse_from_message(&anthropic_response_json(&resp));
3130
3131 let frames: Vec<Value> = sse
3133 .lines()
3134 .filter_map(|l| l.strip_prefix("data: "))
3135 .map(|d| serde_json::from_str::<Value>(d).expect("each SSE data frame is valid JSON"))
3136 .collect();
3137
3138 assert_eq!(frames.first().unwrap()["type"], "message_start");
3140 assert_eq!(frames.last().unwrap()["type"], "message_stop");
3141 assert!(frames.iter().any(|f| f["delta"]["type"] == "text_delta"
3143 && f["delta"]["text"] == "let me check"));
3144 assert!(
3147 frames
3148 .iter()
3149 .any(|f| f["content_block"]["type"] == "tool_use"
3150 && f["content_block"]["name"] == "get_weather"
3151 && f["content_block"]["id"] == "tu_1")
3152 );
3153 assert!(
3154 frames
3155 .iter()
3156 .any(|f| f["delta"]["type"] == "input_json_delta"
3157 && f["delta"]["partial_json"] == r#"{"city":"Paris"}"#)
3158 );
3159 }
3160
3161 #[tokio::test]
3166 async fn enforce_falls_back_to_observe_for_tool_requests() {
3167 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n";
3168 let config = ProxyConfig::from_lookup(|k| match k {
3169 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
3170 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
3171 "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
3172 _ => None,
3173 })
3174 .unwrap();
3175 let mut outs = HashMap::new();
3176 outs.insert(
3177 "anthropic/m".to_owned(),
3178 Ok(model_resp("anthropic/m", "hello")),
3179 );
3180 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
3181 map.insert(
3182 "anthropic".to_owned(),
3183 Arc::new(MockProvider::new("anthropic", outs)),
3184 );
3185 let (traces, _rx) = mpsc::channel(64);
3186 let state = AppState {
3187 config: Arc::new(config),
3188 http: reqwest::Client::new(),
3189 providers: ProviderRegistry::from_map(map),
3190 gate_health: Arc::new(GateHealthRegistry::new()),
3191 traces,
3192 adaptive: None,
3193 bandit: None,
3194 predictor: None,
3195 tenant_rate_limiter: None,
3196 spill: None,
3197 };
3198
3199 let plain =
3201 Bytes::from_static(br#"{"model":"m","messages":[{"role":"user","content":"hi"}]}"#);
3202 let resp = messages(
3203 State(state.clone()),
3204 Extension(TenantId("default".to_owned())),
3205 HeaderMap::new(),
3206 plain,
3207 )
3208 .await;
3209 assert_eq!(
3210 resp.status(),
3211 axum::http::StatusCode::OK,
3212 "plain text should enforce"
3213 );
3214
3215 let tools = Bytes::from_static(
3218 br#"{"model":"m","tools":[{"name":"get_weather"}],"messages":[{"role":"user","content":"hi"}]}"#,
3219 );
3220 let resp = messages(
3221 State(state.clone()),
3222 Extension(TenantId("default".to_owned())),
3223 HeaderMap::new(),
3224 tools.clone(),
3225 )
3226 .await;
3227 assert_eq!(
3228 resp.status(),
3229 axum::http::StatusCode::OK,
3230 "tool request must route through enforce by default (ADR 0005 default-on)"
3231 );
3232
3233 let toml_off = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n[escalation]\nenforce_structured = false\n";
3236 let config_off = ProxyConfig::from_lookup(|k| match k {
3237 "FIRSTPASS_CONFIG_TOML" => Some(toml_off.to_owned()),
3238 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
3239 "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
3240 _ => None,
3241 })
3242 .unwrap();
3243 let state_off = AppState {
3244 config: Arc::new(config_off),
3245 ..state.clone()
3246 };
3247 let resp = messages(
3248 State(state_off),
3249 Extension(TenantId("default".to_owned())),
3250 HeaderMap::new(),
3251 tools,
3252 )
3253 .await;
3254 assert_ne!(
3255 resp.status(),
3256 axum::http::StatusCode::OK,
3257 "with enforce_structured = false a tool request must fall back to observe"
3258 );
3259
3260 let toolres = Bytes::from_static(
3262 br#"{"model":"m","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"42"}]}]}"#,
3263 );
3264 let resp = messages(
3265 State(state),
3266 Extension(TenantId("default".to_owned())),
3267 HeaderMap::new(),
3268 toolres,
3269 )
3270 .await;
3271 assert_eq!(
3272 resp.status(),
3273 axum::http::StatusCode::OK,
3274 "tool_result blocks route through enforce by default too (verbatim carry)"
3275 );
3276 }
3277
3278 async fn feedback_state() -> (AppState, std::path::PathBuf, String) {
3282 let db = std::env::temp_dir().join(format!("firstpass-feedback-{}.db", Uuid::now_v7()));
3283 let (tx, handle) = crate::store::open(&db).unwrap();
3284
3285 let mut trace = build_error_trace(
3286 &ProxyConfig::from_lookup(|_| None).unwrap(),
3287 &Bytes::from_static(b"{}"),
3288 5,
3289 Some("sess-fb"),
3290 );
3291 trace.attempts.push(Attempt {
3292 rung: 0,
3293 model: "anthropic/claude-haiku-4-5".into(),
3294 provider: "anthropic".into(),
3295 in_tokens: 10,
3296 out_tokens: 5,
3297 cost_usd: 0.001,
3298 latency_ms: 5,
3299 gates: vec![],
3300 verdict: Verdict::Pass,
3301 });
3302 let trace_id = trace.trace_id.to_string();
3303 tx.try_send(trace).unwrap();
3304 drop(tx);
3305 handle.await.unwrap();
3306
3307 let db_str = db.to_string_lossy().into_owned();
3308 let config = ProxyConfig::from_lookup(move |k| match k {
3309 "FIRSTPASS_DB" => Some(db_str.clone()),
3310 _ => None,
3311 })
3312 .unwrap();
3313 let (traces, _rx) = mpsc::channel(64);
3314 let state = AppState {
3315 config: Arc::new(config),
3316 http: reqwest::Client::new(),
3317 providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
3318 gate_health: Arc::new(GateHealthRegistry::new()),
3319 traces,
3320 adaptive: None,
3321 bandit: None,
3322 predictor: None,
3323 tenant_rate_limiter: None,
3324 spill: None,
3325 };
3326 (state, db, trace_id)
3327 }
3328
3329 #[tokio::test]
3330 async fn feedback_nudges_the_adaptive_threshold() {
3331 use firstpass_core::conformal::AdaptiveConformal;
3332 let (mut state, _db, trace_id) = feedback_state().await;
3333 let aci = Arc::new(std::sync::Mutex::new(AdaptiveConformal::new(0.1, 0.2, 0.5)));
3334 state.adaptive = Some(aci.clone());
3335 let before = aci.lock().unwrap().threshold();
3336
3337 let fail = Bytes::from(
3339 serde_json::json!({ "trace_id": trace_id, "gate_id": "tests", "verdict": "fail", "reporter": "ci" })
3340 .to_string(),
3341 );
3342 assert_eq!(
3343 feedback(
3344 State(state.clone()),
3345 Extension(TenantId("default".to_owned())),
3346 fail
3347 )
3348 .await
3349 .status(),
3350 axum::http::StatusCode::ACCEPTED
3351 );
3352 let after_fail = aci.lock().unwrap().threshold();
3353 assert!(
3354 after_fail > before,
3355 "served fail should raise the live threshold: {before} -> {after_fail}"
3356 );
3357
3358 let pass = Bytes::from(
3360 serde_json::json!({ "trace_id": trace_id, "gate_id": "tests", "verdict": "pass", "reporter": "ci" })
3361 .to_string(),
3362 );
3363 let _ = feedback(
3364 State(state),
3365 Extension(TenantId("default".to_owned())),
3366 pass,
3367 )
3368 .await;
3369 assert!(aci.lock().unwrap().threshold() < after_fail);
3370 }
3371
3372 #[tokio::test]
3373 async fn feedback_records_a_deferred_verdict_without_breaking_the_chain() {
3374 let (state, db, trace_id) = feedback_state().await;
3375 let body = Bytes::from(
3376 serde_json::json!({
3377 "trace_id": trace_id,
3378 "gate_id": "tests",
3379 "verdict": "pass",
3380 "score": 1.0,
3381 "reporter": "ci",
3382 })
3383 .to_string(),
3384 );
3385 let resp = feedback(
3386 State(state),
3387 Extension(TenantId("default".to_owned())),
3388 body,
3389 )
3390 .await;
3391 assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
3392
3393 let view = crate::store::load_trace_view(&db, "default", &trace_id)
3395 .unwrap()
3396 .unwrap();
3397 assert_eq!(view.deferred.len(), 1);
3398 assert_eq!(view.deferred[0].gate_id, "tests");
3399 let traces = crate::store::load_all_traces(&db).unwrap();
3401 firstpass_core::verify_chain(&traces, GENESIS_HASH).unwrap();
3402
3403 let _ = std::fs::remove_file(&db);
3404 }
3405
3406 #[tokio::test]
3407 async fn feedback_for_unknown_trace_is_404() {
3408 let (state, db, _trace_id) = feedback_state().await;
3409 let body = Bytes::from(
3410 serde_json::json!({
3411 "trace_id": "does-not-exist",
3412 "gate_id": "tests",
3413 "verdict": "pass",
3414 "reporter": "ci",
3415 })
3416 .to_string(),
3417 );
3418 let resp = feedback(
3419 State(state),
3420 Extension(TenantId("default".to_owned())),
3421 body,
3422 )
3423 .await;
3424 assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
3425 let _ = std::fs::remove_file(&db);
3426 }
3427
3428 #[tokio::test]
3431 async fn feedback_across_tenants_is_404_not_403() {
3432 let (state, db, trace_id) = feedback_state().await;
3433 let body = Bytes::from(
3434 serde_json::json!({
3435 "trace_id": trace_id,
3436 "gate_id": "tests",
3437 "verdict": "pass",
3438 "score": 1.0,
3439 "reporter": "attacker",
3440 })
3441 .to_string(),
3442 );
3443 let resp = feedback(
3445 State(state),
3446 Extension(TenantId("tenant-b".to_owned())),
3447 body,
3448 )
3449 .await;
3450 assert_eq!(
3451 resp.status(),
3452 axum::http::StatusCode::NOT_FOUND,
3453 "cross-tenant feedback must look exactly like a missing trace"
3454 );
3455 let _ = std::fs::remove_file(&db);
3456 }
3457
3458 #[tokio::test]
3459 async fn feedback_rejects_bad_verdict_and_score() {
3460 let (state, db, trace_id) = feedback_state().await;
3461 let bad_verdict = Bytes::from(
3462 serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "maybe", "reporter": "x" })
3463 .to_string(),
3464 );
3465 assert_eq!(
3466 feedback(
3467 State(state.clone()),
3468 Extension(TenantId("default".to_owned())),
3469 bad_verdict
3470 )
3471 .await
3472 .status(),
3473 axum::http::StatusCode::BAD_REQUEST
3474 );
3475 let bad_score = Bytes::from(
3476 serde_json::json!({ "trace_id": trace_id, "gate_id": "g", "verdict": "pass", "score": 9.0, "reporter": "x" })
3477 .to_string(),
3478 );
3479 assert_eq!(
3480 feedback(
3481 State(state),
3482 Extension(TenantId("default".to_owned())),
3483 bad_score
3484 )
3485 .await
3486 .status(),
3487 axum::http::StatusCode::BAD_REQUEST
3488 );
3489 let _ = std::fs::remove_file(&db);
3490 }
3491
3492 #[tokio::test]
3493 async fn metrics_endpoint_renders_after_a_real_request() {
3494 use tower::ServiceExt;
3495
3496 let (state, mut rx) = enforce_state(
3497 &["anthropic/claude-haiku-4-5"],
3498 &["non-empty"],
3499 vec![(
3500 "anthropic/claude-haiku-4-5",
3501 Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
3502 )],
3503 );
3504 let router = app(state).expect("prometheus recorder installs");
3505
3506 let req = axum::http::Request::builder()
3507 .method("POST")
3508 .uri("/v1/messages")
3509 .header("content-type", "application/json")
3510 .body(Body::from(user_body()))
3511 .unwrap();
3512 let resp = router.clone().oneshot(req).await.unwrap();
3513 assert_eq!(resp.status(), axum::http::StatusCode::OK);
3514 rx.try_recv().expect("a trace was enqueued");
3515
3516 let metrics_req = axum::http::Request::builder()
3517 .method("GET")
3518 .uri("/metrics")
3519 .body(Body::empty())
3520 .unwrap();
3521 let metrics_resp = router.oneshot(metrics_req).await.unwrap();
3522 assert_eq!(metrics_resp.status(), axum::http::StatusCode::OK);
3523 let bytes = axum::body::to_bytes(metrics_resp.into_body(), 1 << 20)
3524 .await
3525 .unwrap();
3526 let body = String::from_utf8(bytes.to_vec()).unwrap();
3527 assert!(
3528 body.contains("firstpass_enforce_latency_ms"),
3529 "metrics body missing enforce latency histogram: {body}"
3530 );
3531 assert!(
3532 body.contains("firstpass_served_total"),
3533 "metrics body missing served counter: {body}"
3534 );
3535 }
3536
3537 fn auth_state(require_auth: bool, keys_json: Option<String>) -> AppState {
3541 auth_state_rated(require_auth, keys_json, None)
3542 }
3543
3544 fn auth_state_rated(
3547 require_auth: bool,
3548 keys_json: Option<String>,
3549 rate_per_sec: Option<u32>,
3550 ) -> AppState {
3551 let config = ProxyConfig::from_lookup(|k| match k {
3552 "FIRSTPASS_REQUIRE_AUTH" => require_auth.then(|| "true".to_owned()),
3553 "FIRSTPASS_TENANT_KEYS_JSON" => keys_json.clone(),
3554 "FIRSTPASS_TENANT_RATE_PER_SEC" => rate_per_sec.map(|n| n.to_string()),
3555 _ => None,
3556 })
3557 .unwrap();
3558 let (traces, _rx) = mpsc::channel(64);
3559 std::mem::forget(_rx);
3562 let providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
3563 let tenant_rate_limiter = build_tenant_rate_limiter(&config);
3564 AppState {
3565 config: Arc::new(config),
3566 http: reqwest::Client::new(),
3567 providers: ProviderRegistry::from_map(providers),
3568 gate_health: Arc::new(GateHealthRegistry::new()),
3569 traces,
3570 adaptive: None,
3571 bandit: None,
3572 predictor: None,
3573 tenant_rate_limiter,
3574 spill: None,
3575 }
3576 }
3577
3578 fn cap_request(auth_header: Option<&str>) -> axum::http::Request<Body> {
3579 let mut b = axum::http::Request::builder()
3580 .method("GET")
3581 .uri("/v1/capabilities");
3582 if let Some(h) = auth_header {
3583 b = b.header("authorization", h);
3584 }
3585 b.body(Body::empty()).unwrap()
3586 }
3587
3588 #[tokio::test]
3589 async fn auth_off_allows_unauthenticated_request() {
3590 use tower::ServiceExt;
3591 let router = app(auth_state(false, None)).expect("router");
3592 let resp = router.oneshot(cap_request(None)).await.unwrap();
3593 assert_eq!(resp.status(), axum::http::StatusCode::OK);
3595 }
3596
3597 #[tokio::test]
3598 async fn auth_on_missing_key_is_401_opaque() {
3599 use tower::ServiceExt;
3600 let hash = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3601 let keys = format!("{{\"tenant-a\": {hash:?}}}");
3602 let router = app(auth_state(true, Some(keys))).expect("router");
3603
3604 let resp = router.oneshot(cap_request(None)).await.unwrap();
3605 assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
3606 let json = body_json(resp).await;
3607 assert_eq!(json["error"]["type"], "unauthorized");
3608 let msg = json["error"]["message"].as_str().unwrap();
3610 assert!(!msg.contains("tenant"), "no tenant oracle in body: {msg}");
3611 }
3612
3613 #[tokio::test]
3614 async fn auth_on_invalid_key_is_401() {
3615 use tower::ServiceExt;
3616 let hash = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3617 let keys = format!("{{\"tenant-a\": {hash:?}}}");
3618 let router = app(auth_state(true, Some(keys))).expect("router");
3619
3620 let resp = router
3621 .oneshot(cap_request(Some("Bearer wrong-key")))
3622 .await
3623 .unwrap();
3624 assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
3625 }
3626
3627 #[tokio::test]
3628 async fn auth_on_valid_key_proceeds() {
3629 use tower::ServiceExt;
3630 let hash = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3631 let keys = format!("{{\"tenant-a\": {hash:?}}}");
3632 let router = app(auth_state(true, Some(keys))).expect("router");
3633
3634 let resp = router
3636 .oneshot(cap_request(Some("Bearer tenant-a.key-a")))
3637 .await
3638 .unwrap();
3639 assert_eq!(resp.status(), axum::http::StatusCode::OK);
3641 }
3642
3643 fn two_tenant_state(rate_per_sec: Option<u32>) -> AppState {
3645 let hash_a = crate::tenant_auth::TenantKeys::hash_key("key-a").unwrap();
3646 let hash_b = crate::tenant_auth::TenantKeys::hash_key("key-b").unwrap();
3647 let keys = format!("{{\"tenant-a\": {hash_a:?}, \"tenant-b\": {hash_b:?}}}");
3648 auth_state_rated(true, Some(keys), rate_per_sec)
3649 }
3650
3651 #[tokio::test]
3652 async fn tenant_exceeding_rate_limit_gets_429_opaque() {
3653 use tower::ServiceExt;
3654 let router = app(two_tenant_state(Some(1))).expect("router");
3660
3661 let (r1, r2, r3, r4) = tokio::join!(
3662 router
3663 .clone()
3664 .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3665 router
3666 .clone()
3667 .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3668 router
3669 .clone()
3670 .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3671 router
3672 .clone()
3673 .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3674 );
3675 let responses = [r1.unwrap(), r2.unwrap(), r3.unwrap(), r4.unwrap()];
3676 let ok = responses
3677 .iter()
3678 .filter(|r| r.status() == axum::http::StatusCode::OK)
3679 .count();
3680 assert!(ok >= 1, "the burst's first request must pass");
3681 let limited: Vec<_> = responses
3682 .into_iter()
3683 .filter(|r| r.status() == axum::http::StatusCode::TOO_MANY_REQUESTS)
3684 .collect();
3685 assert!(
3686 !limited.is_empty(),
3687 "a 4-request burst against 1 req/sec must trip the limiter"
3688 );
3689
3690 let json = body_json(limited.into_iter().next().unwrap()).await;
3691 assert_eq!(json["error"]["type"], "rate_limited");
3692 let msg = json["error"]["message"].as_str().unwrap();
3694 assert!(!msg.contains('1'), "no limit value in body: {msg}");
3695 }
3696
3697 #[tokio::test]
3698 async fn rate_limit_buckets_are_independent_per_tenant() {
3699 use tower::ServiceExt;
3700 let router = app(two_tenant_state(Some(1))).expect("router");
3701
3702 let (a1, a2, a3) = tokio::join!(
3705 router
3706 .clone()
3707 .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3708 router
3709 .clone()
3710 .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3711 router
3712 .clone()
3713 .oneshot(cap_request(Some("Bearer tenant-a.key-a"))),
3714 );
3715 let a_limited = [a1.unwrap(), a2.unwrap(), a3.unwrap()]
3716 .iter()
3717 .filter(|r| r.status() == axum::http::StatusCode::TOO_MANY_REQUESTS)
3718 .count();
3719 assert!(a_limited >= 1, "tenant A's burst must trip its limiter");
3720
3721 let b1 = router
3723 .clone()
3724 .oneshot(cap_request(Some("Bearer tenant-b.key-b")))
3725 .await
3726 .unwrap();
3727 assert_eq!(b1.status(), axum::http::StatusCode::OK);
3728 }
3729
3730 #[tokio::test]
3731 async fn rate_limit_unset_never_429s() {
3732 use tower::ServiceExt;
3733 let router = app(two_tenant_state(None)).expect("router");
3736 for _ in 0..20 {
3737 let resp = router
3738 .clone()
3739 .oneshot(cap_request(Some("Bearer tenant-a.key-a")))
3740 .await
3741 .unwrap();
3742 assert_eq!(resp.status(), axum::http::StatusCode::OK);
3743 }
3744 }
3745
3746 #[test]
3749 fn u01_is_deterministic_and_in_range() {
3750 let s1 = u01(0xDEAD_BEEF_CAFE_1234_u128);
3751 let s2 = u01(0xDEAD_BEEF_CAFE_1234_u128);
3752 assert_eq!(s1, s2, "u01 must be deterministic for the same seed");
3753 assert!((0.0..1.0).contains(&s1), "u01 must return [0, 1), got {s1}");
3754
3755 let s3 = u01(0x1234_5678_9ABC_DEF0_u128);
3757 assert_ne!(s1, s3, "different seeds should give different values");
3758
3759 for i in 0u64..256 {
3761 let v = u01(i as u128);
3762 assert!((0.0..1.0).contains(&v), "seed {i}: u01={v} out of [0,1)");
3763 }
3764 }
3765
3766 #[test]
3767 fn epsilon_propensity_formula() {
3768 let epsilon = 0.2_f64;
3769 let k = 3_usize;
3770 let greedy = 1_u32;
3771
3772 let p_greedy = epsilon_propensity(greedy, greedy, epsilon, k);
3774 let expected_greedy = (1.0 - epsilon) + epsilon / k as f64;
3775 assert!(
3776 (p_greedy - expected_greedy).abs() < 1e-12,
3777 "{p_greedy} != {expected_greedy}"
3778 );
3779
3780 let p_other = epsilon_propensity(0, greedy, epsilon, k);
3782 let expected_other = epsilon / k as f64;
3783 assert!(
3784 (p_other - expected_other).abs() < 1e-12,
3785 "{p_other} != {expected_other}"
3786 );
3787
3788 for chosen in 0..k as u32 {
3790 let p = epsilon_propensity(chosen, greedy, epsilon, k);
3791 assert!(
3792 p > 0.0 && p <= 1.0,
3793 "propensity {p} out of (0,1] for chosen={chosen}"
3794 );
3795 }
3796 }
3797
3798 #[test]
3799 fn epsilon_branch_and_greedy_branch_both_occur_over_many_seeds() {
3800 let epsilon = 0.3_f64;
3802 let mut saw_explore = false;
3803 let mut saw_greedy = false;
3804 for i in 0u64..200 {
3805 let u = u01(i as u128);
3806 if u < epsilon {
3807 saw_explore = true;
3808 } else {
3809 saw_greedy = true;
3810 }
3811 if saw_explore && saw_greedy {
3812 break;
3813 }
3814 }
3815 assert!(
3816 saw_explore,
3817 "epsilon branch must fire with epsilon=0.3 over 200 seeds"
3818 );
3819 assert!(
3820 saw_greedy,
3821 "greedy branch must occur with epsilon=0.3 over 200 seeds"
3822 );
3823 }
3824
3825 #[test]
3826 fn epsilon_propensity_sums_to_one_over_all_rungs() {
3827 let epsilon = 0.15_f64;
3829 let k = 4_usize;
3830 let greedy = 2_u32;
3831 let total: f64 = (0..k as u32)
3832 .map(|r| epsilon_propensity(r, greedy, epsilon, k))
3833 .sum();
3834 assert!(
3835 (total - 1.0).abs() < 1e-12,
3836 "propensities must sum to 1, got {total}"
3837 );
3838 }
3839
3840 #[tokio::test(start_paused = true)]
3843 async fn keepalive_stream_ticks_then_emits_final_frame() {
3844 let (tx, rx) = tokio::sync::oneshot::channel::<Result<Value, ProxyError>>();
3845 let mut ticks = tokio::time::interval(SSE_KEEPALIVE_EVERY);
3846 ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
3847 ticks.reset();
3848 let mut stream = KeepaliveStream {
3849 rx: Some(rx),
3850 ticks,
3851 format_message: anthropic_sse_from_message,
3852 };
3853 async fn next(
3855 stream: &mut KeepaliveStream,
3856 ) -> Option<Option<Result<Bytes, std::convert::Infallible>>> {
3857 std::future::poll_fn(|cx| {
3858 std::task::Poll::Ready(
3859 match futures_core::Stream::poll_next(std::pin::Pin::new(&mut *stream), cx) {
3860 std::task::Poll::Ready(item) => Some(item),
3861 std::task::Poll::Pending => None,
3862 },
3863 )
3864 })
3865 .await
3866 }
3867
3868 assert!(
3870 next(&mut stream).await.is_none(),
3871 "no frame before an interval"
3872 );
3873
3874 tokio::time::advance(SSE_KEEPALIVE_EVERY + Duration::from_millis(1)).await;
3876 let frame = next(&mut stream)
3877 .await
3878 .expect("keepalive due")
3879 .unwrap()
3880 .unwrap();
3881 assert!(
3882 frame.starts_with(b": "),
3883 "keepalive must be an SSE comment (ignored by every conforming parser)"
3884 );
3885
3886 let message = serde_json::json!({
3888 "id": "msg_1", "type": "message", "role": "assistant", "model": "m",
3889 "content": [{ "type": "text", "text": "done" }],
3890 "usage": { "input_tokens": 1, "output_tokens": 1 }
3891 });
3892 tx.send(Ok(message)).unwrap();
3893 let frame = next(&mut stream)
3894 .await
3895 .expect("final frame")
3896 .unwrap()
3897 .unwrap();
3898 let text = String::from_utf8(frame.to_vec()).unwrap();
3899 assert!(text.contains("event: message_start"));
3900 assert!(text.contains("event: message_stop"));
3901 let eos = next(&mut stream)
3902 .await
3903 .expect("stream must end after the final frame");
3904 assert!(eos.is_none(), "end-of-stream after the final frame");
3905 }
3906
3907 #[tokio::test]
3910 async fn streaming_enforce_serves_full_sse_sequence() {
3911 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/m\"]\ngates = [\"non-empty\"]\n";
3912 let config = ProxyConfig::from_lookup(|k| match k {
3913 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
3914 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
3915 "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some("http://127.0.0.1:1".to_owned()),
3916 _ => None,
3917 })
3918 .unwrap();
3919 let mut outs = HashMap::new();
3920 outs.insert(
3921 "anthropic/m".to_owned(),
3922 Ok(model_resp("anthropic/m", "gated answer")),
3923 );
3924 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
3925 map.insert(
3926 "anthropic".to_owned(),
3927 Arc::new(MockProvider::new("anthropic", outs)),
3928 );
3929 let (traces, _rx) = mpsc::channel(64);
3930 let state = AppState {
3931 config: Arc::new(config),
3932 http: reqwest::Client::new(),
3933 providers: ProviderRegistry::from_map(map),
3934 gate_health: Arc::new(GateHealthRegistry::new()),
3935 traces,
3936 adaptive: None,
3937 bandit: None,
3938 predictor: None,
3939 tenant_rate_limiter: None,
3940 spill: None,
3941 };
3942 let body = Bytes::from_static(
3943 br#"{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}"#,
3944 );
3945 let resp = messages(
3946 State(state),
3947 Extension(TenantId("default".to_owned())),
3948 HeaderMap::new(),
3949 body,
3950 )
3951 .await;
3952 assert_eq!(resp.status(), axum::http::StatusCode::OK);
3953 assert!(
3954 resp.headers()
3955 .get(axum::http::header::CONTENT_TYPE)
3956 .and_then(|v| v.to_str().ok())
3957 .is_some_and(|ct| ct.starts_with("text/event-stream")),
3958 "streaming client must get SSE"
3959 );
3960 let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
3961 .await
3962 .unwrap();
3963 let text = String::from_utf8(bytes.to_vec()).unwrap();
3964 assert!(text.contains("event: message_start"));
3965 assert!(text.contains("gated answer"));
3966 assert!(text.contains("event: message_stop"));
3967 }
3968
3969 #[test]
3974 fn parse_openai_request_plain_text() {
3975 let body = br#"{"model":"gpt-4o","max_tokens":256,"messages":[{"role":"user","content":"hello"}]}"#;
3977 let req = parse_openai_request(body, false).expect("must parse");
3978 assert_eq!(req.model, "gpt-4o");
3979 assert_eq!(req.max_tokens, 256);
3980 assert_eq!(req.messages.len(), 1);
3981 assert_eq!(req.messages[0].role, "user");
3982 assert_eq!(req.messages[0].content, Value::String("hello".to_owned()));
3983 assert!(req.system.is_none());
3984 assert_eq!(req.raw, Value::Null);
3986 }
3987
3988 #[test]
3989 fn parse_openai_request_system_message() {
3990 let body = br#"{"model":"gpt-4o","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"hi"}]}"#;
3991 let req = parse_openai_request(body, false).expect("must parse");
3992 assert_eq!(req.system.as_deref(), Some("be concise"));
3993 assert_eq!(req.messages.len(), 1);
3994 assert_eq!(req.messages[0].role, "user");
3995 }
3996
3997 #[test]
3998 fn parse_openai_request_tool_calls_translate_to_tool_use() {
3999 let body = br#"{
4000 "model":"gpt-4o",
4001 "messages":[
4002 {"role":"user","content":"what's the weather?"},
4003 {"role":"assistant","content":null,"tool_calls":[
4004 {"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}
4005 ]},
4006 {"role":"tool","tool_call_id":"call_1","content":"15C, cloudy"}
4007 ]
4008 }"#;
4009 let req = parse_openai_request(body, false).expect("must parse");
4010 assert_eq!(req.messages.len(), 3);
4012 let asst = &req.messages[1];
4014 assert_eq!(asst.role, "assistant");
4015 let blocks = asst.content.as_array().expect("content array");
4016 assert_eq!(blocks[0]["type"], "tool_use");
4017 assert_eq!(blocks[0]["name"], "get_weather");
4018 assert_eq!(blocks[0]["id"], "call_1");
4019 assert_eq!(blocks[0]["input"]["city"], "Paris");
4020 let tool_msg = &req.messages[2];
4022 assert_eq!(tool_msg.role, "user");
4023 let result_blocks = tool_msg.content.as_array().expect("result blocks");
4024 assert_eq!(result_blocks[0]["type"], "tool_result");
4025 assert_eq!(result_blocks[0]["tool_use_id"], "call_1");
4026 }
4027
4028 #[test]
4029 fn parse_openai_request_tools_translate_to_anthropic_format() {
4030 let body = br#"{
4031 "model":"gpt-4o",
4032 "messages":[{"role":"user","content":"use a tool"}],
4033 "tools":[{"type":"function","function":{"name":"search","description":"web search","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}}]
4034 }"#;
4035 let req = parse_openai_request(body, false).expect("must parse");
4036 let tools = req.tools.as_array().expect("tools array");
4037 assert_eq!(tools.len(), 1);
4038 assert_eq!(tools[0]["name"], "search");
4039 assert_eq!(tools[0]["description"], "web search");
4040 assert_eq!(tools[0]["input_schema"]["type"], "object");
4041 }
4042
4043 #[test]
4044 fn parse_openai_request_raw_carry_preserves_full_body() {
4045 let body =
4047 br#"{"model":"gpt-4o","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}"#;
4048 let req = parse_openai_request(body, true).expect("must parse");
4049 assert!(req.raw.is_object(), "raw must be the full JSON object");
4050 assert_eq!(req.raw["model"], "gpt-4o");
4051 assert_eq!(req.raw["max_tokens"], 100);
4052 assert!(req.tools.is_null(), "no tools in this request");
4054 }
4055
4056 #[test]
4057 fn parse_openai_request_http_image_returns_none() {
4058 let body = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[
4060 {"type":"text","text":"describe this"},
4061 {"type":"image_url","image_url":{"url":"https://example.com/cat.png"}}
4062 ]}]}"#;
4063 let result = parse_openai_request(body, false);
4064 assert!(result.is_none(), "http image URL must fail translation");
4065 }
4066
4067 #[test]
4068 fn parse_openai_request_data_url_image_translates_to_anthropic_base64() {
4069 let body = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[
4070 {"type":"text","text":"describe"},
4071 {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
4072 ]}]}"#;
4073 let req = parse_openai_request(body, false).expect("data URL must parse");
4074 let blocks = req.messages[0].content.as_array().expect("blocks");
4075 let img = blocks
4076 .iter()
4077 .find(|b| b["type"] == "image")
4078 .expect("image block");
4079 assert_eq!(img["source"]["type"], "base64");
4080 assert_eq!(img["source"]["media_type"], "image/png");
4081 assert_eq!(img["source"]["data"], "iVBORw0KGgo=");
4082 }
4083
4084 #[test]
4087 fn openai_response_json_renders_text_response() {
4088 let resp = ModelResponse {
4089 model: "gpt-4o".to_owned(),
4090 text: "Hello!".to_owned(),
4091 in_tokens: 10,
4092 out_tokens: 5,
4093 raw: serde_json::json!({
4094 "content": [{ "type": "text", "text": "Hello!" }]
4095 }),
4096 };
4097 let json = openai_response_json(&resp);
4098 assert_eq!(json["object"], "chat.completion");
4099 assert_eq!(json["model"], "gpt-4o");
4100 assert_eq!(json["choices"][0]["message"]["role"], "assistant");
4101 assert_eq!(json["choices"][0]["message"]["content"], "Hello!");
4102 assert_eq!(json["choices"][0]["finish_reason"], "stop");
4103 assert_eq!(json["usage"]["prompt_tokens"], 10);
4104 assert_eq!(json["usage"]["completion_tokens"], 5);
4105 }
4106
4107 #[test]
4108 fn openai_response_json_renders_tool_call() {
4109 let resp = ModelResponse {
4110 model: "gpt-4o".to_owned(),
4111 text: String::new(),
4112 in_tokens: 20,
4113 out_tokens: 15,
4114 raw: serde_json::json!({
4115 "content": [{
4116 "type": "tool_use",
4117 "id": "call_abc",
4118 "name": "search",
4119 "input": {"q": "Rust async"}
4120 }]
4121 }),
4122 };
4123 let json = openai_response_json(&resp);
4124 assert_eq!(json["choices"][0]["finish_reason"], "tool_calls");
4125 let tc = &json["choices"][0]["message"]["tool_calls"][0];
4126 assert_eq!(tc["id"], "call_abc");
4127 assert_eq!(tc["type"], "function");
4128 assert_eq!(tc["function"]["name"], "search");
4129 assert_eq!(json["choices"][0]["message"]["content"], Value::Null);
4131 }
4132
4133 #[test]
4134 fn openai_sse_from_message_plain_text_has_role_then_content_then_stop() {
4135 let resp = ModelResponse {
4136 model: "gpt-4o".to_owned(),
4137 text: "Hi there!".to_owned(),
4138 in_tokens: 5,
4139 out_tokens: 3,
4140 raw: serde_json::json!({
4141 "content": [{ "type": "text", "text": "Hi there!" }]
4142 }),
4143 };
4144 let sse = openai_sse_from_message(&openai_response_json(&resp));
4145 for line in sse.lines() {
4147 assert!(
4148 line.is_empty() || line.starts_with("data: "),
4149 "bad SSE line: {line:?}"
4150 );
4151 }
4152 let frames: Vec<&str> = sse
4153 .lines()
4154 .filter_map(|l| l.strip_prefix("data: "))
4155 .collect();
4156 assert_eq!(*frames.last().unwrap(), "[DONE]");
4158 let role_frame: Value = serde_json::from_str(frames[0]).unwrap();
4160 assert_eq!(role_frame["choices"][0]["delta"]["role"], "assistant");
4161 assert!(frames.iter().any(|f| {
4163 if *f == "[DONE]" {
4164 return false;
4165 }
4166 serde_json::from_str::<Value>(f)
4167 .ok()
4168 .is_some_and(|v| v["choices"][0]["delta"]["content"] == "Hi there!")
4169 }));
4170 assert!(frames.iter().any(|f| {
4172 if *f == "[DONE]" {
4173 return false;
4174 }
4175 serde_json::from_str::<Value>(f)
4176 .ok()
4177 .is_some_and(|v| v["choices"][0]["finish_reason"] == "stop")
4178 }));
4179 }
4180
4181 #[test]
4184 fn detects_openai_tool_calls() {
4185 let with_tool_calls = Bytes::from_static(br#"{"messages":[
4186 {"role":"user","content":"hi"},
4187 {"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}
4188 ]}"#);
4189 let without = Bytes::from_static(br#"{"messages":[{"role":"user","content":"hi"}]}"#);
4190 let with_tool_msg = Bytes::from_static(
4191 br#"{"messages":[
4192 {"role":"tool","tool_call_id":"c1","content":"result"}
4193 ]}"#,
4194 );
4195 assert!(openai_messages_have_tool_calls(&with_tool_calls));
4196 assert!(!openai_messages_have_tool_calls(&without));
4197 assert!(openai_messages_have_tool_calls(&with_tool_msg));
4198 }
4199
4200 #[test]
4201 fn detects_openai_http_images() {
4202 let http_img = Bytes::from_static(
4203 br#"{"messages":[{"role":"user","content":[
4204 {"type":"image_url","image_url":{"url":"https://example.com/img.png"}}
4205 ]}]}"#,
4206 );
4207 let data_img = Bytes::from_static(
4208 br#"{"messages":[{"role":"user","content":[
4209 {"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}
4210 ]}]}"#,
4211 );
4212 let no_img = Bytes::from_static(br#"{"messages":[{"role":"user","content":"hi"}]}"#);
4213 assert!(openai_has_http_images(&http_img));
4214 assert!(!openai_has_http_images(&data_img));
4215 assert!(!openai_has_http_images(&no_img));
4216 }
4217
4218 #[test]
4219 fn enforce_can_handle_openai_inbound_all_openai_ladder() {
4220 let tools_body = Bytes::from_static(br#"{"model":"gpt-4o","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"c","type":"function","function":{"name":"f","arguments":"{}"}}]}]}"#);
4222 let f = extract_openai_features(&HeaderMap::new(), &tools_body);
4223 let ladder = vec!["openai/gpt-4o-mini".to_owned(), "openai/gpt-4o".to_owned()];
4224 let providers = crate::provider::ProviderRegistry::new("http://x", "http://x");
4225 assert!(enforce_can_handle(
4226 &f,
4227 &tools_body,
4228 true,
4229 &ladder,
4230 &providers,
4231 Dialect::Openai
4232 ));
4233 }
4234
4235 #[test]
4236 fn enforce_can_handle_openai_inbound_all_anthropic_ladder_no_http_image() {
4237 let tools_body = Bytes::from_static(br#"{"model":"gpt-4o","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"c","type":"function","function":{"name":"f","arguments":"{}"}}]}]}"#);
4239 let f = extract_openai_features(&HeaderMap::new(), &tools_body);
4240 let ladder = vec!["anthropic/claude-haiku-4-5".to_owned()];
4241 let providers = crate::provider::ProviderRegistry::new("http://x", "http://x");
4242 assert!(enforce_can_handle(
4243 &f,
4244 &tools_body,
4245 true,
4246 &ladder,
4247 &providers,
4248 Dialect::Openai,
4249 ));
4250 }
4251
4252 #[test]
4253 fn enforce_can_handle_openai_inbound_http_image_falls_back() {
4254 let img_body = Bytes::from_static(
4256 br#"{"model":"gpt-4o","messages":[{"role":"user","content":[
4257 {"type":"image_url","image_url":{"url":"https://example.com/img.png"}}
4258 ]}]}"#,
4259 );
4260 let f = extract_openai_features(&HeaderMap::new(), &img_body);
4261 let ladder = vec!["anthropic/claude-haiku-4-5".to_owned()];
4262 let providers = crate::provider::ProviderRegistry::new("http://x", "http://x");
4263 assert!(!enforce_can_handle(
4264 &f,
4265 &img_body,
4266 true,
4267 &ladder,
4268 &providers,
4269 Dialect::Openai,
4270 ));
4271 }
4272
4273 fn openai_enforce_state(mock_resp: ModelResponse) -> AppState {
4277 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"mock/m\"]\ngates = [\"non-empty\"]\n";
4278 let config = ProxyConfig::from_lookup(|k| match k {
4279 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
4280 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
4281 _ => None,
4282 })
4283 .unwrap();
4284 let mut outs = HashMap::new();
4285 outs.insert("mock/m".to_owned(), Ok(mock_resp));
4286 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
4287 map.insert("mock".to_owned(), Arc::new(MockProvider::new("mock", outs)));
4288 let (traces, _rx) = mpsc::channel(64);
4289 std::mem::forget(_rx);
4290 let tenant_rate_limiter = build_tenant_rate_limiter(&config);
4291 AppState {
4292 config: Arc::new(config),
4293 http: reqwest::Client::new(),
4294 providers: ProviderRegistry::from_map(map),
4295 gate_health: Arc::new(GateHealthRegistry::new()),
4296 traces,
4297 adaptive: None,
4298 bandit: None,
4299 predictor: None,
4300 tenant_rate_limiter,
4301 spill: None,
4302 }
4303 }
4304
4305 #[tokio::test]
4306 async fn chat_completions_plain_text_enforce_returns_openai_shape() {
4307 let mock = model_resp("mock/m", "gated answer");
4308 let state = openai_enforce_state(mock);
4309 let body = Bytes::from_static(
4310 br#"{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}"#,
4311 );
4312 let resp = chat_completions(
4313 State(state),
4314 Extension(TenantId("default".to_owned())),
4315 HeaderMap::new(),
4316 body,
4317 )
4318 .await;
4319 assert_eq!(resp.status(), axum::http::StatusCode::OK);
4320 let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
4321 .await
4322 .unwrap();
4323 let json: Value = serde_json::from_slice(&bytes).expect("must be JSON");
4324 assert_eq!(json["object"], "chat.completion");
4325 assert_eq!(json["choices"][0]["message"]["role"], "assistant");
4326 assert_eq!(json["choices"][0]["message"]["content"], "gated answer");
4327 assert_eq!(json["choices"][0]["finish_reason"], "stop");
4328 assert!(
4329 json["id"]
4330 .as_str()
4331 .is_some_and(|id| id.starts_with("chatcmpl-"))
4332 );
4333 }
4334
4335 #[tokio::test]
4336 async fn chat_completions_stream_true_returns_sse_with_openai_chunks() {
4337 let mock = model_resp("mock/m", "gated answer");
4338 let state = openai_enforce_state(mock);
4339 let body = Bytes::from_static(
4340 br#"{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hello"}]}"#,
4341 );
4342 let resp = chat_completions(
4343 State(state),
4344 Extension(TenantId("default".to_owned())),
4345 HeaderMap::new(),
4346 body,
4347 )
4348 .await;
4349 assert_eq!(resp.status(), axum::http::StatusCode::OK);
4350 assert!(
4351 resp.headers()
4352 .get(axum::http::header::CONTENT_TYPE)
4353 .and_then(|v| v.to_str().ok())
4354 .is_some_and(|ct| ct.starts_with("text/event-stream")),
4355 "stream:true must return SSE"
4356 );
4357 let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
4358 .await
4359 .unwrap();
4360 let text = String::from_utf8(bytes.to_vec()).unwrap();
4361 assert!(
4363 text.contains("chat.completion.chunk"),
4364 "must have OpenAI chunk frames"
4365 );
4366 assert!(text.contains("[DONE]"), "must end with [DONE]");
4367 assert!(
4368 text.contains("gated answer"),
4369 "content must be in the stream"
4370 );
4371 assert!(
4373 !text.contains("message_start"),
4374 "must not have Anthropic event types"
4375 );
4376 }
4377
4378 fn probe_state(
4382 sample_rate: f64,
4383 k: u32,
4384 outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
4385 ) -> (AppState, mpsc::Receiver<Trace>) {
4386 let toml = format!(
4387 "[[route]]\nmatch = {{}}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\ngates = [\"non-empty\"]\n\
4388 [escalation.probe]\nk = {k}\nsample_rate = {sample_rate}\n"
4389 );
4390 let config = ProxyConfig::from_lookup(|k_| match k_ {
4391 "FIRSTPASS_CONFIG_TOML" => Some(toml.clone()),
4392 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
4393 _ => None,
4394 })
4395 .unwrap();
4396 let mut outs = HashMap::new();
4397 for (model, out) in outcomes {
4398 outs.insert(model.to_owned(), out);
4399 }
4400 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
4401 map.insert(
4402 "anthropic".to_owned(),
4403 Arc::new(MockProvider::new("anthropic", outs)),
4404 );
4405 let (traces, rx) = mpsc::channel(64);
4406 let state = AppState {
4407 config: Arc::new(config),
4408 http: reqwest::Client::new(),
4409 providers: ProviderRegistry::from_map(map),
4410 gate_health: Arc::new(GateHealthRegistry::new()),
4411 traces,
4412 adaptive: None,
4413 bandit: None,
4414 predictor: None,
4415 tenant_rate_limiter: None,
4416 spill: None,
4417 };
4418 (state, rx)
4419 }
4420
4421 async fn run_enforce_get_trace(state: AppState, mut rx: mpsc::Receiver<Trace>) -> Trace {
4423 let resp = messages(
4424 State(state),
4425 Extension(TenantId("default".to_owned())),
4426 HeaderMap::new(),
4427 user_body(),
4428 )
4429 .await;
4430 assert_eq!(resp.status(), axum::http::StatusCode::OK);
4431 rx.try_recv().expect("trace must be enqueued")
4432 }
4433
4434 #[tokio::test]
4436 async fn probe_off_trace_has_no_probe_field() {
4437 let (state, rx) = enforce_state(
4438 &["anthropic/claude-haiku-4-5"],
4439 &["non-empty"],
4440 vec![(
4441 "anthropic/claude-haiku-4-5",
4442 Ok(model_resp("anthropic/claude-haiku-4-5", "hello")),
4443 )],
4444 );
4445 assert!(
4446 state
4447 .config
4448 .routing
4449 .as_ref()
4450 .unwrap()
4451 .escalation
4452 .probe
4453 .is_none(),
4454 "probe must default to None"
4455 );
4456 let trace = run_enforce_get_trace(state, rx).await;
4457 assert!(
4458 trace.probe.is_none(),
4459 "probe=None config must not set trace.probe"
4460 );
4461 }
4462
4463 #[tokio::test]
4465 async fn probe_sample_rate_zero_never_fires() {
4466 let (state, rx) = probe_state(
4468 0.0,
4469 5,
4470 vec![(
4471 "anthropic/claude-haiku-4-5",
4472 Ok(model_resp("anthropic/claude-haiku-4-5", "hi")),
4473 )],
4474 );
4475 let trace = run_enforce_get_trace(state, rx).await;
4476 assert!(
4477 trace.probe.is_none(),
4478 "sample_rate=0.0 must never set trace.probe"
4479 );
4480 }
4481
4482 #[tokio::test]
4485 async fn probe_on_all_pass_sets_confident_pass() {
4486 let model = "anthropic/claude-haiku-4-5";
4488 let (state, mut rx) = probe_state(1.0, 3, vec![(model, Ok(model_resp(model, "hello")))]);
4489 let resp = messages(
4490 State(state),
4491 Extension(TenantId("default".to_owned())),
4492 HeaderMap::new(),
4493 user_body(),
4494 )
4495 .await;
4496 assert_eq!(resp.status(), axum::http::StatusCode::OK);
4497 let json = body_json(resp).await;
4499 assert_eq!(
4500 json["content"][0]["text"], "hello",
4501 "served content unchanged"
4502 );
4503
4504 let trace = rx.try_recv().expect("trace enqueued");
4505 let sig = trace.probe.expect("probe must be set when sample_rate=1.0");
4506 assert_eq!(sig.k, 3);
4507 assert_eq!(sig.gate_pass_count, 3, "all 3 samples must pass non-empty");
4508 assert_eq!(
4509 sig.regime,
4510 firstpass_core::ProbeRegime::ConfidentPass,
4511 "all-pass → ConfidentPass"
4512 );
4513 assert!(
4514 sig.probe_cost_usd > 0.0,
4515 "k model calls must cost something"
4516 );
4517 }
4518
4519 #[tokio::test]
4522 async fn probe_on_all_fail_sets_confident_fail() {
4523 let model = "anthropic/claude-haiku-4-5";
4524 let (state, mut rx) = probe_state(1.0, 3, vec![(model, Ok(model_resp(model, "")))]);
4526 let resp = messages(
4528 State(state),
4529 Extension(TenantId("default".to_owned())),
4530 HeaderMap::new(),
4531 user_body(),
4532 )
4533 .await;
4534 assert_eq!(resp.status(), axum::http::StatusCode::OK);
4535
4536 let trace = rx.try_recv().expect("trace enqueued");
4537 let sig = trace.probe.expect("probe must be set when sample_rate=1.0");
4538 assert_eq!(
4539 sig.gate_pass_count, 0,
4540 "empty response fails non-empty: all 0 pass"
4541 );
4542 assert_eq!(
4543 sig.regime,
4544 firstpass_core::ProbeRegime::ConfidentFail,
4545 "0 passes → ConfidentFail"
4546 );
4547 }
4548
4549 #[tokio::test]
4552 async fn probe_on_served_output_identical_to_probe_off() {
4553 let model = "anthropic/claude-haiku-4-5";
4554 let mk = |sample_rate: f64| {
4555 probe_state(
4556 sample_rate,
4557 2,
4558 vec![(model, Ok(model_resp(model, "gated answer")))],
4559 )
4560 };
4561
4562 let (state_off, mut rx_off) = mk(0.0);
4564 let resp_off = messages(
4565 State(state_off),
4566 Extension(TenantId("default".to_owned())),
4567 HeaderMap::new(),
4568 user_body(),
4569 )
4570 .await;
4571 let json_off = body_json(resp_off).await;
4572 let trace_off = rx_off.try_recv().unwrap();
4573
4574 let (state_on, mut rx_on) = mk(1.0);
4576 let resp_on = messages(
4577 State(state_on),
4578 Extension(TenantId("default".to_owned())),
4579 HeaderMap::new(),
4580 user_body(),
4581 )
4582 .await;
4583 let json_on = body_json(resp_on).await;
4584 let trace_on = rx_on.try_recv().unwrap();
4585
4586 assert_eq!(
4588 json_off["content"][0]["text"], json_on["content"][0]["text"],
4589 "served text must be identical regardless of probe"
4590 );
4591 assert!(
4593 (trace_off.final_.total_cost_usd - trace_on.final_.total_cost_usd).abs() < 1e-12,
4594 "total_cost_usd must not include probe cost: off={} on={}",
4595 trace_off.final_.total_cost_usd,
4596 trace_on.final_.total_cost_usd
4597 );
4598 assert!(trace_off.probe.is_none());
4600 assert!(trace_on.probe.is_some());
4601 assert!(
4603 trace_on.probe.as_ref().unwrap().probe_cost_usd > 0.0,
4604 "probe cost must be positive"
4605 );
4606 }
4607
4608 #[tokio::test]
4618 async fn probe_does_not_mutate_gate_health() {
4619 let model = "anthropic/claude-haiku-4-5";
4620 let (mut state, rx) = probe_state(1.0, 2, vec![(model, Ok(model_resp(model, "answer")))]);
4621
4622 let registry = GateHealthRegistry::new().with_budget("non-empty", 2, 0.4);
4625 registry.record("default", "non-empty", true);
4628 assert!(
4629 registry.enabled("default", "non-empty"),
4630 "gate must start enabled (window not full yet)"
4631 );
4632 state.gate_health = Arc::new(registry);
4633
4634 let trace = run_enforce_get_trace(state, rx).await;
4646 let sig = trace.probe.expect("probe must fire with sample_rate=1.0");
4647 assert_eq!(sig.k, 2);
4648 assert!(
4649 sig.gate_pass_count <= 2,
4650 "gate_pass_count must be in [0, k]"
4651 );
4652 }
4653
4654 fn predictor_state(
4656 enabled: bool,
4657 outcome: Result<ModelResponse, ProviderError>,
4658 ) -> (AppState, mpsc::Receiver<Trace>) {
4659 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\ngates = [\"non-empty\"]\n";
4660 let config = ProxyConfig::from_lookup(|k_| match k_ {
4661 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
4662 "FIRSTPASS_MODE" => Some("enforce".to_owned()),
4663 _ => None,
4664 })
4665 .unwrap();
4666 let mut outs = HashMap::new();
4667 outs.insert("anthropic/claude-haiku-4-5".to_owned(), outcome);
4668 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
4669 map.insert(
4670 "anthropic".to_owned(),
4671 Arc::new(MockProvider::new("anthropic", outs)),
4672 );
4673 let (traces, rx) = mpsc::channel(64);
4674 let predictor = enabled.then(|| {
4675 Arc::new(std::sync::Mutex::new(firstpass_core::PassPredictor::new(
4676 0.05, 1e-4,
4677 )))
4678 });
4679 let state = AppState {
4680 config: Arc::new(config),
4681 http: reqwest::Client::new(),
4682 providers: ProviderRegistry::from_map(map),
4683 gate_health: Arc::new(GateHealthRegistry::new()),
4684 traces,
4685 adaptive: None,
4686 bandit: None,
4687 predictor,
4688 tenant_rate_limiter: None,
4689 spill: None,
4690 };
4691 (state, rx)
4692 }
4693
4694 #[tokio::test]
4695 async fn predictor_off_leaves_predicted_pass_none() {
4696 let (state, rx) =
4697 predictor_state(false, Ok(model_resp("anthropic/claude-haiku-4-5", "ok")));
4698 let trace = run_enforce_get_trace(state, rx).await;
4699 assert!(
4700 trace.predicted_pass.is_none(),
4701 "predictor off => no field (byte-identical)"
4702 );
4703 let j = serde_json::to_string(&trace).unwrap();
4705 assert!(!j.contains("predicted_pass"), "None must be omitted: {j}");
4706 }
4707
4708 #[tokio::test]
4709 async fn predictor_on_records_shadow_prediction_and_serves_identically() {
4710 let (state_off, rx_off) = predictor_state(
4713 false,
4714 Ok(model_resp("anthropic/claude-haiku-4-5", "served answer")),
4715 );
4716 let off = run_enforce_get_trace(state_off, rx_off).await;
4717
4718 let (state_on, rx_on) = predictor_state(
4719 true,
4720 Ok(model_resp("anthropic/claude-haiku-4-5", "served answer")),
4721 );
4722 let on = run_enforce_get_trace(state_on, rx_on).await;
4723
4724 assert_eq!(
4725 on.final_.served_rung, off.final_.served_rung,
4726 "served rung identical"
4727 );
4728 assert_eq!(on.attempts.len(), off.attempts.len(), "same attempts");
4729 assert_eq!(
4730 on.final_.total_cost_usd, off.final_.total_cost_usd,
4731 "predictor never adds served cost"
4732 );
4733 let p = on
4734 .predicted_pass
4735 .expect("predictor on => predicted_pass recorded");
4736 assert!(p > 0.0 && p < 1.0, "shadow prediction in (0,1): {p}");
4737 }
4738}