skilltest_core/error.rs
1//! Error type for the core library. The mapping from these errors to process
2//! exit codes lives in the CLI (see `exit.rs` for the documented codes).
3
4use std::path::PathBuf;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Result alias used throughout the core library.
10pub type Result<T> = std::result::Result<T, Error>;
11
12/// Structured classification of a provider failure.
13///
14/// This is the machine-readable category SDK/plugin consumers branch on instead
15/// of matching substrings in the human message (the reason this type exists: a
16/// timeout is [`ProviderErrorKind::Timeout`], not `message.contains("timed
17/// out")`). It is part of the JSON error contract — it serializes as a
18/// snake_case string and is re-exported through every SDK's generated models, so
19/// the vocabulary here is the vocabulary consumers see.
20///
21/// The set is closed on purpose so the generated SDK types are exhaustive
22/// unions; a failure skilltest cannot map to a specific category (e.g. a
23/// provider or oneharness failure label added upstream that this version does
24/// not recognize) becomes [`ProviderErrorKind::Other`] rather than a new,
25/// unhandled string.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
27#[serde(rename_all = "snake_case")]
28pub enum ProviderErrorKind {
29 /// Authentication/authorization failed (missing or rejected credentials).
30 Auth,
31 /// The provider rate-limited the call; a backoff-and-retry may succeed.
32 RateLimit,
33 /// The harness/vendor does not recognize the requested model.
34 ModelNotFound,
35 /// The account's quota or billing limit is exhausted.
36 Quota,
37 /// A transient server-side overload; retried internally where possible.
38 Overloaded,
39 /// The call exceeded its deadline (harness `--timeout`, curl `--max-time`).
40 Timeout,
41 /// The provider process could not be started (binary missing, not runnable).
42 Spawn,
43 /// The provider ran but produced output that violated the protocol
44 /// (unparseable envelope, missing results, malformed verdict).
45 Protocol,
46 /// A classified failure that does not map to a more specific category. The
47 /// human `message` carries the specifics.
48 Other,
49}
50
51impl ProviderErrorKind {
52 /// Map a raw failure label — an oneharness `failure_kind`, or a vendor error
53 /// class skilltest already normalized to one of these snake_case strings — to
54 /// a category. Unrecognized labels become [`ProviderErrorKind::Other`].
55 #[must_use]
56 pub fn classify(raw: &str) -> Self {
57 match raw {
58 "auth" => Self::Auth,
59 "rate_limit" => Self::RateLimit,
60 "model_not_found" => Self::ModelNotFound,
61 "quota" => Self::Quota,
62 "overloaded" => Self::Overloaded,
63 "timeout" => Self::Timeout,
64 "spawn" => Self::Spawn,
65 "protocol" => Self::Protocol,
66 _ => Self::Other,
67 }
68 }
69
70 /// The stable snake_case wire string for this kind (matches serde).
71 #[must_use]
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::Auth => "auth",
75 Self::RateLimit => "rate_limit",
76 Self::ModelNotFound => "model_not_found",
77 Self::Quota => "quota",
78 Self::Overloaded => "overloaded",
79 Self::Timeout => "timeout",
80 Self::Spawn => "spawn",
81 Self::Protocol => "protocol",
82 Self::Other => "other",
83 }
84 }
85}
86
87impl std::fmt::Display for ProviderErrorKind {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.write_str(self.as_str())
90 }
91}
92
93/// Everything that can go wrong while loading configuration, parsing skill or
94/// test-case definitions, talking to a provider, or running evals.
95///
96/// Variants are grouped so the CLI can map them onto stable exit codes: input
97/// problems (`Config`, `Yaml`, `Skill`, `Validation`) are the user's to fix;
98/// `Provider` problems are environmental.
99#[derive(Debug, thiserror::Error)]
100pub enum Error {
101 /// A file the user pointed us at could not be read.
102 #[error("could not read {path}: {source}")]
103 Io {
104 path: PathBuf,
105 #[source]
106 source: std::io::Error,
107 },
108
109 /// A YAML document (config or test case) failed to parse.
110 #[error("invalid YAML in {path}: {source}")]
111 Yaml {
112 path: PathBuf,
113 #[source]
114 source: serde_yaml::Error,
115 },
116
117 /// A test case or config was syntactically valid YAML but semantically
118 /// wrong (e.g. a numeric eval with `min > max`).
119 #[error("invalid test definition: {0}")]
120 Invalid(String),
121
122 /// The provider command could not be spawned or did not behave. `kind`, when
123 /// set, classifies the failure (see [`ProviderErrorKind`]) so consumers can
124 /// distinguish a broken environment from a broken skill without parsing
125 /// `message`.
126 #[error("provider error ({context}): {message}")]
127 Provider {
128 context: String,
129 message: String,
130 kind: Option<ProviderErrorKind>,
131 },
132
133 /// A skill definition failed validation. Carries the human-readable
134 /// findings so the CLI can print them.
135 #[error("skill validation failed with {} finding(s)", .0.len())]
136 Validation(Vec<String>),
137}
138
139impl Error {
140 /// Construct a [`Error::Provider`] with no classification.
141 pub fn provider(context: impl Into<String>, message: impl std::fmt::Display) -> Self {
142 Error::Provider {
143 context: context.into(),
144 message: message.to_string(),
145 kind: None,
146 }
147 }
148
149 /// Construct a classified [`Error::Provider`] (e.g.
150 /// `kind = ProviderErrorKind::Auth`).
151 pub fn provider_classified(
152 context: impl Into<String>,
153 message: impl std::fmt::Display,
154 kind: ProviderErrorKind,
155 ) -> Self {
156 Error::Provider {
157 context: context.into(),
158 message: message.to_string(),
159 kind: Some(kind),
160 }
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 /// Every classified kind's `as_str`, its serde string, its `Display`, and
169 /// `classify` (the inverse) must agree — this is the vocabulary the JSON
170 /// error contract exposes to SDK consumers, so the three views can't drift.
171 #[test]
172 fn provider_error_kind_string_forms_agree() {
173 let all = [
174 ProviderErrorKind::Auth,
175 ProviderErrorKind::RateLimit,
176 ProviderErrorKind::ModelNotFound,
177 ProviderErrorKind::Quota,
178 ProviderErrorKind::Overloaded,
179 ProviderErrorKind::Timeout,
180 ProviderErrorKind::Spawn,
181 ProviderErrorKind::Protocol,
182 ProviderErrorKind::Other,
183 ];
184 for kind in all {
185 let wire = kind.as_str();
186 // serde serializes to the same bare string.
187 assert_eq!(serde_json::to_value(kind).unwrap(), serde_json::json!(wire));
188 // Display matches as_str.
189 assert_eq!(kind.to_string(), wire);
190 // classify is the inverse for known labels.
191 assert_eq!(ProviderErrorKind::classify(wire), kind);
192 }
193 }
194
195 #[test]
196 fn classify_maps_unknown_labels_to_other() {
197 assert_eq!(
198 ProviderErrorKind::classify("brand_new_upstream_kind"),
199 ProviderErrorKind::Other
200 );
201 }
202}