From e4f239762e2e99b1c6c6f8e6149430ce4714afa3 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Mon, 24 Aug 2026 12:04:26 +0000 Subject: [PATCH 1/3] Retry a pinned route that answers without its identity echo A provider response that omits or contradicts the pinned model and provider echo is a transient gateway defect, not a decision about the review, yet it terminated the call on the first occurrence while a 502 from the same gateway was retried. A dropped echo now consumes the same transient retry budget and backoff as a retryable HTTP status. Acceptance is unchanged: a response is only ever used when the echo matches, and an echo that stays absent or wrong across the budget returns the identical terminal error and diagnostic classification it returned before. --- src/llm.rs | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++- tests/e2e.rs | 6 +- 2 files changed, 257 insertions(+), 5 deletions(-) diff --git a/src/llm.rs b/src/llm.rs index a85b126..9567bd4 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -170,6 +170,17 @@ impl std::fmt::Display for AtomicAttributionIdentityFailure { impl std::error::Error for AtomicAttributionIdentityFailure {} +impl AtomicAttributionIdentityFailure { + /// Content-free wording for retry logs: the echo either never arrived or + /// named a different route, and neither case reveals response text. + fn echo_reason(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Mismatch => "mismatched", + } + } +} + #[cfg(feature = "qualification-candidate")] #[derive(Debug)] struct AtomicAttributionInvalidOutput; @@ -4443,12 +4454,40 @@ impl LlmClient { if response.status.is_success() { let actual_identity = route_provider.map(|_| actual_response_identity(&response.text)); - if let Some(expected_provider) = route_provider { - validate_routed_response_identity( + // A pinned route that answers without echoing its + // identity is as transient as a gateway 5xx: the + // response is never accepted, but the attempt is + // retried on the shared transient budget before the + // identity failure becomes terminal. + if let Some(expected_provider) = route_provider + && let Err(error) = validate_routed_response_identity( actual_identity.as_ref(), model, expected_provider, - )?; + ) + { + if let Some(response_usage) = summary.usage { + add_usage(usage, response_usage); + } else { + *usage_accounting_complete = false; + } + if retries < TRANSIENT_RETRIES { + retries += 1; + let wait = provider_retry_delay(retries); + eprintln!( + "postil: model {} returned a routed response whose identity echo is {} after {elapsed}, retrying in {} \ + (retry {retries}/{TRANSIENT_RETRIES})", + log_text(model), + error + .downcast_ref::() + .map_or("unusable", |failure| failure.echo_reason()), + elapsed_text(wait) + ); + self.sleep_with_budget(phase, wait).await?; + attempt_timeout = self.request_timeout; + continue; + } + return Err(error); } eprintln!( "postil: llm response phase={} model={} attempt={} status={} elapsed={} bytes={} request_id={} response_id={} returned_model={} provider={} choices={} finish={} usage={} prompt_tokens={} completion_tokens={} reasoning_tokens={} category={}", @@ -9576,6 +9615,19 @@ mod tests { matches!(failure, AtomicAttributionIdentityFailure::Mismatch) }) ); + let partial_echo = (Some("provider/model".to_string()), None); + let half_missing = + validate_routed_response_identity(Some(&partial_echo), "provider/model", "Fireworks") + .unwrap_err(); + assert!( + half_missing + .downcast_ref::() + .is_some_and(|failure| matches!( + failure, + AtomicAttributionIdentityFailure::Missing + )) + ); + let classified = classify_chat_error(mismatch); assert!(classified.downcast_ref::().is_none()); assert!( @@ -9583,6 +9635,204 @@ mod tests { .downcast_ref::() .is_some() ); + + assert_eq!( + AtomicAttributionIdentityFailure::Missing.echo_reason(), + "missing" + ); + assert_eq!( + AtomicAttributionIdentityFailure::Mismatch.echo_reason(), + "mismatched" + ); + } + + fn pinned_route_client(server: &MockServer) -> LlmClient { + let config = Config { + api_base: server.uri(), + api_format: ApiFormat::OpenaiCompatible, + model: "provider/model".into(), + scorer_enabled: false, + uncertainty_resolution: false, + concise_findings: false, + ..Config::default() + }; + let mut client = LlmClient::build( + &config, + "test-key".into(), + Duration::from_secs(2), + None, + None, + ) + .unwrap(); + client.request_decorations.pinned_upstream_provider = Some("Fireworks".into()); + *client.http.lock().unwrap() = Some(reqwest::Client::new()); + client + } + + #[tokio::test] + async fn pinned_route_retries_a_dropped_identity_echo_and_accepts_the_matching_retry() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "unattributed"}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 2} + }))) + .up_to_n_times(1) + .with_priority(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "model": "provider/model", + "provider": "Fireworks", + "choices": [{"finish_reason": "stop", "message": {"content": "attributed"}}], + "usage": {"prompt_tokens": 12, "completion_tokens": 3} + }))) + .with_priority(2) + .expect(1) + .mount(&server) + .await; + let client = pinned_route_client(&server); + + let mut usage = Usage::default(); + let mut call_usage = Vec::new(); + let mut usage_accounting_complete = true; + let success = client + .chat_inner( + "provider/model", + None, + "system", + "user", + &mut usage, + &mut call_usage, + &mut usage_accounting_complete, + 180, + 0.0, + LlmPhase::Review, + LlmCallPhase::Initial, + None, + ) + .await + .unwrap(); + + assert_eq!(success.content, "attributed"); + assert_eq!(success.returned_model.as_deref(), Some("provider/model")); + assert_eq!(success.provider.as_deref(), Some("Fireworks")); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + assert_eq!( + call_usage + .iter() + .map(|event| (event.attempt, event.prompt_tokens, event.completion_tokens)) + .collect::>(), + vec![(Some(1), 10, 2), (Some(2), 12, 3)] + ); + assert_eq!(usage.prompt_tokens, 22); + assert_eq!(usage.completion_tokens, 5); + assert!(usage_accounting_complete); + assert_eq!(client.admission.lock().unwrap().attempts, 2); + assert_eq!(client.admission.lock().unwrap().reported_token_spend, 27); + } + + #[tokio::test] + async fn a_persistently_missing_identity_echo_stays_terminal_after_the_transient_budget() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "unattributed"}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 2} + }))) + .expect(u64::from(TRANSIENT_RETRIES) + 1) + .mount(&server) + .await; + let client = pinned_route_client(&server); + + let mut usage = Usage::default(); + let mut call_usage = Vec::new(); + let mut usage_accounting_complete = true; + let error = match client + .chat_inner( + "provider/model", + None, + "system", + "user", + &mut usage, + &mut call_usage, + &mut usage_accounting_complete, + 180, + 0.0, + LlmPhase::Review, + LlmCallPhase::Initial, + None, + ) + .await + { + Ok(_) => panic!("a response without an identity echo was accepted"), + Err(error) => error, + }; + + assert!( + error + .downcast_ref::() + .is_some_and(|failure| matches!( + failure, + AtomicAttributionIdentityFailure::Missing + )) + ); + let attempts = TRANSIENT_RETRIES as usize + 1; + assert_eq!(server.received_requests().await.unwrap().len(), attempts); + assert_eq!(call_usage.len(), attempts); + assert!(usage_accounting_complete); + assert_eq!(client.admission.lock().unwrap().attempts, attempts); + } + + #[cfg(feature = "qualification-candidate")] + #[tokio::test] + async fn exhausted_identity_echo_retries_keep_the_terminal_attribution_diagnostic() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{ + "finish_reason": "stop", + "message": {"content": "{\"sameDefect\":true,\"reason\":\"same defect\"}"} + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 2} + }))) + .expect(u64::from(TRANSIENT_RETRIES) + 1) + .mount(&server) + .await; + let client = pinned_route_client(&server); + + let error = client + .attribute_same_defect( + "provider/model", + "Fireworks", + "system", + "user", + Duration::from_secs(60), + ) + .await + .unwrap_err(); + + let diagnostic = error.atomic_attribution_terminal_diagnostic(); + assert!(matches!( + diagnostic.category, + crate::attribution::AttributionTerminalCategory::ResponseIdentityMissing + )); + assert_eq!(diagnostic.identity_present, Some(false)); + assert_eq!(diagnostic.identity_matched, None); + assert_eq!( + diagnostic.provider_attempt_count, + Some(TRANSIENT_RETRIES as usize + 1) + ); + assert_eq!( + server.received_requests().await.unwrap().len(), + TRANSIENT_RETRIES as usize + 1 + ); } #[test] diff --git a/tests/e2e.rs b/tests/e2e.rs index b1336c6..1c2e108 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -6446,10 +6446,12 @@ async fn hidden_atomic_attribution_rejects_provider_substitution() { .failure() .stderr( predicates::str::contains("atomic attribution response identity does not match").and( - predicates::str::contains("postil:atomic-attribution-terminal:v1:"), + predicates::str::contains( + "postil:atomic-attribution-terminal:v1:{\"category\":\"response-identity-mismatch\"", + ), ), ); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(server.received_requests().await.unwrap().len(), 3); } #[tokio::test] From 6d31d0120a1762ab41caf6f1837a8a717fe01d87 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Mon, 24 Aug 2026 12:14:36 +0000 Subject: [PATCH 2/3] Take the identity failure by reference in echo_reason The enum is Copy, so a borrowed receiver and a by-value receiver compile identically at every call site; the reference form reads naturally beside the downcast_ref that produces the borrow. --- src/llm.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm.rs b/src/llm.rs index 9567bd4..2eb912a 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -173,7 +173,7 @@ impl std::error::Error for AtomicAttributionIdentityFailure {} impl AtomicAttributionIdentityFailure { /// Content-free wording for retry logs: the echo either never arrived or /// named a different route, and neither case reveals response text. - fn echo_reason(self) -> &'static str { + fn echo_reason(&self) -> &'static str { match self { Self::Missing => "missing", Self::Mismatch => "mismatched", From a8d6a3ab328745596f45331496794baf85c1a7f5 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Mon, 24 Aug 2026 12:30:40 +0000 Subject: [PATCH 3/3] Inline the identity echo wording at its only use A single-use accessor on a two-variant enum adds a name without adding meaning; the match reads directly in the log construction it serves. --- src/llm.rs | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/llm.rs b/src/llm.rs index 2eb912a..f6be0f0 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -170,17 +170,6 @@ impl std::fmt::Display for AtomicAttributionIdentityFailure { impl std::error::Error for AtomicAttributionIdentityFailure {} -impl AtomicAttributionIdentityFailure { - /// Content-free wording for retry logs: the echo either never arrived or - /// named a different route, and neither case reveals response text. - fn echo_reason(&self) -> &'static str { - match self { - Self::Missing => "missing", - Self::Mismatch => "mismatched", - } - } -} - #[cfg(feature = "qualification-candidate")] #[derive(Debug)] struct AtomicAttributionInvalidOutput; @@ -4480,7 +4469,16 @@ impl LlmClient { log_text(model), error .downcast_ref::() - .map_or("unusable", |failure| failure.echo_reason()), + // Content-free wording: neither case + // reveals response text. + .map_or("unusable", |failure| match failure { + AtomicAttributionIdentityFailure::Missing => { + "missing" + } + AtomicAttributionIdentityFailure::Mismatch => { + "mismatched" + } + }), elapsed_text(wait) ); self.sleep_with_budget(phase, wait).await?; @@ -9635,15 +9633,6 @@ mod tests { .downcast_ref::() .is_some() ); - - assert_eq!( - AtomicAttributionIdentityFailure::Missing.echo_reason(), - "missing" - ); - assert_eq!( - AtomicAttributionIdentityFailure::Mismatch.echo_reason(), - "mismatched" - ); } fn pinned_route_client(server: &MockServer) -> LlmClient {