use thiserror::Error;
#[derive(Debug, Error)]
pub enum LlmError {
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Network connection failed: {0}")]
ConnectionError(String),
#[error("Authentication failed, please check your API key configuration")]
AuthenticationError,
#[error("Rate limit exceeded, please retry later")]
RateLimitError { retry_after: Option<u64> },
#[error("Model not available: {model}")]
ModelNotFound { model: String },
#[error("Server error ({status}): {message}")]
ServerError { status: u16, message: String },
#[error("Response format error: {0}")]
ParseError(String),
#[error(transparent)]
OpenAiError(#[from] async_openai::error::OpenAIError),
#[error("内容审核拦截:模型输出被服务商判定为不适宜内容,请调整提问或稍后重试({detail})")]
ContentModeration { detail: String },
#[error("内容审核拦截:输入内容被服务商判定为不适宜内容,请调整提问或清空上下文/新建会话后重试({detail})")]
ContentModerationInput { detail: String },
#[error("服务商返回错误({code}):{message}")]
ProviderError { code: String, message: String },
}
impl LlmError {
pub fn from_openai_error(err: async_openai::error::OpenAIError) -> Self {
use async_openai::error::OpenAIError;
match err {
OpenAIError::ApiError(resp) => {
let api = &resp.api_error;
match api
.code
.as_deref()
.or_else(|| api.r#type.as_deref())
{
Some(code) if is_content_moderation_code(code) => {
moderation_error(code, &api.message)
}
_ => LlmError::OpenAiError(OpenAIError::ApiError(resp)),
}
}
OpenAIError::JSONDeserialize(_, ref raw) => match extract_provider_error(raw) {
Some((Some(code), message)) => {
if is_content_moderation_code(&code) {
moderation_error(&code, &message)
} else {
LlmError::ProviderError { code, message }
}
}
Some((None, message)) => LlmError::ProviderError {
code: "unknown".to_string(),
message,
},
None => LlmError::OpenAiError(err),
},
other => LlmError::OpenAiError(other),
}
}
}
fn moderation_error(code: &str, message: &str) -> LlmError {
let detail = format!("{}: {}", code, message);
if message.contains("Input data") {
LlmError::ContentModerationInput { detail }
} else {
LlmError::ContentModeration { detail }
}
}
fn is_content_moderation_code(code: &str) -> bool {
matches!(
code,
"data_inspection_failed" | "DataInspectionFailed" |
"content_filter" | "content_policy_violation"
)
}
fn extract_provider_error(raw: &str) -> Option<(Option<String>, String)> {
let value: serde_json::Value = serde_json::from_str(raw).ok()?;
let err = value.get("error")?;
let message = err.get("message")?.as_str()?.to_string();
let code = err
.get("code")
.and_then(|c| c.as_str())
.or_else(|| err.get("type").and_then(|t| t.as_str()))
.map(|s| s.to_string());
Some((code, message))
}
#[cfg(test)]
mod tests {
use super::*;
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\"}";
fn json_deserialize_err(raw: &str) -> async_openai::error::OpenAIError {
use serde::de::Error as _;
let serde_err = serde_json::Error::custom("missing field `choices`");
async_openai::error::OpenAIError::JSONDeserialize(serde_err, raw.to_string())
}
fn api_err(code: &str, message: &str) -> async_openai::error::OpenAIError {
let api_error = async_openai::error::ApiError {
message: message.to_string(),
r#type: Some("data_inspection_failed".to_string()),
param: None,
code: Some(code.to_string()),
misalignment: None,
};
async_openai::error::OpenAIError::ApiError(async_openai::error::ApiErrorResponse {
status_code: reqwest::StatusCode::BAD_REQUEST,
api_error,
})
}
#[test]
fn stream_moderation_error_becomes_friendly_content_moderation() {
let err = json_deserialize_err(DASHSCOPE_MODERATION);
let llm = LlmError::from_openai_error(err);
match &llm {
LlmError::ContentModeration { detail } => {
assert!(detail.contains("data_inspection_failed"));
assert!(detail.contains("inappropriate content"));
}
other => panic!("expected ContentModeration, got {:?}", other),
}
let text = llm.to_string();
assert!(text.contains("内容审核拦截"));
assert!(text.contains("请调整提问或稍后重试"));
}
#[test]
fn api_input_moderation_error_becomes_friendly_input_moderation() {
let err = api_err(
"data_inspection_failed",
"Input data may contain inappropriate content. For details, see: https://help.aliyun.com/zh/model-studio/error-code#inappropriate-content",
);
let llm = LlmError::from_openai_error(err);
match &llm {
LlmError::ContentModerationInput { detail } => {
assert!(detail.contains("data_inspection_failed"));
}
other => panic!("expected ContentModerationInput, got {:?}", other),
}
let text = llm.to_string();
assert!(text.contains("输入内容"));
assert!(text.contains("清空上下文"));
}
#[test]
fn api_output_moderation_error_becomes_friendly_output_moderation() {
let err = api_err(
"data_inspection_failed",
"Output data may contain inappropriate content. For details, see: https://help.aliyun.com/zh/model-studio/error-code#inappropriate-content",
);
let llm = LlmError::from_openai_error(err);
assert!(matches!(llm, LlmError::ContentModeration { .. }));
}
#[test]
fn api_other_error_keeps_structured_display() {
let api_error = async_openai::error::ApiError {
message: "Model not found".to_string(),
r#type: Some("invalid_request_error".to_string()),
param: None,
code: Some("model_not_found".to_string()),
misalignment: None,
};
let err = async_openai::error::OpenAIError::ApiError(async_openai::error::ApiErrorResponse {
status_code: reqwest::StatusCode::NOT_FOUND,
api_error,
});
assert!(matches!(LlmError::from_openai_error(err), LlmError::OpenAiError(_)));
}
#[test]
fn stream_provider_error_keeps_code_and_message() {
let raw = "{\"error\":{\"message\":\"boom\",\"code\":\"internal_error\"}}";
let llm = LlmError::from_openai_error(json_deserialize_err(raw));
match &llm {
LlmError::ProviderError { code, message } => {
assert_eq!(code, "internal_error");
assert_eq!(message, "boom");
}
other => panic!("expected ProviderError, got {:?}", other),
}
}
#[test]
fn stream_unparseable_payload_falls_back_to_openai_error() {
let raw = "not json at all";
let llm = LlmError::from_openai_error(json_deserialize_err(raw));
assert!(matches!(llm, LlmError::OpenAiError(_)));
}
}