Skip to main content

robit_ai/
error.rs

1//! Error types for the robit-ai crate.
2
3use thiserror::Error;
4
5/// Unified error type for LLM operations.
6#[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    /// The provider's content moderation blocked the model OUTPUT, pushed as
33    /// an error event into the SSE stream (no `choices` field, so async-openai
34    /// reports a JSON deserialization failure with the cause buried inside).
35    #[error("内容审核拦截:模型输出被服务商判定为不适宜内容,请调整提问或稍后重试({detail})")]
36    ContentModeration { detail: String },
37
38    /// The provider's content moderation blocked the INPUT (the request /
39    /// conversation context was rejected with e.g. HTTP 400). Every subsequent
40    /// turn resends the same context, so the session stays blocked until the
41    /// context is cleared.
42    #[error("内容审核拦截:输入内容被服务商判定为不适宜内容,请调整提问或清空上下文/新建会话后重试({detail})")]
43    ContentModerationInput { detail: String },
44
45    #[error("服务商返回错误({code}):{message}")]
46    ProviderError { code: String, message: String },
47}
48
49impl LlmError {
50    /// Map an async-openai error to a friendlier one where possible.
51    ///
52    /// Two cases are improved:
53    /// - `ApiError` whose code/type is a known content-moderation code
54    ///   (e.g. DashScope `data_inspection_failed`) — the provider rejects the
55    ///   request outright with HTTP 400.
56    /// - `JSONDeserialize` where the raw payload is actually a provider error
57    ///   event `{"error": {...}}` pushed into the SSE stream — async-openai
58    ///   cannot parse it as a chunk (no `choices`), so the real cause would
59    ///   otherwise be buried in the deserialize error.
60    ///
61    /// Everything else is passed through unchanged.
62    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                    // Other API errors keep their structured display
76                    // ("{status} {type}: {message} (code: ...)").
77                    _ => 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
99/// Build the right moderation error for a provider payload, distinguishing
100/// input-side from output-side blocks (DashScope uses the same code for both;
101/// only the message differs, e.g. "Input data may contain ..." vs "Output").
102fn 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
111/// Whether a provider error code means content moderation blocked the output.
112fn is_content_moderation_code(code: &str) -> bool {
113    matches!(
114        code,
115        // DashScope (QWen): output data inspection
116        "data_inspection_failed" | "DataInspectionFailed" |
117        // OpenAI-compatible content filters
118        "content_filter" | "content_policy_violation"
119    )
120}
121
122/// Extract `(code, message)` from a provider error payload like
123/// `{"error": {"code": "...", "message": "..."}}`.
124fn 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    /// Real-world DashScope payload: moderation blocked the model output
141    /// mid-stream (no `choices` field, so async-openai fails deserialization).
142    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        // Display is friendly and contains the provider's own message.
176        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        // HTTP 400 rejection: the conversation context (input) is flagged.
184        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        // Non-moderation API errors pass through unchanged.
224        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}