1use 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
80pub const LIVE_TESTS_ENV: &str = "AUTH_CLOUDFLARE_LIVE_TESTS";
83
84pub const MAX_COST_ENV: &str = "AUTH_CLOUDFLARE_MAX_COST_USD";
88
89pub const HEALTH_STORE_FILE: &str = "model-health.json";
91
92pub const HEALTH_STORE_VERSION: u32 = 1;
95
96pub const TEXT_PROMPT: &str = "Reply with exactly: CF_HERMES_OK";
99
100pub const EXACT_TEXT: &str = "CF_HERMES_OK";
102
103pub const TOOL_PROMPT: &str = "Call get_project_sentinel exactly once with scope=provider-conformance. Do not answer with prose before calling the tool.";
105
106pub const TOOL_NAME: &str = "get_project_sentinel";
108
109pub const TOOL_SCOPE: &str = "provider-conformance";
111
112pub 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
116pub const PARALLEL_TOOL_NAME: &str = "get_project_marker";
118
119pub const PARALLEL_TOOL_VALUE: &str = "provider-conformance";
121
122pub 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
127pub const STRUCTURED_SENTINEL_FIELD: &str = "sentinel";
129
130pub const STRUCTURED_SENTINEL_VALUE: &str = "CF_HERMES_OK";
132
133pub const TEXT_COMPLETION_MAX_ELAPSED_MS: u64 = 30_000;
135
136pub const STREAM_FIRST_EVENT_MAX_MS: u64 = 20_000;
138
139pub const TEXT_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
142
143pub const STREAM_CHECK_TIMEOUT: Duration = Duration::from_secs(120);
146
147pub const STREAM_FIRST_EVENT_TIMEOUT: Duration = Duration::from_secs(20);
150
151pub const TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
153
154pub const STRUCTURED_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
156
157pub const PARALLEL_TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
159
160pub const SMOKE_SUITE_ESTIMATED_COST_USD: f64 = 0.001;
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum SuiteKind {
170 Smoke,
173 ToolLoop,
177}
178
179impl SuiteKind {
180 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 pub fn as_str(self) -> &'static str {
191 match self {
192 Self::Smoke => "smoke",
193 Self::ToolLoop => "tool-loop",
194 }
195 }
196}
197
198#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
235pub enum StreamEvent {
236 Data(serde_json::Value),
237 Done,
238 Malformed(String),
239}
240
241pub fn live_tests_enabled() -> bool {
244 std::env::var(LIVE_TESTS_ENV).is_ok_and(|value| value == "1")
245}
246
247pub 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
255pub 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
264pub 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
296pub 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 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
365pub 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 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 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
465pub 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
522fn 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
546fn 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
566pub 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
623pub 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
680fn 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
721fn 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
745fn gate_check() -> Result<(), CloudflareError> {
748 if live_tests_enabled() { Ok(()) } else { Err(gate_refusal()) }
749}
750
751fn 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
759fn 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
784pub 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
815fn 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
833fn 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
839pub 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 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
860pub 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
896pub 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
921pub 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
977pub 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
1021pub 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
1087pub fn classify_stream_failure(
1098 events: &[StreamEvent],
1099 first_event_elapsed_ms: Option<u64>,
1100 total_elapsed_ms: u64,
1101) -> Option<FailureClass> {
1102 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 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 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 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
1177pub 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
1243fn rate_of(passed: bool) -> f64 {
1245 if passed { 1.0 } else { 0.0 }
1246}
1247
1248pub 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
1293fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
1330#[serde(rename_all = "snake_case")]
1331pub struct HealthStore {
1332 pub version: u32,
1334 pub updated_at: DateTime<Utc>,
1336 pub records: BTreeMap<String, ModelVerification>,
1338}
1339
1340impl HealthStore {
1341 pub fn new() -> Self {
1343 Self { version: HEALTH_STORE_VERSION, updated_at: Utc::now(), records: BTreeMap::new() }
1344 }
1345
1346 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 pub fn get(&self, model_id: &str) -> Option<&ModelVerification> {
1355 self.records.get(model_id)
1356 }
1357}
1358
1359impl Default for HealthStore {
1360 fn default() -> Self {
1362 Self::new()
1363 }
1364}
1365
1366pub 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
1388pub 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
1398pub 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
1406fn 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 const ACCOUNT: &str = "0123456789abcdef0123456789abcdef";
1427 const TOKEN: &str = "cfut_test_synthetic_token_0001";
1429 const MODEL_A: &str = "@cf/deepseek-ai/deepseek-v4-flash-0731";
1431 const MODEL_B: &str = "@cf/zai-org/glm-5.3-flash";
1433
1434 const ALL_VARS: &[&str] = &[LIVE_TESTS_ENV, MAX_COST_ENV];
1436
1437 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 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 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 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 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 #[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 #[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 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 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 assert_eq!(
1645 classify_http_failure(200, Some(r#"{"success":false,"errors":[]}"#), true),
1646 FailureClass::CloudflareEdgeError
1647 );
1648 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(classify_stream_failure(&[], None, 1_000), Some(FailureClass::StreamIdleTimeout));
1950 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 assert_eq!(
1957 classify_stream_failure(&happy, Some(100), STREAM_CHECK_TIMEOUT.as_millis() as u64),
1958 Some(FailureClass::ReadTimeout)
1959 );
1960 }
1961
1962 #[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 #[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 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 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 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 #[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 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}