- 1
use crate::types::AssistantMessage; - 2
- 3
#[derive(Debug, Clone, thiserror::Error)] - 4
pub enum LlmError { - 5
#[error("authentication failed: {0}")] - 6
Auth(String), - 7
#[error("rate limited: {message}")] - 8
RateLimit { - 9
message: String, - 10
retry_after_secs: Option<u64>, - 11
}, - 12
#[error("provider overloaded: {0}")] - 13
Overloaded(String), - 14
#[error("invalid request: {0}")] - 15
InvalidRequest(String), - 16
#[error("api error (status {status}): {message}")] - 17
Api { status: u16, message: String }, - 18
#[error("network error: {0}")] - 19
Network(String), - 20
#[error("response parse error: {0}")] - 21
Parse(String), - 22
#[error("context budget exceeded: {0}")] - 23
Context(String), - 24
#[error("aborted before completion")] - 25
// Boxed: `AssistantMessage` grew past the threshold where every - 26
// `Result<_, LlmError>` in the crate trips `clippy::result_large_err` - 27
// (adding `Usage::prefill_ms`/`load_ms` and `AssistantMessage::response_id` - 28
// pushed it from ~104 to ~160 bytes). - 29
Aborted { - 30
partial: Option<Box<AssistantMessage>>, - 31
}, - 32
} - 33
- 34
/// Provider phrasing, across every adapter, that means "the prompt does not - 35
/// fit the model's context window" rather than some other malformed - 36
/// request. Keyed off the provider's own wording rather than a status code - 37
/// alone, because 400 also covers unrelated validation failures. - 38
const OVER_LENGTH_MARKERS: [&str; 4] = [ - 39
"exceeds the model's maximum context length", // Ollama - 40
"context_length_exceeded", // OpenAI - 41
"maximum context length", // OpenAI - 42
"prompt is too long", // Anthropic - 43
]; - 44
- 45
impl LlmError { - 46
/// Classify a 400-class rejection: over-length phrasing becomes - 47
/// `Context` (recoverable by re-planning the working set and retrying), - 48
/// everything else stays `InvalidRequest` (a permanent per-request - 49
/// failure). Every adapter's `map_status_error` routes its 400 branch - 50
/// through this so the distinction is made once, not per provider. - 51
pub fn classify_400(message: String) -> Self { - 52
let normalized = message.to_ascii_lowercase(); - 53
if OVER_LENGTH_MARKERS - 54
.iter() - 55
.any(|marker| normalized.contains(marker)) - 56
{ - 57
LlmError::Context(message) - 58
} else { - 59
LlmError::InvalidRequest(message) - 60
} - 61
} - 62
- 63
/// Preserve the provider's rejection while adding endpoint-level guidance - 64
/// when it explicitly identifies an unsupported tools/reasoning pairing. - 65
/// - 66
/// This deliberately keys off the provider's response rather than a model - 67
/// name table: model capabilities change independently of this binary. - 68
pub fn invalid_request_for_endpoint(endpoint: &str, message: impl Into<String>) -> Self { - 69
let message = message.into(); - 70
let normalized = message.to_ascii_lowercase(); - 71
let tool_reasoning_conflict = normalized.contains("reasoning_effort") - 72
&& (normalized.contains("function tool") || normalized.contains("function calling")) - 73
&& normalized.contains("response"); - 74
if endpoint == "/v1/chat/completions" && tool_reasoning_conflict { - 75
return Self::InvalidRequest(format!( - 76
"{message}\n\nThe selected route uses {endpoint}, which the provider reports cannot combine this model's current reasoning effort with function tools. Use a Responses-capable route for this model (vak's native OpenAI route is `openai-responses`), or select a Chat Completions-compatible model/configuration with reasoning effort disabled. This is an endpoint capability mismatch, not a transient failure." - 77
)); - 78
} - 79
Self::InvalidRequest(message) - 80
} - 81
- 82
pub fn is_retryable(&self) -> bool { - 83
matches!( - 84
self, - 85
LlmError::RateLimit { .. } | LlmError::Overloaded(_) | LlmError::Network(_) - 86
) && !self.is_terminal_quota() - 87
} - 88
- 89
/// Some gateways use HTTP 429 for a quota or billing ceiling that will - 90
/// not recover during this run. Retrying those errors burns a dispatch - 91
/// budget and delays a usable fallback without changing the outcome. - 92
pub fn is_terminal_quota(&self) -> bool { - 93
let LlmError::RateLimit { message, .. } = self else { - 94
return false; - 95
}; - 96
let message = message.to_ascii_lowercase(); - 97
[ - 98
"free-models-per-day", - 99
"daily quota", - 100
"monthly quota", - 101
"quota exceeded", - 102
"spend limit", - 103
"credit limit", - 104
"insufficient credits", - 105
"billing limit", - 106
] - 107
.iter() - 108
.any(|marker| message.contains(marker)) - 109
} - 110
- 111
/// Server-advised wait for RateLimit; None otherwise. - 112
pub fn retry_after_secs(&self) -> Option<u64> { - 113
match self { - 114
LlmError::RateLimit { - 115
retry_after_secs, .. - 116
} => *retry_after_secs, - 117
_ => None, - 118
} - 119
} - 120
} - 121
- 122
#[cfg(test)] - 123
mod tests { - 124
use super::LlmError; - 125
- 126
#[test] - 127
fn terminal_quota_429_is_not_retried() { - 128
let error = LlmError::RateLimit { - 129
message: "Rate limit exceeded: free-models-per-day".into(), - 130
retry_after_secs: None, - 131
}; - 132
assert!(error.is_terminal_quota()); - 133
assert!(!error.is_retryable()); - 134
} - 135
- 136
#[test] - 137
fn ordinary_rate_limit_remains_retryable() { - 138
let error = LlmError::RateLimit { - 139
message: "too many requests".into(), - 140
retry_after_secs: Some(2), - 141
}; - 142
assert!(!error.is_terminal_quota()); - 143
assert!(error.is_retryable()); - 144
} - 145
- 146
#[test] - 147
fn chat_completions_tool_reasoning_rejection_explains_the_route_fix() { - 148
let error = LlmError::invalid_request_for_endpoint( - 149
"/v1/chat/completions", - 150
"Function tools with reasoning_effort are not supported for a model in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.", - 151
); - 152
let rendered = error.to_string(); - 153
assert!(rendered.contains("openai-responses")); - 154
assert!(rendered.contains("endpoint capability mismatch")); - 155
} - 156
- 157
#[test] - 158
fn unrelated_invalid_request_is_preserved_verbatim() { - 159
let error = - 160
LlmError::invalid_request_for_endpoint("/v1/chat/completions", "model does not exist"); - 161
assert_eq!(error.to_string(), "invalid request: model does not exist"); - 162
} - 163
- 164
#[test] - 165
fn classify_400_detects_ollama_over_length_phrasing() { - 166
let error = LlmError::classify_400( - 167
"request exceeds the model's maximum context length (8192)".into(), - 168
); - 169
assert!(matches!(error, LlmError::Context(_))); - 170
} - 171
- 172
#[test] - 173
fn classify_400_detects_openai_context_length_exceeded_code() { - 174
let error = LlmError::classify_400( - 175
"This model's maximum context length is 8192 tokens. (context_length_exceeded)".into(), - 176
); - 177
assert!(matches!(error, LlmError::Context(_))); - 178
} - 179
- 180
#[test] - 181
fn classify_400_detects_openai_maximum_context_length_phrase() { - 182
let error = LlmError::classify_400( - 183
"your messages resulted in maximum context length exceeded".into(), - 184
); - 185
assert!(matches!(error, LlmError::Context(_))); - 186
} - 187
- 188
#[test] - 189
fn classify_400_detects_anthropic_prompt_too_long_phrase() { - 190
let error = - 191
LlmError::classify_400("prompt is too long: 220000 tokens > 200000 maximum".into()); - 192
assert!(matches!(error, LlmError::Context(_))); - 193
} - 194
- 195
#[test] - 196
fn classify_400_leaves_unrelated_rejections_as_invalid_request() { - 197
let error = LlmError::classify_400("model does not exist".into()); - 198
assert!(matches!(error, LlmError::InvalidRequest(_))); - 199
} - 200
} - 201
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.