firstpass 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {firstpass-0.1.2 → firstpass-0.1.4}/Cargo.lock +3 -3
  2. {firstpass-0.1.2 → firstpass-0.1.4}/Cargo.toml +2 -2
  3. {firstpass-0.1.2 → firstpass-0.1.4}/PKG-INFO +1 -1
  4. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/config.rs +111 -0
  5. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/lib.rs +2 -2
  6. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/examples/demo.rs +1 -0
  7. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/judge.rs +2 -5
  8. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/provider.rs +209 -25
  9. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/proxy.rs +331 -46
  10. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/run.rs +29 -1
  11. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/tests/end_to_end.rs +1 -0
  12. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/tests/observe_loop.rs +1 -0
  13. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/Cargo.toml +0 -0
  14. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/conformal.rs +0 -0
  15. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/cost.rs +0 -0
  16. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/error.rs +0 -0
  17. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/features.rs +0 -0
  18. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/hashchain.rs +0 -0
  19. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/trace.rs +0 -0
  20. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/verdict.rs +0 -0
  21. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/Cargo.toml +0 -0
  22. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/bin/firstpass.rs +0 -0
  23. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/calibrate.rs +0 -0
  24. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/cli.rs +0 -0
  25. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/config.rs +0 -0
  26. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/error.rs +0 -0
  27. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/gate.rs +0 -0
  28. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/lib.rs +0 -0
  29. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/main.rs +0 -0
  30. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/mcp.rs +0 -0
  31. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/metrics.rs +0 -0
  32. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/router.rs +0 -0
  33. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/store.rs +0 -0
  34. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/subprocess.rs +0 -0
  35. {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/upstream.rs +0 -0
  36. {firstpass-0.1.2 → firstpass-0.1.4}/pyproject.toml +0 -0
@@ -294,7 +294,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
294
294
 
295
295
  [[package]]
296
296
  name = "firstpass-bench"
297
- version = "0.1.2"
297
+ version = "0.1.4"
298
298
  dependencies = [
299
299
  "firstpass-core",
300
300
  "reqwest",
@@ -304,7 +304,7 @@ dependencies = [
304
304
 
305
305
  [[package]]
306
306
  name = "firstpass-core"
307
- version = "0.1.2"
307
+ version = "0.1.4"
308
308
  dependencies = [
309
309
  "hex",
310
310
  "jiff",
@@ -318,7 +318,7 @@ dependencies = [
318
318
 
319
319
  [[package]]
320
320
  name = "firstpass-proxy"
321
- version = "0.1.2"
321
+ version = "0.1.4"
322
322
  dependencies = [
323
323
  "async-trait",
324
324
  "axum",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/*"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.2"
6
+ version = "0.1.4"
7
7
  edition = "2024"
8
8
  rust-version = "1.93"
9
9
  license = "Apache-2.0"
@@ -38,7 +38,7 @@ metrics = "0.24"
38
38
  metrics-exporter-prometheus = { version = "0.18", default-features = false }
39
39
 
40
40
  # Internal crates
41
- firstpass-core = { path = "crates/firstpass-core", version = "0.1.2" }
41
+ firstpass-core = { path = "crates/firstpass-core", version = "0.1.4" }
42
42
 
43
43
  [profile.release]
44
44
  lto = "thin"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firstpass
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Topic :: Software Development :: Build Tools
@@ -38,6 +38,41 @@ pub struct Config {
38
38
  /// `deferred_gates`. Declared as `[[gate]]` sections in TOML.
39
39
  #[serde(rename = "gate", default)]
40
40
  pub gate_defs: Vec<GateDef>,
41
+ /// Extra model providers a ladder can route to, beyond the built-in `anthropic` / `openai`.
42
+ /// Any OpenAI-compatible endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI,
43
+ /// OpenRouter, Ollama, vLLM, Azure, …) is one `[[provider]]` entry — no rebuild. Declared as
44
+ /// `[[provider]]` sections; a ladder rung is then `<id>/<model>`.
45
+ #[serde(rename = "provider", default)]
46
+ pub providers: Vec<ProviderDef>,
47
+ }
48
+
49
+ /// The wire API a provider speaks. `anthropic` = Messages API; `openai` = Chat Completions API
50
+ /// (the de-facto standard that nearly every hosted and open-source model host implements).
51
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
52
+ #[serde(rename_all = "lowercase")]
53
+ pub enum Dialect {
54
+ /// Anthropic Messages API (`POST /v1/messages`).
55
+ Anthropic,
56
+ /// OpenAI Chat Completions API (`POST /v1/chat/completions`).
57
+ Openai,
58
+ }
59
+
60
+ /// A model provider a ladder can route to. Declared as `[[provider]]` in TOML; referenced from a
61
+ /// ladder as `<id>/<model>` (e.g. `groq/llama-3.3-70b-versatile`).
62
+ #[derive(Debug, Clone, Deserialize)]
63
+ #[serde(deny_unknown_fields)]
64
+ pub struct ProviderDef {
65
+ /// Ladder prefix for this provider, e.g. `"groq"`. Overrides a built-in of the same id.
66
+ pub id: String,
67
+ /// Which wire API it speaks.
68
+ pub dialect: Dialect,
69
+ /// Base URL, e.g. `"https://api.groq.com/openai"` or `"http://localhost:11434"` for Ollama.
70
+ pub base_url: String,
71
+ /// Env var the API key is read from at call time, e.g. `"GROQ_API_KEY"`. Omit for a keyless
72
+ /// endpoint (local Ollama / vLLM). Per-request BYOK headers still apply to the built-in
73
+ /// `anthropic` / `openai` providers.
74
+ #[serde(default)]
75
+ pub api_key_env: Option<String>,
41
76
  }
42
77
 
43
78
  /// A user-defined gate (SPEC §8.1). Exactly one kind per definition:
@@ -228,6 +263,32 @@ pub struct Escalation {
228
263
  /// aggregate gate verdict is `Pass` — byte-identical to today.
229
264
  #[serde(default)]
230
265
  pub serve_threshold: Option<f64>,
266
+ /// Online/adaptive conformal (Gibbs-Candès ACI): when set, the serve threshold is tracked
267
+ /// **live** from deferred feedback instead of held fixed, so served-failure stays at target under
268
+ /// distribution shift. `None` (default) uses the fixed `serve_threshold` above — byte-identical.
269
+ #[serde(default)]
270
+ pub adaptive: Option<AdaptiveConfig>,
271
+ /// Opt-in: route tool-calling / multimodal requests through enforce instead of falling back to
272
+ /// observe (ADR 0005). `false` (default) is byte-identical to today — such requests pass through
273
+ /// un-gated. Turn on **only after** verifying enforce faithfully round-trips your tool workload;
274
+ /// content fidelity is preserved either way, but escalating a live tool turn is operator-gated.
275
+ #[serde(default)]
276
+ pub enforce_structured: bool,
277
+ }
278
+
279
+ /// Config for online/adaptive conformal serving ([`crate::conformal::AdaptiveConformal`]).
280
+ #[derive(Debug, Clone, Deserialize)]
281
+ #[serde(deny_unknown_fields)]
282
+ pub struct AdaptiveConfig {
283
+ /// Target served-failure rate the online loop holds to.
284
+ pub alpha: f64,
285
+ /// Step size (default 0.02) — larger tracks shift faster but noisier.
286
+ #[serde(default = "default_adaptive_gamma")]
287
+ pub gamma: f64,
288
+ }
289
+
290
+ fn default_adaptive_gamma() -> f64 {
291
+ 0.02
231
292
  }
232
293
 
233
294
  const fn default_max_rungs() -> u32 {
@@ -241,6 +302,8 @@ impl Default for Escalation {
241
302
  session_promotion: None,
242
303
  speculation: 0,
243
304
  serve_threshold: None,
305
+ adaptive: None,
306
+ enforce_structured: false,
244
307
  }
245
308
  }
246
309
  }
@@ -480,6 +543,54 @@ timeout_ms = 60000
480
543
  assert!(matches!(Config::parse(dup), Err(Error::InvalidConfig(_))));
481
544
  }
482
545
 
546
+ #[test]
547
+ fn parses_adaptive_conformal_config() {
548
+ let c = Config::parse("[escalation.adaptive]\nalpha = 0.1\n").unwrap();
549
+ let a = c
550
+ .escalation
551
+ .adaptive
552
+ .expect("[escalation.adaptive] should parse");
553
+ assert!((a.alpha - 0.1).abs() < 1e-9);
554
+ assert!((a.gamma - 0.02).abs() < 1e-9, "gamma defaults to 0.02");
555
+ // Absent => None (fixed-threshold serving, default byte-identical behavior).
556
+ assert!(Config::parse("").unwrap().escalation.adaptive.is_none());
557
+ }
558
+
559
+ #[test]
560
+ fn parses_provider_entries_and_ladders_can_reference_them() {
561
+ let c = Config::parse(
562
+ r#"
563
+ [[provider]]
564
+ id = "groq"
565
+ dialect = "openai"
566
+ base_url = "https://api.groq.com/openai"
567
+ api_key_env = "GROQ_API_KEY"
568
+
569
+ [[provider]]
570
+ id = "ollama"
571
+ dialect = "openai"
572
+ base_url = "http://localhost:11434"
573
+
574
+ [[route]]
575
+ match = {}
576
+ mode = "enforce"
577
+ ladder = ["groq/llama-3.3-70b-versatile", "anthropic/claude-sonnet-5"]
578
+ "#,
579
+ )
580
+ .unwrap();
581
+ assert_eq!(c.providers.len(), 2);
582
+ let groq = &c.providers[0];
583
+ assert_eq!(groq.id, "groq");
584
+ assert_eq!(groq.dialect, Dialect::Openai);
585
+ assert_eq!(groq.base_url, "https://api.groq.com/openai");
586
+ assert_eq!(groq.api_key_env.as_deref(), Some("GROQ_API_KEY"));
587
+ // A keyless local endpoint (Ollama) parses with no api_key_env.
588
+ assert_eq!(c.providers[1].id, "ollama");
589
+ assert!(c.providers[1].api_key_env.is_none());
590
+ // Absent => no extra providers (built-ins only).
591
+ assert!(Config::parse("").unwrap().providers.is_empty());
592
+ }
593
+
483
594
  #[test]
484
595
  fn empty_match_is_wildcard() {
485
596
  let m = Match::default();
@@ -31,8 +31,8 @@ pub mod trace;
31
31
  pub mod verdict;
32
32
 
33
33
  pub use config::{
34
- Budget, Config, Escalation, GateDef, JudgeDef, Mode, ModelRef, OnExhausted, Route,
35
- SessionPromotion,
34
+ Budget, Config, Dialect, Escalation, GateDef, JudgeDef, Mode, ModelRef, OnExhausted,
35
+ ProviderDef, Route, SessionPromotion,
36
36
  };
37
37
  pub use conformal::{ConformalResult, calibrate, served_failure_rate};
38
38
  pub use cost::{ModelPrice, PriceTable};
@@ -174,6 +174,7 @@ async fn spawn_proxy(upstream: &str, db: &std::path::Path) -> String {
174
174
  providers,
175
175
  gate_health: Arc::new(firstpass_proxy::gate::GateHealthRegistry::new()),
176
176
  traces,
177
+ adaptive: None,
177
178
  };
178
179
  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
179
180
  let addr = listener.local_addr().unwrap();
@@ -115,10 +115,7 @@ pub fn build_judge_request(judge_model: &str, rubric: &str, candidate: &str) ->
115
115
  ModelRequest {
116
116
  model: judge_model.to_owned(),
117
117
  system: Some(JUDGE_SYSTEM.to_owned()),
118
- messages: vec![ChatMessage {
119
- role: "user".to_owned(),
120
- content: user,
121
- }],
118
+ messages: vec![ChatMessage::text("user", user)],
122
119
  max_tokens: 256,
123
120
  tools: Value::Null, // the judge runs no tools (§8.3)
124
121
  }
@@ -215,7 +212,7 @@ mod tests {
215
212
  "system pins anti-injection"
216
213
  );
217
214
  assert_eq!(req.tools, Value::Null, "judge runs no tools");
218
- let user = &req.messages[0].content;
215
+ let user = req.messages[0].text_view();
219
216
  assert!(user.contains("BEGIN_CANDIDATE") && user.contains("the answer is 42"));
220
217
  }
221
218
 
@@ -11,20 +11,50 @@ use axum::http::HeaderMap;
11
11
  use serde::{Deserialize, Serialize};
12
12
  use serde_json::Value;
13
13
 
14
- /// One message in a normalized chat conversation.
14
+ /// One message in a normalized chat conversation. `content` is either a plain string (the common
15
+ /// case — serializes byte-identically to a text message) OR the original array of content blocks
16
+ /// (`text` / `tool_use` / `tool_result` / `image`), forwarded verbatim so tool and multimodal turns
17
+ /// survive the enforce path (ADR 0005). Use [`ChatMessage::text_view`] to read a text projection for
18
+ /// gating.
15
19
  #[derive(Debug, Clone, Serialize, Deserialize)]
16
20
  pub struct ChatMessage {
17
21
  /// `"user"`, `"assistant"`, or `"system"`.
18
22
  pub role: String,
19
- /// Message text.
20
- pub content: String,
23
+ /// Message content — a string, or an array of content blocks, forwarded as-is to the provider.
24
+ pub content: Value,
25
+ }
26
+
27
+ impl ChatMessage {
28
+ /// A text-only message (the common path).
29
+ #[must_use]
30
+ pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
31
+ Self {
32
+ role: role.into(),
33
+ content: Value::String(content.into()),
34
+ }
35
+ }
36
+
37
+ /// Concatenate the text this message carries, for gating. A string is itself; an array yields the
38
+ /// joined `text` blocks (tool_use/tool_result/image blocks contribute no gating text).
39
+ #[must_use]
40
+ pub fn text_view(&self) -> String {
41
+ match &self.content {
42
+ Value::String(s) => s.clone(),
43
+ Value::Array(blocks) => blocks
44
+ .iter()
45
+ .filter_map(|b| b.get("text").and_then(Value::as_str))
46
+ .collect::<Vec<_>>()
47
+ .join("\n"),
48
+ _ => String::new(),
49
+ }
50
+ }
21
51
  }
22
52
 
23
53
  /// A provider-agnostic model request, built once per incoming call and re-used (with
24
54
  /// `model` swapped) across every rung of the ladder.
25
55
  ///
26
- // ponytail: multimodal content and tool-result blocks are collapsed to plain text for now
27
- // enough to route and gate on. A richer content-block model is a later batch.
56
+ // Message content is carried verbatim (string or content-block array, ADR 0005); gates read a text
57
+ // projection via `ChatMessage::text_view`. `tools` is opaque passthrough.
28
58
  #[derive(Debug, Clone, Serialize, Deserialize)]
29
59
  pub struct ModelRequest {
30
60
  /// `provider/model`, e.g. `"anthropic/claude-haiku-4-5"`.
@@ -153,7 +183,7 @@ pub trait Provider: Send + Sync + std::fmt::Debug {
153
183
  #[derive(Serialize)]
154
184
  struct AnthropicWireMessage<'a> {
155
185
  role: &'a str,
156
- content: &'a str,
186
+ content: &'a Value,
157
187
  }
158
188
 
159
189
  /// Strip the `provider/` prefix from a ladder model id for the provider's wire API — Anthropic and
@@ -163,6 +193,17 @@ fn wire_model(model: &str) -> &str {
163
193
  model.split_once('/').map_or(model, |(_, m)| m)
164
194
  }
165
195
 
196
+ /// Resolve the API key for a provider call. A configured `api_key_env` wins (that env var is *this*
197
+ /// provider's key — e.g. `GROQ_API_KEY` for a Groq rung); otherwise fall back to the per-request
198
+ /// BYOK override from headers/env (the built-in `anthropic`/`openai` path). Empty string when
199
+ /// neither is set (a keyless local endpoint).
200
+ fn resolve_api_key(api_key_env: Option<&str>, byok_override: Option<&str>) -> String {
201
+ api_key_env
202
+ .and_then(|e| std::env::var(e).ok())
203
+ .or_else(|| byok_override.map(str::to_owned))
204
+ .unwrap_or_default()
205
+ }
206
+
166
207
  #[derive(Serialize)]
167
208
  struct AnthropicWireRequest<'a> {
168
209
  model: &'a str,
@@ -179,8 +220,13 @@ struct AnthropicWireRequest<'a> {
179
220
  // wire call (see `wire_model`); sending it verbatim 404s.
180
221
  #[derive(Debug, Clone)]
181
222
  pub struct AnthropicProvider {
223
+ /// Ladder prefix / trace label for this provider (usually `"anthropic"`).
224
+ pub id: String,
182
225
  /// Base URL, e.g. `https://api.anthropic.com`.
183
226
  pub base_url: String,
227
+ /// Env var the API key is read from when no per-request BYOK header is present. `None` for the
228
+ /// built-in provider, which resolves the key via [`Auth`] (`x-api-key` header or env).
229
+ pub api_key_env: Option<String>,
184
230
  /// Shared, connection-pooled HTTP client.
185
231
  pub http: reqwest::Client,
186
232
  }
@@ -188,7 +234,7 @@ pub struct AnthropicProvider {
188
234
  #[async_trait]
189
235
  impl Provider for AnthropicProvider {
190
236
  fn id(&self) -> &str {
191
- "anthropic"
237
+ &self.id
192
238
  }
193
239
 
194
240
  async fn complete(
@@ -196,7 +242,7 @@ impl Provider for AnthropicProvider {
196
242
  req: &ModelRequest,
197
243
  auth: &Auth,
198
244
  ) -> Result<ModelResponse, ProviderError> {
199
- let key = auth.anthropic_key.as_deref().unwrap_or_default();
245
+ let key = resolve_api_key(self.api_key_env.as_deref(), auth.anthropic_key.as_deref());
200
246
  let body = AnthropicWireRequest {
201
247
  model: wire_model(&req.model),
202
248
  system: req.system.as_deref(),
@@ -272,7 +318,7 @@ impl Provider for AnthropicProvider {
272
318
  #[derive(Serialize)]
273
319
  struct OpenAiWireMessage<'a> {
274
320
  role: &'a str,
275
- content: &'a str,
321
+ content: Value,
276
322
  }
277
323
 
278
324
  #[derive(Serialize)]
@@ -289,8 +335,14 @@ struct OpenAiWireRequest<'a> {
289
335
  // before relying on it in production.
290
336
  #[derive(Debug, Clone)]
291
337
  pub struct OpenAiProvider {
292
- /// Base URL, e.g. `https://api.openai.com`.
338
+ /// Ladder prefix / trace label for this provider (`"openai"`, `"groq"`, `"together"`, …).
339
+ pub id: String,
340
+ /// Base URL, e.g. `https://api.openai.com` or `https://api.groq.com/openai`.
293
341
  pub base_url: String,
342
+ /// Env var the API key is read from, e.g. `"GROQ_API_KEY"`. `None` for the built-in `openai`
343
+ /// provider (resolves via [`Auth`]: `authorization` header or `OPENAI_API_KEY`) and for keyless
344
+ /// local endpoints (Ollama / vLLM).
345
+ pub api_key_env: Option<String>,
294
346
  /// Shared, connection-pooled HTTP client.
295
347
  pub http: reqwest::Client,
296
348
  }
@@ -298,7 +350,7 @@ pub struct OpenAiProvider {
298
350
  #[async_trait]
299
351
  impl Provider for OpenAiProvider {
300
352
  fn id(&self) -> &str {
301
- "openai"
353
+ &self.id
302
354
  }
303
355
 
304
356
  async fn complete(
@@ -306,17 +358,17 @@ impl Provider for OpenAiProvider {
306
358
  req: &ModelRequest,
307
359
  auth: &Auth,
308
360
  ) -> Result<ModelResponse, ProviderError> {
309
- let key = auth.openai_key.as_deref().unwrap_or_default();
361
+ let key = resolve_api_key(self.api_key_env.as_deref(), auth.openai_key.as_deref());
310
362
  let mut messages = Vec::with_capacity(req.messages.len() + 1);
311
363
  if let Some(system) = req.system.as_deref() {
312
364
  messages.push(OpenAiWireMessage {
313
365
  role: "system",
314
- content: system,
366
+ content: Value::String(system.to_owned()),
315
367
  });
316
368
  }
317
369
  messages.extend(req.messages.iter().map(|m| OpenAiWireMessage {
318
370
  role: &m.role,
319
- content: &m.content,
371
+ content: m.content.clone(),
320
372
  }));
321
373
  let body = OpenAiWireRequest {
322
374
  model: wire_model(&req.model),
@@ -393,34 +445,72 @@ impl std::fmt::Debug for ProviderRegistry {
393
445
  }
394
446
 
395
447
  impl ProviderRegistry {
396
- /// Build the standard registry: Anthropic + OpenAI, sharing one HTTP client.
397
- ///
398
- /// The enforce path is request/response (never streamed through the adapter), so the client
399
- /// carries a total request timeout as well as a connect timeout a hung or slow upstream can't
400
- /// pin a routing decision indefinitely. Falls back to a default client if the builder fails
401
- /// (only on TLS backend init, which is fatal anyway).
402
- #[must_use]
403
- pub fn new(anthropic_base: impl Into<String>, openai_base: impl Into<String>) -> Self {
404
- let http = reqwest::Client::builder()
448
+ /// One HTTP client shared by every provider. The enforce path is request/response (never
449
+ /// streamed through the adapter), so it carries a total request timeout as well as a connect
450
+ /// timeout a hung or slow upstream can't pin a routing decision indefinitely. Falls back to a
451
+ /// default client if the builder fails (only on TLS backend init, which is fatal anyway).
452
+ fn build_http_client() -> reqwest::Client {
453
+ reqwest::Client::builder()
405
454
  .connect_timeout(std::time::Duration::from_secs(10))
406
455
  .timeout(std::time::Duration::from_secs(120))
407
456
  .build()
408
- .unwrap_or_else(|_| reqwest::Client::new());
457
+ .unwrap_or_else(|_| reqwest::Client::new())
458
+ }
459
+
460
+ /// Build the standard registry: the built-in `anthropic` + `openai` providers.
461
+ #[must_use]
462
+ pub fn new(anthropic_base: impl Into<String>, openai_base: impl Into<String>) -> Self {
463
+ Self::from_config(&[], anthropic_base, openai_base)
464
+ }
465
+
466
+ /// Build the registry with the built-in `anthropic` / `openai` providers plus every configured
467
+ /// `[[provider]]` entry — so a ladder can route to any OpenAI-compatible or Anthropic-compatible
468
+ /// endpoint (Groq, Together, Fireworks, DeepSeek, Mistral, xAI, OpenRouter, Ollama, vLLM, Azure,
469
+ /// …) by id. A `[[provider]]` whose id is `anthropic` or `openai` overrides the built-in default
470
+ /// (e.g. to point `openai` at Azure). Shares one HTTP client across all of them.
471
+ #[must_use]
472
+ pub fn from_config(
473
+ defs: &[firstpass_core::ProviderDef],
474
+ anthropic_base: impl Into<String>,
475
+ openai_base: impl Into<String>,
476
+ ) -> Self {
477
+ let http = Self::build_http_client();
409
478
  let mut providers: HashMap<String, Arc<dyn Provider>> = HashMap::new();
410
479
  providers.insert(
411
480
  "anthropic".to_owned(),
412
481
  Arc::new(AnthropicProvider {
482
+ id: "anthropic".to_owned(),
413
483
  base_url: anthropic_base.into(),
484
+ api_key_env: None,
414
485
  http: http.clone(),
415
486
  }),
416
487
  );
417
488
  providers.insert(
418
489
  "openai".to_owned(),
419
490
  Arc::new(OpenAiProvider {
491
+ id: "openai".to_owned(),
420
492
  base_url: openai_base.into(),
421
- http,
493
+ api_key_env: None,
494
+ http: http.clone(),
422
495
  }),
423
496
  );
497
+ for def in defs {
498
+ let provider: Arc<dyn Provider> = match def.dialect {
499
+ firstpass_core::Dialect::Anthropic => Arc::new(AnthropicProvider {
500
+ id: def.id.clone(),
501
+ base_url: def.base_url.clone(),
502
+ api_key_env: def.api_key_env.clone(),
503
+ http: http.clone(),
504
+ }),
505
+ firstpass_core::Dialect::Openai => Arc::new(OpenAiProvider {
506
+ id: def.id.clone(),
507
+ base_url: def.base_url.clone(),
508
+ api_key_env: def.api_key_env.clone(),
509
+ http: http.clone(),
510
+ }),
511
+ };
512
+ providers.insert(def.id.clone(), provider);
513
+ }
424
514
  Self { providers }
425
515
  }
426
516
 
@@ -519,6 +609,100 @@ mod tests {
519
609
  assert_eq!(wire_model("claude-opus-4-8"), "claude-opus-4-8"); // no prefix → unchanged
520
610
  }
521
611
 
612
+ #[test]
613
+ fn from_config_registers_custom_providers_alongside_builtins() {
614
+ let defs = vec![
615
+ firstpass_core::ProviderDef {
616
+ id: "groq".to_owned(),
617
+ dialect: firstpass_core::Dialect::Openai,
618
+ base_url: "https://api.groq.com/openai".to_owned(),
619
+ api_key_env: Some("GROQ_API_KEY".to_owned()),
620
+ },
621
+ // A custom provider may override a built-in id (e.g. point `openai` at Azure).
622
+ firstpass_core::ProviderDef {
623
+ id: "openai".to_owned(),
624
+ dialect: firstpass_core::Dialect::Openai,
625
+ base_url: "https://my-azure.openai.azure.com".to_owned(),
626
+ api_key_env: Some("AZURE_OPENAI_KEY".to_owned()),
627
+ },
628
+ ];
629
+ let reg = ProviderRegistry::from_config(
630
+ &defs,
631
+ "https://api.anthropic.com",
632
+ "https://api.openai.com",
633
+ );
634
+ // Built-in anthropic is still present; the custom groq resolves and labels itself "groq".
635
+ assert_eq!(reg.get("anthropic").unwrap().id(), "anthropic");
636
+ assert_eq!(reg.get("groq").unwrap().id(), "groq");
637
+ // Unknown provider → None (router fails over rather than guessing).
638
+ assert!(reg.get("does-not-exist").is_none());
639
+ }
640
+
641
+ #[test]
642
+ fn resolve_api_key_prefers_configured_env_then_byok() {
643
+ // Use PATH (always present) to exercise the env branch without mutating process env.
644
+ let path = std::env::var("PATH").expect("PATH is set");
645
+ // Configured env wins over any BYOK override (the env var is *this* provider's key).
646
+ assert_eq!(resolve_api_key(Some("PATH"), Some("byok")), path);
647
+ // An unset configured env → fall back to the per-request BYOK override.
648
+ assert_eq!(
649
+ resolve_api_key(Some("FIRSTPASS_DEFINITELY_UNSET_KEY"), Some("byok")),
650
+ "byok"
651
+ );
652
+ // No configured env → fall back to BYOK.
653
+ assert_eq!(resolve_api_key(None, Some("byok")), "byok");
654
+ // Neither → empty (keyless local endpoint).
655
+ assert_eq!(resolve_api_key(None, None), "");
656
+ }
657
+
658
+ #[test]
659
+ fn anthropic_wire_forwards_tool_and_image_content_verbatim() {
660
+ // ADR 0005 I3 (request side): the Anthropic adapter serializes tool_use / tool_result /
661
+ // image content blocks byte-for-byte into the wire body — enforce forwards them, it does not
662
+ // flatten them. A plain-string message still serializes as a bare string (I1).
663
+ let messages = [
664
+ ChatMessage::text("user", "hi"),
665
+ ChatMessage {
666
+ role: "assistant".to_owned(),
667
+ content: serde_json::json!([
668
+ { "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }
669
+ ]),
670
+ },
671
+ ChatMessage {
672
+ role: "user".to_owned(),
673
+ content: serde_json::json!([
674
+ { "type": "tool_result", "tool_use_id": "t1", "content": "2" },
675
+ { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
676
+ ]),
677
+ },
678
+ ];
679
+ let body = AnthropicWireRequest {
680
+ model: "claude-haiku-4-5",
681
+ system: None,
682
+ max_tokens: 64,
683
+ messages: messages
684
+ .iter()
685
+ .map(|m| AnthropicWireMessage {
686
+ role: &m.role,
687
+ content: &m.content,
688
+ })
689
+ .collect(),
690
+ };
691
+ let wire = serde_json::to_value(&body).unwrap();
692
+ assert_eq!(wire["messages"][0]["content"], serde_json::json!("hi"));
693
+ assert_eq!(
694
+ wire["messages"][1]["content"],
695
+ serde_json::json!([{ "type": "tool_use", "id": "t1", "name": "calc", "input": { "x": 1 } }])
696
+ );
697
+ assert_eq!(
698
+ wire["messages"][2]["content"],
699
+ serde_json::json!([
700
+ { "type": "tool_result", "tool_use_id": "t1", "content": "2" },
701
+ { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "AA==" } }
702
+ ])
703
+ );
704
+ }
705
+
522
706
  fn resp(model: &str, text: &str) -> ModelResponse {
523
707
  ModelResponse {
524
708
  model: model.to_owned(),
@@ -45,6 +45,10 @@ pub struct AppState {
45
45
  pub gate_health: Arc<GateHealthRegistry>,
46
46
  /// Fire-and-forget sender to the background trace writer.
47
47
  pub traces: store::TraceSender,
48
+ /// Optional online/adaptive conformal serve threshold (Gibbs-Candès ACI). `None` = fixed
49
+ /// `serve_threshold` from config (default). When present, `/v1/feedback` nudges it live and the
50
+ /// enforce path reads its current value per request — the reactive, self-tuning loop.
51
+ pub adaptive: Option<Arc<std::sync::Mutex<firstpass_core::conformal::AdaptiveConformal>>>,
48
52
  }
49
53
 
50
54
  impl std::fmt::Debug for AppState {
@@ -222,6 +226,12 @@ async fn feedback(State(state): State<AppState>, body: Bytes) -> Response {
222
226
  }
223
227
  }
224
228
 
229
+ // Correctness signal for the online adaptive loop — only a clear Pass/Fail nudges the threshold.
230
+ let feedback_signal = match verdict {
231
+ Verdict::Pass => Some(true),
232
+ Verdict::Fail => Some(false),
233
+ Verdict::Abstain => None,
234
+ };
225
235
  let dv = DeferredVerdict {
226
236
  gate_id: req.gate_id,
227
237
  verdict,
@@ -232,11 +242,19 @@ async fn feedback(State(state): State<AppState>, body: Bytes) -> Response {
232
242
  let trace_id = req.trace_id.clone();
233
243
  match tokio::task::spawn_blocking(move || store::append_deferred(&db, &req.trace_id, &dv)).await
234
244
  {
235
- Ok(Ok(())) => (
236
- axum::http::StatusCode::ACCEPTED,
237
- Json(serde_json::json!({ "status": "recorded", "trace_id": trace_id })),
238
- )
239
- .into_response(),
245
+ Ok(Ok(())) => {
246
+ // Close the reactive loop: nudge the live serve threshold toward the target.
247
+ if let (Some(a), Some(correct)) = (state.adaptive.as_ref(), feedback_signal)
248
+ && let Ok(mut g) = a.lock()
249
+ {
250
+ g.observe_served(correct);
251
+ }
252
+ (
253
+ axum::http::StatusCode::ACCEPTED,
254
+ Json(serde_json::json!({ "status": "recorded", "trace_id": trace_id })),
255
+ )
256
+ .into_response()
257
+ }
240
258
  Ok(Err(e)) => {
241
259
  tracing::error!(%e, "feedback: append_deferred failed");
242
260
  ProxyError::Internal(e.to_string()).into_response()
@@ -275,29 +293,35 @@ async fn messages(State(state): State<AppState>, headers: HeaderMap, body: Bytes
275
293
  // Clone the matched route so no borrow of `state.config` is held across the await;
276
294
  // routes are tiny (a handful of strings).
277
295
  let route = route.clone();
278
- if enforce_can_handle(&features, &body) {
296
+ if enforce_can_handle(&features, &body, routing.escalation.enforce_structured) {
279
297
  return handle_enforce(&state, &headers, &body, features, &route, session_header)
280
298
  .await;
281
299
  }
282
- // The request carries tools / images / tool-result blocks the text-only enforce path
283
- // can't round-trip faithfully yet. Rather than drop them and serve corrupted output,
284
- // fall through to transparent observe passthrough (correct, un-gated) for this request.
300
+ // With `enforce_structured` off (default), tool/image/tool-block requests fall through
301
+ // to transparent observe passthrough (correct, un-gated) rather than being routed
302
+ // byte-identical to before ADR 0005. (With it on, `enforce_can_handle` returns true and
303
+ // we never reach here.)
285
304
  tracing::info!(
286
- "enforce route matched but request has tool/image content; serving via observe passthrough"
305
+ "enforce route matched but request carries tools/images; serving via observe passthrough"
287
306
  );
288
307
  }
289
308
  }
290
309
  observe_passthrough(state, headers, body, session_header).await
291
310
  }
292
311
 
293
- /// Whether the enforce path can faithfully handle this request. Enforce normalizes content to
294
- /// text and re-synthesizes a text response, so it cannot round-trip tool calls or images — a
295
- /// request that declares tools, carries images, or contains tool_use/tool_result blocks is served
296
- /// via transparent observe passthrough instead of being silently corrupted.
312
+ /// Whether the enforce path can faithfully handle this request.
297
313
  ///
298
- // ponytail: full tool/multimodal round-tripping through the ladder is the follow-on (needs
299
- // provider-adapter work + live verification); this guard just refuses to corrupt in the meantime.
300
- fn enforce_can_handle(features: &Features, body: &[u8]) -> bool {
314
+ /// Request content is carried verbatim (ADR 0005 P1), so tool_use/tool_result/image blocks survive
315
+ /// the round trip, and a streaming client is served the gated result as SSE (P3). Whether such
316
+ /// requests are actually *routed* is the operator's call:
317
+ /// - `enforce_structured == false` (default): tools/images/tool-blocks fall back to observe —
318
+ /// byte-identical to before the ADR (invariant I1).
319
+ /// - `enforce_structured == true` (opt-in, live-verified): tools, images, and streaming all route
320
+ /// through enforce.
321
+ fn enforce_can_handle(features: &Features, body: &[u8], enforce_structured: bool) -> bool {
322
+ if enforce_structured {
323
+ return true;
324
+ }
301
325
  features.tool_count == 0 && !features.has_images && !messages_have_tool_blocks(body)
302
326
  }
303
327
 
@@ -394,6 +418,13 @@ async fn handle_enforce(
394
418
  ),
395
419
  None => (None, 3, 0, None),
396
420
  };
421
+ // Online adaptive conformal: serve against the LIVE-tracked threshold (updated by /v1/feedback).
422
+ // Falls back to the fixed config threshold when adaptive is off or its lock is poisoned.
423
+ let serve_threshold = state
424
+ .adaptive
425
+ .as_ref()
426
+ .and_then(|a| a.lock().ok().map(|g| g.threshold()))
427
+ .or(serve_threshold);
397
428
 
398
429
  let ctx = EnforceCtx {
399
430
  ladder: &route.ladder,
@@ -420,11 +451,25 @@ async fn handle_enforce(
420
451
  offer_trace(&state.traces, trace);
421
452
 
422
453
  match outcome {
423
- EngineOutcome::Served(resp) => (
424
- axum::http::StatusCode::OK,
425
- Json(anthropic_response_json(&resp)),
426
- )
427
- .into_response(),
454
+ EngineOutcome::Served(resp) => {
455
+ let message = anthropic_response_json(&resp);
456
+ // A streaming client gets the gated result re-emitted as SSE (ADR 0005 P3): the gate
457
+ // needs the whole candidate, so enforce can't stream token-by-token from the model — it
458
+ // buffers to gate, then streams the served blocks out. tool_use blocks are preserved.
459
+ if is_stream_request(body) {
460
+ (
461
+ axum::http::StatusCode::OK,
462
+ [(
463
+ axum::http::header::CONTENT_TYPE,
464
+ "text/event-stream; charset=utf-8",
465
+ )],
466
+ anthropic_sse_from_message(&message),
467
+ )
468
+ .into_response()
469
+ } else {
470
+ (axum::http::StatusCode::OK, Json(message)).into_response()
471
+ }
472
+ }
428
473
  EngineOutcome::Failed(msg) => ProxyError::Engine(msg).into_response(),
429
474
  }
430
475
  }
@@ -432,10 +477,11 @@ async fn handle_enforce(
432
477
  /// Parse an Anthropic Messages request body into the normalized [`ModelRequest`]. Returns
433
478
  /// `None` if the body isn't valid JSON or lacks a `messages` array.
434
479
  ///
435
- // Content blocks are collapsed to their concatenated text. Requests carrying tool_use/tool_result
436
- // or image blocks never reach this function `enforce_can_handle` routes them to transparent
437
- // observe passthrough instead, so nothing is silently dropped here. Full multimodal round-tripping
438
- // through the ladder is the follow-up; the enforce beachhead is text/code.
480
+ // Message content is preserved **verbatim** (string or array of blocks) — a plain-string content
481
+ // serializes byte-identical on the wire, and tool_use/tool_result/image blocks survive the round
482
+ // trip (ADR 0005, invariant I2). Gates operate on `ChatMessage::text_view()`, not the raw content,
483
+ // so gate behavior is unchanged. Which requests actually enter enforce is still governed by
484
+ // `enforce_can_handle`; this function only guarantees no fidelity is lost once they do.
439
485
  fn parse_model_request(body: &[u8]) -> Option<ModelRequest> {
440
486
  let json: Value = serde_json::from_slice(body).ok()?;
441
487
  let messages_json = json.get("messages")?.as_array()?;
@@ -447,7 +493,10 @@ fn parse_model_request(body: &[u8]) -> Option<ModelRequest> {
447
493
  .and_then(Value::as_str)
448
494
  .unwrap_or("user")
449
495
  .to_owned(),
450
- content: content_to_text(m.get("content")),
496
+ content: m
497
+ .get("content")
498
+ .cloned()
499
+ .unwrap_or_else(|| Value::String(String::new())),
451
500
  })
452
501
  .collect();
453
502
  let system = json
@@ -473,32 +522,127 @@ fn parse_model_request(body: &[u8]) -> Option<ModelRequest> {
473
522
  })
474
523
  }
475
524
 
476
- /// Flatten Anthropic message content (a string, or an array of `{type,text}` blocks) to text.
477
- fn content_to_text(content: Option<&Value>) -> String {
478
- match content {
479
- Some(Value::String(s)) => s.clone(),
480
- Some(Value::Array(blocks)) => blocks
481
- .iter()
482
- .filter_map(|b| b.get("text").and_then(Value::as_str))
483
- .collect::<Vec<_>>()
484
- .join("\n"),
485
- _ => String::new(),
486
- }
487
- }
488
-
489
525
  /// Render a served [`ModelResponse`] back into an Anthropic Messages response envelope, so the
490
526
  /// caller sees the same wire shape regardless of which provider actually answered.
527
+ ///
528
+ /// The `content` blocks come **verbatim** from the upstream response (`resp.raw`) when it is an
529
+ /// Anthropic message — so `tool_use` / `thinking` / multiple text blocks reach the caller intact
530
+ /// (ADR 0005 I2). Only when `raw` has no Anthropic `content` array (a synthetic response, or the
531
+ /// OpenAI adapter, which has `choices` instead) do we fall back to a single reconstructed text
532
+ /// block. The envelope (`id`, `model`, `usage`) is always normalized so the served model id is the
533
+ /// prefixed ladder id, not the bare wire id.
491
534
  fn anthropic_response_json(resp: &ModelResponse) -> Value {
535
+ let content = resp
536
+ .raw
537
+ .get("content")
538
+ .filter(|c| c.is_array())
539
+ .cloned()
540
+ .unwrap_or_else(|| serde_json::json!([{ "type": "text", "text": resp.text }]));
492
541
  serde_json::json!({
493
542
  "id": format!("msg_{}", Uuid::now_v7()),
494
543
  "type": "message",
495
544
  "role": "assistant",
496
545
  "model": resp.model,
497
- "content": [{ "type": "text", "text": resp.text }],
546
+ "content": content,
498
547
  "usage": { "input_tokens": resp.in_tokens, "output_tokens": resp.out_tokens },
499
548
  })
500
549
  }
501
550
 
551
+ /// Append one `event: <type>\ndata: <json>\n\n` SSE frame.
552
+ fn sse_event(out: &mut String, event: &str, data: &Value) {
553
+ out.push_str("event: ");
554
+ out.push_str(event);
555
+ out.push_str("\ndata: ");
556
+ out.push_str(&data.to_string());
557
+ out.push_str("\n\n");
558
+ }
559
+
560
+ /// Re-emit a served Anthropic message envelope (from [`anthropic_response_json`]) as an SSE stream
561
+ /// body, so a `stream: true` client is served even though enforce buffered the response to gate it
562
+ /// (ADR 0005 P3). The gate needs the full candidate, so this is not token-by-token streaming from
563
+ /// the model — each content block is emitted as a single delta. `tool_use` blocks are preserved:
564
+ /// their `input` is streamed as one `input_json_delta` (invariant I2), so the caller reconstructs
565
+ /// the exact tool call.
566
+ fn anthropic_sse_from_message(message: &Value) -> String {
567
+ let mut out = String::new();
568
+
569
+ // message_start carries the envelope with content emptied — the blocks stream next.
570
+ let mut start_msg = message.clone();
571
+ start_msg["content"] = Value::Array(Vec::new());
572
+ sse_event(
573
+ &mut out,
574
+ "message_start",
575
+ &serde_json::json!({ "type": "message_start", "message": start_msg }),
576
+ );
577
+
578
+ let empty = Vec::new();
579
+ let blocks = message
580
+ .get("content")
581
+ .and_then(Value::as_array)
582
+ .unwrap_or(&empty);
583
+ for (i, block) in blocks.iter().enumerate() {
584
+ match block.get("type").and_then(Value::as_str) {
585
+ Some("tool_use") => {
586
+ // Start with an empty input object, then stream the real input as one JSON delta.
587
+ let mut shell = block.clone();
588
+ shell["input"] = serde_json::json!({});
589
+ sse_event(
590
+ &mut out,
591
+ "content_block_start",
592
+ &serde_json::json!({ "type": "content_block_start", "index": i, "content_block": shell }),
593
+ );
594
+ let input_json = block
595
+ .get("input")
596
+ .map_or_else(|| "{}".to_owned(), std::string::ToString::to_string);
597
+ sse_event(
598
+ &mut out,
599
+ "content_block_delta",
600
+ &serde_json::json!({ "type": "content_block_delta", "index": i,
601
+ "delta": { "type": "input_json_delta", "partial_json": input_json } }),
602
+ );
603
+ }
604
+ _ => {
605
+ // text (and any other text-bearing block): start empty, stream the text as one delta.
606
+ let text = block.get("text").and_then(Value::as_str).unwrap_or("");
607
+ sse_event(
608
+ &mut out,
609
+ "content_block_start",
610
+ &serde_json::json!({ "type": "content_block_start", "index": i,
611
+ "content_block": { "type": "text", "text": "" } }),
612
+ );
613
+ sse_event(
614
+ &mut out,
615
+ "content_block_delta",
616
+ &serde_json::json!({ "type": "content_block_delta", "index": i,
617
+ "delta": { "type": "text_delta", "text": text } }),
618
+ );
619
+ }
620
+ }
621
+ sse_event(
622
+ &mut out,
623
+ "content_block_stop",
624
+ &serde_json::json!({ "type": "content_block_stop", "index": i }),
625
+ );
626
+ }
627
+
628
+ let out_tokens = message
629
+ .pointer("/usage/output_tokens")
630
+ .cloned()
631
+ .unwrap_or_else(|| Value::from(0));
632
+ sse_event(
633
+ &mut out,
634
+ "message_delta",
635
+ &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "end_turn" },
636
+ "usage": { "output_tokens": out_tokens } }),
637
+ );
638
+ sse_event(
639
+ &mut out,
640
+ "message_stop",
641
+ &serde_json::json!({ "type": "message_stop" }),
642
+ );
643
+ out
644
+ }
645
+
502
646
  /// Observe mode (SPEC §7.1a): forward unchanged, return unchanged, trace asynchronously.
503
647
  async fn observe_passthrough(
504
648
  state: AppState,
@@ -922,7 +1066,7 @@ mod tests {
922
1066
  }
923
1067
 
924
1068
  #[test]
925
- fn parse_model_request_flattens_content_blocks() {
1069
+ fn parse_model_request_preserves_content_verbatim_and_projects_text() {
926
1070
  let body = br#"{"model":"m","system":"sys","max_tokens":50,
927
1071
  "messages":[{"role":"user","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]},
928
1072
  {"role":"assistant","content":"c"}]}"#;
@@ -930,8 +1074,49 @@ mod tests {
930
1074
  assert_eq!(req.system.as_deref(), Some("sys"));
931
1075
  assert_eq!(req.max_tokens, 50);
932
1076
  assert_eq!(req.messages.len(), 2);
933
- assert_eq!(req.messages[0].content, "a\nb");
934
- assert_eq!(req.messages[1].content, "c");
1077
+ // I2: the block array is carried verbatim, not flattened away...
1078
+ assert_eq!(
1079
+ req.messages[0].content,
1080
+ serde_json::json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])
1081
+ );
1082
+ // ...and a plain string stays a plain string (I1: byte-identical on the wire).
1083
+ assert_eq!(req.messages[1].content, Value::String("c".to_owned()));
1084
+ // Gates see the same text they always did.
1085
+ assert_eq!(req.messages[0].text_view(), "a\nb");
1086
+ assert_eq!(req.messages[1].text_view(), "c");
1087
+ }
1088
+
1089
+ #[test]
1090
+ fn tool_and_image_blocks_survive_the_request_round_trip() {
1091
+ // ADR 0005 I2: tool_use / tool_result / image blocks are never dropped on the request side.
1092
+ let body = br#"{"model":"m","max_tokens":50,"messages":[
1093
+ {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}}]},
1094
+ {"role":"user","content":[
1095
+ {"type":"tool_result","tool_use_id":"t1","content":"2"},
1096
+ {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
1097
+ ]}]}"#;
1098
+ let req = parse_model_request(body).unwrap();
1099
+ let round_tripped = serde_json::to_value(&req.messages).unwrap();
1100
+ assert_eq!(
1101
+ round_tripped,
1102
+ serde_json::json!([
1103
+ {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}}]},
1104
+ {"role":"user","content":[
1105
+ {"type":"tool_result","tool_use_id":"t1","content":"2"},
1106
+ {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
1107
+ ]}
1108
+ ])
1109
+ );
1110
+ }
1111
+
1112
+ #[test]
1113
+ fn text_message_serializes_byte_identical_to_a_plain_string() {
1114
+ // I1: a string-content message must not gain array wrapping on the wire.
1115
+ let m = ChatMessage::text("user", "hello");
1116
+ assert_eq!(
1117
+ serde_json::to_string(&m).unwrap(),
1118
+ r#"{"role":"user","content":"hello"}"#
1119
+ );
935
1120
  }
936
1121
 
937
1122
  #[test]
@@ -1003,6 +1188,7 @@ mod tests {
1003
1188
  providers,
1004
1189
  gate_health: Arc::new(GateHealthRegistry::new()),
1005
1190
  traces,
1191
+ adaptive: None,
1006
1192
  };
1007
1193
  (state, rx)
1008
1194
  }
@@ -1102,6 +1288,7 @@ mod tests {
1102
1288
  providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
1103
1289
  gate_health: Arc::new(GateHealthRegistry::new()),
1104
1290
  traces,
1291
+ adaptive: None,
1105
1292
  };
1106
1293
  let resp = messages(State(state), HeaderMap::new(), user_body()).await;
1107
1294
  // Observe path forwards upstream; the bogus host yields a gateway error, not our 200.
@@ -1134,8 +1321,72 @@ mod tests {
1134
1321
  );
1135
1322
  let f_plain = extract_features(&HeaderMap::new(), &plain);
1136
1323
  let f_tools = extract_features(&HeaderMap::new(), &tools);
1137
- assert!(enforce_can_handle(&f_plain, &plain));
1138
- assert!(!enforce_can_handle(&f_tools, &tools));
1324
+ // Default (enforce_structured = false): plain text routes, tools fall back to observe.
1325
+ assert!(enforce_can_handle(&f_plain, &plain, false));
1326
+ assert!(!enforce_can_handle(&f_tools, &tools, false));
1327
+ }
1328
+
1329
+ #[test]
1330
+ fn structured_enforce_routes_tools_and_streaming() {
1331
+ // ADR 0005 P2+P3: with the opt-in flag on, tool and streaming requests both route through
1332
+ // enforce (streaming is served as the gated result re-emitted as SSE).
1333
+ let tools = Bytes::from_static(
1334
+ br#"{"model":"m","tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
1335
+ );
1336
+ let streaming_tools = Bytes::from_static(
1337
+ br#"{"model":"m","stream":true,"tools":[{"name":"t"}],"messages":[{"role":"user","content":"hi"}]}"#,
1338
+ );
1339
+ let f = extract_features(&HeaderMap::new(), &tools);
1340
+ assert!(enforce_can_handle(&f, &tools, true));
1341
+ assert!(enforce_can_handle(&f, &streaming_tools, true));
1342
+ }
1343
+
1344
+ #[test]
1345
+ fn enforce_sse_reemission_preserves_text_and_tool_use() {
1346
+ // ADR 0005 P3 + I2: a served response with a text block AND a tool_use block round-trips
1347
+ // through the SSE re-emitter — the tool call's input survives as an input_json_delta.
1348
+ let resp = ModelResponse {
1349
+ model: "anthropic/claude-haiku-4-5".to_owned(),
1350
+ text: "let me check".to_owned(),
1351
+ in_tokens: 5,
1352
+ out_tokens: 7,
1353
+ raw: serde_json::json!({
1354
+ "content": [
1355
+ { "type": "text", "text": "let me check" },
1356
+ { "type": "tool_use", "id": "tu_1", "name": "get_weather", "input": { "city": "Paris" } }
1357
+ ]
1358
+ }),
1359
+ };
1360
+ let sse = anthropic_sse_from_message(&anthropic_response_json(&resp));
1361
+
1362
+ // Parse every data frame structurally (key order is not part of the contract).
1363
+ let frames: Vec<Value> = sse
1364
+ .lines()
1365
+ .filter_map(|l| l.strip_prefix("data: "))
1366
+ .map(|d| serde_json::from_str::<Value>(d).expect("each SSE data frame is valid JSON"))
1367
+ .collect();
1368
+
1369
+ // Full lifecycle, in order.
1370
+ assert_eq!(frames.first().unwrap()["type"], "message_start");
1371
+ assert_eq!(frames.last().unwrap()["type"], "message_stop");
1372
+ // The text block streams its text as a text_delta.
1373
+ assert!(frames.iter().any(|f| f["delta"]["type"] == "text_delta"
1374
+ && f["delta"]["text"] == "let me check"));
1375
+ // The tool_use block is present with its id/name, and its input streams as one JSON delta —
1376
+ // not dropped (ADR 0005 I2).
1377
+ assert!(
1378
+ frames
1379
+ .iter()
1380
+ .any(|f| f["content_block"]["type"] == "tool_use"
1381
+ && f["content_block"]["name"] == "get_weather"
1382
+ && f["content_block"]["id"] == "tu_1")
1383
+ );
1384
+ assert!(
1385
+ frames
1386
+ .iter()
1387
+ .any(|f| f["delta"]["type"] == "input_json_delta"
1388
+ && f["delta"]["partial_json"] == r#"{"city":"Paris"}"#)
1389
+ );
1139
1390
  }
1140
1391
 
1141
1392
  /// B2: an enforce route serves plain text (200 from the mock) but falls back to transparent
@@ -1168,6 +1419,7 @@ mod tests {
1168
1419
  providers: ProviderRegistry::from_map(map),
1169
1420
  gate_health: Arc::new(GateHealthRegistry::new()),
1170
1421
  traces,
1422
+ adaptive: None,
1171
1423
  };
1172
1424
 
1173
1425
  // Plain text enforces: the mock serves 200.
@@ -1245,10 +1497,43 @@ mod tests {
1245
1497
  providers: ProviderRegistry::new("http://127.0.0.1:1", "http://127.0.0.1:1"),
1246
1498
  gate_health: Arc::new(GateHealthRegistry::new()),
1247
1499
  traces,
1500
+ adaptive: None,
1248
1501
  };
1249
1502
  (state, db, trace_id)
1250
1503
  }
1251
1504
 
1505
+ #[tokio::test]
1506
+ async fn feedback_nudges_the_adaptive_threshold() {
1507
+ use firstpass_core::conformal::AdaptiveConformal;
1508
+ let (mut state, _db, trace_id) = feedback_state().await;
1509
+ let aci = Arc::new(std::sync::Mutex::new(AdaptiveConformal::new(0.1, 0.2, 0.5)));
1510
+ state.adaptive = Some(aci.clone());
1511
+ let before = aci.lock().unwrap().threshold();
1512
+
1513
+ // A served FAILURE raises the threshold (serve more conservatively).
1514
+ let fail = Bytes::from(
1515
+ serde_json::json!({ "trace_id": trace_id, "gate_id": "tests", "verdict": "fail", "reporter": "ci" })
1516
+ .to_string(),
1517
+ );
1518
+ assert_eq!(
1519
+ feedback(State(state.clone()), fail).await.status(),
1520
+ axum::http::StatusCode::ACCEPTED
1521
+ );
1522
+ let after_fail = aci.lock().unwrap().threshold();
1523
+ assert!(
1524
+ after_fail > before,
1525
+ "served fail should raise the live threshold: {before} -> {after_fail}"
1526
+ );
1527
+
1528
+ // A served PASS nudges it back down — the loop is reactive both ways.
1529
+ let pass = Bytes::from(
1530
+ serde_json::json!({ "trace_id": trace_id, "gate_id": "tests", "verdict": "pass", "reporter": "ci" })
1531
+ .to_string(),
1532
+ );
1533
+ let _ = feedback(State(state), pass).await;
1534
+ assert!(aci.lock().unwrap().threshold() < after_fail);
1535
+ }
1536
+
1252
1537
  #[tokio::test]
1253
1538
  async fn feedback_records_a_deferred_verdict_without_breaking_the_chain() {
1254
1539
  let (state, db, trace_id) = feedback_state().await;
@@ -45,8 +45,35 @@ pub fn build_gate_health(config: &ProxyConfig) -> GateHealthRegistry {
45
45
  pub async fn serve(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
46
46
  let (traces, writer) = store::open(&config.db_path)?;
47
47
  let bind = config.bind.clone();
48
- let providers = ProviderRegistry::new(&config.upstream_anthropic, &config.upstream_openai);
48
+ // Build the provider registry from any `[[provider]]` entries in the routing config (Groq,
49
+ // Together, Ollama, …), on top of the built-in anthropic/openai defaults. No config => defaults.
50
+ let provider_defs = config
51
+ .routing
52
+ .as_ref()
53
+ .map(|r| r.providers.as_slice())
54
+ .unwrap_or_default();
55
+ let providers = ProviderRegistry::from_config(
56
+ provider_defs,
57
+ &config.upstream_anthropic,
58
+ &config.upstream_openai,
59
+ );
49
60
  let gate_health = build_gate_health(&config);
61
+ // Online adaptive conformal (opt-in): seed the live threshold from the fixed one (or 0.5) and
62
+ // let /v1/feedback track it. Absent config => None => fixed-threshold behavior, byte-identical.
63
+ let adaptive = config
64
+ .routing
65
+ .as_ref()
66
+ .and_then(|r| r.escalation.adaptive.as_ref())
67
+ .map(|a| {
68
+ let init = config
69
+ .routing
70
+ .as_ref()
71
+ .and_then(|r| r.escalation.serve_threshold)
72
+ .unwrap_or(0.5);
73
+ Arc::new(std::sync::Mutex::new(
74
+ firstpass_core::conformal::AdaptiveConformal::new(a.alpha, a.gamma, init),
75
+ ))
76
+ });
50
77
 
51
78
  let state = AppState {
52
79
  config: Arc::new(config),
@@ -59,6 +86,7 @@ pub async fn serve(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>
59
86
  providers,
60
87
  gate_health: Arc::new(gate_health),
61
88
  traces,
89
+ adaptive,
62
90
  };
63
91
 
64
92
  let listener = tokio::net::TcpListener::bind(&bind).await?;
@@ -116,6 +116,7 @@ async fn spawn_proxy(upstream: &str, ladder: &[&str]) -> (String, std::path::Pat
116
116
  providers,
117
117
  gate_health: Arc::new(firstpass_proxy::gate::GateHealthRegistry::new()),
118
118
  traces,
119
+ adaptive: None,
119
120
  };
120
121
 
121
122
  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -57,6 +57,7 @@ async fn spawn_proxy(upstream: &str) -> (String, std::path::PathBuf) {
57
57
  providers,
58
58
  gate_health: Arc::new(firstpass_proxy::gate::GateHealthRegistry::new()),
59
59
  traces,
60
+ adaptive: None,
60
61
  };
61
62
 
62
63
  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
File without changes