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}