Skip to main content

auth_cloudflare/
verify.rs

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