1use thiserror::Error;
4
5#[derive(Debug, Error)]
7pub enum LlmError {
8 #[error("Configuration error: {0}")]
9 ConfigError(String),
10
11 #[error("Network connection failed: {0}")]
12 ConnectionError(String),
13
14 #[error("Authentication failed, please check your API key configuration")]
15 AuthenticationError,
16
17 #[error("Rate limit exceeded, please retry later")]
18 RateLimitError { retry_after: Option<u64> },
19
20 #[error("Model not available: {model}")]
21 ModelNotFound { model: String },
22
23 #[error("Server error ({status}): {message}")]
24 ServerError { status: u16, message: String },
25
26 #[error("Response format error: {0}")]
27 ParseError(String),
28
29 #[error(transparent)]
30 OpenAiError(#[from] async_openai::error::OpenAIError),
31
32 #[error("内容审核拦截:模型输出被服务商判定为不适宜内容,请调整提问或稍后重试({detail})")]
36 ContentModeration { detail: String },
37
38 #[error("内容审核拦截:输入内容被服务商判定为不适宜内容,请调整提问或清空上下文/新建会话后重试({detail})")]
43 ContentModerationInput { detail: String },
44
45 #[error("服务商返回错误({code}):{message}")]
46 ProviderError { code: String, message: String },
47}
48
49impl LlmError {
50 pub fn from_openai_error(err: async_openai::error::OpenAIError) -> Self {
63 use async_openai::error::OpenAIError;
64 match err {
65 OpenAIError::ApiError(resp) => {
66 let api = &resp.api_error;
67 match api
68 .code
69 .as_deref()
70 .or_else(|| api.r#type.as_deref())
71 {
72 Some(code) if is_content_moderation_code(code) => {
73 moderation_error(code, &api.message)
74 }
75 _ => LlmError::OpenAiError(OpenAIError::ApiError(resp)),
78 }
79 }
80 OpenAIError::JSONDeserialize(_, ref raw) => match extract_provider_error(raw) {
81 Some((Some(code), message)) => {
82 if is_content_moderation_code(&code) {
83 moderation_error(&code, &message)
84 } else {
85 LlmError::ProviderError { code, message }
86 }
87 }
88 Some((None, message)) => LlmError::ProviderError {
89 code: "unknown".to_string(),
90 message,
91 },
92 None => LlmError::OpenAiError(err),
93 },
94 other => LlmError::OpenAiError(other),
95 }
96 }
97}
98
99fn moderation_error(code: &str, message: &str) -> LlmError {
103 let detail = format!("{}: {}", code, message);
104 if message.contains("Input data") {
105 LlmError::ContentModerationInput { detail }
106 } else {
107 LlmError::ContentModeration { detail }
108 }
109}
110
111fn is_content_moderation_code(code: &str) -> bool {
113 matches!(
114 code,
115 "data_inspection_failed" | "DataInspectionFailed" |
117 "content_filter" | "content_policy_violation"
119 )
120}
121
122fn extract_provider_error(raw: &str) -> Option<(Option<String>, String)> {
125 let value: serde_json::Value = serde_json::from_str(raw).ok()?;
126 let err = value.get("error")?;
127 let message = err.get("message")?.as_str()?.to_string();
128 let code = err
129 .get("code")
130 .and_then(|c| c.as_str())
131 .or_else(|| err.get("type").and_then(|t| t.as_str()))
132 .map(|s| s.to_string());
133 Some((code, message))
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 const DASHSCOPE_MODERATION: &str = "{\"error\":{\"message\":\"Output data may contain inappropriate content. For details, see: https://help.aliyun.com/zh/model-studio/error-code#inappropriate-content\",\"type\":\"data_inspection_failed\",\"param\":null,\"code\":\"data_inspection_failed\"},\"id\":\"chatcmpl-aaadc65d\",\"request_id\":\"aaadc65d\"}";
143
144 fn json_deserialize_err(raw: &str) -> async_openai::error::OpenAIError {
145 use serde::de::Error as _;
146 let serde_err = serde_json::Error::custom("missing field `choices`");
147 async_openai::error::OpenAIError::JSONDeserialize(serde_err, raw.to_string())
148 }
149
150 fn api_err(code: &str, message: &str) -> async_openai::error::OpenAIError {
151 let api_error = async_openai::error::ApiError {
152 message: message.to_string(),
153 r#type: Some("data_inspection_failed".to_string()),
154 param: None,
155 code: Some(code.to_string()),
156 misalignment: None,
157 };
158 async_openai::error::OpenAIError::ApiError(async_openai::error::ApiErrorResponse {
159 status_code: reqwest::StatusCode::BAD_REQUEST,
160 api_error,
161 })
162 }
163
164 #[test]
165 fn stream_moderation_error_becomes_friendly_content_moderation() {
166 let err = json_deserialize_err(DASHSCOPE_MODERATION);
167 let llm = LlmError::from_openai_error(err);
168 match &llm {
169 LlmError::ContentModeration { detail } => {
170 assert!(detail.contains("data_inspection_failed"));
171 assert!(detail.contains("inappropriate content"));
172 }
173 other => panic!("expected ContentModeration, got {:?}", other),
174 }
175 let text = llm.to_string();
177 assert!(text.contains("内容审核拦截"));
178 assert!(text.contains("请调整提问或稍后重试"));
179 }
180
181 #[test]
182 fn api_input_moderation_error_becomes_friendly_input_moderation() {
183 let err = api_err(
185 "data_inspection_failed",
186 "Input data may contain inappropriate content. For details, see: https://help.aliyun.com/zh/model-studio/error-code#inappropriate-content",
187 );
188 let llm = LlmError::from_openai_error(err);
189 match &llm {
190 LlmError::ContentModerationInput { detail } => {
191 assert!(detail.contains("data_inspection_failed"));
192 }
193 other => panic!("expected ContentModerationInput, got {:?}", other),
194 }
195 let text = llm.to_string();
196 assert!(text.contains("输入内容"));
197 assert!(text.contains("清空上下文"));
198 }
199
200 #[test]
201 fn api_output_moderation_error_becomes_friendly_output_moderation() {
202 let err = api_err(
203 "data_inspection_failed",
204 "Output data may contain inappropriate content. For details, see: https://help.aliyun.com/zh/model-studio/error-code#inappropriate-content",
205 );
206 let llm = LlmError::from_openai_error(err);
207 assert!(matches!(llm, LlmError::ContentModeration { .. }));
208 }
209
210 #[test]
211 fn api_other_error_keeps_structured_display() {
212 let api_error = async_openai::error::ApiError {
213 message: "Model not found".to_string(),
214 r#type: Some("invalid_request_error".to_string()),
215 param: None,
216 code: Some("model_not_found".to_string()),
217 misalignment: None,
218 };
219 let err = async_openai::error::OpenAIError::ApiError(async_openai::error::ApiErrorResponse {
220 status_code: reqwest::StatusCode::NOT_FOUND,
221 api_error,
222 });
223 assert!(matches!(LlmError::from_openai_error(err), LlmError::OpenAiError(_)));
225 }
226
227 #[test]
228 fn stream_provider_error_keeps_code_and_message() {
229 let raw = "{\"error\":{\"message\":\"boom\",\"code\":\"internal_error\"}}";
230 let llm = LlmError::from_openai_error(json_deserialize_err(raw));
231 match &llm {
232 LlmError::ProviderError { code, message } => {
233 assert_eq!(code, "internal_error");
234 assert_eq!(message, "boom");
235 }
236 other => panic!("expected ProviderError, got {:?}", other),
237 }
238 }
239
240 #[test]
241 fn stream_unparseable_payload_falls_back_to_openai_error() {
242 let raw = "not json at all";
243 let llm = LlmError::from_openai_error(json_deserialize_err(raw));
244 assert!(matches!(llm, LlmError::OpenAiError(_)));
245 }
246}