flowproof 0.8.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.8.0 → flowproof-0.9.0}/Cargo.lock +7 -7
  2. {flowproof-0.8.0 → flowproof-0.9.0}/Cargo.toml +1 -1
  3. {flowproof-0.8.0 → flowproof-0.9.0}/PKG-INFO +1 -1
  4. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_proxy.rs +236 -25
  5. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_runner.rs +357 -16
  6. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/lib.rs +1 -1
  7. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/sap_com.rs +30 -11
  8. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/agent_flow.rs +19 -4
  9. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/lib.rs +81 -0
  10. flowproof-0.9.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +1131 -0
  11. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/examples_resolve.rs +13 -1
  12. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/src/report.rs +34 -0
  13. {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/__init__.py +1 -1
  14. {flowproof-0.8.0 → flowproof-0.9.0}/pyproject.toml +1 -1
  15. flowproof-0.8.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -508
  16. {flowproof-0.8.0 → flowproof-0.9.0}/README.md +0 -0
  17. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/Cargo.toml +0 -0
  18. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress.rs +0 -0
  19. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress_linux.rs +0 -0
  20. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  21. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  22. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
  23. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/vision.rs +0 -0
  24. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/web.rs +0 -0
  25. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/Cargo.toml +0 -0
  26. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  27. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/author.rs +0 -0
  28. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/clarify.rs +0 -0
  29. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/heal.rs +0 -0
  30. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/lib.rs +0 -0
  31. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/llm.rs +0 -0
  32. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/recorder.rs +0 -0
  33. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/rules.rs +0 -0
  34. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/spec.rs +0 -0
  35. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/Cargo.toml +0 -0
  36. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/capture.rs +0 -0
  37. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/main.rs +0 -0
  38. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  39. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
  40. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  41. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  42. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
  43. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/doctor_e2e.rs +0 -0
  44. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
  45. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  46. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
  47. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  48. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  49. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  50. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  51. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
  52. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  53. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  54. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  55. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  56. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  57. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  58. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  59. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  60. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  61. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  62. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
  63. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/Cargo.toml +0 -0
  64. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/app.rs +0 -0
  65. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/backend.rs +0 -0
  66. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/gdi.rs +0 -0
  67. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/lib.rs +0 -0
  68. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/mock.rs +0 -0
  69. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/oob.rs +0 -0
  70. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/recording.rs +0 -0
  71. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/redact.rs +0 -0
  72. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/visual.rs +0 -0
  73. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/window.rs +0 -0
  74. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-python/Cargo.toml +0 -0
  75. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-python/src/lib.rs +0 -0
  76. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/Cargo.toml +0 -0
  77. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/src/lib.rs +0 -0
  78. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/src/runrecord.rs +0 -0
  79. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  80. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/Cargo.toml +0 -0
  81. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  82. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette.rs +0 -0
  83. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
  84. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/egress.rs +0 -0
  85. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/format.rs +0 -0
  86. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/lib.rs +0 -0
  87. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret.rs +0 -0
  88. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret_scan.rs +0 -0
  89. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/substitution.rs +0 -0
  90. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  91. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  92. {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  93. {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/cli.py +0 -0
  94. {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/flow.py +0 -0
  95. {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/mcp_server.py +0 -0
  96. {flowproof-0.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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
@@ -450,15 +450,27 @@ fn serve_one(
450
450
  }
451
451
  Err(why) => {
452
452
  let mut log = log.lock().unwrap_or_else(|e| e.into_inner());
453
- if log.upstream_error.is_none() {
454
- 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());
455
456
  }
456
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.
457
469
  respond(
458
470
  &mut writer,
459
471
  &mut reader,
460
472
  &response(
461
- 502,
473
+ why.agent_facing_status(),
462
474
  &error_body(&format!("upstream model call failed: {why}")),
463
475
  ),
464
476
  );
@@ -471,29 +483,124 @@ fn serve_one(
471
483
  /// Forward a request to the upstream model and read its reply. Returns the
472
484
  /// parsed assistant message (for the cassette) and the raw response body
473
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
+
474
577
  fn forward(
475
578
  upstream: &str,
476
579
  auth: Option<&str>,
477
580
  body: &serde_json::Value,
478
- ) -> Result<(Message, String), String> {
581
+ ) -> Result<(Message, String), UpstreamFailure> {
479
582
  let url = format!("{upstream}/chat/completions");
480
- let bytes = serde_json::to_vec(body).map_err(|e| e.to_string())?;
481
- 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");
482
587
  if let Some(auth) = auth {
483
588
  request = request.header("authorization", auth);
484
589
  }
485
- let mut response = request.send(&bytes[..]).map_err(|e| e.to_string())?;
486
- let raw = response
487
- .body_mut()
488
- .read_to_string()
489
- .map_err(|e| e.to_string())?;
490
- let parsed: serde_json::Value =
491
- 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}")))?;
492
599
  let message = parsed
493
600
  .get("choices")
494
601
  .and_then(|c| c.get(0))
495
602
  .and_then(|c| c.get("message"))
496
- .ok_or("upstream response has no choices[0].message")?;
603
+ .ok_or_else(|| UpstreamFailure::transport("upstream response has no choices[0].message"))?;
497
604
  Ok((parse_message(message), raw))
498
605
  }
499
606
 
@@ -512,24 +619,27 @@ fn forward_anthropic(
512
619
  auth: Option<&str>,
513
620
  body: &serde_json::Value,
514
621
  version: Option<&str>,
515
- ) -> Result<(Message, Option<String>, String), String> {
622
+ ) -> Result<(Message, Option<String>, String), UpstreamFailure> {
516
623
  let url = format!("{upstream}/v1/messages");
517
- let bytes = serde_json::to_vec(body).map_err(|e| e.to_string())?;
518
- 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)
519
627
  .header("content-type", "application/json")
520
628
  .header("anthropic-version", version.unwrap_or("2023-06-01"));
521
629
  if let Some(auth) = auth {
522
630
  let key = auth.strip_prefix("Bearer ").unwrap_or(auth);
523
631
  request = request.header("x-api-key", key);
524
632
  }
525
- let mut response = request.send(&bytes[..]).map_err(|e| e.to_string())?;
526
- let raw = response
527
- .body_mut()
528
- .read_to_string()
529
- .map_err(|e| e.to_string())?;
530
- let parsed: serde_json::Value =
531
- serde_json::from_str(&raw).map_err(|e| format!("upstream returned non-JSON: {e}"))?;
532
- 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)?;
533
643
  let stop_reason = parsed
534
644
  .get("stop_reason")
535
645
  .and_then(|s| s.as_str())
@@ -1162,10 +1272,20 @@ fn error_body(message: &str) -> String {
1162
1272
  }
1163
1273
 
1164
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.
1165
1279
  let reason = match status {
1166
1280
  200 => "OK",
1167
1281
  400 => "Bad Request",
1282
+ 401 => "Unauthorized",
1283
+ 403 => "Forbidden",
1168
1284
  404 => "Not Found",
1285
+ 409 => "Conflict",
1286
+ 429 => "Too Many Requests",
1287
+ 500 => "Internal Server Error",
1288
+ 502 => "Bad Gateway",
1169
1289
  _ => "Conflict",
1170
1290
  };
1171
1291
  format!(
@@ -2038,6 +2158,97 @@ mod tests {
2038
2158
  format!("http://127.0.0.1:{port}/v1")
2039
2159
  }
2040
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
+
2041
2252
  /// The real round trip: RECORD against a fake model captures a
2042
2253
  /// cassette, and REPLAYING that cassette serves the same reply back
2043
2254
  /// with no model at all.