liter_llm 1.0.0.pre.rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +239 -0
- data/ext/liter_llm_rb/extconf.rb +65 -0
- data/ext/liter_llm_rb/native/.cargo/config.toml +23 -0
- data/ext/liter_llm_rb/native/Cargo.lock +3713 -0
- data/ext/liter_llm_rb/native/Cargo.toml +32 -0
- data/ext/liter_llm_rb/native/build.rs +15 -0
- data/ext/liter_llm_rb/native/src/lib.rs +1079 -0
- data/lib/liter_llm.rb +8 -0
- data/sig/liter_llm.rbs +416 -0
- data/vendor/Cargo.toml +54 -0
- data/vendor/liter-llm/Cargo.toml +92 -0
- data/vendor/liter-llm/README.md +252 -0
- data/vendor/liter-llm/schemas/pricing.json +40 -0
- data/vendor/liter-llm/schemas/providers.json +1662 -0
- data/vendor/liter-llm/src/auth/azure_ad.rs +264 -0
- data/vendor/liter-llm/src/auth/bedrock_sts.rs +353 -0
- data/vendor/liter-llm/src/auth/mod.rs +68 -0
- data/vendor/liter-llm/src/auth/vertex_oauth.rs +353 -0
- data/vendor/liter-llm/src/client/config.rs +351 -0
- data/vendor/liter-llm/src/client/managed.rs +622 -0
- data/vendor/liter-llm/src/client/mod.rs +864 -0
- data/vendor/liter-llm/src/cost.rs +212 -0
- data/vendor/liter-llm/src/error.rs +190 -0
- data/vendor/liter-llm/src/http/eventstream.rs +860 -0
- data/vendor/liter-llm/src/http/mod.rs +12 -0
- data/vendor/liter-llm/src/http/request.rs +438 -0
- data/vendor/liter-llm/src/http/retry.rs +72 -0
- data/vendor/liter-llm/src/http/streaming.rs +289 -0
- data/vendor/liter-llm/src/lib.rs +37 -0
- data/vendor/liter-llm/src/provider/anthropic.rs +2250 -0
- data/vendor/liter-llm/src/provider/azure.rs +579 -0
- data/vendor/liter-llm/src/provider/bedrock.rs +1543 -0
- data/vendor/liter-llm/src/provider/cohere.rs +654 -0
- data/vendor/liter-llm/src/provider/custom.rs +404 -0
- data/vendor/liter-llm/src/provider/google_ai.rs +281 -0
- data/vendor/liter-llm/src/provider/mistral.rs +188 -0
- data/vendor/liter-llm/src/provider/mod.rs +616 -0
- data/vendor/liter-llm/src/provider/vertex.rs +1504 -0
- data/vendor/liter-llm/src/tests.rs +1425 -0
- data/vendor/liter-llm/src/tokenizer.rs +281 -0
- data/vendor/liter-llm/src/tower/budget.rs +599 -0
- data/vendor/liter-llm/src/tower/cache.rs +502 -0
- data/vendor/liter-llm/src/tower/cache_opendal.rs +270 -0
- data/vendor/liter-llm/src/tower/cooldown.rs +231 -0
- data/vendor/liter-llm/src/tower/cost.rs +404 -0
- data/vendor/liter-llm/src/tower/fallback.rs +121 -0
- data/vendor/liter-llm/src/tower/health.rs +219 -0
- data/vendor/liter-llm/src/tower/hooks.rs +369 -0
- data/vendor/liter-llm/src/tower/mod.rs +77 -0
- data/vendor/liter-llm/src/tower/rate_limit.rs +300 -0
- data/vendor/liter-llm/src/tower/router.rs +436 -0
- data/vendor/liter-llm/src/tower/service.rs +181 -0
- data/vendor/liter-llm/src/tower/tests.rs +539 -0
- data/vendor/liter-llm/src/tower/tests_common.rs +252 -0
- data/vendor/liter-llm/src/tower/tracing.rs +209 -0
- data/vendor/liter-llm/src/tower/types.rs +170 -0
- data/vendor/liter-llm/src/types/audio.rs +52 -0
- data/vendor/liter-llm/src/types/batch.rs +77 -0
- data/vendor/liter-llm/src/types/chat.rs +214 -0
- data/vendor/liter-llm/src/types/common.rs +244 -0
- data/vendor/liter-llm/src/types/embedding.rs +84 -0
- data/vendor/liter-llm/src/types/files.rs +58 -0
- data/vendor/liter-llm/src/types/image.rs +40 -0
- data/vendor/liter-llm/src/types/mod.rs +27 -0
- data/vendor/liter-llm/src/types/models.rs +21 -0
- data/vendor/liter-llm/src/types/moderation.rs +80 -0
- data/vendor/liter-llm/src/types/ocr.rs +87 -0
- data/vendor/liter-llm/src/types/rerank.rs +46 -0
- data/vendor/liter-llm/src/types/responses.rs +55 -0
- data/vendor/liter-llm/src/types/search.rs +45 -0
- data/vendor/liter-llm/tests/contract.rs +332 -0
- data/vendor/liter-llm-ffi/Cargo.toml +30 -0
- data/vendor/liter-llm-ffi/build.rs +66 -0
- data/vendor/liter-llm-ffi/cbindgen.toml +60 -0
- data/vendor/liter-llm-ffi/liter_llm.h +850 -0
- data/vendor/liter-llm-ffi/src/lib.rs +2488 -0
- metadata +286 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
//! Cost estimation for LLM API calls.
|
|
2
|
+
//!
|
|
3
|
+
//! Pricing data is embedded at compile time from `schemas/pricing.json` and
|
|
4
|
+
//! covers the most commonly used models across major providers. Prices are
|
|
5
|
+
//! approximate and derived from the [litellm](https://github.com/BerriAI/litellm)
|
|
6
|
+
//! project (MIT License, Copyright 2023 Berri AI).
|
|
7
|
+
//!
|
|
8
|
+
//! # Example
|
|
9
|
+
//!
|
|
10
|
+
//! ```rust
|
|
11
|
+
//! use liter_llm::cost;
|
|
12
|
+
//!
|
|
13
|
+
//! // Returns None for unknown models.
|
|
14
|
+
//! assert!(cost::completion_cost("unknown-model", 100, 50).is_none());
|
|
15
|
+
//!
|
|
16
|
+
//! // Returns Some(cost_in_usd) for known models.
|
|
17
|
+
//! let cost = cost::completion_cost("gpt-4o", 1000, 500).unwrap();
|
|
18
|
+
//! assert!(cost > 0.0);
|
|
19
|
+
//! ```
|
|
20
|
+
|
|
21
|
+
use std::collections::HashMap;
|
|
22
|
+
use std::sync::LazyLock;
|
|
23
|
+
|
|
24
|
+
use serde::Deserialize;
|
|
25
|
+
|
|
26
|
+
// Embedded at compile time so the binary is self-contained with no runtime
|
|
27
|
+
// file-system dependency.
|
|
28
|
+
const PRICING_JSON: &str = include_str!("../schemas/pricing.json");
|
|
29
|
+
|
|
30
|
+
/// Lazy-initialised registry parsed from the embedded JSON.
|
|
31
|
+
/// Stores a `Result` so that parse failures surface at call time rather than
|
|
32
|
+
/// panicking the process (mirrors the pattern used in `provider/mod.rs`).
|
|
33
|
+
static PRICING: LazyLock<std::result::Result<PricingRegistry, String>> =
|
|
34
|
+
LazyLock::new(|| serde_json::from_str(PRICING_JSON).map_err(|e| e.to_string()));
|
|
35
|
+
|
|
36
|
+
/// Access the pricing registry, returning `None` if the embedded JSON was invalid.
|
|
37
|
+
///
|
|
38
|
+
/// Invalid embedded JSON is a compile-time defect; callers treat it the same
|
|
39
|
+
/// as an unknown model (no pricing available).
|
|
40
|
+
fn pricing() -> Option<&'static PricingRegistry> {
|
|
41
|
+
PRICING.as_ref().ok()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Registry ─────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
#[derive(Debug, Deserialize)]
|
|
47
|
+
struct PricingRegistry {
|
|
48
|
+
models: HashMap<String, ModelPricing>,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Per-token pricing for a single model (USD per token).
|
|
52
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
53
|
+
pub struct ModelPricing {
|
|
54
|
+
/// Cost in USD per input (prompt) token.
|
|
55
|
+
pub input_cost_per_token: f64,
|
|
56
|
+
/// Cost in USD per output (completion) token. Zero for embedding models.
|
|
57
|
+
pub output_cost_per_token: f64,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/// Calculate the estimated cost of a completion given a model name and token
|
|
63
|
+
/// counts.
|
|
64
|
+
///
|
|
65
|
+
/// Returns `None` if the model is not present in the embedded pricing registry.
|
|
66
|
+
/// Returns `Some(cost_usd)` otherwise, where the value is in US dollars.
|
|
67
|
+
///
|
|
68
|
+
/// When an exact model name match is not found, progressively shorter prefixes
|
|
69
|
+
/// are tried by stripping from the last `-` or `.` separator. For example,
|
|
70
|
+
/// `gpt-4-0613` will match `gpt-4` if no `gpt-4-0613` entry exists.
|
|
71
|
+
///
|
|
72
|
+
/// # Example
|
|
73
|
+
///
|
|
74
|
+
/// ```rust
|
|
75
|
+
/// use liter_llm::cost;
|
|
76
|
+
///
|
|
77
|
+
/// let usd = cost::completion_cost("gpt-4o", 1_000, 500).unwrap();
|
|
78
|
+
/// // 1000 * 0.0000025 + 500 * 0.00001 = 0.0025 + 0.005 = 0.0075
|
|
79
|
+
/// assert!((usd - 0.0075).abs() < 1e-9);
|
|
80
|
+
/// ```
|
|
81
|
+
#[must_use]
|
|
82
|
+
pub fn completion_cost(model: &str, prompt_tokens: u64, completion_tokens: u64) -> Option<f64> {
|
|
83
|
+
let pricing = model_pricing(model)?;
|
|
84
|
+
Some(
|
|
85
|
+
(prompt_tokens as f64) * pricing.input_cost_per_token
|
|
86
|
+
+ (completion_tokens as f64) * pricing.output_cost_per_token,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Look up the per-token pricing for a model.
|
|
91
|
+
///
|
|
92
|
+
/// Returns `None` if the model is not present in the embedded pricing registry.
|
|
93
|
+
/// The returned reference is valid for the lifetime of the process (`'static`).
|
|
94
|
+
///
|
|
95
|
+
/// When an exact model name match is not found, progressively shorter prefixes
|
|
96
|
+
/// are tried by stripping from the last `-` or `.` separator. For example,
|
|
97
|
+
/// `gpt-4-0613` will try `gpt-4-0613`, then `gpt-4`, then `gpt`. The first
|
|
98
|
+
/// match wins.
|
|
99
|
+
#[must_use]
|
|
100
|
+
pub fn model_pricing(model: &str) -> Option<&'static ModelPricing> {
|
|
101
|
+
let models = &pricing()?.models;
|
|
102
|
+
|
|
103
|
+
// Exact match first.
|
|
104
|
+
if let Some(p) = models.get(model) {
|
|
105
|
+
return Some(p);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Progressively strip the last `-` or `.` segment and retry.
|
|
109
|
+
let mut candidate = model;
|
|
110
|
+
while let Some(pos) = candidate.rfind(['-', '.']) {
|
|
111
|
+
candidate = &candidate[..pos];
|
|
112
|
+
if let Some(p) = models.get(candidate) {
|
|
113
|
+
return Some(p);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
None
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
#[cfg(test)]
|
|
123
|
+
mod tests {
|
|
124
|
+
use super::*;
|
|
125
|
+
|
|
126
|
+
#[test]
|
|
127
|
+
fn completion_cost_known_model_returns_expected_value() {
|
|
128
|
+
// gpt-4: input=0.00003, output=0.00006
|
|
129
|
+
// 100 * 0.00003 + 50 * 0.00006 = 0.003 + 0.003 = 0.006
|
|
130
|
+
let cost = completion_cost("gpt-4", 100, 50).expect("gpt-4 must be in registry");
|
|
131
|
+
let expected = 100.0 * 0.00003 + 50.0 * 0.00006;
|
|
132
|
+
assert!((cost - expected).abs() < 1e-12, "expected {expected}, got {cost}");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[test]
|
|
136
|
+
fn completion_cost_unknown_model_returns_none() {
|
|
137
|
+
assert!(
|
|
138
|
+
completion_cost("unknown-model-xyz", 100, 50).is_none(),
|
|
139
|
+
"unknown model should return None"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn completion_cost_gpt4o_matches_published_pricing() {
|
|
145
|
+
// gpt-4o: input=$2.50/1M tokens = 0.0000025/token
|
|
146
|
+
// output=$10/1M tokens = 0.00001/token
|
|
147
|
+
let cost = completion_cost("gpt-4o", 1_000, 500).expect("gpt-4o must be in registry");
|
|
148
|
+
let expected = 1_000.0 * 0.0000025 + 500.0 * 0.00001;
|
|
149
|
+
assert!((cost - expected).abs() < 1e-12, "expected {expected}, got {cost}");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn completion_cost_embedding_model_has_zero_output_cost() {
|
|
154
|
+
// Embedding models only charge for input tokens.
|
|
155
|
+
let cost =
|
|
156
|
+
completion_cost("text-embedding-3-small", 100, 0).expect("text-embedding-3-small must be in registry");
|
|
157
|
+
assert!(cost > 0.0, "input tokens must have a positive cost");
|
|
158
|
+
|
|
159
|
+
let pricing = model_pricing("text-embedding-3-small").unwrap();
|
|
160
|
+
assert_eq!(pricing.output_cost_per_token, 0.0, "embedding output cost must be zero");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[test]
|
|
164
|
+
fn model_pricing_returns_none_for_unknown_model() {
|
|
165
|
+
assert!(model_pricing("does-not-exist").is_none());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[test]
|
|
169
|
+
fn model_pricing_prefix_fallback_matches_shorter_name() {
|
|
170
|
+
// gpt-4 is in the registry; gpt-4-0613 is a versioned variant that
|
|
171
|
+
// should fall back to the gpt-4 entry via prefix stripping.
|
|
172
|
+
let exact = model_pricing("gpt-4").expect("gpt-4 must be in registry");
|
|
173
|
+
let prefix = model_pricing("gpt-4-0613").expect("gpt-4-0613 should match gpt-4 via prefix");
|
|
174
|
+
assert!(
|
|
175
|
+
(exact.input_cost_per_token - prefix.input_cost_per_token).abs() < 1e-15,
|
|
176
|
+
"prefix match should return the same pricing as exact match"
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn completion_cost_prefix_fallback() {
|
|
182
|
+
// Versioned model name should resolve via prefix stripping.
|
|
183
|
+
let cost = completion_cost("gpt-4-0613", 100, 50);
|
|
184
|
+
assert!(cost.is_some(), "gpt-4-0613 should resolve via prefix fallback to gpt-4");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[test]
|
|
188
|
+
fn model_pricing_returns_correct_fields_for_known_model() {
|
|
189
|
+
let p = model_pricing("gpt-4o-mini").expect("gpt-4o-mini must be in registry");
|
|
190
|
+
// Published: input $0.15/1M = 0.00000015, output $0.60/1M = 0.0000006
|
|
191
|
+
assert!(
|
|
192
|
+
(p.input_cost_per_token - 0.00000015).abs() < 1e-12,
|
|
193
|
+
"unexpected input_cost_per_token: {}",
|
|
194
|
+
p.input_cost_per_token
|
|
195
|
+
);
|
|
196
|
+
assert!(
|
|
197
|
+
(p.output_cost_per_token - 0.0000006).abs() < 1e-12,
|
|
198
|
+
"unexpected output_cost_per_token: {}",
|
|
199
|
+
p.output_cost_per_token
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#[test]
|
|
204
|
+
fn pricing_registry_embedded_json_is_valid() {
|
|
205
|
+
// Confirm the embedded JSON parses correctly — PRICING holds Ok(...).
|
|
206
|
+
assert!(
|
|
207
|
+
PRICING.as_ref().is_ok(),
|
|
208
|
+
"embedded schemas/pricing.json failed to parse: {:?}",
|
|
209
|
+
PRICING.as_ref().err()
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
use std::time::Duration;
|
|
2
|
+
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
|
|
5
|
+
/// Error response from an OpenAI-compatible API.
|
|
6
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
7
|
+
pub struct ErrorResponse {
|
|
8
|
+
pub error: ApiError,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// Inner error object.
|
|
12
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
13
|
+
pub struct ApiError {
|
|
14
|
+
pub message: String,
|
|
15
|
+
#[serde(rename = "type")]
|
|
16
|
+
pub error_type: String,
|
|
17
|
+
#[serde(default)]
|
|
18
|
+
pub param: Option<String>,
|
|
19
|
+
#[serde(default)]
|
|
20
|
+
pub code: Option<String>,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// All errors that can occur when using `liter-llm`.
|
|
24
|
+
#[derive(Debug, thiserror::Error)]
|
|
25
|
+
#[non_exhaustive]
|
|
26
|
+
pub enum LiterLlmError {
|
|
27
|
+
#[error("authentication failed: {message}")]
|
|
28
|
+
Authentication { message: String },
|
|
29
|
+
|
|
30
|
+
#[error("rate limited: {message}")]
|
|
31
|
+
RateLimited {
|
|
32
|
+
message: String,
|
|
33
|
+
retry_after: Option<Duration>,
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
#[error("bad request: {message}")]
|
|
37
|
+
BadRequest { message: String },
|
|
38
|
+
|
|
39
|
+
#[error("context window exceeded: {message}")]
|
|
40
|
+
ContextWindowExceeded { message: String },
|
|
41
|
+
|
|
42
|
+
#[error("content policy violation: {message}")]
|
|
43
|
+
ContentPolicy { message: String },
|
|
44
|
+
|
|
45
|
+
#[error("not found: {message}")]
|
|
46
|
+
NotFound { message: String },
|
|
47
|
+
|
|
48
|
+
#[error("server error: {message}")]
|
|
49
|
+
ServerError { message: String },
|
|
50
|
+
|
|
51
|
+
#[error("service unavailable: {message}")]
|
|
52
|
+
ServiceUnavailable { message: String },
|
|
53
|
+
|
|
54
|
+
#[error("request timeout")]
|
|
55
|
+
Timeout,
|
|
56
|
+
|
|
57
|
+
#[cfg(feature = "native-http")]
|
|
58
|
+
#[error(transparent)]
|
|
59
|
+
Network(#[from] reqwest::Error),
|
|
60
|
+
|
|
61
|
+
/// A catch-all for errors that occur during streaming response processing.
|
|
62
|
+
///
|
|
63
|
+
/// This variant covers multiple sub-conditions including UTF-8 decoding
|
|
64
|
+
/// failures, CRC/checksum mismatches (AWS EventStream), JSON parse errors
|
|
65
|
+
/// in individual SSE chunks, and buffer overflow conditions. The `message`
|
|
66
|
+
/// field contains a human-readable description of the specific failure.
|
|
67
|
+
#[error("streaming error: {message}")]
|
|
68
|
+
Streaming { message: String },
|
|
69
|
+
|
|
70
|
+
#[error("provider {provider} does not support {endpoint}")]
|
|
71
|
+
EndpointNotSupported { endpoint: String, provider: String },
|
|
72
|
+
|
|
73
|
+
#[error("invalid header {name:?}: {reason}")]
|
|
74
|
+
InvalidHeader { name: String, reason: String },
|
|
75
|
+
|
|
76
|
+
#[error("serialization error: {0}")]
|
|
77
|
+
Serialization(#[from] serde_json::Error),
|
|
78
|
+
|
|
79
|
+
#[error("budget exceeded: {message}")]
|
|
80
|
+
BudgetExceeded { message: String, model: Option<String> },
|
|
81
|
+
|
|
82
|
+
#[error("hook rejected: {message}")]
|
|
83
|
+
HookRejected { message: String },
|
|
84
|
+
|
|
85
|
+
/// An internal logic error (e.g. unexpected Tower response variant).
|
|
86
|
+
///
|
|
87
|
+
/// This should never surface in normal operation — if it does, it
|
|
88
|
+
/// indicates a bug in the library.
|
|
89
|
+
#[error("internal error: {message}")]
|
|
90
|
+
InternalError { message: String },
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
impl LiterLlmError {
|
|
94
|
+
/// Returns `true` for errors that are worth retrying on a different service
|
|
95
|
+
/// or deployment (transient failures).
|
|
96
|
+
///
|
|
97
|
+
/// Used by [`crate::tower::fallback::FallbackService`] and
|
|
98
|
+
/// [`crate::tower::router::Router`] to decide whether to route to an
|
|
99
|
+
/// alternative endpoint.
|
|
100
|
+
#[must_use]
|
|
101
|
+
pub fn is_transient(&self) -> bool {
|
|
102
|
+
match self {
|
|
103
|
+
Self::RateLimited { .. } | Self::ServiceUnavailable { .. } | Self::Timeout | Self::ServerError { .. } => {
|
|
104
|
+
true
|
|
105
|
+
}
|
|
106
|
+
#[cfg(feature = "native-http")]
|
|
107
|
+
Self::Network(_) => true,
|
|
108
|
+
_ => false,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Return the OpenTelemetry `error.type` string for this error variant.
|
|
113
|
+
///
|
|
114
|
+
/// Used by the tracing middleware to record the `error.type` span attribute
|
|
115
|
+
/// on failed requests per the GenAI semantic conventions.
|
|
116
|
+
#[must_use]
|
|
117
|
+
pub fn error_type(&self) -> &'static str {
|
|
118
|
+
match self {
|
|
119
|
+
Self::Authentication { .. } => "Authentication",
|
|
120
|
+
Self::RateLimited { .. } => "RateLimited",
|
|
121
|
+
Self::BadRequest { .. } => "BadRequest",
|
|
122
|
+
Self::ContextWindowExceeded { .. } => "ContextWindowExceeded",
|
|
123
|
+
Self::ContentPolicy { .. } => "ContentPolicy",
|
|
124
|
+
Self::NotFound { .. } => "NotFound",
|
|
125
|
+
Self::ServerError { .. } => "ServerError",
|
|
126
|
+
Self::ServiceUnavailable { .. } => "ServiceUnavailable",
|
|
127
|
+
Self::Timeout => "Timeout",
|
|
128
|
+
#[cfg(feature = "native-http")]
|
|
129
|
+
Self::Network(_) => "Network",
|
|
130
|
+
Self::Streaming { .. } => "Streaming",
|
|
131
|
+
Self::EndpointNotSupported { .. } => "EndpointNotSupported",
|
|
132
|
+
Self::InvalidHeader { .. } => "InvalidHeader",
|
|
133
|
+
Self::Serialization(_) => "Serialization",
|
|
134
|
+
Self::BudgetExceeded { .. } => "BudgetExceeded",
|
|
135
|
+
Self::HookRejected { .. } => "HookRejected",
|
|
136
|
+
Self::InternalError { .. } => "InternalError",
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Create from an HTTP status code, an API error response body, and an
|
|
141
|
+
/// optional `Retry-After` duration already parsed from the response header.
|
|
142
|
+
///
|
|
143
|
+
/// The `retry_after` value is forwarded into [`LiterLlmError::RateLimited`]
|
|
144
|
+
/// so callers can honour the server-requested delay without re-parsing the
|
|
145
|
+
/// header.
|
|
146
|
+
pub fn from_status(status: u16, body: &str, retry_after: Option<Duration>) -> Self {
|
|
147
|
+
let parsed = serde_json::from_str::<ErrorResponse>(body).ok();
|
|
148
|
+
let code = parsed.as_ref().and_then(|r| r.error.code.clone());
|
|
149
|
+
let message = parsed.map(|r| r.error.message).unwrap_or_else(|| body.to_string());
|
|
150
|
+
|
|
151
|
+
match status {
|
|
152
|
+
401 | 403 => Self::Authentication { message },
|
|
153
|
+
429 => Self::RateLimited { message, retry_after },
|
|
154
|
+
400 | 422 => {
|
|
155
|
+
// Check the structured `code` field first — it is more reliable
|
|
156
|
+
// than substring matching on the human-readable message.
|
|
157
|
+
if code.as_deref() == Some("context_length_exceeded") {
|
|
158
|
+
Self::ContextWindowExceeded { message }
|
|
159
|
+
} else if code.as_deref() == Some("content_policy_violation")
|
|
160
|
+
|| code.as_deref() == Some("content_filter")
|
|
161
|
+
{
|
|
162
|
+
Self::ContentPolicy { message }
|
|
163
|
+
}
|
|
164
|
+
// Fall back to message-based heuristics for providers that do not
|
|
165
|
+
// populate the `code` field.
|
|
166
|
+
else if message.contains("context_length_exceeded")
|
|
167
|
+
|| message.contains("context window")
|
|
168
|
+
|| message.contains("maximum context length")
|
|
169
|
+
{
|
|
170
|
+
Self::ContextWindowExceeded { message }
|
|
171
|
+
} else if message.contains("content_policy") || message.contains("content_filter") {
|
|
172
|
+
Self::ContentPolicy { message }
|
|
173
|
+
} else {
|
|
174
|
+
Self::BadRequest { message }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
404 => Self::NotFound { message },
|
|
178
|
+
405 | 413 => Self::BadRequest { message },
|
|
179
|
+
408 => Self::Timeout,
|
|
180
|
+
500 => Self::ServerError { message },
|
|
181
|
+
502..=504 => Self::ServiceUnavailable { message },
|
|
182
|
+
// Map remaining 4xx codes to BadRequest (client errors) and
|
|
183
|
+
// everything else (5xx, unknown) to ServerError.
|
|
184
|
+
400..=499 => Self::BadRequest { message },
|
|
185
|
+
_ => Self::ServerError { message },
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pub type Result<T> = std::result::Result<T, LiterLlmError>;
|