1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Deserializer, Serialize};
6use sha2::{Digest, Sha256};
7use uuid::Uuid;
8
9use crate::Session;
10
11pub const LAST_RUN_LLM_DIAGNOSTIC_KEY: &str = "runtime.llm_request_diagnostic";
12const MAX_DIAGNOSTIC_BYTES: usize = 4096;
13const DIAGNOSTIC_RUN_FINGERPRINT_KEY: &str = "runtime.llm_request_diagnostic_run";
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum LlmRequestStopReason {
18 AttemptsExhausted,
19 TimeBudgetExhausted,
20 RetryDelayExceedsBudget,
21 NonRetryableTransport,
22 UnclassifiedErrorBody,
23 CancelledOrDropped,
24}
25
26impl LlmRequestStopReason {
27 pub fn guidance(self) -> &'static str {
28 match self {
29 Self::AttemptsExhausted => "Automatic retry attempt limit reached",
30 Self::TimeBudgetExhausted => "Automatic retry time budget exhausted",
31 Self::RetryDelayExceedsBudget => {
32 "Required retry delay exceeds the remaining time budget"
33 }
34 Self::NonRetryableTransport => "Transport failure is not automatically retryable",
35 Self::UnclassifiedErrorBody => "Upstream error response could not be safely classified",
36 Self::CancelledOrDropped => "Request cancelled or its caller stopped waiting",
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct InitialRequestDiagnosticFields {
46 schema_version: u8,
47 diagnostic_id: Uuid,
48 request_id: Uuid,
49 error_id: Option<Uuid>,
50 observed_at: DateTime<Utc>,
51 provider: String,
52 session_hash: Option<String>,
53 run_id: Option<Uuid>,
54 attempt: u32,
55 attempt_limit: u32,
56 event: String,
57 phase: String,
58 failure: String,
59 status: Option<u16>,
60 attempt_timeout_ms: u64,
61 total_budget_ms: u64,
62 attempt_elapsed_ms: u64,
63 total_elapsed_ms: u64,
64 remaining_budget_ms: u64,
65 retry_delay_ms: Option<u64>,
66 stop_reason: LlmRequestStopReason,
67 upstream_outcome: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct StreamTimeoutDiagnostic {
75 pub schema_version: u8,
76 pub kind: String,
77 pub diagnostic_id: Uuid,
78 pub error_id: Uuid,
79 pub observed_at: DateTime<Utc>,
80 pub provider: Option<String>,
81 pub model: Option<String>,
82 pub session_hash: String,
83 pub run_id: Option<Uuid>,
84 pub phase: String,
85 pub deadline_ms: u64,
86 pub last_transport_ms_ago: u64,
87 pub last_semantic_ms_ago: Option<u64>,
88 pub semantic_output_started: bool,
89 pub retry_safe: bool,
90 pub last_http_status: Option<u16>,
91 pub retry_delay_ms: Option<u64>,
92}
93
94impl StreamTimeoutDiagnostic {
95 pub fn valid_identifier(value: &str) -> bool {
99 !value.is_empty()
100 && value.len() <= 120
101 && value
102 .bytes()
103 .all(|b| b.is_ascii_alphanumeric() || b"-_.:/@".contains(&b))
104 && !value.contains("://")
105 && !value.to_ascii_lowercase().starts_with("sk-")
106 && !value.to_ascii_lowercase().starts_with("bearer")
107 }
108
109 fn valid(&self) -> bool {
110 const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
112 let has_semantic = self.last_semantic_ms_ago.is_some();
113 self.schema_version == 1
114 && self.kind == "stream_timeout"
115 && self.error_id == self.diagnostic_id
116 && valid_session_hash(&self.session_hash)
117 && self.provider.as_deref().is_none_or(Self::valid_identifier)
118 && self.model.as_deref().is_none_or(Self::valid_identifier)
119 && (1_000..=86_400_000).contains(&self.deadline_ms)
120 && self.last_transport_ms_ago <= MAX_SAFE_INTEGER
121 && self.last_semantic_ms_ago.is_none_or(|elapsed| {
122 elapsed <= MAX_SAFE_INTEGER && self.last_transport_ms_ago <= elapsed
123 })
124 && self.semantic_output_started == has_semantic
125 && (!self.retry_safe || !has_semantic)
126 && match self.phase.as_str() {
127 "bootstrap" | "first_semantic" => !has_semantic,
128 "transport_idle" => true,
129 "semantic_idle" => has_semantic,
130 _ => false,
131 }
132 && match (self.last_http_status, self.retry_delay_ms) {
133 (None, None) => true,
134 (Some(status), Some(delay)) => {
135 self.phase == "bootstrap"
136 && (100..=599).contains(&status)
137 && delay <= MAX_SAFE_INTEGER
138 }
139 _ => false,
140 }
141 }
142
143 fn error_message(&self) -> String {
144 let last_semantic = self
145 .last_semantic_ms_ago
146 .map(|elapsed| format!("{elapsed}ms"))
147 .unwrap_or_else(|| "never".into());
148 let mut message = format!(
149 "Stream timed out: phase={}, deadline_ms={}, provider={}, model={}, last_transport_ms_ago={}, \
150 last_semantic_ms_ago={}, semantic_output_started={}, retry_safe={}",
151 self.phase,
152 self.deadline_ms,
153 self.provider.as_deref().unwrap_or("unknown"),
154 self.model.as_deref().unwrap_or("unknown"),
155 self.last_transport_ms_ago,
156 last_semantic,
157 self.semantic_output_started,
158 self.retry_safe,
159 );
160 if let (Some(status), Some(delay)) = (self.last_http_status, self.retry_delay_ms) {
161 use std::fmt::Write;
162 let _ = write!(
163 message,
164 ", last_http_status={status}, retry_delay_ms={delay}"
165 );
166 }
167 format!("{message}. Diagnostic ID: {}.", self.diagnostic_id)
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(untagged)]
173enum DiagnosticFields {
174 InitialRequest(InitialRequestDiagnosticFields),
175 StreamTimeout(StreamTimeoutDiagnostic),
176}
177
178impl DiagnosticFields {
179 fn valid(&self) -> bool {
180 match self {
181 Self::InitialRequest(fields) => fields.valid(),
182 Self::StreamTimeout(fields) => fields.valid(),
183 }
184 }
185
186 fn run_id(&self) -> Option<Uuid> {
187 match self {
188 Self::InitialRequest(fields) => fields.run_id,
189 Self::StreamTimeout(fields) => fields.run_id,
190 }
191 }
192
193 fn set_run_id(&mut self, run_id: Option<Uuid>) {
194 match self {
195 Self::InitialRequest(fields) => fields.run_id = run_id,
196 Self::StreamTimeout(fields) => fields.run_id = run_id,
197 }
198 }
199}
200
201fn valid_session_hash(hash: &str) -> bool {
202 hash.len() == 16
203 && hash
204 .bytes()
205 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
211#[serde(transparent)]
212pub struct LlmRequestDiagnostic(DiagnosticFields);
213
214impl<'de> Deserialize<'de> for LlmRequestDiagnostic {
215 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
216 let fields = DiagnosticFields::deserialize(deserializer)?;
217 if !fields.valid() {
218 return Err(serde::de::Error::custom("invalid LLM failure diagnostic"));
219 }
220 Ok(Self(fields))
221 }
222}
223
224impl InitialRequestDiagnosticFields {
225 fn valid(&self) -> bool {
226 self.schema_version == 1
227 && self.provider == "anthropic"
228 && self.event == "stop"
229 && self.request_id == self.diagnostic_id
230 && self.error_id == Some(self.diagnostic_id)
231 && self.session_hash.as_deref().is_some_and(valid_session_hash)
232 && (1..=4).contains(&self.attempt_limit)
233 && self.attempt <= self.attempt_limit
234 && self
235 .status
236 .is_none_or(|status| (100..=599).contains(&status))
237 && self.attempt_timeout_ms <= 60_000
238 && self.total_budget_ms <= 60_000
239 && self.remaining_budget_ms <= self.total_budget_ms
240 && ["send_headers", "error_body", "response_status", "backoff"]
241 .contains(&self.phase.as_str())
242 && ["unknown", "response_received"].contains(&self.upstream_outcome.as_str())
243 && [
244 "none",
245 "request deadline exceeded",
246 "request attempt limit reached",
247 "initial response headers timed out",
248 "timeout",
249 "connect",
250 "request",
251 "body",
252 "decode",
253 "status",
254 "transport",
255 "upstream error body deadline exceeded",
256 "upstream error body read failed",
257 "connector database is temporarily recovering",
258 "temporary upstream failure",
259 ]
260 .contains(&self.failure.as_str())
261 }
262}
263
264pub fn llm_diagnostic_session_hash(session_id: &str) -> String {
267 let mut digest = Sha256::new();
268 digest.update(b"bamboo-provider-operational-log-v1\0");
269 digest.update(session_id.as_bytes());
270 hex::encode(&digest.finalize()[..8])
271}
272
273impl LlmRequestDiagnostic {
274 pub fn from_stream_timeout(fields: StreamTimeoutDiagnostic) -> Option<Self> {
275 fields
276 .valid()
277 .then_some(Self(DiagnosticFields::StreamTimeout(fields)))
278 }
279
280 pub fn from_json(raw: &str) -> Option<Self> {
281 (raw.len() <= MAX_DIAGNOSTIC_BYTES)
282 .then(|| serde_json::from_str(raw).ok())
283 .flatten()
284 }
285
286 pub fn diagnostic_id(&self) -> Uuid {
287 match &self.0 {
288 DiagnosticFields::InitialRequest(fields) => fields.diagnostic_id,
289 DiagnosticFields::StreamTimeout(fields) => fields.diagnostic_id,
290 }
291 }
292
293 pub fn matches_session(&self, session_id: &str) -> bool {
294 let hash = llm_diagnostic_session_hash(session_id);
295 match &self.0 {
296 DiagnosticFields::InitialRequest(fields) => {
297 fields.session_hash.as_deref() == Some(hash.as_str())
298 }
299 DiagnosticFields::StreamTimeout(fields) => fields.session_hash == hash,
300 }
301 }
302
303 pub fn matches_error_message(&self, message: &str) -> bool {
306 let message = match &self.0 {
307 DiagnosticFields::InitialRequest(_) => {
308 message.strip_prefix("LLM error: ").unwrap_or(message)
309 }
310 DiagnosticFields::StreamTimeout(_) => message,
311 };
312 message == self.error_message()
313 }
314
315 pub fn error_message(&self) -> String {
316 let f = match &self.0 {
317 DiagnosticFields::InitialRequest(fields) => fields,
318 DiagnosticFields::StreamTimeout(fields) => return fields.error_message(),
319 };
320 let status = f
321 .status
322 .map(|s| format!("HTTP {s}"))
323 .unwrap_or_else(|| "no HTTP response".into());
324 format!(
325 "Anthropic upstream request failed ({status}): {}. {} after {} request(s) (limit {}). Diagnostic ID: {}. Try again later or contact the upstream gateway administrator.",
326 f.failure, f.stop_reason.guidance(), f.attempt, f.attempt_limit, f.diagnostic_id,
327 )
328 }
329}
330
331impl Session {
332 fn llm_diagnostic_run_fingerprint(&self) -> Option<String> {
333 self.agent_runtime_state.as_ref().map(|state| {
334 let mut digest = Sha256::new();
335 digest.update(b"bamboo-llm-diagnostic-run-v1\0");
336 digest.update((state.run_id.len() as u64).to_be_bytes());
337 digest.update(state.run_id.as_bytes());
338 if let Some(execution) = &state.execution_id {
339 digest.update([1]);
340 digest.update((execution.len() as u64).to_be_bytes());
341 digest.update(execution.as_bytes());
342 } else {
343 digest.update([0]);
344 }
345 hex::encode(&digest.finalize()[..16])
346 })
347 }
348
349 pub(crate) fn clear_llm_request_diagnostic(&mut self) {
350 self.metadata.remove(LAST_RUN_LLM_DIAGNOSTIC_KEY);
351 self.metadata.remove(DIAGNOSTIC_RUN_FINGERPRINT_KEY);
352 }
353
354 fn stored_llm_request_diagnostic(&self) -> Option<LlmRequestDiagnostic> {
355 let diagnostic =
356 LlmRequestDiagnostic::from_json(self.metadata.get(LAST_RUN_LLM_DIAGNOSTIC_KEY)?)?;
357 let run_id = self
358 .agent_runtime_state
359 .as_ref()
360 .and_then(|state| Uuid::parse_str(&state.run_id).ok());
361 let expected_run = self.llm_diagnostic_run_fingerprint();
362 (diagnostic.matches_session(&self.id)
363 && diagnostic.0.run_id() == run_id
364 && self.metadata.get(DIAGNOSTIC_RUN_FINGERPRINT_KEY) == expected_run.as_ref())
365 .then_some(diagnostic)
366 }
367
368 pub fn record_llm_request_diagnostic(&mut self, mut diagnostic: LlmRequestDiagnostic) -> bool {
371 if !diagnostic.matches_session(&self.id) {
372 return false;
373 }
374 diagnostic.0.set_run_id(
375 self.agent_runtime_state
376 .as_ref()
377 .and_then(|state| Uuid::parse_str(&state.run_id).ok()),
378 );
379 let Ok(raw) = serde_json::to_string(&diagnostic) else {
380 return false;
381 };
382 if raw.len() > MAX_DIAGNOSTIC_BYTES {
383 return false;
384 }
385 self.metadata
386 .insert(LAST_RUN_LLM_DIAGNOSTIC_KEY.into(), raw);
387 match self.llm_diagnostic_run_fingerprint() {
388 Some(fingerprint) => {
389 self.metadata
390 .insert(DIAGNOSTIC_RUN_FINGERPRINT_KEY.into(), fingerprint);
391 }
392 None => {
393 self.metadata.remove(DIAGNOSTIC_RUN_FINGERPRINT_KEY);
394 }
395 }
396 true
397 }
398
399 pub(crate) fn retain_llm_request_diagnostic_for_error(&mut self, error: &str) {
400 if !self
401 .stored_llm_request_diagnostic()
402 .is_some_and(|d| d.matches_error_message(error))
403 {
404 self.clear_llm_request_diagnostic();
405 }
406 }
407
408 pub fn last_run_diagnostic(&self) -> Option<LlmRequestDiagnostic> {
411 let error = self.last_run_error()?;
412 self.stored_llm_request_diagnostic()
413 .filter(|d| d.matches_error_message(&error))
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 fn timeout_sample(session: &Session) -> LlmRequestDiagnostic {
422 let id = Uuid::new_v4();
423 LlmRequestDiagnostic::from_stream_timeout(StreamTimeoutDiagnostic {
424 schema_version: 1,
425 kind: "stream_timeout".into(),
426 diagnostic_id: id,
427 error_id: id,
428 observed_at: Utc::now(),
429 provider: Some("a2a3cebf-8abc-45cb-8407-66c90fd9a978".into()),
430 model: Some("gpt-6-astra".into()),
431 session_hash: llm_diagnostic_session_hash(&session.id),
432 run_id: None,
433 phase: "transport_idle".into(),
434 deadline_ms: 120_000,
435 last_transport_ms_ago: 120_002,
436 last_semantic_ms_ago: Some(120_002),
437 semantic_output_started: true,
438 retry_safe: false,
439 last_http_status: None,
440 retry_delay_ms: None,
441 })
442 .unwrap()
443 }
444
445 fn sample(session: &Session) -> LlmRequestDiagnostic {
446 let id = Uuid::new_v4();
447 LlmRequestDiagnostic::from_json(
448 &serde_json::json!({
449 "schema_version":1,"diagnostic_id":id,"request_id":id,"error_id":id,
450 "observed_at":Utc::now(),"provider":"anthropic",
451 "session_hash":llm_diagnostic_session_hash(&session.id),"run_id":null,
452 "attempt":1,"attempt_limit":4,"event":"stop","phase":"send_headers",
453 "failure":"initial response headers timed out","status":null,
454 "attempt_timeout_ms":20000,"total_budget_ms":60000,"attempt_elapsed_ms":20000,
455 "total_elapsed_ms":60000,"remaining_budget_ms":0,"retry_delay_ms":null,
456 "stop_reason":"time_budget_exhausted","upstream_outcome":"unknown",
457 })
458 .to_string(),
459 )
460 .unwrap()
461 }
462
463 #[test]
464 fn failure_receipt_survives_cold_session_round_trip_and_is_bound_to_error_and_session() {
465 let mut session = Session::new("diagnostic-current", "model");
466 let diagnostic = sample(&session);
467 assert!(session.record_llm_request_diagnostic(diagnostic.clone()));
468 assert!(session.last_run_diagnostic().is_none());
469 session.set_last_run_error(format!("LLM error: {}", diagnostic.error_message()));
470 let cold: Session =
471 serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap();
472 assert_eq!(cold.last_run_diagnostic(), Some(diagnostic.clone()));
473 let mut foreign = cold.clone();
474 foreign.id = "other".into();
475 assert!(foreign.last_run_diagnostic().is_none());
476 let mut changed = cold.clone();
477 changed.set_last_run_error("another provider failure");
478 assert!(changed.last_run_diagnostic().is_none());
479 assert!(!changed.metadata.contains_key(LAST_RUN_LLM_DIAGNOSTIC_KEY));
480 let mut cleared = cold;
481 cleared.clear_last_run_error();
482 assert!(!cleared.metadata.contains_key(LAST_RUN_LLM_DIAGNOSTIC_KEY));
483 }
484
485 #[test]
486 fn invalid_free_text_unknown_fields_and_stale_run_fail_closed() {
487 let mut session = Session::new("diagnostic-current", "model");
488 let diagnostic = sample(&session);
489 let value = serde_json::to_value(&diagnostic).unwrap();
490 for field in [
491 "provider",
492 "phase",
493 "failure",
494 "upstream_outcome",
495 "session_hash",
496 "future_secret",
497 ] {
498 let mut invalid = value.clone();
499 invalid[field] = serde_json::json!("PRIVATE_CANARY_body_key_path");
500 assert!(LlmRequestDiagnostic::from_json(&invalid.to_string()).is_none());
501 }
502 assert!(!diagnostic.matches_error_message(&format!(
503 "arbitrary upstream body: {}",
504 diagnostic.error_message()
505 )));
506 session.record_llm_request_diagnostic(diagnostic.clone());
507 session.set_last_run_error(diagnostic.error_message());
508 session.agent_runtime_state =
509 Some(crate::AgentRuntimeState::new(Uuid::new_v4().to_string()));
510 assert!(session.last_run_diagnostic().is_none());
511 }
512
513 #[test]
514 fn non_uuid_run_and_execution_replacement_cannot_reuse_the_old_receipt() {
515 let mut session = Session::new("diagnostic-current", "model");
516 let mut runtime = crate::AgentRuntimeState::new("private-non-uuid-run-a");
517 runtime.execution_id = Some("private-execution-a".into());
518 session.agent_runtime_state = Some(runtime);
519 let diagnostic = sample(&session);
520 session.record_llm_request_diagnostic(diagnostic.clone());
521 session.set_last_run_error(diagnostic.error_message());
522 let encoded = serde_json::to_string(&session).unwrap();
523 let mut cold: Session = serde_json::from_str(&encoded).unwrap();
524 assert!(cold.last_run_diagnostic().is_some());
525 let public = serde_json::to_string(&cold.last_run_diagnostic().unwrap()).unwrap();
526 assert!(!public.contains("private-non-uuid-run-a"));
527 assert!(!public.contains("private-execution-a"));
528 cold.agent_runtime_state.as_mut().unwrap().run_id = "private-non-uuid-run-b".into();
529 assert!(cold.last_run_diagnostic().is_none());
530 let mut cold: Session = serde_json::from_str(&encoded).unwrap();
531 cold.agent_runtime_state.as_mut().unwrap().execution_id =
532 Some("private-execution-b".into());
533 assert!(cold.last_run_diagnostic().is_none());
534 }
535
536 #[test]
537 fn stream_timeout_receipt_survives_cold_read_only_for_its_exact_run_and_error() {
538 let mut session = Session::new("timeout-session-private", "model");
539 let mut runtime = crate::AgentRuntimeState::new(Uuid::new_v4().to_string());
540 runtime.execution_id = Some("private-execution".into());
541 session.agent_runtime_state = Some(runtime);
542 let receipt = timeout_sample(&session);
543 assert!(session.record_llm_request_diagnostic(receipt.clone()));
544 assert!(session.last_run_diagnostic().is_none());
545 session.set_last_run_error(receipt.error_message());
546 let encoded = serde_json::to_string(&session).unwrap();
547 let cold: Session = serde_json::from_str(&encoded).unwrap();
548 let actual = cold.last_run_diagnostic().unwrap();
549 assert_eq!(actual.diagnostic_id(), receipt.diagnostic_id());
550 assert_eq!(actual.error_message(), receipt.error_message());
551 let public = serde_json::to_value(&actual).unwrap();
552 assert_eq!(
553 public["run_id"],
554 session.agent_runtime_state.as_ref().unwrap().run_id
555 );
556 assert!(!public.to_string().contains("timeout-session-private"));
557 assert!(!public.to_string().contains("private-execution"));
558
559 let mut wrong_session = cold.clone();
560 wrong_session.id = "other-session".into();
561 assert!(wrong_session.last_run_diagnostic().is_none());
562 let mut wrong_run = cold.clone();
563 wrong_run.agent_runtime_state.as_mut().unwrap().run_id = Uuid::new_v4().to_string();
564 assert!(wrong_run.last_run_diagnostic().is_none());
565 let mut wrong_execution = cold.clone();
566 wrong_execution
567 .agent_runtime_state
568 .as_mut()
569 .unwrap()
570 .execution_id = Some("next-execution".into());
571 assert!(wrong_execution.last_run_diagnostic().is_none());
572 let mut repeated_timeout = cold;
573 repeated_timeout.set_last_run_error(timeout_sample(&session).error_message());
574 assert!(repeated_timeout.last_run_diagnostic().is_none());
575 assert!(!repeated_timeout
576 .metadata
577 .contains_key(LAST_RUN_LLM_DIAGNOSTIC_KEY));
578 }
579
580 #[test]
581 fn timeout_receipt_rejects_inconsistent_observations_unknown_keys_and_unsafe_labels() {
582 let session = Session::new("session", "model");
583 let value = serde_json::to_value(timeout_sample(&session)).unwrap();
584 for (field, invalid) in [
585 ("schema_version", serde_json::json!(2)),
586 ("kind", serde_json::json!("another_timeout")),
587 ("error_id", serde_json::json!(Uuid::new_v4())),
588 ("phase", serde_json::json!("first_semantic")),
589 ("deadline_ms", serde_json::json!(0)),
590 ("deadline_ms", serde_json::json!(86_400_001_u64)),
591 ("last_transport_ms_ago", serde_json::json!(120_003)),
592 (
593 "last_semantic_ms_ago",
594 serde_json::json!(9_007_199_254_740_992_u64),
595 ),
596 ("semantic_output_started", serde_json::json!(false)),
597 ("retry_safe", serde_json::json!(true)),
598 ("last_http_status", serde_json::json!(429)),
599 ("retry_delay_ms", serde_json::json!(5_000)),
600 (
601 "provider",
602 serde_json::json!("https://private.invalid/token"),
603 ),
604 ("model", serde_json::json!("sk-private")),
605 ("model", serde_json::json!("Bearer_private")),
606 ("future_secret", serde_json::json!("PRIVATE_CANARY")),
607 ] {
608 let mut forged = value.clone();
609 forged[field] = invalid;
610 assert!(
611 LlmRequestDiagnostic::from_json(&forged.to_string()).is_none(),
612 "accepted invalid {field}"
613 );
614 }
615 let receipt = timeout_sample(&session);
616 assert!(
617 !receipt.matches_error_message(&format!("upstream said {}", receipt.error_message()))
618 );
619 assert!(!receipt.matches_error_message(&format!("LLM error: {}", receipt.error_message())));
620 }
621}