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.
- {flowproof-0.8.0 → flowproof-0.9.0}/Cargo.lock +7 -7
- {flowproof-0.8.0 → flowproof-0.9.0}/Cargo.toml +1 -1
- {flowproof-0.8.0 → flowproof-0.9.0}/PKG-INFO +1 -1
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_proxy.rs +236 -25
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/agent_runner.rs +357 -16
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/lib.rs +1 -1
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/sap_com.rs +30 -11
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/agent_flow.rs +19 -4
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/lib.rs +81 -0
- flowproof-0.9.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +1131 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/examples_resolve.rs +13 -1
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/src/report.rs +34 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/__init__.py +1 -1
- {flowproof-0.8.0 → flowproof-0.9.0}/pyproject.toml +1 -1
- flowproof-0.8.0/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -508
- {flowproof-0.8.0 → flowproof-0.9.0}/README.md +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/Cargo.toml +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/egress_linux.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/vision.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-adapters/src/web.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/Cargo.toml +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/author.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/clarify.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/heal.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/lib.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/llm.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/recorder.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/rules.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-agent/src/spec.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/Cargo.toml +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/capture.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/src/main.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/doctor_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/Cargo.toml +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/app.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/backend.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/gdi.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/lib.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/mock.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/oob.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/recording.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/redact.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/visual.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-driver/src/window.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-python/Cargo.toml +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-python/src/lib.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/Cargo.toml +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/src/lib.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/src/runrecord.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/Cargo.toml +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/egress.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/format.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/lib.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/secret_scan.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/substitution.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/cli.py +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/flow.py +0 -0
- {flowproof-0.8.0 → flowproof-0.9.0}/flowproof/mcp_server.py +0 -0
- {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.
|
|
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"
|
|
@@ -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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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),
|
|
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 =
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
.
|
|
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),
|
|
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 =
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
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)?;
|
|
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.
|