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.
- {firstpass-0.1.2 → firstpass-0.1.4}/Cargo.lock +3 -3
- {firstpass-0.1.2 → firstpass-0.1.4}/Cargo.toml +2 -2
- {firstpass-0.1.2 → firstpass-0.1.4}/PKG-INFO +1 -1
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/config.rs +111 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/lib.rs +2 -2
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/examples/demo.rs +1 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/judge.rs +2 -5
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/provider.rs +209 -25
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/proxy.rs +331 -46
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/run.rs +29 -1
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/tests/end_to_end.rs +1 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/tests/observe_loop.rs +1 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/Cargo.toml +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/conformal.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/cost.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/error.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/features.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/hashchain.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/trace.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-core/src/verdict.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/Cargo.toml +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/bin/firstpass.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/calibrate.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/cli.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/config.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/error.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/gate.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/lib.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/main.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/mcp.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/metrics.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/router.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/store.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/subprocess.rs +0 -0
- {firstpass-0.1.2 → firstpass-0.1.4}/crates/firstpass-proxy/src/upstream.rs +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
41
|
+
firstpass-core = { path = "crates/firstpass-core", version = "0.1.4" }
|
|
42
42
|
|
|
43
43
|
[profile.release]
|
|
44
44
|
lto = "thin"
|
|
@@ -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,
|
|
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 =
|
|
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
|
|
20
|
-
pub content:
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
///
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
///
|
|
397
|
-
///
|
|
398
|
-
///
|
|
399
|
-
///
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
.
|
|
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
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
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:
|
|
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":
|
|
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
|
|
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
|
-
|
|
934
|
-
assert_eq!(
|
|
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
|
-
|
|
1138
|
-
assert!(
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|