use super::{ModelError, ModelId};
pub fn classify_status(
model: &ModelId,
status: u16,
headers: &reqwest::header::HeaderMap,
body: &str,
) -> ModelError {
let detail = format!("HTTP {status}: {}", trim(body));
match status {
429 | 529 => ModelError::RateLimited {
model: model.clone(),
detail,
retry_after: retry_after(headers),
},
408 | 425 => ModelError::Unavailable {
model: model.clone(),
detail,
},
400..=499 => ModelError::Refused {
model: model.clone(),
detail,
},
_ => ModelError::Unavailable {
model: model.clone(),
detail,
},
}
}
fn retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
crate::core::retry_after_seconds(headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?)
}
pub fn classify_transport(model: &ModelId, e: &reqwest::Error) -> ModelError {
if e.is_connect() {
return ModelError::Unreachable {
model: model.clone(),
detail: format!("could not connect: {e}"),
};
}
ModelError::Unavailable {
model: model.clone(),
detail: e.to_string(),
}
}
pub fn structured(
schema: Option<&serde_json::Value>,
text: &str,
model: &ModelId,
usage: super::Usage,
) -> Result<Option<serde_json::Value>, ModelError> {
let Some(schema) = schema else {
return Ok(None);
};
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| ModelError::Unusable {
model: model.clone(),
usage,
detail: format!("a schema was required and the answer is not JSON: {e}"),
})?;
super::validate_schema(schema, &value).map_err(|detail| ModelError::Unusable {
model: model.clone(),
usage,
detail,
})?;
Ok(Some(value))
}
pub const RESPOND_TOOL: &str = "agentplane_respond";
pub fn strict_schema_problem(schema: &serde_json::Value) -> Option<String> {
fn walk(node: &serde_json::Value, path: &str, out: &mut Vec<String>) {
let Some(obj) = node.as_object() else { return };
if obj.contains_key("default") {
out.push(format!(
"`{path}` uses `default`, which strict mode rejects"
));
}
if obj.get("type").and_then(|t| t.as_str()) == Some("object") {
if obj.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) {
out.push(format!(
"`{path}` is an object without `additionalProperties: false`"
));
}
let properties = obj.get("properties").and_then(|p| p.as_object());
if let Some(properties) = properties {
let required: Vec<&str> = obj
.get("required")
.and_then(|r| r.as_array())
.map(|r| r.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
for key in properties.keys() {
if !required.contains(&key.as_str()) {
out.push(format!(
"`{path}.{key}` is optional; strict mode requires every \
property to be listed in `required`"
));
}
}
}
}
for (key, child) in obj {
match key.as_str() {
"properties" | "$defs" | "definitions" => {
if let Some(map) = child.as_object() {
for (name, sub) in map {
walk(sub, &format!("{path}.{name}"), out);
}
}
}
"items" | "not" => walk(child, &format!("{path}.{key}"), out),
"anyOf" | "oneOf" | "allOf" => {
if let Some(list) = child.as_array() {
for (i, sub) in list.iter().enumerate() {
walk(sub, &format!("{path}.{key}[{i}]"), out);
}
}
}
_ => {}
}
}
}
let mut problems = Vec::new();
walk(schema, "schema", &mut problems);
if problems.is_empty() {
return None;
}
Some(problems.join("; "))
}
fn trim(body: &str) -> String {
const LIMIT: usize = 400;
if body.len() <= LIMIT {
return body.to_owned();
}
let mut cut = LIMIT;
while cut > 0 && !body.is_char_boundary(cut) {
cut -= 1;
}
format!("{}… ({} bytes)", &body[..cut], body.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Disposition;
fn model() -> ModelId {
ModelId::new("test", "m")
}
fn no_headers() -> reqwest::header::HeaderMap {
reqwest::header::HeaderMap::new()
}
fn advising(value: &str) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::RETRY_AFTER,
reqwest::header::HeaderValue::from_str(value).expect("a header value"),
);
headers
}
#[test]
fn a_named_rate_limit_window_survives_classification() {
let e = classify_status(&model(), 429, &advising("42"), "");
assert!(
matches!(
e,
ModelError::RateLimited {
retry_after: Some(42),
..
}
),
"the provider named its window and the classification dropped it: {e}"
);
}
#[test]
fn an_unnamed_window_is_absent_rather_than_invented() {
for value in [
"",
" ",
"0",
"later",
"-5",
"Wed, 21 Oct 2026 07:28:00 GMT",
] {
let e = classify_status(&model(), 429, &advising(value), "");
assert!(
matches!(
e,
ModelError::RateLimited {
retry_after: None,
..
}
),
"'{value}' is not advice this crate can act on, and reading it as \
one would replace a real backoff with a made-up schedule: {e}"
);
}
assert!(matches!(
classify_status(&model(), 429, &no_headers(), ""),
ModelError::RateLimited {
retry_after: None,
..
}
));
}
#[test]
fn rate_limiting_is_told_apart_from_refusal() {
for s in [429u16, 529] {
assert!(matches!(
classify_status(&model(), s, &no_headers(), ""),
ModelError::RateLimited { .. }
));
}
}
#[test]
fn a_client_error_did_not_generate() {
for s in [400u16, 401, 403, 404, 422] {
let e = classify_status(&model(), s, &no_headers(), "");
assert_eq!(e.disposition(), Disposition::DidNotHappen);
assert_eq!(e.usage().spend().tokens, 0);
assert!(
matches!(e, ModelError::Refused { .. }),
"HTTP {s} is a judgement about the request, and repeating a \
judged request asks the same rule the same question"
);
}
}
#[test]
fn the_transient_4xx_are_not_judgements() {
for s in [408u16, 425] {
let e = classify_status(&model(), s, &no_headers(), "");
assert_eq!(e.disposition(), Disposition::DidNotHappen);
assert!(
matches!(e, ModelError::Unavailable { .. }),
"HTTP {s} is transient and must stay retryable, got: {e}"
);
}
}
#[test]
fn a_server_error_says_it_does_not_know() {
assert!(matches!(
classify_status(&model(), 500, &no_headers(), ""),
ModelError::Unavailable { .. }
));
}
#[test]
fn a_long_error_body_is_trimmed() {
let secret = "x".repeat(5_000);
let e = classify_status(&model(), 400, &no_headers(), &secret);
let rendered = e.to_string();
assert!(
rendered.len() < 600,
"an error body went into the log at full length ({} chars), and a \
provider echoes the prompt back in it",
rendered.len()
);
assert!(rendered.contains("5000 bytes"), "{rendered}");
}
#[test]
fn trimming_respects_character_boundaries() {
let body = "ü".repeat(1_000);
let _ = trim(&body);
}
}
#[cfg(test)]
mod schema_validation_tests {
use serde_json::json;
use super::*;
#[test]
fn parseable_but_nonconforming_structured_output_is_unusable() {
let model = ModelId::new("test", "structured");
let usage = super::super::Usage {
output_tokens: 5,
..Default::default()
};
let error = structured(
Some(&json!({
"type": "object",
"properties": {"id": {"type": "string", "minLength": 5}},
"required": ["id"]
})),
r#"{"id":"abc"}"#,
&model,
usage,
)
.expect_err("provider-constrained output still needs defense-in-depth validation");
assert!(matches!(error, ModelError::Unusable { usage: u, .. } if u == usage));
}
}