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.
Files changed (96) hide show
  1. {flowproof-0.7.0 → flowproof-0.9.0}/Cargo.lock +7 -7
  2. {flowproof-0.7.0 → flowproof-0.9.0}/Cargo.toml +1 -1
  3. {flowproof-0.7.0 → flowproof-0.9.0}/PKG-INFO +1 -1
  4. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_proxy.rs +303 -30
  5. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_runner.rs +357 -16
  6. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress_linux.rs +91 -13
  7. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/lib.rs +1 -1
  8. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_stdio.rs +44 -2
  9. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/sap_com.rs +30 -11
  10. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/agent_flow.rs +391 -18
  11. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/lib.rs +105 -0
  12. flowproof-0.9.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +1131 -0
  13. flowproof-0.9.0/crates/flowproof-cli/tests/doctor_e2e.rs +83 -0
  14. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/examples_resolve.rs +13 -1
  15. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/src/report.rs +34 -0
  16. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette.rs +308 -56
  17. {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/__init__.py +1 -1
  18. {flowproof-0.7.0 → flowproof-0.9.0}/pyproject.toml +1 -1
  19. flowproof-0.7.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -508
  20. {flowproof-0.7.0 → flowproof-0.9.0}/README.md +0 -0
  21. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/Cargo.toml +0 -0
  22. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress.rs +0 -0
  23. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  24. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  25. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/vision.rs +0 -0
  26. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/web.rs +0 -0
  27. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/Cargo.toml +0 -0
  28. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  29. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/author.rs +0 -0
  30. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/clarify.rs +0 -0
  31. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/heal.rs +0 -0
  32. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/lib.rs +0 -0
  33. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/llm.rs +0 -0
  34. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/recorder.rs +0 -0
  35. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/rules.rs +0 -0
  36. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-agent/src/spec.rs +0 -0
  37. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/Cargo.toml +0 -0
  38. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/capture.rs +0 -0
  39. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/src/main.rs +0 -0
  40. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  41. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
  42. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  43. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  44. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
  45. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
  46. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  47. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
  48. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  49. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  50. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  51. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  52. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
  53. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  54. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  55. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  56. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  57. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  58. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  59. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  60. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  61. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  62. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  63. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
  64. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/Cargo.toml +0 -0
  65. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/app.rs +0 -0
  66. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/backend.rs +0 -0
  67. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/gdi.rs +0 -0
  68. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/lib.rs +0 -0
  69. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/mock.rs +0 -0
  70. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/oob.rs +0 -0
  71. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/recording.rs +0 -0
  72. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/redact.rs +0 -0
  73. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/visual.rs +0 -0
  74. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-driver/src/window.rs +0 -0
  75. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-python/Cargo.toml +0 -0
  76. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-python/src/lib.rs +0 -0
  77. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/Cargo.toml +0 -0
  78. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/src/lib.rs +0 -0
  79. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/src/runrecord.rs +0 -0
  80. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  81. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/Cargo.toml +0 -0
  82. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  83. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
  84. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/egress.rs +0 -0
  85. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/format.rs +0 -0
  86. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/lib.rs +0 -0
  87. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret.rs +0 -0
  88. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret_scan.rs +0 -0
  89. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/substitution.rs +0 -0
  90. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  91. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  92. {flowproof-0.7.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  93. {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/cli.py +0 -0
  94. {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/flow.py +0 -0
  95. {flowproof-0.7.0 → flowproof-0.9.0}/flowproof/mcp_server.py +0 -0
  96. {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.7.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.7.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.7.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.7.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.7.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.7.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.7.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.7.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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowproof
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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
- serve_one(stream, &mode, &mocks, &mut turn, &log, &captured);
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.turn(index, &incoming, protocol) {
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
- if log.upstream_error.is_none() {
392
- log.upstream_error = Some(why.clone());
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
- 502,
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), 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 = ureq::post(&url).header("content-type", "application/json");
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 mut response = request.send(&bytes[..]).map_err(|e| e.to_string())?;
424
- let raw = response
425
- .body_mut()
426
- .read_to_string()
427
- .map_err(|e| e.to_string())?;
428
- let parsed: serde_json::Value =
429
- serde_json::from_str(&raw).map_err(|e| format!("upstream returned non-JSON: {e}"))?;
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
- .ok_or("upstream response has no choices[0].message")?;
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), 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 = ureq::post(&url)
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 mut response = request.send(&bytes[..]).map_err(|e| e.to_string())?;
464
- let raw = response
465
- .body_mut()
466
- .read_to_string()
467
- .map_err(|e| e.to_string())?;
468
- let parsed: serde_json::Value =
469
- serde_json::from_str(&raw).map_err(|e| format!("upstream returned non-JSON: {e}"))?;
470
- let message = message_from_anthropic_response(&parsed)?;
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.