Skip to main content

firstpass_proxy/
gate.rs

1//! The gate framework (SPEC §8 — the moat).
2//!
3//! A gate inspects a candidate [`ModelResponse`] and returns a [`GateResult`] verdict. Gates are
4//! **async** because a real gate is I/O: a subprocess plugin (§8.1), an LLM judge, a test run.
5//! Pure inline gates (non-empty, json-valid, schema) simply don't await. Gate execution is
6//! wrapped by an **error budget** ([`GateHealth`]): a gate that errors too often is auto-disabled
7//! with an alarm, so a broken gate can neither silently fail closed (burns money) nor silently
8//! fail open (burns trust) (§7.2).
9
10use crate::judge::JudgeGate;
11use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderRegistry};
12use crate::subprocess::SubprocessGate;
13use async_trait::async_trait;
14use firstpass_core::{GateDef, GateResult, Verdict};
15use std::collections::VecDeque;
16use std::sync::Mutex;
17use std::time::Duration;
18
19/// A verification gate. Object-safe + async so subprocess/model gates fit the same contract.
20#[async_trait]
21pub trait Gate: Send + Sync + std::fmt::Debug {
22    /// Stable gate id (matches the name used in routing config).
23    fn id(&self) -> &str;
24    /// Evaluate the candidate response, producing a verdict + evidence.
25    async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult;
26}
27
28/// Fails an empty (whitespace-only) completion. The cheapest possible sanity gate.
29#[derive(Debug, Clone, Copy)]
30pub struct NonEmptyGate;
31
32#[async_trait]
33impl Gate for NonEmptyGate {
34    fn id(&self) -> &str {
35        "non-empty"
36    }
37    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
38        let verdict = if resp.text.trim().is_empty() {
39            Verdict::Fail
40        } else {
41            Verdict::Pass
42        };
43        GateResult::deterministic(self.id(), verdict, 0)
44    }
45}
46
47/// Passes only if the completion parses as JSON. Useful for structured-output routes.
48#[derive(Debug, Clone, Copy)]
49pub struct JsonValidGate;
50
51#[async_trait]
52impl Gate for JsonValidGate {
53    fn id(&self) -> &str {
54        "json-valid"
55    }
56    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
57        let ok = serde_json::from_str::<serde_json::Value>(resp.text.trim()).is_ok();
58        GateResult::deterministic(self.id(), if ok { Verdict::Pass } else { Verdict::Fail }, 0)
59    }
60}
61
62/// Validates the candidate (parsed as JSON) against a minimal JSON-Schema subset: top-level
63/// `type`, `required`, and per-property `type`. Covers tool-call args and extraction tasks.
64///
65// ponytail: not full JSON Schema draft 2020-12 — just type/required/properties, the 90% that
66// structured-output routes need. Swap for the `jsonschema` crate if nested/`$ref` schemas appear.
67#[derive(Debug, Clone)]
68pub struct SchemaGate {
69    schema: serde_json::Value,
70}
71
72impl SchemaGate {
73    /// Build a schema gate from a JSON Schema value.
74    #[must_use]
75    pub fn new(schema: serde_json::Value) -> Self {
76        Self { schema }
77    }
78
79    /// Check `value` against the minimal schema subset; returns the first violation, if any.
80    fn violation(&self, value: &serde_json::Value) -> Option<String> {
81        use serde_json::Value;
82        let type_ok = |v: &Value, ty: &str| match ty {
83            "object" => v.is_object(),
84            "array" => v.is_array(),
85            "string" => v.is_string(),
86            "number" => v.is_number(),
87            "integer" => v.is_i64() || v.is_u64(),
88            "boolean" => v.is_boolean(),
89            "null" => v.is_null(),
90            _ => true, // unknown type keyword: don't fail on it
91        };
92        if let Some(ty) = self.schema.get("type").and_then(Value::as_str)
93            && !type_ok(value, ty)
94        {
95            return Some(format!("root is not of type {ty}"));
96        }
97        if let Some(req) = self.schema.get("required").and_then(Value::as_array) {
98            for field in req.iter().filter_map(Value::as_str) {
99                if value.get(field).is_none() {
100                    return Some(format!("missing required field {field:?}"));
101                }
102            }
103        }
104        if let Some(props) = self.schema.get("properties").and_then(Value::as_object) {
105            for (name, subschema) in props {
106                if let (Some(actual), Some(ty)) = (
107                    value.get(name),
108                    subschema.get("type").and_then(Value::as_str),
109                ) && !type_ok(actual, ty)
110                {
111                    return Some(format!("property {name:?} is not of type {ty}"));
112                }
113            }
114        }
115        None
116    }
117}
118
119#[async_trait]
120impl Gate for SchemaGate {
121    fn id(&self) -> &str {
122        "schema"
123    }
124    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
125        let Ok(value) = serde_json::from_str::<serde_json::Value>(resp.text.trim()) else {
126            let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
127            r.reason = Some("candidate is not valid JSON".to_owned());
128            return r;
129        };
130        match self.violation(&value) {
131            None => GateResult::deterministic(self.id(), Verdict::Pass, 0),
132            Some(reason) => {
133                let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
134                r.reason = Some(reason);
135                r
136            }
137        }
138    }
139}
140
141/// Rolling per-gate error budget (§7.2, §8.3.4). Tracks the last `window` outcomes; once the
142/// error (abstain) fraction over a full window exceeds `max_error_rate`, the gate is
143/// auto-disabled and an alarm is logged. A disabled gate is skipped by the runner (its verdict
144/// stops counting) rather than silently failing open or closed.
145#[derive(Debug)]
146pub struct GateHealth {
147    window: usize,
148    max_error_rate: f64,
149    outcomes: Mutex<VecDeque<bool>>, // true = error (abstain), false = ok
150    disabled: std::sync::atomic::AtomicBool,
151}
152
153impl GateHealth {
154    /// Create a health tracker. `max_error_rate` is the abstain fraction (over a full `window`)
155    /// beyond which the gate auto-disables.
156    #[must_use]
157    pub fn new(window: usize, max_error_rate: f64) -> Self {
158        Self {
159            window: window.max(1),
160            max_error_rate,
161            outcomes: Mutex::new(VecDeque::new()),
162            disabled: std::sync::atomic::AtomicBool::new(false),
163        }
164    }
165
166    /// Whether the gate is currently enabled (not auto-disabled).
167    #[must_use]
168    pub fn is_enabled(&self) -> bool {
169        !self.disabled.load(std::sync::atomic::Ordering::Relaxed)
170    }
171
172    /// Record one outcome (`errored` = the gate abstained/crashed) and re-evaluate the budget.
173    pub fn record(&self, gate_id: &str, errored: bool) {
174        // ponytail: a single global lock per gate-health; fine at proxy request rates. Shard by
175        // gate if a hot gate's lock ever shows up in a profile.
176        let Ok(mut q) = self.outcomes.lock() else {
177            return; // poisoned lock: skip accounting rather than panic on the request path
178        };
179        q.push_back(errored);
180        while q.len() > self.window {
181            q.pop_front();
182        }
183        if q.len() == self.window {
184            let errors = q.iter().filter(|e| **e).count();
185            let rate = errors as f64 / self.window as f64;
186            if rate > self.max_error_rate && self.is_enabled() {
187                self.disabled
188                    .store(true, std::sync::atomic::Ordering::Relaxed);
189                tracing::error!(
190                    gate = %gate_id,
191                    error_rate = rate,
192                    "gate exceeded its error budget — auto-disabled (ALARM)"
193                );
194            }
195        }
196    }
197}
198
199/// Per-gate error budgets for a running proxy (app-level, shared across requests). Lookup by
200/// gate id; unknown gates default to enabled with no accounting (a gate the operator didn't
201/// register a budget for is simply never auto-disabled).
202#[derive(Debug, Default)]
203pub struct GateHealthRegistry {
204    gates: std::collections::HashMap<String, GateHealth>,
205}
206
207impl GateHealthRegistry {
208    /// Empty registry — every gate enabled, no accounting.
209    #[must_use]
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    /// Register an error budget for `gate_id` (window size + max abstain fraction).
215    #[must_use]
216    pub fn with_budget(
217        mut self,
218        gate_id: impl Into<String>,
219        window: usize,
220        max_error_rate: f64,
221    ) -> Self {
222        self.gates
223            .insert(gate_id.into(), GateHealth::new(window, max_error_rate));
224        self
225    }
226
227    /// Whether `gate_id` is currently enabled (unregistered gates are always enabled).
228    #[must_use]
229    pub fn enabled(&self, gate_id: &str) -> bool {
230        self.gates.get(gate_id).is_none_or(GateHealth::is_enabled)
231    }
232
233    /// Record one outcome for `gate_id` (`errored` = abstained/crashed). No-op if unregistered.
234    pub fn record(&self, gate_id: &str, errored: bool) {
235        if let Some(h) = self.gates.get(gate_id) {
236            h.record(gate_id, errored);
237        }
238    }
239}
240
241/// Resolve a route's gate ids into runnable gates. Built-in ids (`non-empty`, `json-valid`) map to
242/// inline gates; any other id is looked up among the config's `[[gate]]` definitions and built as
243/// either a [`SubprocessGate`] (a `cmd` gate, SPEC §8.1) or a [`JudgeGate`] (a `judge` gate, §8.3).
244/// The judge needs a provider (from `registry`) and the caller's credentials (`auth`, BYOK). An id
245/// that is neither built-in nor defined — or a judge whose provider isn't registered — is skipped
246/// with a warning rather than failing the request.
247#[must_use]
248pub fn resolve_gates(
249    names: &[String],
250    defs: &[GateDef],
251    registry: &ProviderRegistry,
252    auth: &Auth,
253) -> Vec<Box<dyn Gate>> {
254    let mut gates: Vec<Box<dyn Gate>> = Vec::new();
255    for name in names {
256        match name.as_str() {
257            "non-empty" => gates.push(Box::new(NonEmptyGate)),
258            "json-valid" => gates.push(Box::new(JsonValidGate)),
259            other => match defs.iter().find(|d| d.id == other) {
260                Some(def) if def.judge.is_some() => {
261                    // `Config::parse` guarantees exactly one kind, so this `if let` always binds.
262                    if let Some(judge) = def.judge.as_ref() {
263                        let provider_id = judge.model.split('/').next().unwrap_or_default();
264                        match registry.get(provider_id) {
265                            Some(provider) => gates.push(Box::new(JudgeGate::new(
266                                def.id.clone(),
267                                provider,
268                                judge.model.clone(),
269                                auth.clone(),
270                                judge.threshold,
271                                judge.rubric.clone().unwrap_or_default(),
272                            ))),
273                            None => tracing::warn!(
274                                gate = %other, provider = %provider_id,
275                                "judge gate provider not registered — skipped"
276                            ),
277                        }
278                    }
279                }
280                Some(def) => {
281                    let Some((program, args)) = def.cmd.split_first() else {
282                        tracing::warn!(gate = %other, "configured gate has empty cmd — skipped");
283                        continue;
284                    };
285                    gates.push(Box::new(SubprocessGate::new(
286                        def.id.clone(),
287                        program.clone(),
288                        args.to_vec(),
289                        Duration::from_millis(def.timeout_ms),
290                    )));
291                }
292                None => tracing::warn!(
293                    gate = %other,
294                    "unknown gate id — not a built-in and not defined in [[gate]]; skipped"
295                ),
296            },
297        }
298    }
299    gates
300}
301
302/// Aggregate per-gate verdicts into the attempt's overall verdict.
303///
304/// `Fail` if any gate fails; otherwise `Pass`. An empty gate set passes.
305///
306// ponytail: `Abstain` is treated as pass (fail-open). Per-gate fail-open/closed policy is a
307// follow-up; until then an abstaining/disabled gate never blocks serving.
308#[must_use]
309pub fn aggregate(results: &[GateResult]) -> Verdict {
310    if results.iter().any(|r| r.verdict == Verdict::Fail) {
311        Verdict::Fail
312    } else {
313        Verdict::Pass
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use serde_json::{Value, json};
321
322    fn resp(text: &str) -> ModelResponse {
323        ModelResponse {
324            model: "anthropic/claude-haiku-4-5".to_owned(),
325            text: text.to_owned(),
326            in_tokens: 1,
327            out_tokens: 1,
328            raw: Value::Null,
329        }
330    }
331
332    fn req() -> ModelRequest {
333        ModelRequest {
334            model: "anthropic/claude-haiku-4-5".to_owned(),
335            system: None,
336            messages: vec![],
337            max_tokens: 16,
338            tools: Value::Null,
339        }
340    }
341
342    #[tokio::test]
343    async fn non_empty_gate() {
344        assert_eq!(
345            NonEmptyGate.evaluate(&req(), &resp("hi")).await.verdict,
346            Verdict::Pass
347        );
348        assert_eq!(
349            NonEmptyGate.evaluate(&req(), &resp("   ")).await.verdict,
350            Verdict::Fail
351        );
352    }
353
354    #[tokio::test]
355    async fn json_valid_gate() {
356        assert_eq!(
357            JsonValidGate
358                .evaluate(&req(), &resp(r#"{"ok":true}"#))
359                .await
360                .verdict,
361            Verdict::Pass
362        );
363        assert_eq!(
364            JsonValidGate.evaluate(&req(), &resp("nope")).await.verdict,
365            Verdict::Fail
366        );
367    }
368
369    #[tokio::test]
370    async fn schema_gate_type_and_required() {
371        let g = SchemaGate::new(json!({
372            "type": "object",
373            "required": ["name", "age"],
374            "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }
375        }));
376        assert_eq!(
377            g.evaluate(&req(), &resp(r#"{"name":"a","age":3}"#))
378                .await
379                .verdict,
380            Verdict::Pass
381        );
382        // missing required
383        assert_eq!(
384            g.evaluate(&req(), &resp(r#"{"name":"a"}"#)).await.verdict,
385            Verdict::Fail
386        );
387        // wrong property type
388        assert_eq!(
389            g.evaluate(&req(), &resp(r#"{"name":"a","age":"x"}"#))
390                .await
391                .verdict,
392            Verdict::Fail
393        );
394        // wrong root type
395        assert_eq!(g.evaluate(&req(), &resp("[]")).await.verdict, Verdict::Fail);
396        // not even JSON
397        assert_eq!(
398            g.evaluate(&req(), &resp("plain text")).await.verdict,
399            Verdict::Fail
400        );
401    }
402
403    fn empty_registry() -> ProviderRegistry {
404        ProviderRegistry::new("http://localhost", "http://localhost")
405    }
406
407    #[test]
408    fn resolve_skips_unknown_and_keeps_known() {
409        let gates = resolve_gates(
410            &[
411                "non-empty".to_owned(),
412                "judge-diff".to_owned(),
413                "json-valid".to_owned(),
414            ],
415            &[],
416            &empty_registry(),
417            &Auth::default(),
418        );
419        let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
420        assert_eq!(ids, ["non-empty", "json-valid"]);
421    }
422
423    #[test]
424    fn resolve_builds_configured_subprocess_gate() {
425        // A gate id that isn't built-in resolves to a SubprocessGate when defined in `[[gate]]`.
426        let defs = vec![GateDef {
427            id: "my-tests".to_owned(),
428            cmd: vec!["true".to_owned()],
429            timeout_ms: 1000,
430            judge: None,
431        }];
432        let gates = resolve_gates(
433            &["my-tests".to_owned(), "undefined".to_owned()],
434            &defs,
435            &empty_registry(),
436            &Auth::default(),
437        );
438        let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
439        assert_eq!(
440            ids,
441            ["my-tests"],
442            "configured id resolves; unknown id skipped"
443        );
444    }
445
446    #[tokio::test]
447    async fn configured_subprocess_gate_runs_end_to_end() {
448        // A user-defined gate that fails iff the candidate text contains "BAD" — proving the config
449        // → SubprocessGate → verdict path works over stdin, no hard-coded gate name.
450        let script = r#"c=$(cat); case "$c" in *BAD*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#;
451        let defs = vec![GateDef {
452            id: "no-bad".to_owned(),
453            cmd: vec!["bash".to_owned(), "-c".to_owned(), script.to_owned()],
454            timeout_ms: 5000,
455            judge: None,
456        }];
457        let gates = resolve_gates(
458            &["no-bad".to_owned()],
459            &defs,
460            &empty_registry(),
461            &Auth::default(),
462        );
463        assert_eq!(gates.len(), 1);
464        let good = gates[0].evaluate(&req(), &resp("all good")).await;
465        assert_eq!(good.verdict, Verdict::Pass);
466        let bad = gates[0].evaluate(&req(), &resp("this is BAD")).await;
467        assert_eq!(bad.verdict, Verdict::Fail);
468    }
469
470    #[test]
471    fn resolve_builds_configured_judge_gate() {
472        use crate::provider::{MockProvider, Provider};
473        use std::collections::HashMap;
474        use std::sync::Arc;
475
476        let defs = vec![GateDef {
477            id: "quality".to_owned(),
478            cmd: vec![],
479            timeout_ms: 30_000,
480            judge: Some(firstpass_core::JudgeDef {
481                model: "anthropic/judge".to_owned(),
482                threshold: 0.7,
483                rubric: None,
484            }),
485        }];
486
487        // Registry that serves `anthropic` → the judge gate is built.
488        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
489        map.insert(
490            "anthropic".to_owned(),
491            Arc::new(MockProvider::new("anthropic", HashMap::new())),
492        );
493        let gates = resolve_gates(
494            &["quality".to_owned()],
495            &defs,
496            &ProviderRegistry::from_map(map),
497            &Auth::default(),
498        );
499        assert_eq!(
500            gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
501            ["quality"]
502        );
503
504        // Registry without that provider → skipped, not a hard failure.
505        let skipped = resolve_gates(
506            &["quality".to_owned()],
507            &defs,
508            &ProviderRegistry::from_map(HashMap::new()),
509            &Auth::default(),
510        );
511        assert!(
512            skipped.is_empty(),
513            "judge with no registered provider is skipped"
514        );
515    }
516
517    #[test]
518    fn aggregate_semantics() {
519        let pass = GateResult::deterministic("a", Verdict::Pass, 0);
520        let fail = GateResult::deterministic("b", Verdict::Fail, 0);
521        let abstain = GateResult::abstain("c", "x", 0);
522        assert_eq!(aggregate(&[]), Verdict::Pass);
523        assert_eq!(aggregate(std::slice::from_ref(&pass)), Verdict::Pass);
524        assert_eq!(aggregate(&[pass.clone(), fail]), Verdict::Fail);
525        assert_eq!(aggregate(&[pass, abstain]), Verdict::Pass);
526    }
527
528    #[test]
529    fn error_budget_auto_disables_past_threshold() {
530        let h = GateHealth::new(10, 0.5); // disable when >50% of the last 10 error
531        // Window fills to exactly 5 errors / 10 = 50% — NOT above threshold, still enabled.
532        for _ in 0..5 {
533            h.record("g", true);
534        }
535        for _ in 0..5 {
536            h.record("g", false);
537        }
538        assert!(h.is_enabled(), "50% is at, not above, the budget");
539        // Flood errors: the window slides until errors exceed 50% -> auto-disabled.
540        for _ in 0..6 {
541            h.record("g", true);
542        }
543        assert!(
544            !h.is_enabled(),
545            "gate should auto-disable once error rate exceeds budget"
546        );
547    }
548
549    #[test]
550    fn healthy_gate_stays_enabled() {
551        let h = GateHealth::new(20, 0.5);
552        for _ in 0..100 {
553            h.record("g", false);
554        }
555        assert!(h.is_enabled());
556    }
557}