Skip to main content

auth_cloudflare/
verify.rs

1//! Verify - the Phase-3 live conformance smoke suite, sanitized failure
2//! evidence, and the versioned `model-health.json` store (task sa-0).
3//!
4//! Three cheap, non-destructive checks exercise the OpenAI-compatible Chat
5//! Completions surface exactly as the Hermes plugin uses it:
6//!
7//! 1. **text_completion** - a non-streaming completion must return the exact
8//!    string `CF_HERMES_OK` in under 30 seconds (feedback 02
9//!    `10-inference` acceptance: `content_exact` + `max_elapsed_ms`).
10//! 2. **streaming** - a streaming completion must emit its first valid SSE
11//!    event within 20 seconds, deliver at least one content delta, reach a
12//!    terminal chunk (`finish_reason` non-null), close without a protocol
13//!    error, and never emit a duplicate terminal event (feedback 02
14//!    `20-streaming` acceptance). SSE is hand-rolled (line-based `data:`
15//!    parsing) because the workspace forbids extra dependencies.
16//! 3. **tool_call** - a single call to the harmless `get_project_sentinel`
17//!    function with `scope=provider-conformance` exactly once (feedback 02
18//!    `30-tool-calling` acceptance).
19//! 4. **structured_output** - a single JSON object with `sentinel` exactly
20//!    `CF_HERMES_OK`, constrained prompt-only (feedback 02
21//!    `50-structured-output` acceptance).
22//! 5. **parallel_tools** - two distinct harmless tool calls in one assistant
23//!    turn (feedback 02 `30-tool-calling` acceptance).
24//!
25//! Checks 4 and 5 are standalone: they are exposed as public functions and
26//! are deliberately NOT part of [`run_smoke_suite`] (wiring them into the
27//! suite or the CLI is a separate task).
28//!
29//! Live gate: **every HTTP-calling function refuses to run unless the
30//! environment variable `AUTH_CLOUDFLARE_LIVE_TESTS` is exactly `1`** -
31//! `live_tests_enabled()` - returning a typed
32//! [`CloudflareError::MissingEnv`] refusal that names the variable. This is
33//! the guardrail against accidental paid inference (feedback 02: paid runs
34//! are opt-in). `AUTH_CLOUDFLARE_MAX_COST_USD` is an optional *documented*
35//! conformance budget: when present, the run report carries
36//! `cost_estimate_usd`; the value is reported and documented but **never
37//! enforced** - enforcement is the operator's job.
38//!
39//! Exit-code contract (feedback 02, binding table):
40//! - `0` - all three checks passed;
41//! - `1` - the live gate is closed (operational refusal);
42//! - `2` - credentials missing (resolved by the CLI before this module);
43//! - `3` - the live API was unreachable at send time (no HTTP response was
44//!   obtained - connect/DNS/TLS/overall-timeout), i.e. a *remote Cloudflare
45//!   API failure*, not a conformance verdict;
46//! - `6` - the suite ran to completion and at least one check failed
47//!   acceptance (non-2xx status or a 200 that failed acceptance), with
48//!   sanitized [`FailureEvidence`] persisted.
49//!
50//! Security contract (feedback 02, CI-tested): the token travels exclusively
51//! through `crate::fetch::auth_header`; every error text and every response
52//! excerpt is scrubbed with `redact_token`; [`FailureEvidence::new`] caps
53//! excerpts at 512 characters; prompts, tool outputs, Authorization headers
54//! and tokens are **never** persisted. The health store records only
55//! sanitized evidence.
56//!
57//! Health store layout (under `Config::cache_dir()`):
58//! `model-health.json` - `{ version: 1, updated_at, records: { <model_id>:
59//! ModelVerification } }`, written atomically (sibling `.tmp` + rename,
60//! `0o600`) via `crate::cache::atomic_write`.
61
62use std::collections::BTreeMap;
63use std::io::BufRead;
64use std::path::Path;
65use std::time::{Duration, Instant};
66
67use chrono::{DateTime, Utc};
68use serde::{Deserialize, Serialize};
69
70use crate::cache::atomic_write;
71use crate::config::Config;
72use crate::error::CloudflareError;
73use crate::fetch::auth_header;
74use crate::health::{
75	CONFORMANCE_SUITE_VERSION, FailureClass, FailureEvidence, ModelVerification, VerificationConfidence,
76	VerificationStatus,
77};
78use crate::tool_loop::ToolLoopOutcome;
79
80/// Env var gating live inference: the suite runs only when this is exactly
81/// `"1"` (feedback 02: paid runs are opt-in).
82pub const LIVE_TESTS_ENV: &str = "AUTH_CLOUDFLARE_LIVE_TESTS";
83
84/// Optional conformance budget env var. When present (a positive finite
85/// number), the run report carries `cost_estimate_usd`; the budget is
86/// documented and reported, never enforced.
87pub const MAX_COST_ENV: &str = "AUTH_CLOUDFLARE_MAX_COST_USD";
88
89/// File name of the health store, inside `Config::cache_dir()`.
90pub const HEALTH_STORE_FILE: &str = "model-health.json";
91
92/// Schema version of the health store file layout. Bump on layout change so
93/// older stores are rejected loudly instead of misread.
94pub const HEALTH_STORE_VERSION: u32 = 1;
95
96/// The fixed user message for the text and streaming checks (feedback 02
97/// `10-inference`).
98pub const TEXT_PROMPT: &str = "Reply with exactly: CF_HERMES_OK";
99
100/// The exact content the non-streaming check must deliver.
101pub const EXACT_TEXT: &str = "CF_HERMES_OK";
102
103/// The tool-calling check prompt (feedback 02 `30-tool-calling`).
104pub const TOOL_PROMPT: &str = "Call get_project_sentinel exactly once with scope=provider-conformance. Do not answer with prose before calling the tool.";
105
106/// The harmless sentinel tool name (feedback 02 `30-tool-calling`).
107pub const TOOL_NAME: &str = "get_project_sentinel";
108
109/// The only permitted `scope` argument value.
110pub const TOOL_SCOPE: &str = "provider-conformance";
111
112/// The parallel-tools check prompt (feedback 02 `30-tool-calling`): both
113/// harmless tools must be called in one assistant turn.
114pub const PARALLEL_TOOL_PROMPT: &str = "Call BOTH get_project_sentinel and get_project_marker in this one turn. Do not answer with prose before calling the tools.";
115
116/// The second harmless sentinel tool name (parallel-tools check).
117pub const PARALLEL_TOOL_NAME: &str = "get_project_marker";
118
119/// The only permitted `marker` argument value for the second tool.
120pub const PARALLEL_TOOL_VALUE: &str = "provider-conformance";
121
122/// The structured-output check prompt. Prompt-only JSON constraint (no
123/// `response_format` parameter) so models that reject that parameter as
124/// unsupported still exercise structured output.
125pub const STRUCTURED_PROMPT: &str = "Reply with a single JSON object of shape {\"sentinel\":\"CF_HERMES_OK\"}. Do not add prose or markdown code fences.";
126
127/// The required field name in the structured-output object.
128pub const STRUCTURED_SENTINEL_FIELD: &str = "sentinel";
129
130/// The required field value in the structured-output object.
131pub const STRUCTURED_SENTINEL_VALUE: &str = "CF_HERMES_OK";
132
133/// Non-streaming acceptance: `elapsed_ms` must stay under this (feedback 02).
134pub const TEXT_COMPLETION_MAX_ELAPSED_MS: u64 = 30_000;
135
136/// Streaming acceptance: first valid SSE event within this many ms.
137pub const STREAM_FIRST_EVENT_MAX_MS: u64 = 20_000;
138
139/// Overall agent timeout for the non-streaming text check (enforces the
140/// 30 s acceptance at the transport level).
141pub const TEXT_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
142
143/// Overall agent timeout for the streaming check - the "configured timeout"
144/// capping total stream duration.
145pub const STREAM_CHECK_TIMEOUT: Duration = Duration::from_secs(120);
146
147/// Per-read timeout for the streaming check (a stalled upstream must not
148/// hold the first event past the 20 s acceptance window).
149pub const STREAM_FIRST_EVENT_TIMEOUT: Duration = Duration::from_secs(20);
150
151/// Overall agent timeout for the tool-call check.
152pub const TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
153
154/// Overall agent timeout for the structured-output check.
155pub const STRUCTURED_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
156
157/// Overall agent timeout for the parallel-tools check.
158pub const PARALLEL_TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
159
160/// Conservative upper-bound cost estimate for one smoke run (three tiny
161/// requests, roughly 80 tokens round-trip at the most expensive in-catalog
162/// price). Reported as `cost_estimate_usd` only when `AUTH_CLOUDFLARE_MAX_COST_USD`
163/// is set; the budget itself is documented, never enforced.
164pub const SMOKE_SUITE_ESTIMATED_COST_USD: f64 = 0.001;
165
166/// The suites this runner knows. `smoke` and `tool-loop` exist in Phase 3;
167/// the CLI rejects anything else as a usage error.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum SuiteKind {
170	/// Cheap live smoke suite: exact text completion, streaming completion,
171	/// one tool call.
172	Smoke,
173	/// Multi-turn fake-tool conformance loop (feedback 02 suite 40): the
174	/// model must read a fixture, run its test, write the patch, and deliver
175	/// a final answer within the turn budget.
176	ToolLoop,
177}
178
179impl SuiteKind {
180	/// Parse a `--suite` value; `None` defaults to `smoke`.
181	pub fn parse(value: Option<&str>) -> Result<Self, String> {
182		match value {
183			None | Some("smoke") => Ok(Self::Smoke),
184			Some("tool-loop") => Ok(Self::ToolLoop),
185			Some(other) => Err(format!("unsupported suite {other}: only 'smoke' and 'tool-loop' are available")),
186		}
187	}
188
189	/// The suite's machine-readable name (`"smoke"` / `"tool-loop"`).
190	pub fn as_str(self) -> &'static str {
191		match self {
192			Self::Smoke => "smoke",
193			Self::ToolLoop => "tool-loop",
194		}
195	}
196}
197
198/// Outcome of one check: passed + elapsed, with sanitized failure evidence
199/// when the check failed.
200#[derive(Debug, Clone, Serialize)]
201#[serde(rename_all = "snake_case")]
202pub struct CheckOutcome {
203	pub passed: bool,
204	pub elapsed_ms: u64,
205	#[serde(skip_serializing_if = "Option::is_none")]
206	pub failure: Option<FailureEvidence>,
207}
208
209/// The three smoke checks, keyed for the CLI report.
210#[derive(Debug, Clone, Serialize)]
211#[serde(rename_all = "snake_case")]
212pub struct ChecksReport {
213	pub text_completion: CheckOutcome,
214	pub streaming: CheckOutcome,
215	pub tool_call: CheckOutcome,
216}
217
218/// Full smoke run report (the CLI adds `gate` and `exit_code`).
219#[derive(Debug, Clone, Serialize)]
220#[serde(rename_all = "snake_case")]
221pub struct SmokeRunReport {
222	pub model_id: String,
223	pub suite: String,
224	pub status: VerificationStatus,
225	pub passed: bool,
226	pub checks: ChecksReport,
227	pub verification: ModelVerification,
228	#[serde(skip_serializing_if = "Option::is_none")]
229	pub cost_estimate_usd: Option<f64>,
230}
231
232/// One parsed SSE event: a JSON `data:` payload, the `[DONE]` sentinel, or a
233/// malformed `data:` line (a protocol error).
234#[derive(Debug, Clone, PartialEq)]
235pub enum StreamEvent {
236	Data(serde_json::Value),
237	Done,
238	Malformed(String),
239}
240
241/// True when the live gate is open: `AUTH_CLOUDFLARE_LIVE_TESTS` is exactly
242/// `"1"`. Pure and testable; every HTTP-calling function consults this first.
243pub fn live_tests_enabled() -> bool {
244	std::env::var(LIVE_TESTS_ENV).is_ok_and(|value| value == "1")
245}
246
247/// The typed refusal returned when the live gate is closed.
248pub fn gate_refusal() -> CloudflareError {
249	CloudflareError::MissingEnv {
250		env_var: LIVE_TESTS_ENV,
251		hint: "live tests disabled (set AUTH_CLOUDFLARE_LIVE_TESTS=1 to allow paid inference)".to_string(),
252	}
253}
254
255/// Optional `AUTH_CLOUDFLARE_MAX_COST_USD` budget: a positive finite number,
256/// when present. Reported as `cost_estimate_usd`; never enforced.
257pub fn max_cost_usd_env() -> Option<f64> {
258	std::env::var(MAX_COST_ENV)
259		.ok()
260		.and_then(|value| value.trim().parse::<f64>().ok())
261		.filter(|value| value.is_finite() && *value > 0.0)
262}
263
264/// Run the full smoke suite against the model id **exactly as given** - the
265/// catalog is never consulted, so a model absent from the catalog is still
266/// verified (the CLI contract: fall back to the id as passed).
267///
268/// The suite always completes when the three requests could be attempted:
269/// per-check failures (non-2xx statuses, acceptance failures, body-read
270/// failures) become sanitized evidence, never aborts. The only `Err` paths
271/// are the closed live gate (refusal) and a send-phase transport failure
272/// (the live API was unreachable - no HTTP response was obtained).
273pub fn run_smoke_suite(config: &Config, model_id: &str) -> Result<SmokeRunReport, CloudflareError> {
274	gate_check()?;
275	let text = check_text_completion(config, model_id)?;
276	let streaming = check_streaming_completion(config, model_id)?;
277	let tool = check_tool_call(config, model_id)?;
278	let verification = aggregate_verification(model_id, &text, &streaming, &tool);
279	let passed = text.passed && streaming.passed && tool.passed;
280	let report = SmokeRunReport {
281		model_id: model_id.to_string(),
282		suite: SuiteKind::Smoke.as_str().to_string(),
283		status: verification.status,
284		passed,
285		checks: ChecksReport { text_completion: text, streaming, tool_call: tool },
286		verification,
287		cost_estimate_usd: if max_cost_usd_env().is_some() {
288			Some(SMOKE_SUITE_ESTIMATED_COST_USD)
289		} else {
290			None
291		},
292	};
293	Ok(report)
294}
295
296/// Check (a): non-streaming exact-text completion. Acceptance: content
297/// exactly `CF_HERMES_OK`, elapsed under 30 000 ms.
298pub fn check_text_completion(config: &Config, model_id: &str) -> Result<CheckOutcome, CloudflareError> {
299	gate_check()?;
300	let started = Instant::now();
301	let request_id = next_request_id();
302	let body = serde_json::json!({
303		"model": model_id,
304		"messages": [{ "role": "user", "content": TEXT_PROMPT }],
305		"stream": false,
306	});
307	let response = send_completion(config, &body, "application/json", TEXT_CHECK_TIMEOUT, None)?;
308	let cf_ray = response.header("cf-ray").map(str::to_string);
309	let status = response.status();
310	let elapsed_ms = started.elapsed().as_millis() as u64;
311	if status != 200 {
312		return Ok(non_streaming_failure(
313			config,
314			model_id,
315			&request_id,
316			status,
317			cf_ray,
318			elapsed_ms,
319			response,
320		));
321	}
322	let body = match response.into_string() {
323		Ok(body) => body,
324		Err(_) => {
325			return Ok(failed_outcome(
326				FailureClass::ReadTimeout,
327				Some(200),
328				cf_ray,
329				elapsed_ms,
330				model_id,
331				&request_id,
332				None,
333			));
334		},
335	};
336	match inspect_text_completion(&body) {
337		Ok(()) if elapsed_ms < TEXT_COMPLETION_MAX_ELAPSED_MS => {
338			Ok(CheckOutcome { passed: true, elapsed_ms, failure: None })
339		},
340		Ok(()) => {
341			// The acceptance caps latency; the taxonomy has no dedicated
342			// class, so Unknown is the honest verdict (never guess).
343			Ok(failed_outcome(
344				FailureClass::Unknown,
345				Some(200),
346				cf_ray,
347				elapsed_ms,
348				model_id,
349				&request_id,
350				Some(redact_token(&body, config.api_token().as_ref())),
351			))
352		},
353		Err(class) => Ok(failed_outcome(
354			class,
355			Some(200),
356			cf_ray,
357			elapsed_ms,
358			model_id,
359			&request_id,
360			Some(redact_token(&body, config.api_token().as_ref())),
361		)),
362	}
363}
364
365/// Check (b): streaming completion. Acceptance (feedback 02 `20-streaming`):
366/// first valid SSE event within 20 s, at least one content delta, a terminal
367/// chunk with non-null `finish_reason`, clean close with no protocol error,
368/// no duplicate terminal event, total duration under the configured timeout.
369pub fn check_streaming_completion(config: &Config, model_id: &str) -> Result<CheckOutcome, CloudflareError> {
370	gate_check()?;
371	let started = Instant::now();
372	let request_id = next_request_id();
373	let body = serde_json::json!({
374		"model": model_id,
375		"messages": [{ "role": "user", "content": TEXT_PROMPT }],
376		"stream": true,
377	});
378	let response = send_completion(
379		config,
380		&body,
381		"text/event-stream",
382		STREAM_CHECK_TIMEOUT,
383		Some(STREAM_FIRST_EVENT_TIMEOUT),
384	)?;
385	let cf_ray = response.header("cf-ray").map(str::to_string);
386	let status = response.status();
387	let elapsed_ms = started.elapsed().as_millis() as u64;
388	if status != 200 {
389		return Ok(non_streaming_failure(
390			config,
391			model_id,
392			&request_id,
393			status,
394			cf_ray,
395			elapsed_ms,
396			response,
397		));
398	}
399
400	// Hand-rolled SSE: read lines, keep `data:` payloads, parse each as JSON.
401	let reader = response.into_reader();
402	let mut lines = std::io::BufReader::new(reader).lines();
403	let mut events: Vec<StreamEvent> = Vec::new();
404	let mut first_event_elapsed_ms: Option<u64> = None;
405	let mut last_raw: Option<String> = None;
406	let mut read_error: Option<String> = None;
407	loop {
408		match lines.next() {
409			Some(Ok(line)) => {
410				let Some(payload) = line.strip_prefix("data:") else { continue };
411				let payload = payload.trim();
412				last_raw = Some(redact_token(payload, config.api_token().as_ref()));
413				if payload == "[DONE]" {
414					events.push(StreamEvent::Done);
415				} else {
416					match serde_json::from_str::<serde_json::Value>(payload) {
417						Ok(value) => {
418							if first_event_elapsed_ms.is_none() {
419								first_event_elapsed_ms = Some(started.elapsed().as_millis() as u64);
420							}
421							events.push(StreamEvent::Data(value));
422						},
423						Err(_) => {
424							events.push(StreamEvent::Malformed(redact_token(payload, config.api_token().as_ref())));
425						},
426					}
427				}
428			},
429			Some(Err(error)) => {
430				// A mid-stream read failure (timeout/reset) means the stream
431				// did not close cleanly - an acceptance failure with evidence,
432				// not an abort.
433				read_error = Some(redact_token(&error.to_string(), config.api_token().as_ref()));
434				break;
435			},
436			None => break,
437		}
438	}
439	let elapsed_ms = started.elapsed().as_millis() as u64;
440	if let Some(message) = read_error {
441		return Ok(failed_outcome(
442			classify_transport_message(&message),
443			Some(200),
444			cf_ray,
445			elapsed_ms,
446			model_id,
447			&request_id,
448			last_raw,
449		));
450	}
451	match classify_stream_failure(&events, first_event_elapsed_ms, elapsed_ms) {
452		None => Ok(CheckOutcome { passed: true, elapsed_ms, failure: None }),
453		Some(class) => Ok(failed_outcome(
454			class,
455			Some(200),
456			cf_ray,
457			elapsed_ms,
458			model_id,
459			&request_id,
460			last_raw,
461		)),
462	}
463}
464
465/// Check (c): one harmless tool call. Acceptance (feedback 02
466/// `30-tool-calling`): exactly one standard tool call, name exactly
467/// `get_project_sentinel`, argument JSON parses, `scope` exactly
468/// `provider-conformance`.
469pub fn check_tool_call(config: &Config, model_id: &str) -> Result<CheckOutcome, CloudflareError> {
470	gate_check()?;
471	let started = Instant::now();
472	let request_id = next_request_id();
473	let body = serde_json::json!({
474		"model": model_id,
475		"messages": [{ "role": "user", "content": TOOL_PROMPT }],
476		"stream": false,
477		"tools": [sentinel_tool_schema()],
478	});
479	let response = send_completion(config, &body, "application/json", TOOL_CHECK_TIMEOUT, None)?;
480	let cf_ray = response.header("cf-ray").map(str::to_string);
481	let status = response.status();
482	let elapsed_ms = started.elapsed().as_millis() as u64;
483	if status != 200 {
484		return Ok(non_streaming_failure(
485			config,
486			model_id,
487			&request_id,
488			status,
489			cf_ray,
490			elapsed_ms,
491			response,
492		));
493	}
494	let body = match response.into_string() {
495		Ok(body) => body,
496		Err(_) => {
497			return Ok(failed_outcome(
498				FailureClass::ReadTimeout,
499				Some(200),
500				cf_ray,
501				elapsed_ms,
502				model_id,
503				&request_id,
504				None,
505			));
506		},
507	};
508	match classify_tool_response(Some(body.as_str())) {
509		None => Ok(CheckOutcome { passed: true, elapsed_ms, failure: None }),
510		Some(class) => Ok(failed_outcome(
511			class,
512			Some(200),
513			cf_ray,
514			elapsed_ms,
515			model_id,
516			&request_id,
517			Some(redact_token(&body, config.api_token().as_ref())),
518		)),
519	}
520}
521
522// ---------------------------------------------------------------------------
523// HTTP transport
524// ---------------------------------------------------------------------------
525
526/// The harmless `get_project_sentinel` tool schema (feedback 02
527/// `30-tool-calling`): a `scope` argument restricted to `provider-conformance`.
528fn sentinel_tool_schema() -> serde_json::Value {
529	serde_json::json!({
530		"type": "function",
531		"function": {
532			"name": TOOL_NAME,
533			"description": "Return the configured test sentinel. Use this tool before answering.",
534			"parameters": {
535				"type": "object",
536				"properties": {
537					"scope": { "type": "string", "enum": [TOOL_SCOPE] }
538				},
539				"required": ["scope"],
540				"additionalProperties": false
541			}
542		}
543	})
544}
545
546/// The second harmless `get_project_marker` tool schema (parallel-tools
547/// check): a `marker` argument restricted to `provider-conformance`.
548fn marker_tool_schema() -> serde_json::Value {
549	serde_json::json!({
550		"type": "function",
551		"function": {
552			"name": PARALLEL_TOOL_NAME,
553			"description": "Return the configured test marker. Use this tool before answering.",
554			"parameters": {
555				"type": "object",
556				"properties": {
557					"marker": { "type": "string", "enum": [PARALLEL_TOOL_VALUE] }
558				},
559				"required": ["marker"],
560				"additionalProperties": false
561			}
562		}
563	})
564}
565
566/// Check (d): structured output. Acceptance (feedback 02
567/// `50-structured-output`): the completion content parses as a JSON object
568/// with `sentinel` exactly `CF_HERMES_OK`. Prompt-only constraint - no
569/// `response_format` parameter, so the model must honor a natural-language
570/// shape instruction.
571pub fn check_structured_output(config: &Config, model_id: &str) -> Result<CheckOutcome, CloudflareError> {
572	gate_check()?;
573	let started = Instant::now();
574	let request_id = next_request_id();
575	let body = serde_json::json!({
576		"model": model_id,
577		"messages": [{ "role": "user", "content": STRUCTURED_PROMPT }],
578		"stream": false,
579	});
580	let response = send_completion(config, &body, "application/json", STRUCTURED_CHECK_TIMEOUT, None)?;
581	let cf_ray = response.header("cf-ray").map(str::to_string);
582	let status = response.status();
583	let elapsed_ms = started.elapsed().as_millis() as u64;
584	if status != 200 {
585		return Ok(non_streaming_failure(
586			config,
587			model_id,
588			&request_id,
589			status,
590			cf_ray,
591			elapsed_ms,
592			response,
593		));
594	}
595	let body = match response.into_string() {
596		Ok(body) => body,
597		Err(_) => {
598			return Ok(failed_outcome(
599				FailureClass::ReadTimeout,
600				Some(200),
601				cf_ray,
602				elapsed_ms,
603				model_id,
604				&request_id,
605				None,
606			));
607		},
608	};
609	match classify_structured_output(Some(body.as_str())) {
610		None => Ok(CheckOutcome { passed: true, elapsed_ms, failure: None }),
611		Some(class) => Ok(failed_outcome(
612			class,
613			Some(200),
614			cf_ray,
615			elapsed_ms,
616			model_id,
617			&request_id,
618			Some(redact_token(&body, config.api_token().as_ref())),
619		)),
620	}
621}
622
623/// Check (e): parallel tool calls. Acceptance (feedback 02
624/// `30-tool-calling`): exactly two tool calls in one assistant turn, one
625/// `get_project_sentinel` and one `get_project_marker` (order-agnostic),
626/// both with valid JSON arguments.
627pub fn check_parallel_tools(config: &Config, model_id: &str) -> Result<CheckOutcome, CloudflareError> {
628	gate_check()?;
629	let started = Instant::now();
630	let request_id = next_request_id();
631	let body = serde_json::json!({
632		"model": model_id,
633		"messages": [{ "role": "user", "content": PARALLEL_TOOL_PROMPT }],
634		"stream": false,
635		"tools": [sentinel_tool_schema(), marker_tool_schema()],
636	});
637	let response = send_completion(config, &body, "application/json", PARALLEL_TOOL_CHECK_TIMEOUT, None)?;
638	let cf_ray = response.header("cf-ray").map(str::to_string);
639	let status = response.status();
640	let elapsed_ms = started.elapsed().as_millis() as u64;
641	if status != 200 {
642		return Ok(non_streaming_failure(
643			config,
644			model_id,
645			&request_id,
646			status,
647			cf_ray,
648			elapsed_ms,
649			response,
650		));
651	}
652	let body = match response.into_string() {
653		Ok(body) => body,
654		Err(_) => {
655			return Ok(failed_outcome(
656				FailureClass::ReadTimeout,
657				Some(200),
658				cf_ray,
659				elapsed_ms,
660				model_id,
661				&request_id,
662				None,
663			));
664		},
665	};
666	match classify_parallel_tools(Some(body.as_str())) {
667		None => Ok(CheckOutcome { passed: true, elapsed_ms, failure: None }),
668		Some(class) => Ok(failed_outcome(
669			class,
670			Some(200),
671			cf_ray,
672			elapsed_ms,
673			model_id,
674			&request_id,
675			Some(redact_token(&body, config.api_token().as_ref())),
676		)),
677	}
678}
679
680// ---------------------------------------------------------------------------
681// HTTP transport
682// ---------------------------------------------------------------------------
683
684/// POST a Chat Completions body and return the response for the caller to
685/// classify. Non-2xx statuses are returned as responses too (ureq surfaces
686/// them as `Error::Status`) so the caller can read the error envelope and
687/// build evidence. The only hard `Err` is a send-phase transport failure
688/// (no HTTP response obtained) or a closed live gate.
689fn send_completion(
690	config: &Config,
691	body: &serde_json::Value,
692	accept: &str,
693	overall_timeout: Duration,
694	read_timeout: Option<Duration>,
695) -> Result<ureq::Response, CloudflareError> {
696	gate_check()?;
697	let base_url = config
698		.base_url()
699		.map_err(|error| CloudflareError::Http(format!("resolve base url: {error}")))?;
700	let url = format!("{base_url}/chat/completions");
701	let mut builder = ureq::AgentBuilder::new().timeout(overall_timeout);
702	if let Some(read_timeout) = read_timeout {
703		builder = builder.timeout_read(read_timeout);
704	}
705	let agent = builder.build();
706	let request = agent
707		.post(&url)
708		.set("Authorization", &auth_header(config.api_token()))
709		.set("Accept", accept)
710		.set("Content-Type", "application/json");
711	match request.send_string(&body.to_string()) {
712		Ok(response) => Ok(response),
713		Err(ureq::Error::Status(_, response)) => Ok(response),
714		Err(transport) => {
715			let message = redact_token(&transport.to_string(), config.api_token().as_ref());
716			Err(CloudflareError::Http(message))
717		},
718	}
719}
720
721/// Failure path for a non-2xx response: read the error envelope body (best
722/// effort), classify by HTTP status + envelope + cf-ray, and build evidence.
723fn non_streaming_failure(
724	config: &Config,
725	model_id: &str,
726	request_id: &str,
727	status: u16,
728	cf_ray: Option<String>,
729	elapsed_ms: u64,
730	response: ureq::Response,
731) -> CheckOutcome {
732	let body = match response.into_string() {
733		Ok(body) => redact_token(&body, config.api_token().as_ref()),
734		Err(_) => String::new(),
735	};
736	let class = if body.is_empty() {
737		FailureClass::Unknown
738	} else {
739		classify_http_failure(status, Some(body.as_str()), cf_ray.is_some())
740	};
741	let excerpt = if body.is_empty() { None } else { Some(body) };
742	failed_outcome(class, Some(status), cf_ray, elapsed_ms, model_id, request_id, excerpt)
743}
744
745/// The live gate: refuse with a typed `MissingEnv` naming
746/// `AUTH_CLOUDFLARE_LIVE_TESTS` when it is not exactly `1`.
747fn gate_check() -> Result<(), CloudflareError> {
748	if live_tests_enabled() { Ok(()) } else { Err(gate_refusal()) }
749}
750
751/// Monotonic request id: chrono millis + a process-local counter (no uuid
752/// dependency - the workspace forbids new deps).
753fn next_request_id() -> String {
754	static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
755	let sequence = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
756	format!("verify-{}-{sequence}", Utc::now().timestamp_millis())
757}
758
759/// Build sanitized failure evidence for a failed check.
760fn failed_outcome(
761	class: FailureClass,
762	http_status: Option<u16>,
763	cf_ray: Option<String>,
764	elapsed_ms: u64,
765	model_id: &str,
766	request_id: &str,
767	excerpt: Option<String>,
768) -> CheckOutcome {
769	CheckOutcome {
770		passed: false,
771		elapsed_ms,
772		failure: Some(FailureEvidence::new(
773			class,
774			http_status,
775			cf_ray,
776			Some(elapsed_ms),
777			model_id.to_string(),
778			Some(request_id.to_string()),
779			excerpt,
780		)),
781	}
782}
783
784// ---------------------------------------------------------------------------
785// pure, unit-testable failure classification
786// ---------------------------------------------------------------------------
787
788/// Classify a non-2xx (or envelope-carrying 200) response: 401 → AuthRejected,
789/// 403 → AuthRejected or AccountNotFound per the Cloudflare error envelope,
790/// 429 → RateLimited, 5xx → ProviderServerError, and an envelope with
791/// `success: false` plus a cf-ray → CloudflareEdgeError. Everything else is
792/// Unknown - never guess a specific class without evidence.
793pub fn classify_http_failure(status: u16, body: Option<&str>, has_cf_ray: bool) -> FailureClass {
794	match status {
795		401 => FailureClass::AuthRejected,
796		403 => {
797			if account_not_found(body) {
798				FailureClass::AccountNotFound
799			} else {
800				FailureClass::AuthRejected
801			}
802		},
803		429 => FailureClass::RateLimited,
804		500..=599 => FailureClass::ProviderServerError,
805		_ => {
806			if has_cf_ray && envelope_success_false(body) {
807				FailureClass::CloudflareEdgeError
808			} else {
809				FailureClass::Unknown
810			}
811		},
812	}
813}
814
815/// True when the 403 envelope names a missing account: Cloudflare error code
816/// 9103 (`Account not found`) or a message saying so. Every other 403 is
817/// treated as rejected credentials.
818fn account_not_found(body: Option<&str>) -> bool {
819	let Some(body) = body else { return false };
820	let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else { return false };
821	let first = value
822		.get("errors")
823		.and_then(|errors| errors.as_array())
824		.and_then(|array| array.first());
825	if first.and_then(|f| f.get("code")).and_then(|code| code.as_u64()) == Some(9103) {
826		return true;
827	}
828	let message = first.and_then(|f| f.get("message")).and_then(|m| m.as_str()).unwrap_or("");
829	let lower = message.to_lowercase();
830	lower.contains("account") && lower.contains("not found")
831}
832
833/// True when the body is a Cloudflare envelope with `"success": false`.
834fn envelope_success_false(body: Option<&str>) -> bool {
835	body.and_then(|body| serde_json::from_str::<serde_json::Value>(body).ok())
836		.is_some_and(|value| value.get("success").and_then(|s| s.as_bool()) == Some(false))
837}
838
839/// Classify a ureq transport message into the failure taxonomy by keyword:
840/// timeouts → ReadTimeout, connection failures → ConnectTimeout, resets →
841/// ConnectionReset, TLS → TlsFailure, else Unknown.
842pub fn classify_transport_message(message: &str) -> FailureClass {
843	let lower = message.to_lowercase();
844	if lower.contains("timed out") || lower.contains("timeout") {
845		return FailureClass::ReadTimeout;
846	}
847	// "connection reset" must be checked before the generic "connect" test.
848	if lower.contains("reset") {
849		return FailureClass::ConnectionReset;
850	}
851	if lower.contains("connection refused") || lower.contains("connection failed") || lower.contains("connect") {
852		return FailureClass::ConnectTimeout;
853	}
854	if lower.contains("tls") || lower.contains("certificate") {
855		return FailureClass::TlsFailure;
856	}
857	FailureClass::Unknown
858}
859
860/// Classify a non-streaming completion body that failed acceptance: empty
861/// content → EmptyCompletion, truncated (`finish_reason` "length") →
862/// TruncatedCompletion, invalid JSON → InvalidJson, wrong shape →
863/// InvalidChatCompletionShape, missing/`null` finish reason →
864/// MissingFinishReason, else Unknown.
865pub fn classify_completion_failure(body: Option<&str>) -> FailureClass {
866	let Some(body) = body else { return FailureClass::EmptyCompletion };
867	if body.trim().is_empty() {
868		return FailureClass::EmptyCompletion;
869	}
870	let value = match serde_json::from_str::<serde_json::Value>(body) {
871		Ok(value) => value,
872		Err(_) => return FailureClass::InvalidJson,
873	};
874	let Some(first) = value
875		.get("choices")
876		.and_then(|choices| choices.as_array())
877		.and_then(|array| array.first())
878	else {
879		return FailureClass::InvalidChatCompletionShape;
880	};
881	let content = match first.get("message").and_then(|message| message.get("content")) {
882		Some(serde_json::Value::String(content)) => content.as_str(),
883		Some(_) => return FailureClass::InvalidChatCompletionShape,
884		None => return FailureClass::EmptyCompletion,
885	};
886	if content.is_empty() {
887		return FailureClass::EmptyCompletion;
888	}
889	match first.get("finish_reason") {
890		Some(serde_json::Value::String(reason)) if reason == "length" => FailureClass::TruncatedCompletion,
891		Some(serde_json::Value::Null) | None => FailureClass::MissingFinishReason,
892		_ => FailureClass::Unknown,
893	}
894}
895
896/// Pure acceptance for the non-streaming text check: content must be exactly
897/// `CF_HERMES_OK` in a well-formed completion. Returns the failure class, or
898/// `Ok(())` when the content criterion holds (latency is checked by the
899/// caller).
900pub fn inspect_text_completion(body: &str) -> Result<(), FailureClass> {
901	let value: serde_json::Value = serde_json::from_str(body).map_err(|_| FailureClass::InvalidJson)?;
902	let first = value
903		.get("choices")
904		.and_then(|choices| choices.as_array())
905		.and_then(|array| array.first())
906		.ok_or(FailureClass::InvalidChatCompletionShape)?;
907	let content = match first.get("message").and_then(|message| message.get("content")) {
908		Some(serde_json::Value::String(content)) => content.as_str(),
909		Some(_) => return Err(FailureClass::InvalidChatCompletionShape),
910		None => return Err(FailureClass::EmptyCompletion),
911	};
912	if content.is_empty() {
913		return Err(FailureClass::EmptyCompletion);
914	}
915	if content != EXACT_TEXT {
916		return Err(FailureClass::Unknown);
917	}
918	Ok(())
919}
920
921/// Classify a tool-call response that failed acceptance: no tool call →
922/// NoToolCall, wrong name → InvalidToolName, unparsable/wrong arguments →
923/// InvalidToolArguments, more than one call → DuplicateToolCall, malformed
924/// envelope → InvalidJson/InvalidChatCompletionShape. `None` means the
925/// response is a valid single `get_project_sentinel(scope=provider-conformance)`
926/// call - `FailureClass::Unknown` must never be conflated with "valid".
927pub fn classify_tool_response(body: Option<&str>) -> Option<FailureClass> {
928	let Some(body) = body else { return Some(FailureClass::NoToolCall) };
929	let value: serde_json::Value = match serde_json::from_str(body) {
930		Ok(value) => value,
931		Err(_) => return Some(FailureClass::InvalidJson),
932	};
933	let Some(first) = value
934		.get("choices")
935		.and_then(|choices| choices.as_array())
936		.and_then(|array| array.first())
937	else {
938		return Some(FailureClass::InvalidChatCompletionShape);
939	};
940	let Some(calls) = first
941		.get("message")
942		.and_then(|message| message.get("tool_calls"))
943		.and_then(|calls| calls.as_array())
944	else {
945		return Some(FailureClass::NoToolCall);
946	};
947	if calls.is_empty() {
948		return Some(FailureClass::NoToolCall);
949	}
950	if calls.len() > 1 {
951		return Some(FailureClass::DuplicateToolCall);
952	}
953	let call = &calls[0];
954	let name = call
955		.get("function")
956		.and_then(|function| function.get("name"))
957		.and_then(|name| name.as_str())
958		.unwrap_or("");
959	if name != TOOL_NAME {
960		return Some(FailureClass::InvalidToolName);
961	}
962	let args_raw = call
963		.get("function")
964		.and_then(|function| function.get("arguments"))
965		.and_then(|arguments| arguments.as_str())
966		.unwrap_or("");
967	let args: serde_json::Value = match serde_json::from_str(args_raw) {
968		Ok(args) => args,
969		Err(_) => return Some(FailureClass::InvalidToolArguments),
970	};
971	if args.get("scope").and_then(|scope| scope.as_str()) != Some(TOOL_SCOPE) {
972		return Some(FailureClass::InvalidToolArguments);
973	}
974	None
975}
976
977/// Pure acceptance for the structured-output check (feedback 02
978/// `50-structured-output`): the completion content must parse as a JSON
979/// object with `sentinel` exactly `CF_HERMES_OK`. `None` means the
980/// criterion holds; otherwise the closest existing taxonomy class is
981/// returned (InvalidJson for unparsable/`wrong-value` JSON, InvalidChatCompletionShape
982/// for a non-object, EmptyCompletion for empty content) - no new variant is
983/// introduced.
984pub fn classify_structured_output(body: Option<&str>) -> Option<FailureClass> {
985	let Some(body) = body else { return Some(FailureClass::EmptyCompletion) };
986	if body.trim().is_empty() {
987		return Some(FailureClass::EmptyCompletion);
988	}
989	let value: serde_json::Value = match serde_json::from_str(body) {
990		Ok(value) => value,
991		Err(_) => return Some(FailureClass::InvalidJson),
992	};
993	let Some(first) = value
994		.get("choices")
995		.and_then(|choices| choices.as_array())
996		.and_then(|array| array.first())
997	else {
998		return Some(FailureClass::InvalidChatCompletionShape);
999	};
1000	let content = match first.get("message").and_then(|message| message.get("content")) {
1001		Some(serde_json::Value::String(content)) => content.as_str(),
1002		Some(_) => return Some(FailureClass::InvalidChatCompletionShape),
1003		None => return Some(FailureClass::EmptyCompletion),
1004	};
1005	if content.trim().is_empty() {
1006		return Some(FailureClass::EmptyCompletion);
1007	}
1008	let parsed: serde_json::Value = match serde_json::from_str(content) {
1009		Ok(parsed) => parsed,
1010		Err(_) => return Some(FailureClass::InvalidJson),
1011	};
1012	let Some(object) = parsed.as_object() else {
1013		return Some(FailureClass::InvalidChatCompletionShape);
1014	};
1015	if object.get(STRUCTURED_SENTINEL_FIELD).and_then(|field| field.as_str()) != Some(STRUCTURED_SENTINEL_VALUE) {
1016		return Some(FailureClass::InvalidJson);
1017	}
1018	None
1019}
1020
1021/// Pure acceptance for the parallel-tools check (feedback 02
1022/// `30-tool-calling`): exactly two tool calls in one turn, one per expected
1023/// name (`get_project_sentinel` + `get_project_marker`, order-agnostic), both
1024/// with valid JSON arguments. `None` means acceptance holds; otherwise
1025/// NoToolCall (fewer than two calls), DuplicateToolCall (more than two, or
1026/// the same name twice), InvalidToolName (an unexpected name), or
1027/// InvalidToolArguments (an argument string that is not valid JSON).
1028pub fn classify_parallel_tools(body: Option<&str>) -> Option<FailureClass> {
1029	let Some(body) = body else { return Some(FailureClass::NoToolCall) };
1030	let value: serde_json::Value = match serde_json::from_str(body) {
1031		Ok(value) => value,
1032		Err(_) => return Some(FailureClass::InvalidJson),
1033	};
1034	let Some(first) = value
1035		.get("choices")
1036		.and_then(|choices| choices.as_array())
1037		.and_then(|array| array.first())
1038	else {
1039		return Some(FailureClass::InvalidChatCompletionShape);
1040	};
1041	let Some(calls) = first
1042		.get("message")
1043		.and_then(|message| message.get("tool_calls"))
1044		.and_then(|calls| calls.as_array())
1045	else {
1046		return Some(FailureClass::NoToolCall);
1047	};
1048	if calls.is_empty() {
1049		return Some(FailureClass::NoToolCall);
1050	}
1051	if calls.len() > 2 {
1052		return Some(FailureClass::DuplicateToolCall);
1053	}
1054	if calls.len() < 2 {
1055		return Some(FailureClass::NoToolCall);
1056	}
1057	let names: Vec<&str> = calls
1058		.iter()
1059		.map(|call| {
1060			call.get("function")
1061				.and_then(|function| function.get("name"))
1062				.and_then(|name| name.as_str())
1063				.unwrap_or("")
1064		})
1065		.collect();
1066	for name in &names {
1067		if *name != TOOL_NAME && *name != PARALLEL_TOOL_NAME {
1068			return Some(FailureClass::InvalidToolName);
1069		}
1070	}
1071	if names[0] == names[1] {
1072		return Some(FailureClass::DuplicateToolCall);
1073	}
1074	for call in calls {
1075		let args_raw = call
1076			.get("function")
1077			.and_then(|function| function.get("arguments"))
1078			.and_then(|arguments| arguments.as_str())
1079			.unwrap_or("");
1080		if serde_json::from_str::<serde_json::Value>(args_raw).is_err() {
1081			return Some(FailureClass::InvalidToolArguments);
1082		}
1083	}
1084	None
1085}
1086
1087/// Pure streaming acceptance over a fully read event stream. `None` means
1088/// every acceptance criterion holds: first valid event within 20 s, at least
1089/// one content delta, a terminal chunk with a finish reason, no protocol
1090/// error, total duration under the configured timeout.
1091///
1092/// Terminal-chunk policy: a second `finish_reason` is tolerated when it
1093/// repeats the SAME reason on an empty delta (DeepSeek re-emits the terminal
1094/// as the final usage-carrying chunk before `[DONE]` - the live smoke run
1095/// observed `finish_reason:"stop"` twice). A conflicting second reason, or
1096/// any content delta delivered AFTER a terminal chunk, is a protocol error.
1097pub fn classify_stream_failure(
1098	events: &[StreamEvent],
1099	first_event_elapsed_ms: Option<u64>,
1100	total_elapsed_ms: u64,
1101) -> Option<FailureClass> {
1102	// Protocol errors take precedence over everything else.
1103	for event in events {
1104		if let StreamEvent::Malformed(_) = event {
1105			return Some(FailureClass::InvalidSseEvent);
1106		}
1107	}
1108	if total_elapsed_ms >= STREAM_CHECK_TIMEOUT.as_millis() as u64 {
1109		return Some(FailureClass::ReadTimeout);
1110	}
1111	match first_event_elapsed_ms {
1112		None => return Some(FailureClass::StreamIdleTimeout),
1113		Some(elapsed) if elapsed > STREAM_FIRST_EVENT_MAX_MS => return Some(FailureClass::StreamIdleTimeout),
1114		Some(_) => {},
1115	}
1116	let mut content_delta = false;
1117	let mut terminal_reason: Option<String> = None;
1118	for event in events {
1119		match event {
1120			StreamEvent::Data(value) => {
1121				let choices_field = value.get("choices");
1122				let first = choices_field
1123					.and_then(|choices| choices.as_array())
1124					.and_then(|array| array.first());
1125				let Some(first) = first else {
1126					// A usage-only trailer chunk (empty or missing `choices`
1127					// array - the standard OpenAI-compatible chunk after the
1128					// terminal, e.g. DeepSeek's `{"choices":[],"usage":{...}}`)
1129					// is NOT a protocol error. A `choices` field that is
1130					// present but not an array IS.
1131					if choices_field.is_some_and(|choices| !choices.is_array()) {
1132						return Some(FailureClass::InvalidSseEvent);
1133					}
1134					continue;
1135				};
1136				if let Some(delta) = first
1137					.get("delta")
1138					.and_then(|delta| delta.get("content"))
1139					.and_then(|content| content.as_str())
1140				{
1141					if !delta.is_empty() {
1142						if terminal_reason.is_some() {
1143							// Content delivered after a terminal chunk is a
1144							// genuine protocol violation.
1145							return Some(FailureClass::InvalidSseEvent);
1146						}
1147						content_delta = true;
1148					}
1149				}
1150				if let Some(reason) = first.get("finish_reason").and_then(|reason| reason.as_str()) {
1151					if let Some(first_reason) = &terminal_reason {
1152						if *first_reason != reason {
1153							// A second terminal chunk with a DIFFERENT reason
1154							// is a protocol violation; the same reason
1155							// re-emitted on an empty delta is the provider's
1156							// usage trailer and is accepted.
1157							return Some(FailureClass::InvalidSseEvent);
1158						}
1159					} else {
1160						terminal_reason = Some(reason.to_string());
1161					}
1162				}
1163			},
1164			StreamEvent::Done => break,
1165			StreamEvent::Malformed(_) => continue,
1166		}
1167	}
1168	if !content_delta {
1169		return Some(FailureClass::EmptyCompletion);
1170	}
1171	if terminal_reason.is_none() {
1172		return Some(FailureClass::MissingFinishReason);
1173	}
1174	None
1175}
1176
1177// ---------------------------------------------------------------------------
1178// aggregation into ModelVerification
1179// ---------------------------------------------------------------------------
1180
1181/// Aggregate the three check outcomes into the canonical
1182/// [`ModelVerification`] (feedback 02). Status formula: 0 failures →
1183/// Passing, 1 failure → Degraded, 2+ failures → Failing. Each check counts
1184/// as one run (`total_runs` = 3), so `delivery_success_rate()` is the
1185/// fraction of smoke checks that delivered - the picker's delivery metric.
1186/// `last_failure` is the first failed check's evidence (text, stream, tool
1187/// order); failure counters map from [`FailureClass`].
1188pub fn aggregate_verification(
1189	model_id: &str,
1190	text: &CheckOutcome,
1191	streaming: &CheckOutcome,
1192	tool: &CheckOutcome,
1193) -> ModelVerification {
1194	let outcomes = [text, streaming, tool];
1195	let failures = outcomes.iter().filter(|outcome| !outcome.passed).count();
1196	let status = match failures {
1197		0 => VerificationStatus::Passing,
1198		1 => VerificationStatus::Degraded,
1199		_ => VerificationStatus::Failing,
1200	};
1201	let (
1202		timeout_failures,
1203		transport_failures,
1204		provider_5xx_failures,
1205		malformed_response_failures,
1206		malformed_tool_call_failures,
1207	) = failure_counter_map(&outcomes);
1208	let mut latencies = [text.elapsed_ms, streaming.elapsed_ms, tool.elapsed_ms];
1209	latencies.sort_unstable();
1210	let last_failure = outcomes
1211		.iter()
1212		.find(|outcome| !outcome.passed)
1213		.and_then(|outcome| outcome.failure.clone());
1214	ModelVerification {
1215		model_id: model_id.to_string(),
1216		latest_run_at: Some(Utc::now()),
1217		expires_at: None,
1218		suite_version: CONFORMANCE_SUITE_VERSION.to_string(),
1219		runner_version: env!("CARGO_PKG_VERSION").to_string(),
1220		status,
1221		agent_eligible: true,
1222		confidence: VerificationConfidence::SmokeTested,
1223		total_runs: 3,
1224		successful_runs: 3 - failures as u32,
1225		text_completion_success_rate: Some(rate_of(text.passed)),
1226		stream_completion_success_rate: Some(rate_of(streaming.passed)),
1227		single_tool_success_rate: Some(rate_of(tool.passed)),
1228		multi_turn_tool_success_rate: None,
1229		structured_output_success_rate: None,
1230		median_latency_ms: Some(latencies[1]),
1231		p95_latency_ms: None,
1232		total_failures: failures as u32,
1233		timeout_failures,
1234		transport_failures,
1235		provider_5xx_failures,
1236		malformed_response_failures,
1237		malformed_tool_call_failures,
1238		tool_loop_failures: 0,
1239		last_failure,
1240	}
1241}
1242
1243/// 1.0 for a passed check, 0.0 for a failed one (each check is one run).
1244fn rate_of(passed: bool) -> f64 {
1245	if passed { 1.0 } else { 0.0 }
1246}
1247
1248/// Aggregate one tool-loop outcome into a [`ModelVerification`] (feedback 02
1249/// suite 40). The loop counts as one run: `total_runs` = 1,
1250/// `multi_turn_tool_success_rate` is 1.0 when the loop converged, 0.0
1251/// otherwise; a non-converged loop is `Failing` with `tool_loop_failures` =
1252/// 1 and the loop's own [`FailureClass`] as sanitized evidence (no HTTP
1253/// status, no excerpt - the loop carries no response body).
1254pub fn verification_from_tool_loop(model_id: &str, outcome: &ToolLoopOutcome) -> ModelVerification {
1255	let converged = outcome.converged;
1256	let status = if converged {
1257		VerificationStatus::Passing
1258	} else {
1259		VerificationStatus::Failing
1260	};
1261	let last_failure = outcome
1262		.failure_class
1263		.map(|class| FailureEvidence::new(class, None, None, None, model_id, None, None));
1264	ModelVerification {
1265		model_id: model_id.to_string(),
1266		latest_run_at: Some(Utc::now()),
1267		expires_at: None,
1268		suite_version: CONFORMANCE_SUITE_VERSION.to_string(),
1269		runner_version: env!("CARGO_PKG_VERSION").to_string(),
1270		status,
1271		agent_eligible: true,
1272		confidence: VerificationConfidence::SmokeTested,
1273		total_runs: 1,
1274		successful_runs: if converged { 1 } else { 0 },
1275		text_completion_success_rate: None,
1276		stream_completion_success_rate: None,
1277		single_tool_success_rate: None,
1278		multi_turn_tool_success_rate: Some(if converged { 1.0 } else { 0.0 }),
1279		structured_output_success_rate: None,
1280		median_latency_ms: None,
1281		p95_latency_ms: None,
1282		total_failures: if converged { 0 } else { 1 },
1283		timeout_failures: 0,
1284		transport_failures: 0,
1285		provider_5xx_failures: 0,
1286		malformed_response_failures: 0,
1287		malformed_tool_call_failures: 0,
1288		tool_loop_failures: if converged { 0 } else { 1 },
1289		last_failure,
1290	}
1291}
1292
1293/// Map failed-check evidence classes onto the five counter buckets.
1294fn failure_counter_map(outcomes: &[&CheckOutcome]) -> (u32, u32, u32, u32, u32) {
1295	let mut timeout = 0u32;
1296	let mut transport = 0u32;
1297	let mut fivexx = 0u32;
1298	let mut malformed_response = 0u32;
1299	let mut malformed_tool = 0u32;
1300	for outcome in outcomes {
1301		let Some(class) = outcome.failure.as_ref().map(|evidence| evidence.failure_class) else { continue };
1302		match class {
1303			FailureClass::ReadTimeout | FailureClass::StreamIdleTimeout | FailureClass::ConnectTimeout => timeout += 1,
1304			FailureClass::ConnectionReset | FailureClass::TlsFailure => transport += 1,
1305			FailureClass::ProviderServerError => fivexx += 1,
1306			FailureClass::EmptyCompletion
1307			| FailureClass::TruncatedCompletion
1308			| FailureClass::InvalidJson
1309			| FailureClass::InvalidChatCompletionShape
1310			| FailureClass::InvalidSseEvent
1311			| FailureClass::MissingFinishReason => malformed_response += 1,
1312			FailureClass::NoToolCall
1313			| FailureClass::InvalidToolName
1314			| FailureClass::InvalidToolArguments
1315			| FailureClass::DuplicateToolCall => malformed_tool += 1,
1316			_ => {},
1317		}
1318	}
1319	(timeout, transport, fivexx, malformed_response, malformed_tool)
1320}
1321
1322// ---------------------------------------------------------------------------
1323// model-health.json store (atomic, versioned, account-scoped)
1324// ---------------------------------------------------------------------------
1325
1326/// The versioned health store: one `ModelVerification` per model id, keyed
1327/// by `model_id`. Lives at `Config::cache_dir()/model-health.json`, written
1328/// atomically (sibling `.tmp` + rename, `0o600`).
1329#[derive(Debug, Clone, Serialize, Deserialize)]
1330#[serde(rename_all = "snake_case")]
1331pub struct HealthStore {
1332	/// File layout version (`HEALTH_STORE_VERSION`).
1333	pub version: u32,
1334	/// Last write time (RFC 3339).
1335	pub updated_at: DateTime<Utc>,
1336	/// Verification records keyed by model id.
1337	pub records: BTreeMap<String, ModelVerification>,
1338}
1339
1340impl HealthStore {
1341	/// An empty store at the current version.
1342	pub fn new() -> Self {
1343		Self { version: HEALTH_STORE_VERSION, updated_at: Utc::now(), records: BTreeMap::new() }
1344	}
1345
1346	/// Insert or replace one model's verification record and bump
1347	/// `updated_at`. Other models' records are preserved.
1348	pub fn upsert(&mut self, verification: ModelVerification) {
1349		self.records.insert(verification.model_id.clone(), verification);
1350		self.updated_at = Utc::now();
1351	}
1352
1353	/// Read one model's record.
1354	pub fn get(&self, model_id: &str) -> Option<&ModelVerification> {
1355		self.records.get(model_id)
1356	}
1357}
1358
1359impl Default for HealthStore {
1360	/// [`Self::new`] - a store with no records is the natural default state.
1361	fn default() -> Self {
1362		Self::new()
1363	}
1364}
1365
1366/// Load the health store from `dir/model-health.json`. A missing file is an
1367/// empty store; a present-but-corrupt file or an unsupported schema version
1368/// is a typed error (never a panic).
1369pub fn load_health_store(dir: &Path) -> Result<HealthStore, CloudflareError> {
1370	let path = dir.join(HEALTH_STORE_FILE);
1371	if !path.exists() {
1372		return Ok(HealthStore::new());
1373	}
1374	let raw = std::fs::read_to_string(&path)
1375		.map_err(|error| CloudflareError::Http(format!("read {}: {error}", path.display())))?;
1376	let store: HealthStore = serde_json::from_str(&raw)
1377		.map_err(|error| CloudflareError::Http(format!("parse {}: {error}", path.display())))?;
1378	if store.version != HEALTH_STORE_VERSION {
1379		return Err(CloudflareError::Http(format!(
1380			"{} has unsupported schema version {} (expected {HEALTH_STORE_VERSION})",
1381			path.display(),
1382			store.version
1383		)));
1384	}
1385	Ok(store)
1386}
1387
1388/// Persist the health store atomically under `dir` (create dirs, sibling
1389/// `.tmp` + rename, `0o600`). Never leaves a half-written store behind.
1390pub fn save_health_store(dir: &Path, store: &HealthStore) -> Result<(), CloudflareError> {
1391	std::fs::create_dir_all(dir)
1392		.map_err(|error| CloudflareError::Http(format!("create {}: {error}", dir.display())))?;
1393	let json = serde_json::to_string_pretty(store)
1394		.map_err(|error| CloudflareError::Http(format!("serialize {HEALTH_STORE_FILE}: {error}")))?;
1395	atomic_write(&dir.join(HEALTH_STORE_FILE), &json)
1396}
1397
1398/// Read-modify-write upsert of one verification record after a run: load the
1399/// existing store (preserving every other model's record), upsert, save.
1400pub fn save_verification(dir: &Path, verification: &ModelVerification) -> Result<(), CloudflareError> {
1401	let mut store = load_health_store(dir)?;
1402	store.upsert(verification.clone());
1403	save_health_store(dir, &store)
1404}
1405
1406// ---------------------------------------------------------------------------
1407// token scrubbing (mirrors fetch.rs; kept private to this module)
1408// ---------------------------------------------------------------------------
1409
1410/// Replace the token with `<redacted>` in any text that could reach an error
1411/// string or an evidence excerpt. The token value never survives.
1412fn redact_token(text: &str, token: &str) -> String {
1413	if token.is_empty() {
1414		text.to_string()
1415	} else {
1416		text.replace(token, "<redacted>")
1417	}
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422	use super::*;
1423	use crate::config::ConfigBuilder;
1424
1425	/// Synthetic Cloudflare-shaped account id (32 hex digits) - never real.
1426	const ACCOUNT: &str = "0123456789abcdef0123456789abcdef";
1427	/// Synthetic token - never a real credential.
1428	const TOKEN: &str = "cfut_test_synthetic_token_0001";
1429	/// First synthetic model id.
1430	const MODEL_A: &str = "@cf/deepseek-ai/deepseek-v4-flash-0731";
1431	/// Second synthetic model id.
1432	const MODEL_B: &str = "@cf/zai-org/glm-5.3-flash";
1433
1434	/// Every env var this module reads, saved/restored for isolation.
1435	const ALL_VARS: &[&str] = &[LIVE_TESTS_ENV, MAX_COST_ENV];
1436
1437	/// `std::env` is process-global and tests run in parallel - serialize env
1438	/// mutation through a static mutex and restore prior values after.
1439	static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1440
1441	fn with_env<F, R>(vars: &[(&str, Option<&str>)], f: F) -> R
1442	where
1443		F: FnOnce() -> R,
1444	{
1445		let _guard = ENV_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1446		let saved: Vec<(String, Option<String>)> = ALL_VARS
1447			.iter()
1448			.map(|key| ((*key).to_string(), std::env::var(key).ok()))
1449			.collect();
1450		for key in ALL_VARS {
1451			std::env::remove_var(key);
1452		}
1453		for (key, value) in vars {
1454			match value {
1455				Some(value) => std::env::set_var(key, value),
1456				None => std::env::remove_var(key),
1457			}
1458		}
1459		let result = f();
1460		for (key, value) in saved {
1461			match value {
1462				Some(value) => std::env::set_var(&key, value),
1463				None => std::env::remove_var(&key),
1464			}
1465		}
1466		result
1467	}
1468
1469	/// Unique scratch dir per test - tests run in parallel.
1470	fn scratch_dir(name: &str) -> std::path::PathBuf {
1471		std::env::temp_dir().join(format!("auth-cloudflare-verify-test-{}-{name}", std::process::id()))
1472	}
1473
1474	/// A config that resolves without touching the environment beyond the
1475	/// vars `with_env` controls.
1476	fn test_config() -> Config {
1477		ConfigBuilder::new()
1478			.account_id(ACCOUNT)
1479			.api_token(TOKEN)
1480			.cache_dir(scratch_dir("cfg"))
1481			.build()
1482			.expect("test config resolves")
1483	}
1484
1485	/// A minimal valid verification record for store tests.
1486	fn verification_record(model_id: &str, status: VerificationStatus) -> ModelVerification {
1487		ModelVerification {
1488			model_id: model_id.to_string(),
1489			latest_run_at: Some(Utc::now()),
1490			expires_at: None,
1491			suite_version: CONFORMANCE_SUITE_VERSION.to_string(),
1492			runner_version: "test".to_string(),
1493			status,
1494			agent_eligible: true,
1495			confidence: VerificationConfidence::SmokeTested,
1496			total_runs: 3,
1497			successful_runs: 3,
1498			text_completion_success_rate: Some(1.0),
1499			stream_completion_success_rate: Some(1.0),
1500			single_tool_success_rate: Some(1.0),
1501			multi_turn_tool_success_rate: None,
1502			structured_output_success_rate: None,
1503			median_latency_ms: Some(100),
1504			p95_latency_ms: None,
1505			total_failures: 0,
1506			timeout_failures: 0,
1507			transport_failures: 0,
1508			provider_5xx_failures: 0,
1509			malformed_response_failures: 0,
1510			malformed_tool_call_failures: 0,
1511			tool_loop_failures: 0,
1512			last_failure: None,
1513		}
1514	}
1515
1516	fn passed(elapsed_ms: u64) -> CheckOutcome {
1517		CheckOutcome { passed: true, elapsed_ms, failure: None }
1518	}
1519
1520	fn failed(elapsed_ms: u64, class: FailureClass) -> CheckOutcome {
1521		CheckOutcome {
1522			passed: false,
1523			elapsed_ms,
1524			failure: Some(FailureEvidence::new(
1525				class,
1526				Some(200),
1527				Some("ray-test".to_string()),
1528				Some(elapsed_ms),
1529				MODEL_A,
1530				Some("req-test".to_string()),
1531				Some("excerpt".to_string()),
1532			)),
1533		}
1534	}
1535
1536	/// A streaming `data:` event with an optional content delta and optional
1537	/// string finish reason.
1538	fn data_event(delta: Option<&str>, finish: Option<&str>) -> StreamEvent {
1539		let mut delta_object = serde_json::Map::new();
1540		if let Some(delta) = delta {
1541			delta_object.insert("content".to_string(), serde_json::json!(delta));
1542		}
1543		let mut choice = serde_json::Map::new();
1544		choice.insert("delta".to_string(), serde_json::Value::Object(delta_object));
1545		if let Some(finish) = finish {
1546			choice.insert("finish_reason".to_string(), serde_json::json!(finish));
1547		}
1548		StreamEvent::Data(serde_json::json!({ "choices": [choice] }))
1549	}
1550
1551	// ------------------------------------------------------------------
1552	// live gate
1553	// ------------------------------------------------------------------
1554
1555	#[test]
1556	fn live_gate_requires_exactly_one() {
1557		with_env(&[(LIVE_TESTS_ENV, Some("1"))], || {
1558			assert!(live_tests_enabled(), "exactly '1' opens the gate")
1559		});
1560		with_env(&[(LIVE_TESTS_ENV, Some("0"))], || assert!(!live_tests_enabled()));
1561		with_env(&[(LIVE_TESTS_ENV, Some("yes"))], || assert!(!live_tests_enabled()));
1562		with_env(&[(LIVE_TESTS_ENV, Some(" 1 "))], || {
1563			assert!(!live_tests_enabled(), "no trimming - exactly '1'")
1564		});
1565		with_env(&[(LIVE_TESTS_ENV, None)], || assert!(!live_tests_enabled()));
1566	}
1567
1568	#[test]
1569	fn gate_closed_refuses_every_http_function_without_network() {
1570		with_env(&[(LIVE_TESTS_ENV, None)], || {
1571			let config = test_config();
1572			let error = run_smoke_suite(&config, MODEL_A).unwrap_err();
1573			assert!(
1574				matches!(error, CloudflareError::MissingEnv { env_var: LIVE_TESTS_ENV, .. }),
1575				"refusal must name AUTH_CLOUDFLARE_LIVE_TESTS: {error}"
1576			);
1577			assert!(error.to_string().contains("AUTH_CLOUDFLARE_LIVE_TESTS=1"));
1578			assert!(
1579				matches!(
1580					check_text_completion(&config, MODEL_A).unwrap_err(),
1581					CloudflareError::MissingEnv { env_var: LIVE_TESTS_ENV, .. }
1582				),
1583				"every HTTP-calling function gates first"
1584			);
1585			assert!(matches!(
1586				check_streaming_completion(&config, MODEL_A).unwrap_err(),
1587				CloudflareError::MissingEnv { env_var: LIVE_TESTS_ENV, .. }
1588			));
1589			assert!(matches!(
1590				check_tool_call(&config, MODEL_A).unwrap_err(),
1591				CloudflareError::MissingEnv { env_var: LIVE_TESTS_ENV, .. }
1592			));
1593			assert!(matches!(
1594				check_structured_output(&config, MODEL_A).unwrap_err(),
1595				CloudflareError::MissingEnv { env_var: LIVE_TESTS_ENV, .. }
1596			));
1597			assert!(matches!(
1598				check_parallel_tools(&config, MODEL_A).unwrap_err(),
1599				CloudflareError::MissingEnv { env_var: LIVE_TESTS_ENV, .. }
1600			));
1601		});
1602	}
1603
1604	// ------------------------------------------------------------------
1605	// pure failure classification
1606	// ------------------------------------------------------------------
1607
1608	#[test]
1609	fn http_401_and_403_classify_auth_rejected_or_account_not_found() {
1610		assert_eq!(
1611			classify_http_failure(401, Some(r#"{"success":false,"errors":[{"code":9109}]}"#), false),
1612			FailureClass::AuthRejected
1613		);
1614		// Cloudflare code 9103 = Account not found.
1615		assert_eq!(
1616			classify_http_failure(
1617				403,
1618				Some(r#"{"success":false,"errors":[{"code":9103,"message":"Account not found"}]}"#),
1619				true
1620			),
1621			FailureClass::AccountNotFound
1622		);
1623		// Any other 403 is rejected credentials.
1624		assert_eq!(
1625			classify_http_failure(403, Some(r#"{"success":false,"errors":[{"code":10000}]}"#), false),
1626			FailureClass::AuthRejected
1627		);
1628		assert_eq!(classify_http_failure(403, None, false), FailureClass::AuthRejected);
1629	}
1630
1631	#[test]
1632	fn http_429_5xx_and_edge_envelope_classify() {
1633		assert_eq!(
1634			classify_http_failure(429, Some("rate limited"), false),
1635			FailureClass::RateLimited
1636		);
1637		for status in [500u16, 502, 503, 504] {
1638			assert_eq!(
1639				classify_http_failure(status, Some("boom"), false),
1640				FailureClass::ProviderServerError
1641			);
1642		}
1643		// success:false envelope + cf-ray on a 200 -> edge error.
1644		assert_eq!(
1645			classify_http_failure(200, Some(r#"{"success":false,"errors":[]}"#), true),
1646			FailureClass::CloudflareEdgeError
1647		);
1648		// ... but only with a cf-ray, and only with a success:false envelope.
1649		assert_eq!(
1650			classify_http_failure(200, Some(r#"{"success":false,"errors":[]}"#), false),
1651			FailureClass::Unknown
1652		);
1653		assert_eq!(
1654			classify_http_failure(200, Some(r#"{"success":true}"#), true),
1655			FailureClass::Unknown
1656		);
1657		assert_eq!(classify_http_failure(400, Some("bad request"), false), FailureClass::Unknown);
1658	}
1659
1660	#[test]
1661	fn transport_messages_classify_by_keyword() {
1662		assert_eq!(
1663			classify_transport_message("request timed out after 30s"),
1664			FailureClass::ReadTimeout
1665		);
1666		assert_eq!(classify_transport_message("timed out"), FailureClass::ReadTimeout);
1667		assert_eq!(classify_transport_message("connection refused"), FailureClass::ConnectTimeout);
1668		assert_eq!(classify_transport_message("connection failed"), FailureClass::ConnectTimeout);
1669		// "reset" wins over the generic "connect" test.
1670		assert_eq!(
1671			classify_transport_message("connection reset by peer"),
1672			FailureClass::ConnectionReset
1673		);
1674		assert_eq!(classify_transport_message("tls handshake failed"), FailureClass::TlsFailure);
1675		assert_eq!(
1676			classify_transport_message("certificate verify failed"),
1677			FailureClass::TlsFailure
1678		);
1679		assert_eq!(classify_transport_message("weird mystery error"), FailureClass::Unknown);
1680	}
1681
1682	#[test]
1683	fn completion_failure_classification_covers_the_taxonomy() {
1684		assert_eq!(classify_completion_failure(None), FailureClass::EmptyCompletion);
1685		assert_eq!(classify_completion_failure(Some("")), FailureClass::EmptyCompletion);
1686		assert_eq!(
1687			classify_completion_failure(Some(r#"{"choices":[{"message":{"content":""}}]}"#)),
1688			FailureClass::EmptyCompletion
1689		);
1690		assert_eq!(classify_completion_failure(Some("not json")), FailureClass::InvalidJson);
1691		assert_eq!(
1692			classify_completion_failure(Some(r#"{"choices":[]}"#)),
1693			FailureClass::InvalidChatCompletionShape
1694		);
1695		assert_eq!(
1696			classify_completion_failure(Some(r#"{"choices":[{"message":{"content":"hi"},"finish_reason":null}]}"#)),
1697			FailureClass::MissingFinishReason
1698		);
1699		assert_eq!(
1700			classify_completion_failure(Some(r#"{"choices":[{"message":{"content":"hi"},"finish_reason":"length"}]}"#)),
1701			FailureClass::TruncatedCompletion
1702		);
1703		assert_eq!(
1704			classify_completion_failure(Some(r#"{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}"#)),
1705			FailureClass::Unknown
1706		);
1707	}
1708
1709	#[test]
1710	fn text_completion_acceptance_is_exact() {
1711		let ok = format!(r#"{{"choices":[{{"message":{{"content":"{EXACT_TEXT}"}},"finish_reason":"stop"}}]}}"#);
1712		assert_eq!(inspect_text_completion(&ok), Ok(()));
1713		assert_eq!(
1714			inspect_text_completion(r#"{"choices":[{"message":{"content":""}}]}"#),
1715			Err(FailureClass::EmptyCompletion)
1716		);
1717		assert_eq!(
1718			inspect_text_completion(r#"{"choices":[{"message":{"content":"WRONG_ANSWER"}}]}"#),
1719			Err(FailureClass::Unknown)
1720		);
1721		assert_eq!(inspect_text_completion("not json"), Err(FailureClass::InvalidJson));
1722		assert_eq!(
1723			inspect_text_completion(r#"{"choices":[]}"#),
1724			Err(FailureClass::InvalidChatCompletionShape)
1725		);
1726	}
1727
1728	#[test]
1729	fn tool_response_classification_covers_the_taxonomy() {
1730		let valid = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_sentinel","arguments":"{\"scope\":\"provider-conformance\"}"}}]}}]}"#;
1731		assert_eq!(
1732			classify_tool_response(Some(valid)),
1733			None,
1734			"a valid single call is not a failure"
1735		);
1736
1737		let no_call = r#"{"choices":[{"message":{"content":"no tool call"}}]}"#;
1738		assert_eq!(classify_tool_response(Some(no_call)), Some(FailureClass::NoToolCall));
1739		assert_eq!(classify_tool_response(None), Some(FailureClass::NoToolCall));
1740
1741		let wrong_name =
1742			r#"{"choices":[{"message":{"tool_calls":[{"function":{"name":"other_tool","arguments":"{}"}}]}}]}"#;
1743		assert_eq!(classify_tool_response(Some(wrong_name)), Some(FailureClass::InvalidToolName));
1744
1745		let bad_args = r#"{"choices":[{"message":{"tool_calls":[{"function":{"name":"get_project_sentinel","arguments":"not json"}}]}}]}"#;
1746		assert_eq!(classify_tool_response(Some(bad_args)), Some(FailureClass::InvalidToolArguments));
1747
1748		let wrong_scope = r#"{"choices":[{"message":{"tool_calls":[{"function":{"name":"get_project_sentinel","arguments":"{\"scope\":\"other\"}"}}]}}]}"#;
1749		assert_eq!(
1750			classify_tool_response(Some(wrong_scope)),
1751			Some(FailureClass::InvalidToolArguments)
1752		);
1753
1754		let duplicate = r#"{"choices":[{"message":{"tool_calls":[{"function":{"name":"get_project_sentinel","arguments":"{}"}},{"function":{"name":"get_project_sentinel","arguments":"{}"}}]}}]}"#;
1755		assert_eq!(classify_tool_response(Some(duplicate)), Some(FailureClass::DuplicateToolCall));
1756
1757		assert_eq!(classify_tool_response(Some("not json")), Some(FailureClass::InvalidJson));
1758		assert_eq!(
1759			classify_tool_response(Some(r#"{"no":"choices"}"#)),
1760			Some(FailureClass::InvalidChatCompletionShape)
1761		);
1762	}
1763
1764	#[test]
1765	fn structured_output_acceptance_is_exact() {
1766		let valid = format!(
1767			r#"{{"choices":[{{"message":{{"content":"{{\"{STRUCTURED_SENTINEL_FIELD}\":\"{STRUCTURED_SENTINEL_VALUE}\"}}"}},"finish_reason":"stop"}}]}}"#
1768		);
1769		assert_eq!(classify_structured_output(Some(&valid)), None, "exact sentinel object passes");
1770
1771		// Invalid: content is not JSON at all.
1772		let invalid_json = r#"{"choices":[{"message":{"content":"not json"},"finish_reason":"stop"}]}"#;
1773		assert_eq!(classify_structured_output(Some(invalid_json)), Some(FailureClass::InvalidJson));
1774
1775		// Invalid: content is valid JSON but not an object.
1776		let not_object = r#"{"choices":[{"message":{"content":"[1,2,3]"},"finish_reason":"stop"}]}"#;
1777		assert_eq!(
1778			classify_structured_output(Some(not_object)),
1779			Some(FailureClass::InvalidChatCompletionShape)
1780		);
1781
1782		// Invalid: object is missing the required field.
1783		let missing_field = r#"{"choices":[{"message":{"content":"{\"other\":true}"},"finish_reason":"stop"}]}"#;
1784		assert_eq!(classify_structured_output(Some(missing_field)), Some(FailureClass::InvalidJson));
1785
1786		// Invalid: required field present but wrong value.
1787		let wrong_value = r#"{"choices":[{"message":{"content":"{\"sentinel\":\"WRONG\"}"},"finish_reason":"stop"}]}"#;
1788		assert_eq!(classify_structured_output(Some(wrong_value)), Some(FailureClass::InvalidJson));
1789
1790		// Envelope-level failures reuse the completion taxonomy.
1791		assert_eq!(classify_structured_output(None), Some(FailureClass::EmptyCompletion));
1792		assert_eq!(classify_structured_output(Some("")), Some(FailureClass::EmptyCompletion));
1793		assert_eq!(classify_structured_output(Some("not json")), Some(FailureClass::InvalidJson));
1794		assert_eq!(
1795			classify_structured_output(Some(r#"{"no":"choices"}"#)),
1796			Some(FailureClass::InvalidChatCompletionShape)
1797		);
1798	}
1799
1800	#[test]
1801	fn parallel_tools_acceptance_is_exact_and_order_agnostic() {
1802		// Valid: two distinct calls, both expected names, both args valid JSON.
1803		let valid = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_sentinel","arguments":"{\"scope\":\"provider-conformance\"}"}},{"id":"call_2","type":"function","function":{"name":"get_project_marker","arguments":"{\"marker\":\"provider-conformance\"}"}}]}}]}"#;
1804		assert_eq!(classify_parallel_tools(Some(valid)), None, "two expected calls pass");
1805
1806		// Order-agnostic: reversed order still passes.
1807		let reversed = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_marker","arguments":"{\"marker\":\"provider-conformance\"}"}},{"id":"call_2","type":"function","function":{"name":"get_project_sentinel","arguments":"{\"scope\":\"provider-conformance\"}"}}]}}]}"#;
1808		assert_eq!(classify_parallel_tools(Some(reversed)), None, "order must not matter");
1809
1810		// Missing one call -> NoToolCall.
1811		let missing_one = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_sentinel","arguments":"{\"scope\":\"provider-conformance\"}"}}]}}]}"#;
1812		assert_eq!(classify_parallel_tools(Some(missing_one)), Some(FailureClass::NoToolCall));
1813		assert_eq!(classify_parallel_tools(None), Some(FailureClass::NoToolCall));
1814
1815		// Wrong name -> InvalidToolName.
1816		let wrong_name = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_sentinel","arguments":"{}"}},{"id":"call_2","type":"function","function":{"name":"other_tool","arguments":"{}"}}]}}]}"#;
1817		assert_eq!(classify_parallel_tools(Some(wrong_name)), Some(FailureClass::InvalidToolName));
1818
1819		// Same name twice -> DuplicateToolCall.
1820		let duplicate = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_sentinel","arguments":"{}"}},{"id":"call_2","type":"function","function":{"name":"get_project_sentinel","arguments":"{}"}}]}}]}"#;
1821		assert_eq!(classify_parallel_tools(Some(duplicate)), Some(FailureClass::DuplicateToolCall));
1822
1823		// Three calls -> DuplicateToolCall.
1824		let three_calls = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_sentinel","arguments":"{}"}},{"id":"call_2","type":"function","function":{"name":"get_project_marker","arguments":"{}"}},{"id":"call_3","type":"function","function":{"name":"get_project_marker","arguments":"{}"}}]}}]}"#;
1825		assert_eq!(
1826			classify_parallel_tools(Some(three_calls)),
1827			Some(FailureClass::DuplicateToolCall)
1828		);
1829
1830		// Invalid argument JSON -> InvalidToolArguments.
1831		let bad_args = r#"{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_project_sentinel","arguments":"not json"}},{"id":"call_2","type":"function","function":{"name":"get_project_marker","arguments":"{}"}}]}}]}"#;
1832		assert_eq!(
1833			classify_parallel_tools(Some(bad_args)),
1834			Some(FailureClass::InvalidToolArguments)
1835		);
1836
1837		// Envelope-level failures.
1838		assert_eq!(classify_parallel_tools(Some("not json")), Some(FailureClass::InvalidJson));
1839		assert_eq!(
1840			classify_parallel_tools(Some(r#"{"no":"choices"}"#)),
1841			Some(FailureClass::InvalidChatCompletionShape)
1842		);
1843	}
1844
1845	#[test]
1846	fn stream_acceptance_passes_a_clean_stream() {
1847		let events = vec![
1848			data_event(Some("CF_HERMES"), None),
1849			data_event(Some("_OK"), None),
1850			data_event(None, Some("stop")),
1851		];
1852		assert_eq!(classify_stream_failure(&events, Some(300), 1_500), None);
1853	}
1854
1855	#[test]
1856	fn stream_acceptance_rejects_protocol_and_terminal_errors() {
1857		let malformed = vec![
1858			StreamEvent::Malformed("garbage".to_string()),
1859			data_event(Some("x"), Some("stop")),
1860		];
1861		assert_eq!(
1862			classify_stream_failure(&malformed, Some(100), 500),
1863			Some(FailureClass::InvalidSseEvent)
1864		);
1865
1866		let no_terminal = vec![data_event(Some("x"), None)];
1867		assert_eq!(
1868			classify_stream_failure(&no_terminal, Some(100), 500),
1869			Some(FailureClass::MissingFinishReason)
1870		);
1871
1872		let no_delta = vec![data_event(None, Some("stop"))];
1873		assert_eq!(
1874			classify_stream_failure(&no_delta, Some(100), 500),
1875			Some(FailureClass::EmptyCompletion)
1876		);
1877
1878		let duplicate_terminal = vec![
1879			data_event(Some("x"), None),
1880			data_event(None, Some("stop")),
1881			data_event(None, Some("stop")),
1882		];
1883		assert_eq!(
1884			classify_stream_failure(&duplicate_terminal, Some(100), 500),
1885			None,
1886			"the SAME finish_reason re-emitted on an empty delta is DeepSeek's usage trailer - accepted"
1887		);
1888
1889		let conflicting_terminal = vec![
1890			data_event(Some("x"), None),
1891			data_event(None, Some("stop")),
1892			data_event(None, Some("length")),
1893		];
1894		assert_eq!(
1895			classify_stream_failure(&conflicting_terminal, Some(100), 500),
1896			Some(FailureClass::InvalidSseEvent),
1897			"a second terminal chunk with a DIFFERENT reason is a protocol violation"
1898		);
1899
1900		let content_after_terminal = vec![
1901			data_event(Some("x"), None),
1902			data_event(None, Some("stop")),
1903			data_event(Some("y"), None),
1904		];
1905		assert_eq!(
1906			classify_stream_failure(&content_after_terminal, Some(100), 500),
1907			Some(FailureClass::InvalidSseEvent),
1908			"content delivered after a terminal chunk is a protocol violation"
1909		);
1910
1911		let done_without_terminal = vec![data_event(Some("x"), None), StreamEvent::Done];
1912		assert_eq!(
1913			classify_stream_failure(&done_without_terminal, Some(100), 500),
1914			Some(FailureClass::MissingFinishReason)
1915		);
1916	}
1917
1918	#[test]
1919	fn stream_acceptance_ignores_usage_only_trailer_chunk() {
1920		// DeepSeek (and other OpenAI-compatible providers) emit a standard
1921		// trailing chunk with an EMPTY choices array carrying usage stats
1922		// after the terminal chunk, before the [DONE] sentinel. It is not a
1923		// protocol error (regression: live smoke run classified it as
1924		// InvalidSseEvent).
1925		let with_trailer = vec![
1926			data_event(Some("x"), None),
1927			data_event(None, Some("stop")),
1928			StreamEvent::Data(serde_json::json!({ "choices": [], "usage": { "total_tokens": 12 } })),
1929			StreamEvent::Done,
1930		];
1931		assert_eq!(
1932			classify_stream_failure(&with_trailer, Some(100), 500),
1933			None,
1934			"empty-choices usage trailer + [DONE] must pass acceptance"
1935		);
1936
1937		// A choices field that is present but NOT an array stays an error.
1938		let malformed_choices = vec![StreamEvent::Data(serde_json::json!({ "choices": "nope" }))];
1939		assert_eq!(
1940			classify_stream_failure(&malformed_choices, Some(100), 500),
1941			Some(FailureClass::InvalidSseEvent)
1942		);
1943	}
1944
1945	#[test]
1946	fn stream_acceptance_enforces_timing() {
1947		let happy = vec![data_event(Some("x"), None), data_event(None, Some("stop"))];
1948		// No valid event at all -> StreamIdleTimeout.
1949		assert_eq!(classify_stream_failure(&[], None, 1_000), Some(FailureClass::StreamIdleTimeout));
1950		// First event past 20 s -> StreamIdleTimeout.
1951		assert_eq!(
1952			classify_stream_failure(&happy, Some(STREAM_FIRST_EVENT_MAX_MS + 1), STREAM_FIRST_EVENT_MAX_MS + 1),
1953			Some(FailureClass::StreamIdleTimeout)
1954		);
1955		// Total duration at/over the configured timeout -> ReadTimeout.
1956		assert_eq!(
1957			classify_stream_failure(&happy, Some(100), STREAM_CHECK_TIMEOUT.as_millis() as u64),
1958			Some(FailureClass::ReadTimeout)
1959		);
1960	}
1961
1962	// ------------------------------------------------------------------
1963	// aggregation
1964	// ------------------------------------------------------------------
1965
1966	#[test]
1967	fn all_passing_checks_aggregate_to_passing() {
1968		let verification = aggregate_verification(MODEL_A, &passed(100), &passed(200), &passed(150));
1969		assert_eq!(verification.status, VerificationStatus::Passing);
1970		assert_eq!(verification.confidence, VerificationConfidence::SmokeTested);
1971		assert!(verification.agent_eligible);
1972		assert_eq!(verification.suite_version, CONFORMANCE_SUITE_VERSION);
1973		assert_eq!(verification.runner_version, env!("CARGO_PKG_VERSION"));
1974		assert_eq!(verification.total_runs, 3);
1975		assert_eq!(verification.successful_runs, 3);
1976		assert_eq!(verification.total_failures, 0);
1977		assert_eq!(verification.text_completion_success_rate, Some(1.0));
1978		assert_eq!(verification.stream_completion_success_rate, Some(1.0));
1979		assert_eq!(verification.single_tool_success_rate, Some(1.0));
1980		assert_eq!(verification.median_latency_ms, Some(150), "median of 100/200/150");
1981		assert_eq!(verification.last_failure, None);
1982	}
1983
1984	#[test]
1985	fn one_failure_is_degraded_with_evidence_and_counters() {
1986		let verification =
1987			aggregate_verification(MODEL_A, &failed(30_000, FailureClass::ReadTimeout), &passed(200), &passed(150));
1988		assert_eq!(verification.status, VerificationStatus::Degraded);
1989		assert_eq!(verification.successful_runs, 2);
1990		assert_eq!(verification.total_failures, 1);
1991		assert_eq!(verification.timeout_failures, 1);
1992		assert_eq!(verification.transport_failures, 0);
1993		assert_eq!(verification.text_completion_success_rate, Some(0.0));
1994		let evidence = verification.last_failure.expect("last_failure set");
1995		assert_eq!(evidence.failure_class, FailureClass::ReadTimeout);
1996		assert_eq!(evidence.model_id, MODEL_A);
1997	}
1998
1999	#[test]
2000	fn two_failures_are_failing() {
2001		let verification = aggregate_verification(
2002			MODEL_A,
2003			&failed(100, FailureClass::EmptyCompletion),
2004			&failed(200, FailureClass::MissingFinishReason),
2005			&passed(150),
2006		);
2007		assert_eq!(verification.status, VerificationStatus::Failing);
2008		assert_eq!(verification.successful_runs, 1);
2009		assert_eq!(verification.malformed_response_failures, 2);
2010	}
2011
2012	#[test]
2013	fn failure_counters_map_classes_to_buckets() {
2014		let verification = aggregate_verification(
2015			MODEL_A,
2016			&failed(100, FailureClass::ProviderServerError),
2017			&failed(200, FailureClass::ConnectionReset),
2018			&failed(150, FailureClass::InvalidToolArguments),
2019		);
2020		assert_eq!(verification.status, VerificationStatus::Failing);
2021		assert_eq!(verification.provider_5xx_failures, 1);
2022		assert_eq!(verification.transport_failures, 1);
2023		assert_eq!(verification.malformed_tool_call_failures, 1);
2024		assert_eq!(verification.timeout_failures, 0);
2025		assert_eq!(verification.malformed_response_failures, 0);
2026	}
2027
2028	#[test]
2029	fn tool_failures_count_as_malformed_tool_calls() {
2030		let verification =
2031			aggregate_verification(MODEL_A, &passed(100), &passed(200), &failed(150, FailureClass::NoToolCall));
2032		assert_eq!(verification.status, VerificationStatus::Degraded);
2033		assert_eq!(verification.malformed_tool_call_failures, 1);
2034		assert_eq!(verification.single_tool_success_rate, Some(0.0));
2035	}
2036
2037	// ------------------------------------------------------------------
2038	// health store
2039	// ------------------------------------------------------------------
2040
2041	#[test]
2042	fn health_store_roundtrip_and_atomic_write_leaves_no_tmp() {
2043		let dir = scratch_dir("roundtrip");
2044		let _ = std::fs::remove_dir_all(&dir);
2045		let mut store = HealthStore::new();
2046		store.upsert(verification_record(MODEL_A, VerificationStatus::Passing));
2047		save_health_store(&dir, &store).expect("save");
2048		let loaded = load_health_store(&dir).expect("load");
2049		// `ModelVerification` deliberately has no `PartialEq` (health.rs), so
2050		// compare through serialization.
2051		assert_eq!(
2052			serde_json::to_value(&loaded.records).expect("records serialize"),
2053			serde_json::to_value(&store.records).expect("records serialize")
2054		);
2055		assert_eq!(loaded.version, HEALTH_STORE_VERSION);
2056		// The atomic write leaves no stray temp file behind.
2057		assert!(!dir.join("model-health.tmp").exists(), "no .tmp may survive a save");
2058		assert!(dir.join(HEALTH_STORE_FILE).exists());
2059		let _ = std::fs::remove_dir_all(&dir);
2060	}
2061
2062	#[test]
2063	fn missing_health_store_loads_empty() {
2064		let dir = scratch_dir("missing");
2065		let _ = std::fs::remove_dir_all(&dir);
2066		let store = load_health_store(&dir).expect("missing store is an empty store");
2067		assert_eq!(store.version, HEALTH_STORE_VERSION);
2068		assert!(store.records.is_empty());
2069		let _ = std::fs::remove_dir_all(&dir);
2070	}
2071
2072	#[test]
2073	fn save_verification_preserves_other_models() {
2074		let dir = scratch_dir("read-modify-write");
2075		let _ = std::fs::remove_dir_all(&dir);
2076		save_verification(&dir, &verification_record(MODEL_A, VerificationStatus::Passing)).expect("seed A");
2077		save_verification(&dir, &verification_record(MODEL_B, VerificationStatus::Failing)).expect("upsert B");
2078		let store = load_health_store(&dir).expect("load");
2079		assert!(store.get(MODEL_A).is_some(), "model A's record must survive model B's upsert");
2080		assert_eq!(
2081			store.get(MODEL_B).map(|record| record.status),
2082			Some(VerificationStatus::Failing)
2083		);
2084		assert_eq!(store.records.len(), 2);
2085		let _ = std::fs::remove_dir_all(&dir);
2086	}
2087
2088	#[test]
2089	fn corrupt_health_store_errors_without_panic() {
2090		let dir = scratch_dir("corrupt");
2091		let _ = std::fs::remove_dir_all(&dir);
2092		std::fs::create_dir_all(&dir).expect("create scratch dir");
2093		std::fs::write(dir.join(HEALTH_STORE_FILE), b"not json at all{").expect("write corrupt store");
2094		assert!(load_health_store(&dir).is_err(), "corrupt JSON must be an error, not a panic");
2095		// Unsupported schema version is also a typed error.
2096		std::fs::write(
2097			dir.join(HEALTH_STORE_FILE),
2098			br#"{"version":99,"updated_at":"2026-09-09T00:00:00Z","records":{}}"#,
2099		)
2100		.expect("write v99 store");
2101		assert!(load_health_store(&dir).is_err(), "unknown schema version must be an error");
2102		let _ = std::fs::remove_dir_all(&dir);
2103	}
2104
2105	#[test]
2106	fn upsert_bumps_updated_at() {
2107		let mut store = HealthStore::new();
2108		let first = store.updated_at;
2109		store.upsert(verification_record(MODEL_A, VerificationStatus::Passing));
2110		assert!(store.updated_at >= first, "updated_at must advance on upsert");
2111		assert_eq!(store.get(MODEL_A).map(|record| record.model_id.as_str()), Some(MODEL_A));
2112	}
2113
2114	// ------------------------------------------------------------------
2115	// budget env
2116	// ------------------------------------------------------------------
2117
2118	#[test]
2119	fn budget_env_is_optional_and_only_reported() {
2120		with_env(&[(MAX_COST_ENV, None)], || {
2121			assert_eq!(max_cost_usd_env(), None);
2122		});
2123		with_env(&[(MAX_COST_ENV, Some("0.05"))], || {
2124			assert_eq!(max_cost_usd_env(), Some(0.05));
2125		});
2126		with_env(&[(MAX_COST_ENV, Some("garbage"))], || {
2127			assert_eq!(max_cost_usd_env(), None, "unparsable budget is ignored");
2128		});
2129		with_env(&[(MAX_COST_ENV, Some("0"))], || {
2130			assert_eq!(max_cost_usd_env(), None, "a zero budget is treated as absent");
2131		});
2132		// The estimate is reported only when the budget env is present.
2133		with_env(&[(MAX_COST_ENV, Some("0.05")), (LIVE_TESTS_ENV, Some("1"))], || {
2134			assert_eq!(max_cost_usd_env(), Some(0.05));
2135		});
2136	}
2137}