1use 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
79pub const LIVE_TESTS_ENV: &str = "AUTH_CLOUDFLARE_LIVE_TESTS";
82
83pub const MAX_COST_ENV: &str = "AUTH_CLOUDFLARE_MAX_COST_USD";
87
88pub const HEALTH_STORE_FILE: &str = "model-health.json";
90
91pub const HEALTH_STORE_VERSION: u32 = 1;
94
95pub const TEXT_PROMPT: &str = "Reply with exactly: CF_HERMES_OK";
98
99pub const EXACT_TEXT: &str = "CF_HERMES_OK";
101
102pub const TOOL_PROMPT: &str = "Call get_project_sentinel exactly once with scope=provider-conformance. Do not answer with prose before calling the tool.";
104
105pub const TOOL_NAME: &str = "get_project_sentinel";
107
108pub const TOOL_SCOPE: &str = "provider-conformance";
110
111pub 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
115pub const PARALLEL_TOOL_NAME: &str = "get_project_marker";
117
118pub const PARALLEL_TOOL_VALUE: &str = "provider-conformance";
120
121pub 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
126pub const STRUCTURED_SENTINEL_FIELD: &str = "sentinel";
128
129pub const STRUCTURED_SENTINEL_VALUE: &str = "CF_HERMES_OK";
131
132pub const TEXT_COMPLETION_MAX_ELAPSED_MS: u64 = 30_000;
134
135pub const STREAM_FIRST_EVENT_MAX_MS: u64 = 20_000;
137
138pub const TEXT_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
141
142pub const STREAM_CHECK_TIMEOUT: Duration = Duration::from_secs(120);
145
146pub const STREAM_FIRST_EVENT_TIMEOUT: Duration = Duration::from_secs(20);
149
150pub const TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
152
153pub const STRUCTURED_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
155
156pub const PARALLEL_TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
158
159pub const SMOKE_SUITE_ESTIMATED_COST_USD: f64 = 0.001;
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum SuiteKind {
169 Smoke,
172 ToolLoop,
176}
177
178impl SuiteKind {
179 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 pub fn as_str(self) -> &'static str {
190 match self {
191 Self::Smoke => "smoke",
192 Self::ToolLoop => "tool-loop",
193 }
194 }
195}
196
197#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
234pub enum StreamEvent {
235 Data(serde_json::Value),
236 Done,
237 Malformed(String),
238}
239
240pub fn live_tests_enabled() -> bool {
243 std::env::var(LIVE_TESTS_ENV).is_ok_and(|value| value == "1")
244}
245
246pub 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
254pub 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
263pub 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
295pub 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 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
364pub 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 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 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
464pub 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
520fn 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
544fn 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
564pub 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
620pub 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
676fn 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
717fn 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
741fn gate_check() -> Result<(), CloudflareError> {
744 if live_tests_enabled() { Ok(()) } else { Err(gate_refusal()) }
745}
746
747fn 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
755fn 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
780pub 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
811fn 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
829fn 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
835pub 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 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
856pub 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
892pub 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
917pub 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
973pub 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
1016pub 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
1081pub fn classify_stream_failure(
1092 events: &[StreamEvent],
1093 first_event_elapsed_ms: Option<u64>,
1094 total_elapsed_ms: u64,
1095) -> Option<FailureClass> {
1096 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 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 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 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
1171pub 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
1237fn rate_of(passed: bool) -> f64 {
1239 if passed { 1.0 } else { 0.0 }
1240}
1241
1242pub 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
1287fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
1324#[serde(rename_all = "snake_case")]
1325pub struct HealthStore {
1326 pub version: u32,
1328 pub updated_at: DateTime<Utc>,
1330 pub records: BTreeMap<String, ModelVerification>,
1332}
1333
1334impl HealthStore {
1335 pub fn new() -> Self {
1337 Self { version: HEALTH_STORE_VERSION, updated_at: Utc::now(), records: BTreeMap::new() }
1338 }
1339
1340 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 pub fn get(&self, model_id: &str) -> Option<&ModelVerification> {
1349 self.records.get(model_id)
1350 }
1351}
1352
1353impl Default for HealthStore {
1354 fn default() -> Self {
1356 Self::new()
1357 }
1358}
1359
1360pub 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
1382pub 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
1392pub 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
1400fn 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 const ACCOUNT: &str = "0123456789abcdef0123456789abcdef";
1421 const TOKEN: &str = "cfut_test_synthetic_token_0001";
1423 const MODEL_A: &str = "@cf/deepseek-ai/deepseek-v4-flash-0731";
1425 const MODEL_B: &str = "@cf/zai-org/glm-5.3-flash";
1427
1428 const ALL_VARS: &[&str] = &[LIVE_TESTS_ENV, MAX_COST_ENV];
1430
1431 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 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 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 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 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 #[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 #[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 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 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 assert_eq!(
1639 classify_http_failure(200, Some(r#"{"success":false,"errors":[]}"#), true),
1640 FailureClass::CloudflareEdgeError
1641 );
1642 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(classify_stream_failure(&[], None, 1_000), Some(FailureClass::StreamIdleTimeout));
1944 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 assert_eq!(
1951 classify_stream_failure(&happy, Some(100), STREAM_CHECK_TIMEOUT.as_millis() as u64),
1952 Some(FailureClass::ReadTimeout)
1953 );
1954 }
1955
1956 #[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 #[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 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 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 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 #[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 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}