flowproof 0.7.0__tar.gz → 0.9.0__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.
- {flowproof-0.7.0 → flowproof-0.9.0}/Cargo.lock +7 -7
- {flowproof-0.7.0 → flowproof-0.9.0}/Cargo.toml +1 -1
- {flowproof-0.7.0 → flowproof-0.9.0}/PKG-INFO +1 -1
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_proxy.rs +303 -30
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_runner.rs +357 -16
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress_linux.rs +91 -13
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/lib.rs +1 -1
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_stdio.rs +44 -2
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/sap_com.rs +30 -11
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/agent_flow.rs +391 -18
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/lib.rs +105 -0
- flowproof-0.9.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +1131 -0
- flowproof-0.9.0/crates/flowproof-cli/tests/doctor_e2e.rs +83 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/examples_resolve.rs +13 -1
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/src/report.rs +34 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette.rs +308 -56
- {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/__init__.py +1 -1
- {flowproof-0.7.0 → flowproof-0.9.0}/pyproject.toml +1 -1
- flowproof-0.7.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -508
- {flowproof-0.7.0 → flowproof-0.9.0}/README.md +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/Cargo.toml +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/vision.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/web.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/Cargo.toml +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/author.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/clarify.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/heal.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/lib.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/llm.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/recorder.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/rules.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/spec.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/Cargo.toml +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/capture.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/main.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/Cargo.toml +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/app.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/backend.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/gdi.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/lib.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/mock.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/oob.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/recording.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/redact.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/visual.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/window.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-python/Cargo.toml +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-python/src/lib.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/Cargo.toml +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/src/lib.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/src/runrecord.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/Cargo.toml +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/egress.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/format.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/lib.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret_scan.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/substitution.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/cli.py +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/flow.py +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/mcp_server.py +0 -0
- {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/py.typed +0 -0
|
@@ -760,7 +760,7 @@ dependencies = [
|
|
|
760
760
|
|
|
761
761
|
[[package]]
|
|
762
762
|
name = "flowproof-adapters"
|
|
763
|
-
version = "0.
|
|
763
|
+
version = "0.9.0"
|
|
764
764
|
dependencies = [
|
|
765
765
|
"anyhow",
|
|
766
766
|
"flowproof-driver",
|
|
@@ -779,7 +779,7 @@ dependencies = [
|
|
|
779
779
|
|
|
780
780
|
[[package]]
|
|
781
781
|
name = "flowproof-agent"
|
|
782
|
-
version = "0.
|
|
782
|
+
version = "0.9.0"
|
|
783
783
|
dependencies = [
|
|
784
784
|
"chrono",
|
|
785
785
|
"flowproof-driver",
|
|
@@ -795,7 +795,7 @@ dependencies = [
|
|
|
795
795
|
|
|
796
796
|
[[package]]
|
|
797
797
|
name = "flowproof-cli"
|
|
798
|
-
version = "0.
|
|
798
|
+
version = "0.9.0"
|
|
799
799
|
dependencies = [
|
|
800
800
|
"ab_glyph",
|
|
801
801
|
"chrono",
|
|
@@ -816,7 +816,7 @@ dependencies = [
|
|
|
816
816
|
|
|
817
817
|
[[package]]
|
|
818
818
|
name = "flowproof-driver"
|
|
819
|
-
version = "0.
|
|
819
|
+
version = "0.9.0"
|
|
820
820
|
dependencies = [
|
|
821
821
|
"image",
|
|
822
822
|
"postgres",
|
|
@@ -830,7 +830,7 @@ dependencies = [
|
|
|
830
830
|
|
|
831
831
|
[[package]]
|
|
832
832
|
name = "flowproof-python"
|
|
833
|
-
version = "0.
|
|
833
|
+
version = "0.9.0"
|
|
834
834
|
dependencies = [
|
|
835
835
|
"flowproof-agent",
|
|
836
836
|
"flowproof-cli",
|
|
@@ -842,7 +842,7 @@ dependencies = [
|
|
|
842
842
|
|
|
843
843
|
[[package]]
|
|
844
844
|
name = "flowproof-replay"
|
|
845
|
-
version = "0.
|
|
845
|
+
version = "0.9.0"
|
|
846
846
|
dependencies = [
|
|
847
847
|
"chrono",
|
|
848
848
|
"flowproof-agent",
|
|
@@ -856,7 +856,7 @@ dependencies = [
|
|
|
856
856
|
|
|
857
857
|
[[package]]
|
|
858
858
|
name = "flowproof-trace"
|
|
859
|
-
version = "0.
|
|
859
|
+
version = "0.9.0"
|
|
860
860
|
dependencies = [
|
|
861
861
|
"jsonschema",
|
|
862
862
|
"regex",
|
|
@@ -3,7 +3,7 @@ resolver = "2"
|
|
|
3
3
|
members = ["crates/flowproof-driver", "crates/flowproof-trace", "crates/flowproof-replay", "crates/flowproof-agent", "crates/flowproof-adapters", "crates/flowproof-cli", "crates/flowproof-python"]
|
|
4
4
|
|
|
5
5
|
[workspace.package]
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.9.0"
|
|
7
7
|
edition = "2021"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
repository = "https://github.com/automators-com/flowproof"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
19
19
|
use std::net::{SocketAddr, TcpListener, TcpStream};
|
|
20
|
-
use std::sync::atomic::{AtomicBool, Ordering};
|
|
20
|
+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
21
21
|
use std::sync::{Arc, Mutex};
|
|
22
22
|
|
|
23
23
|
use flowproof_trace::cassette::{
|
|
@@ -64,6 +64,12 @@ pub struct AgentProxy {
|
|
|
64
64
|
thread: Option<std::thread::JoinHandle<()>>,
|
|
65
65
|
/// Turns captured in record mode, in order. Empty in replay mode.
|
|
66
66
|
captured: Arc<Mutex<Vec<Turn>>>,
|
|
67
|
+
/// Bumped once when a request starts and again when it finishes, so a
|
|
68
|
+
/// caller can tell "nothing is happening" from "between two requests".
|
|
69
|
+
/// See [`AgentProxy::quiesce`].
|
|
70
|
+
activity: Arc<AtomicUsize>,
|
|
71
|
+
/// Requests started but not yet finished.
|
|
72
|
+
in_flight: Arc<AtomicUsize>,
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
impl AgentProxy {
|
|
@@ -126,16 +132,41 @@ impl AgentProxy {
|
|
|
126
132
|
let log = Arc::new(Mutex::new(ProxyLog::default()));
|
|
127
133
|
let stop = Arc::new(AtomicBool::new(false));
|
|
128
134
|
let captured = Arc::new(Mutex::new(Vec::new()));
|
|
135
|
+
let activity = Arc::new(AtomicUsize::new(0));
|
|
136
|
+
let in_flight = Arc::new(AtomicUsize::new(0));
|
|
129
137
|
let thread = {
|
|
130
138
|
let (log, stop, captured) =
|
|
131
139
|
(Arc::clone(&log), Arc::clone(&stop), Arc::clone(&captured));
|
|
140
|
+
let (activity, in_flight) = (Arc::clone(&activity), Arc::clone(&in_flight));
|
|
132
141
|
std::thread::spawn(move || {
|
|
133
142
|
let mut turn = 0usize;
|
|
143
|
+
// One flag per recorded turn, marking the ones already
|
|
144
|
+
// served. Matching consumes rather than indexes, so an
|
|
145
|
+
// agent that issues calls concurrently still replays.
|
|
146
|
+
let mut consumed: Vec<bool> = Vec::new();
|
|
134
147
|
while !stop.load(Ordering::Relaxed) {
|
|
135
148
|
match listener.accept() {
|
|
136
149
|
Ok((stream, _)) => {
|
|
137
150
|
stream.set_nonblocking(false).ok();
|
|
138
|
-
|
|
151
|
+
// Mark the request in flight BEFORE serving it.
|
|
152
|
+
// A record run reads the cassette as soon as the
|
|
153
|
+
// agent exits; without this an agent that fired a
|
|
154
|
+
// call and did not wait for it would have that
|
|
155
|
+
// call forwarded, answered, and then silently
|
|
156
|
+
// missed from the trace.
|
|
157
|
+
in_flight.fetch_add(1, Ordering::SeqCst);
|
|
158
|
+
activity.fetch_add(1, Ordering::SeqCst);
|
|
159
|
+
serve_one(
|
|
160
|
+
stream,
|
|
161
|
+
&mode,
|
|
162
|
+
&mocks,
|
|
163
|
+
&mut turn,
|
|
164
|
+
&mut consumed,
|
|
165
|
+
&log,
|
|
166
|
+
&captured,
|
|
167
|
+
);
|
|
168
|
+
in_flight.fetch_sub(1, Ordering::SeqCst);
|
|
169
|
+
activity.fetch_add(1, Ordering::SeqCst);
|
|
139
170
|
}
|
|
140
171
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
141
172
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
@@ -151,9 +182,40 @@ impl AgentProxy {
|
|
|
151
182
|
stop,
|
|
152
183
|
thread: Some(thread),
|
|
153
184
|
captured,
|
|
185
|
+
activity,
|
|
186
|
+
in_flight,
|
|
154
187
|
})
|
|
155
188
|
}
|
|
156
189
|
|
|
190
|
+
/// Wait until the proxy has been idle for `grace`, or `cap` elapses.
|
|
191
|
+
///
|
|
192
|
+
/// An agent process exiting does NOT mean the proxy is done: an agent
|
|
193
|
+
/// can fire a model call and exit without waiting for it (goose does
|
|
194
|
+
/// exactly this, generating a session title). That call is still being
|
|
195
|
+
/// forwarded when the agent's exit status arrives, so reading the
|
|
196
|
+
/// cassette right then races it - and loses, non-deterministically,
|
|
197
|
+
/// whenever the upstream is slow enough. A dropped turn is invisible:
|
|
198
|
+
/// `record` still exits 0 and still writes a trace, which then cannot
|
|
199
|
+
/// replay, because replay serves instantly and always sees the call.
|
|
200
|
+
///
|
|
201
|
+
/// Idle means both: nothing in flight, AND no request started or
|
|
202
|
+
/// finished during the last `grace` window. The second half matters
|
|
203
|
+
/// because the accept loop is sequential - between finishing one
|
|
204
|
+
/// request and accepting the next that is already queued, `in_flight`
|
|
205
|
+
/// is briefly zero.
|
|
206
|
+
pub fn quiesce(&self, grace: std::time::Duration, cap: std::time::Duration) {
|
|
207
|
+
let deadline = std::time::Instant::now() + cap;
|
|
208
|
+
loop {
|
|
209
|
+
let before = self.activity.load(Ordering::SeqCst);
|
|
210
|
+
std::thread::sleep(grace);
|
|
211
|
+
let settled = self.activity.load(Ordering::SeqCst) == before
|
|
212
|
+
&& self.in_flight.load(Ordering::SeqCst) == 0;
|
|
213
|
+
if settled || std::time::Instant::now() >= deadline {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
/// The cassette captured in record mode, in order. Empty in replay.
|
|
158
220
|
pub fn captured(&self) -> Cassette {
|
|
159
221
|
Cassette {
|
|
@@ -206,6 +268,7 @@ fn serve_one(
|
|
|
206
268
|
mode: &Mode,
|
|
207
269
|
mocks: &Mocks,
|
|
208
270
|
turn: &mut usize,
|
|
271
|
+
consumed: &mut Vec<bool>,
|
|
209
272
|
log: &Mutex<ProxyLog>,
|
|
210
273
|
captured: &Mutex<Vec<Turn>>,
|
|
211
274
|
) {
|
|
@@ -303,12 +366,11 @@ fn serve_one(
|
|
|
303
366
|
}
|
|
304
367
|
};
|
|
305
368
|
|
|
306
|
-
let index = *turn;
|
|
307
369
|
*turn += 1;
|
|
308
370
|
|
|
309
371
|
match mode {
|
|
310
|
-
Mode::Replay(cassette) => match cassette.
|
|
311
|
-
Ok(recorded) => {
|
|
372
|
+
Mode::Replay(cassette) => match cassette.match_turn(consumed, &incoming, protocol) {
|
|
373
|
+
Ok((_index, recorded)) => {
|
|
312
374
|
log.lock().unwrap_or_else(|e| e.into_inner()).served += 1;
|
|
313
375
|
// Render the recorded assistant message in the dialect the
|
|
314
376
|
// agent asked in. OpenAI honors `stream: true` with a
|
|
@@ -388,15 +450,27 @@ fn serve_one(
|
|
|
388
450
|
}
|
|
389
451
|
Err(why) => {
|
|
390
452
|
let mut log = log.lock().unwrap_or_else(|e| e.into_inner());
|
|
391
|
-
|
|
392
|
-
|
|
453
|
+
let first = log.upstream_error.is_none();
|
|
454
|
+
if first {
|
|
455
|
+
log.upstream_error = Some(why.to_string());
|
|
393
456
|
}
|
|
394
457
|
drop(log);
|
|
458
|
+
// Say it out loud, once. A record run whose every model
|
|
459
|
+
// call is rejected otherwise prints nothing at all until
|
|
460
|
+
// it times out, and "silent for ten minutes" reads as a
|
|
461
|
+
// hang rather than as a credential the upstream refused.
|
|
462
|
+
if first {
|
|
463
|
+
eprintln!("flowproof: upstream model call failed: {why}");
|
|
464
|
+
}
|
|
465
|
+
// Answer with the upstream's own status when it was a
|
|
466
|
+
// client error. 401/403 are verdicts about the credential:
|
|
467
|
+
// retrying cannot change them, and a 502 would invite the
|
|
468
|
+
// agent to retry with backoff until the run times out.
|
|
395
469
|
respond(
|
|
396
470
|
&mut writer,
|
|
397
471
|
&mut reader,
|
|
398
472
|
&response(
|
|
399
|
-
|
|
473
|
+
why.agent_facing_status(),
|
|
400
474
|
&error_body(&format!("upstream model call failed: {why}")),
|
|
401
475
|
),
|
|
402
476
|
);
|
|
@@ -409,29 +483,124 @@ fn serve_one(
|
|
|
409
483
|
/// Forward a request to the upstream model and read its reply. Returns the
|
|
410
484
|
/// parsed assistant message (for the cassette) and the raw response body
|
|
411
485
|
/// (handed back to the agent unchanged).
|
|
486
|
+
/// Why an upstream model call did not produce a message.
|
|
487
|
+
///
|
|
488
|
+
/// Carries the upstream HTTP status when there was one, because the caller
|
|
489
|
+
/// needs it to answer the agent HONESTLY. Blanket-502ing an upstream 401 tells
|
|
490
|
+
/// the agent "bad gateway, try again", and a well-behaved agent then retries
|
|
491
|
+
/// with backoff, turning an auth misconfiguration into what looks like a hang.
|
|
492
|
+
/// `None` means the request never got a status at all (transport failure).
|
|
493
|
+
#[derive(Debug, Clone)]
|
|
494
|
+
pub(crate) struct UpstreamFailure {
|
|
495
|
+
pub(crate) status: Option<u16>,
|
|
496
|
+
pub(crate) detail: String,
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
impl std::fmt::Display for UpstreamFailure {
|
|
500
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
501
|
+
match self.status {
|
|
502
|
+
Some(code) => write!(f, "upstream returned {code}: {}", self.detail),
|
|
503
|
+
None => write!(f, "{}", self.detail),
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
impl UpstreamFailure {
|
|
509
|
+
/// A transport failure: no HTTP status was ever seen.
|
|
510
|
+
fn transport(detail: impl Into<String>) -> Self {
|
|
511
|
+
Self {
|
|
512
|
+
status: None,
|
|
513
|
+
detail: detail.into(),
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/// The status to answer the agent with.
|
|
518
|
+
///
|
|
519
|
+
/// A CLIENT error is passed through unchanged: 401 and 403 are verdicts
|
|
520
|
+
/// about the credential, and no amount of retrying makes an unauthorized
|
|
521
|
+
/// key authorized, so the agent must be allowed to give up. Everything
|
|
522
|
+
/// else stays 502, which is what it is: the gateway could not complete the
|
|
523
|
+
/// call, and retrying may well help.
|
|
524
|
+
fn agent_facing_status(&self) -> u16 {
|
|
525
|
+
match self.status {
|
|
526
|
+
Some(code) if (400..500).contains(&code) => code,
|
|
527
|
+
_ => 502,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/// Read a response body without letting a non-2xx status swallow it.
|
|
533
|
+
///
|
|
534
|
+
/// `http_status_as_error(false)`: a 4xx/5xx is a real answer whose status AND
|
|
535
|
+
/// body are the diagnosis. ureq's default turns it into an opaque
|
|
536
|
+
/// "http status: 401" and discards what the server said, which is precisely
|
|
537
|
+
/// the sentence a caller needs ("unauthorized", "model not allowed", ...).
|
|
538
|
+
fn send_json(
|
|
539
|
+
request: ureq::RequestBuilder<ureq::typestate::WithBody>,
|
|
540
|
+
bytes: &[u8],
|
|
541
|
+
) -> Result<(u16, String), UpstreamFailure> {
|
|
542
|
+
let mut response = request
|
|
543
|
+
.send(bytes)
|
|
544
|
+
.map_err(|e| UpstreamFailure::transport(e.to_string()))?;
|
|
545
|
+
let status = response.status().as_u16();
|
|
546
|
+
let raw = response
|
|
547
|
+
.body_mut()
|
|
548
|
+
.read_to_string()
|
|
549
|
+
.map_err(|e| UpstreamFailure::transport(e.to_string()))?;
|
|
550
|
+
Ok((status, raw))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/// The upstream's own words, trimmed to something a log line can carry.
|
|
554
|
+
///
|
|
555
|
+
/// The body is what actually explains the failure ("unauthorized",
|
|
556
|
+
/// "model not allowed"); a bare status forces the reader to go guessing.
|
|
557
|
+
fn body_detail(raw: &str) -> String {
|
|
558
|
+
let trimmed = raw.trim();
|
|
559
|
+
if trimmed.is_empty() {
|
|
560
|
+
return "(empty body)".to_string();
|
|
561
|
+
}
|
|
562
|
+
const LIMIT: usize = 400;
|
|
563
|
+
if trimmed.chars().count() <= LIMIT {
|
|
564
|
+
return trimmed.to_string();
|
|
565
|
+
}
|
|
566
|
+
format!("{}...", trimmed.chars().take(LIMIT).collect::<String>())
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/// An agent that reports statuses instead of erroring on them.
|
|
570
|
+
fn upstream_agent() -> ureq::Agent {
|
|
571
|
+
ureq::config::Config::builder()
|
|
572
|
+
.http_status_as_error(false)
|
|
573
|
+
.build()
|
|
574
|
+
.new_agent()
|
|
575
|
+
}
|
|
576
|
+
|
|
412
577
|
fn forward(
|
|
413
578
|
upstream: &str,
|
|
414
579
|
auth: Option<&str>,
|
|
415
580
|
body: &serde_json::Value,
|
|
416
|
-
) -> Result<(Message, String),
|
|
581
|
+
) -> Result<(Message, String), UpstreamFailure> {
|
|
417
582
|
let url = format!("{upstream}/chat/completions");
|
|
418
|
-
let bytes = serde_json::to_vec(body).map_err(|e| e.to_string())?;
|
|
419
|
-
let mut request =
|
|
583
|
+
let bytes = serde_json::to_vec(body).map_err(|e| UpstreamFailure::transport(e.to_string()))?;
|
|
584
|
+
let mut request = upstream_agent()
|
|
585
|
+
.post(&url)
|
|
586
|
+
.header("content-type", "application/json");
|
|
420
587
|
if let Some(auth) = auth {
|
|
421
588
|
request = request.header("authorization", auth);
|
|
422
589
|
}
|
|
423
|
-
let
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
590
|
+
let (status, raw) = send_json(request, &bytes[..])?;
|
|
591
|
+
if !(200..300).contains(&status) {
|
|
592
|
+
return Err(UpstreamFailure {
|
|
593
|
+
status: Some(status),
|
|
594
|
+
detail: body_detail(&raw),
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
let parsed: serde_json::Value = serde_json::from_str(&raw)
|
|
598
|
+
.map_err(|e| UpstreamFailure::transport(format!("upstream returned non-JSON: {e}")))?;
|
|
430
599
|
let message = parsed
|
|
431
600
|
.get("choices")
|
|
432
601
|
.and_then(|c| c.get(0))
|
|
433
602
|
.and_then(|c| c.get("message"))
|
|
434
|
-
.
|
|
603
|
+
.ok_or_else(|| UpstreamFailure::transport("upstream response has no choices[0].message"))?;
|
|
435
604
|
Ok((parse_message(message), raw))
|
|
436
605
|
}
|
|
437
606
|
|
|
@@ -450,24 +619,27 @@ fn forward_anthropic(
|
|
|
450
619
|
auth: Option<&str>,
|
|
451
620
|
body: &serde_json::Value,
|
|
452
621
|
version: Option<&str>,
|
|
453
|
-
) -> Result<(Message, Option<String>, String),
|
|
622
|
+
) -> Result<(Message, Option<String>, String), UpstreamFailure> {
|
|
454
623
|
let url = format!("{upstream}/v1/messages");
|
|
455
|
-
let bytes = serde_json::to_vec(body).map_err(|e| e.to_string())?;
|
|
456
|
-
let mut request =
|
|
624
|
+
let bytes = serde_json::to_vec(body).map_err(|e| UpstreamFailure::transport(e.to_string()))?;
|
|
625
|
+
let mut request = upstream_agent()
|
|
626
|
+
.post(&url)
|
|
457
627
|
.header("content-type", "application/json")
|
|
458
628
|
.header("anthropic-version", version.unwrap_or("2023-06-01"));
|
|
459
629
|
if let Some(auth) = auth {
|
|
460
630
|
let key = auth.strip_prefix("Bearer ").unwrap_or(auth);
|
|
461
631
|
request = request.header("x-api-key", key);
|
|
462
632
|
}
|
|
463
|
-
let
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
let
|
|
633
|
+
let (status, raw) = send_json(request, &bytes[..])?;
|
|
634
|
+
if !(200..300).contains(&status) {
|
|
635
|
+
return Err(UpstreamFailure {
|
|
636
|
+
status: Some(status),
|
|
637
|
+
detail: body_detail(&raw),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
let parsed: serde_json::Value = serde_json::from_str(&raw)
|
|
641
|
+
.map_err(|e| UpstreamFailure::transport(format!("upstream returned non-JSON: {e}")))?;
|
|
642
|
+
let message = message_from_anthropic_response(&parsed).map_err(UpstreamFailure::transport)?;
|
|
471
643
|
let stop_reason = parsed
|
|
472
644
|
.get("stop_reason")
|
|
473
645
|
.and_then(|s| s.as_str())
|
|
@@ -1100,10 +1272,20 @@ fn error_body(message: &str) -> String {
|
|
|
1100
1272
|
}
|
|
1101
1273
|
|
|
1102
1274
|
fn response(status: u16, body: &str) -> Vec<u8> {
|
|
1275
|
+
// Upstream client errors are now passed through (see
|
|
1276
|
+
// `UpstreamFailure::agent_facing_status`), so the reason phrase has to
|
|
1277
|
+
// cover them: a 401 emitted as "401 Conflict" is a lie that costs the next
|
|
1278
|
+
// reader time.
|
|
1103
1279
|
let reason = match status {
|
|
1104
1280
|
200 => "OK",
|
|
1105
1281
|
400 => "Bad Request",
|
|
1282
|
+
401 => "Unauthorized",
|
|
1283
|
+
403 => "Forbidden",
|
|
1106
1284
|
404 => "Not Found",
|
|
1285
|
+
409 => "Conflict",
|
|
1286
|
+
429 => "Too Many Requests",
|
|
1287
|
+
500 => "Internal Server Error",
|
|
1288
|
+
502 => "Bad Gateway",
|
|
1107
1289
|
_ => "Conflict",
|
|
1108
1290
|
};
|
|
1109
1291
|
format!(
|
|
@@ -1976,6 +2158,97 @@ mod tests {
|
|
|
1976
2158
|
format!("http://127.0.0.1:{port}/v1")
|
|
1977
2159
|
}
|
|
1978
2160
|
|
|
2161
|
+
/// A fake upstream that answers every request with a fixed status and
|
|
2162
|
+
/// body, so the proxy's handling of a REJECTION can be exercised.
|
|
2163
|
+
fn fake_model_status(status: u16, reason: &'static str, body: &'static str) -> String {
|
|
2164
|
+
use std::io::Write;
|
|
2165
|
+
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
2166
|
+
let port = listener.local_addr().expect("addr").port();
|
|
2167
|
+
std::thread::spawn(move || {
|
|
2168
|
+
for stream in listener.incoming() {
|
|
2169
|
+
let Ok(mut stream) = stream else { continue };
|
|
2170
|
+
let _ = read_http_request(&mut stream);
|
|
2171
|
+
let _ = stream.write_all(
|
|
2172
|
+
format!(
|
|
2173
|
+
"HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\n\
|
|
2174
|
+
content-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
2175
|
+
body.len()
|
|
2176
|
+
)
|
|
2177
|
+
.as_bytes(),
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
format!("http://127.0.0.1:{port}/v1")
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/// An upstream 401 must reach the agent AS a 401.
|
|
2185
|
+
///
|
|
2186
|
+
/// The regression this pins: it used to be flattened to 502, which says
|
|
2187
|
+
/// "bad gateway, try again". A well-behaved agent then retries with
|
|
2188
|
+
/// backoff, and a record run whose credential is simply wrong looks like a
|
|
2189
|
+
/// hang for as long as the agent is willing to keep trying.
|
|
2190
|
+
#[test]
|
|
2191
|
+
fn an_upstream_401_is_not_disguised_as_a_bad_gateway() {
|
|
2192
|
+
let upstream = fake_model_status(401, "Unauthorized", r#"{"error":"unauthorized"}"#);
|
|
2193
|
+
let rec = AgentProxy::record(&upstream, Some("wrong-key".to_string()), Mocks::new(), 0)
|
|
2194
|
+
.expect("record starts");
|
|
2195
|
+
|
|
2196
|
+
let (status, body) = post(
|
|
2197
|
+
&rec.base_url(),
|
|
2198
|
+
chat(serde_json::json!([{"role": "user", "content": "hi"}])),
|
|
2199
|
+
);
|
|
2200
|
+
|
|
2201
|
+
assert_eq!(status, 401, "an unauthorized credential is not retryable");
|
|
2202
|
+
let text = body["error"]["message"]
|
|
2203
|
+
.as_str()
|
|
2204
|
+
.unwrap_or_default()
|
|
2205
|
+
.to_string();
|
|
2206
|
+
assert!(text.contains("401"), "status is named: {text}");
|
|
2207
|
+
assert!(
|
|
2208
|
+
text.contains("unauthorized"),
|
|
2209
|
+
"the upstream's OWN words survive, which is what names the cause: {text}"
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/// The same for the Anthropic dialect, which takes a different code path
|
|
2214
|
+
/// (`forward_anthropic`, `x-api-key` rather than `authorization`).
|
|
2215
|
+
#[test]
|
|
2216
|
+
fn an_upstream_401_on_the_anthropic_path_is_also_passed_through() {
|
|
2217
|
+
let upstream = fake_model_status(401, "Unauthorized", r#"{"error":"no key"}"#);
|
|
2218
|
+
let rec = AgentProxy::record(&upstream, Some("Bearer wrong".to_string()), Mocks::new(), 0)
|
|
2219
|
+
.expect("record starts");
|
|
2220
|
+
|
|
2221
|
+
let (status, body) = post_messages(
|
|
2222
|
+
&rec.base_url(),
|
|
2223
|
+
serde_json::json!({
|
|
2224
|
+
"model": "claude-test",
|
|
2225
|
+
"max_tokens": 16,
|
|
2226
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
2227
|
+
}),
|
|
2228
|
+
);
|
|
2229
|
+
|
|
2230
|
+
assert_eq!(status, 401);
|
|
2231
|
+
assert!(body["error"]["message"]
|
|
2232
|
+
.as_str()
|
|
2233
|
+
.unwrap_or_default()
|
|
2234
|
+
.contains("no key"));
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/// A 5xx stays a 502: the gateway genuinely could not complete the call,
|
|
2238
|
+
/// and retrying may well help, so the agent must not be told to give up.
|
|
2239
|
+
#[test]
|
|
2240
|
+
fn an_upstream_500_still_reads_as_a_gateway_failure() {
|
|
2241
|
+
let upstream = fake_model_status(500, "Internal Server Error", r#"{"error":"boom"}"#);
|
|
2242
|
+
let rec = AgentProxy::record(&upstream, None, Mocks::new(), 0).expect("record starts");
|
|
2243
|
+
|
|
2244
|
+
let (status, _) = post(
|
|
2245
|
+
&rec.base_url(),
|
|
2246
|
+
chat(serde_json::json!([{"role": "user", "content": "hi"}])),
|
|
2247
|
+
);
|
|
2248
|
+
|
|
2249
|
+
assert_eq!(status, 502, "a server-side failure is retryable");
|
|
2250
|
+
}
|
|
2251
|
+
|
|
1979
2252
|
/// The real round trip: RECORD against a fake model captures a
|
|
1980
2253
|
/// cassette, and REPLAYING that cassette serves the same reply back
|
|
1981
2254
|
/// with no model at all.
|