Skip to main content

firstpass_proxy/
demo.rs

1//! The no-keys demo: `firstpass demo`.
2//!
3//! A self-contained, real-HTTP demonstration of the whole Firstpass loop. It stands up a local
4//! server that speaks the Anthropic wire protocol (the cheap model returns a weak answer, the next
5//! rung a good one), runs one enforce-mode decision through the real proxy, and prints the audit
6//! receipt — which model was tried, which gate caught what, what it cost, and what it saved versus
7//! always calling the top tier. Then it reports a downstream outcome via the feedback API and
8//! shows it attach without breaking the tamper-evident chain.
9//!
10//! Everything here is real code over real HTTP; only the upstream is local, so no API keys are
11//! needed. Point the same proxy at real providers (base URLs + BYOK) and it behaves identically.
12//!
13//! This lives in the library, not in `examples/`, so it ships inside the `firstpass` binary and is
14//! therefore reachable from every install channel — `uvx firstpass demo` works without a Rust
15//! toolchain. `examples/demo.rs` is a shim over this same code so the cargo invocation in older
16//! docs keeps working.
17
18use std::sync::Arc;
19use std::time::Duration;
20
21use axum::routing::post;
22use axum::{Json, Router};
23use bytes::Bytes;
24use firstpass_core::{GENESIS_HASH, verify_chain};
25use serde_json::{Value, json};
26
27use crate::provider::ProviderRegistry;
28use crate::proxy::AppState;
29use crate::{ProxyConfig, app, store};
30
31/// Anything that can go wrong in the demo. It is a demonstration, so a failure is reported plainly
32/// rather than panicking — the binary is the same one operators run in production.
33type Fail = Box<dyn std::error::Error>;
34
35/// Run the demo end to end. Prints to stdout; leaves no state behind (the trace DB is a temp file).
36///
37/// # Errors
38/// If the local upstream or proxy cannot bind, the request fails, or no trace is recorded.
39pub async fn run() -> Result<(), Fail> {
40    // 1. A faithful local Anthropic upstream: haiku answers weakly (empty), sonnet answers well.
41    let upstream = spawn_upstream().await?;
42
43    // 2. The real proxy, enforce route haiku → sonnet → opus, gated on non-empty output.
44    let db = std::env::temp_dir().join(format!("firstpass-demo-{}.db", uuid::Uuid::now_v7()));
45    let (proxy, _writer) = spawn_proxy(&upstream, &db).await?;
46
47    println!("\n\x1b[1mFirstpass demo\x1b[0m — routing one request through a real enforce ladder");
48    println!("no API keys, no config: the upstream is local, everything else is the real proxy\n");
49
50    // 3. Send a request, exactly as a coding agent would (Anthropic wire format, BYOK header).
51    let client = reqwest::Client::new();
52    let served: Value = client
53        .post(format!("{proxy}/v1/messages"))
54        .header("x-api-key", "byok-demo")
55        .json(&json!({
56            "model": "claude-haiku-4-5",
57            "max_tokens": 256,
58            "messages": [{ "role": "user", "content": "write a hello world in rust" }],
59        }))
60        .send()
61        .await?
62        .json()
63        .await?;
64
65    println!("served output : {}", served["content"][0]["text"]);
66    println!("served model  : {}\n", served["model"]);
67
68    // 4. Read back the audit trace and print the receipt.
69    let trace = wait_for_trace(&db).await.ok_or("no trace recorded")?;
70    println!("\x1b[1m── audit receipt ──────────────────────────────────\x1b[0m");
71    for a in &trace.attempts {
72        let verdict = match a.verdict {
73            firstpass_core::Verdict::Pass => "\x1b[32mPASS\x1b[0m",
74            firstpass_core::Verdict::Fail => "\x1b[31mFAIL\x1b[0m",
75            firstpass_core::Verdict::Abstain => "\x1b[33mABSTAIN\x1b[0m",
76        };
77        println!(
78            "  rung {} · {:<28} · {verdict} · ${:.4}",
79            a.rung, a.model, a.cost_usd
80        );
81    }
82    let f = &trace.final_;
83    println!("  ─────────────────────────────────────────────────");
84    println!("  total     ${:.4}", f.total_cost_usd);
85    println!(
86        "  baseline  ${:.4}   (always top-tier)",
87        f.counterfactual_baseline_usd
88    );
89    let pct = if f.counterfactual_baseline_usd > 0.0 {
90        f.savings_usd / f.counterfactual_baseline_usd * 100.0
91    } else {
92        0.0
93    };
94    println!(
95        "  \x1b[32mSAVED     ${:.4}   ({pct:.0}% cheaper at proven quality)\x1b[0m",
96        f.savings_usd
97    );
98    println!("  trace_id  {}", trace.trace_id);
99    println!(
100        "  chain     {}\n",
101        if verify_chain(std::slice::from_ref(&trace), &trace.prev_hash).is_ok() {
102            "verified ✓"
103        } else {
104            "BROKEN"
105        }
106    );
107
108    // 5. The outcome loop: report that the served code actually passed CI, and show it attach.
109    let trace_id = trace.trace_id.to_string();
110    let fb = client
111        .post(format!("{proxy}/v1/feedback"))
112        .json(&json!({
113            "trace_id": trace_id, "gate_id": "ci-tests",
114            "verdict": "pass", "reporter": "github-actions",
115        }))
116        .send()
117        .await?;
118    println!(
119        "feedback POST /v1/feedback → {} (downstream outcome recorded)",
120        fb.status()
121    );
122
123    let view = store::load_trace_view(&db, "default", &trace_id)?.ok_or("trace view missing")?;
124    let reporter = view
125        .deferred
126        .first()
127        .map_or("(none)", |d| d.reporter.as_str());
128    println!(
129        "deferred verdicts on trace: {} ({reporter} reported it)",
130        view.deferred.len()
131    );
132    let all = store::load_all_traces(&db)?;
133    println!(
134        "audit chain after feedback : {}\n",
135        if verify_chain(&all, GENESIS_HASH).is_ok() {
136            "\x1b[32mstill verified ✓\x1b[0m — the sealed record never changed"
137        } else {
138            "BROKEN"
139        }
140    );
141    println!("next: `firstpass onboard` wires your own agent through this in observe mode.\n");
142
143    let _ = std::fs::remove_file(&db);
144    Ok(())
145}
146
147/// A local stand-in for the Anthropic API: haiku returns an empty answer (so the gate fails and the
148/// ladder escalates), anything above it returns real code.
149async fn spawn_upstream() -> Result<String, Fail> {
150    async fn messages(body: Bytes) -> Json<Value> {
151        let model = serde_json::from_slice::<Value>(&body)
152            .ok()
153            .and_then(|v| v.get("model").and_then(Value::as_str).map(str::to_owned))
154            .unwrap_or_default();
155        let text = if model.contains("haiku") {
156            ""
157        } else {
158            "fn main() { println!(\"hello world\"); }"
159        };
160        Json(json!({
161            "id": "msg_demo", "type": "message", "role": "assistant", "model": model,
162            "content": [{ "type": "text", "text": text }],
163            "usage": { "input_tokens": 1200, "output_tokens": 220 },
164        }))
165    }
166    let router = Router::new().route("/v1/messages", post(messages));
167    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
168    let addr = listener.local_addr()?;
169    tokio::spawn(async move {
170        let _ = axum::serve(listener, router).await;
171    });
172    Ok(format!("http://{addr}"))
173}
174
175/// Stand up the real proxy against the local upstream. Returns its base URL and the trace-writer
176/// task handle, kept alive alongside the server for the life of the demo.
177async fn spawn_proxy(
178    upstream: &str,
179    db: &std::path::Path,
180) -> Result<(String, tokio::task::JoinHandle<()>), Fail> {
181    let routing = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\", \"anthropic/claude-sonnet-5\", \"anthropic/claude-opus-4-8\"]\ngates = [\"non-empty\"]\n";
182    let (up, dbs) = (upstream.to_owned(), db.to_string_lossy().into_owned());
183    let config = ProxyConfig::from_lookup(move |k| match k {
184        "FIRSTPASS_UPSTREAM_ANTHROPIC" => Some(up.clone()),
185        "FIRSTPASS_MODE" => Some("enforce".to_owned()),
186        "FIRSTPASS_CONFIG_TOML" => Some(routing.to_owned()),
187        "FIRSTPASS_DB" => Some(dbs.clone()),
188        _ => None,
189    })?;
190    let providers = ProviderRegistry::new(&config.upstream_anthropic, &config.upstream_openai);
191    let (traces, writer) = store::open(db)?;
192    let state = AppState {
193        config: Arc::new(config),
194        http: reqwest::Client::new(),
195        providers,
196        gate_health: Arc::new(crate::gate::GateHealthRegistry::new()),
197        shadow_ledger: Arc::new(crate::shadow::ShadowLedger::new()),
198        guardrails: Arc::new(crate::guard::GuardrailRegistry::new()),
199        traces,
200        adaptive: None,
201        bandit: None,
202        predictor: None,
203        tenant_rate_limiter: None,
204        spill: None,
205    };
206    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
207    let addr = listener.local_addr()?;
208    let router = app(state)?;
209    tokio::spawn(async move {
210        let _ = axum::serve(listener, router).await;
211    });
212    Ok((format!("http://{addr}"), writer))
213}
214
215/// Traces are written off the hot path, so poll briefly for the first one to land.
216async fn wait_for_trace(db: &std::path::Path) -> Option<firstpass_core::Trace> {
217    for _ in 0..150 {
218        if let Ok(t) = store::load_all_traces(db)
219            && let Some(first) = t.into_iter().next()
220        {
221            return Some(first);
222        }
223        tokio::time::sleep(Duration::from_millis(20)).await;
224    }
225    None
226}