Skip to main content

ai_profile/
error.rs

1//! 结构化错误 —— 调用方「一键修正」的唯一前提。
2//!
3//! # 🔴 为什么不用 `Err(String)`
4//!
5//! 调用方要靠错误**类型**决定给用户什么动作。返回字符串 = 界面只能显示一行红字,
6//! 「一键补 /v1」「展开可用模型」这类动作根本做不出来。
7//!
8//! 每个变体都对应一个具体的 UI 动作:
9//!
10//! | 变体                | 调用方应当                     |
11//! |---------------------|--------------------------------|
12//! | `AuthFailed`        | 聚焦到密钥输入框               |
13//! | `NotFound`          | 给「一键改用 suggested_url」   |
14//! | `Unreachable`       | 跳到网络 / 代理设置            |
15//! | `ModelNotFound`     | 展开 `available` 让用户选      |
16//! | `ProtocolMismatch`  | 提示切到 `expect` 对应的模板   |
17//! | `MissingExtraField` | 验证前就禁用按钮,指出缺哪个   |
18//!
19//! **加新变体前先问:调用方拿到它能做什么动作?** 做不出动作的变体不该加。
20
21use serde::Serialize;
22
23use crate::kind::Protocol;
24
25/// 验证 / 调用失败的结构化原因。
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, thiserror::Error)]
27#[serde(tag = "code", rename_all = "snake_case")]
28#[non_exhaustive]
29pub enum VerifyError {
30    /// 401 / 403 —— 密钥无效或已过期。
31    #[error("密钥无效或已过期:{detail}")]
32    AuthFailed {
33        /// 端点返回的原始说明(已脱敏,不含密钥)
34        detail: String,
35    },
36
37    /// 404 —— 端点不存在。多半是 base_url 缺版本段。
38    ///
39    /// `suggested_url` 非空时,调用方应提供「一键改用」按钮。
40    #[error("端点不存在:{requested_url}")]
41    NotFound {
42        /// 实际请求的完整 URL —— 直接展示给用户,省去他猜"到底打了哪个地址"
43        requested_url: String,
44        /// 推断出的正确地址;非空时调用方应给「一键改用」按钮
45        suggested_url: Option<String>,
46    },
47
48    /// 连不上(超时 / DNS / TLS)。`proxy_hint` 为真时建议提示用户配代理。
49    #[error("无法连接到端点{}", if *.proxy_hint { "(国内访问该站点可能需要代理)" } else { "" })]
50    Unreachable {
51        /// 该 host 在国内通常需要代理,调用方可据此提示去网络设置
52        proxy_hint: bool,
53    },
54
55    /// 端点可达,但它不认当前填的模型名。`available` 是端点返回的真实清单。
56    #[error("端点不认识该模型,它提供了 {} 个可选模型", available.len())]
57    ModelNotFound {
58        /// 端点返回的真实可用清单,调用方展开成下拉让用户改选
59        available: Vec<String>,
60    },
61
62    /// 密钥只接受另一种协议(典型:中转站的 key 限定 `/v1/messages`)。
63    #[error("密钥要求 {expect:?} 协议,当前配置不匹配")]
64    ProtocolMismatch {
65        /// 密钥实际要求的协议,调用方据此提示切到对应模板
66        expect: Protocol,
67    },
68
69    /// 服务商要求的专有字段没填(如豆包 TTS 的 `appid`)。
70    ///
71    /// 调用方应在**发起验证之前**就用它禁用按钮 —— 禁用态优于「点了才报错」。
72    #[error("缺少该服务商要求的配置项:{key}")]
73    MissingExtraField {
74        /// 缺失字段的 key(对应预置里的 `extra_fields`)
75        key: String,
76    },
77
78    /// 端点返回了预期之外的内容(解析失败等)。兜底变体。
79    #[error("端点返回了无法解析的内容:{detail}")]
80    Malformed {
81        /// 解析失败的简述(不含响应体原文,避免把密钥带进日志)
82        detail: String,
83    },
84}
85
86impl VerifyError {
87    /// 这个错误是否可能通过「改配置」解决(而非重试)。
88    ///
89    /// 调用方据此决定给「重试」还是给「去修改」按钮。
90    pub fn is_actionable(&self) -> bool {
91        !matches!(self, VerifyError::Unreachable { .. })
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    /// 错误要能原样序列化给前端 —— 带 `code` 判别字段,前端按 code 分支。
100    #[test]
101    fn serializes_with_code_tag() {
102        let e = VerifyError::NotFound {
103            requested_url: "https://api.deepseek.com/chat/completions".into(),
104            suggested_url: Some("https://api.deepseek.com/v1".into()),
105        };
106        let j = serde_json::to_string(&e).unwrap();
107        assert!(
108            j.contains(r#""code":"not_found""#),
109            "前端要按 code 分支:{j}"
110        );
111        assert!(j.contains("suggested_url"), "一键修正靠这个字段:{j}");
112    }
113
114    /// 网络不通只能重试,其余都是「改配置」能解决的。
115    #[test]
116    fn unreachable_is_not_actionable() {
117        assert!(!VerifyError::Unreachable { proxy_hint: true }.is_actionable());
118        assert!(VerifyError::AuthFailed { detail: "x".into() }.is_actionable());
119    }
120}