Skip to main content

ai_profile/
protocol.rs

1//! `ai.profile` —— 跨应用的模型服务配置交换格式。
2//!
3//! # 信封
4//!
5//! ```json
6//! {
7//!   "kind": "ai.profile",
8//!   "v": 1,
9//!   "data": {
10//!     "name": "我的 DeepSeek",
11//!     "provider": "deepseek",
12//!     "baseURL": "https://api.deepseek.com/v1",
13//!     "apiKey": "sk-...",
14//!     "model": "deepseek-flash",
15//!     "hints": { "toolId": "claude-code" }
16//!   }
17//! }
18//! ```
19//!
20//! # 🔴 字段命名:规范一种、接受三种
21//!
22//! 规范写法是 `baseURL` / `apiKey`(URL 全大写是历史既成事实,不是笔误)。
23//! 但现实中各家生成的 JSON 并不统一,所以解析时**同时接受**:
24//!
25//! | 规范 | 也接受 |
26//! |---|---|
27//! | `baseURL` | `baseUrl`、`base_url` |
28//! | `apiKey` | `api_key` |
29//!
30//! 这不是洁癖问题:调研四份既有实现时发现,其中一份的 Rust 解析器只认
31//! `baseURL` / `base_url`,**漏了 `baseUrl`** —— 另一个应用生成的配置它解析不了,
32//! 而两边都自认为"实现了 ai.profile"。统一到本 crate 正是为了消掉这类静默不兼容。
33//!
34//! # 为什么 `model` 不是必填
35//!
36//! 不少来源软件分享的是"中转站的 key + 端点",model 留空让接收方自己选。
37//! 解析时用 [`ParsedProfile::model_fallback`] 标记这种情况,调用方可据此提示
38//! 用户确认模型名。
39//!
40//! # 多条打包
41//!
42//! ```json
43//! { "kind": "ai.profile.bundle", "v": 1, "data": { "profiles": [ { …单条的 data… } ] } }
44//! ```
45//!
46//! 用独立的 `kind`:只认单条的旧实现遇到它会报「不是 ai.profile」,而不是误读成一条。
47//! 🔴 兼容**已经发出去**的写法:智码(tauri-cc)的打包是 `data.api_profiles`,每条是它
48//! 同步载荷里的 snake_case 档案(`base_url` / `api_key` / 顶层 `tool_id` / `auth_type`),
49//! 还带一个 `manifest`。这些都照收;`auth_type = "oauth"` 的条目跳过 —— OAuth 凭据与
50//! 签发它的设备绑定,换一台机器不可用。
51//!
52//! 入口是 [`parse_profiles`]:单条、多条都接受,调用方不必先判断是哪种。
53
54use serde::{Deserialize, Serialize};
55
56use crate::kind::Protocol;
57
58/// 信封的 `kind` 常量。
59pub const AI_PROFILE_KIND: &str = "ai.profile";
60/// 多条打包的 `kind` 常量。
61pub const AI_PROFILE_BUNDLE_KIND: &str = "ai.profile.bundle";
62/// 当前协议版本。
63pub const AI_PROFILE_VERSION: u32 = 1;
64
65/// 解析成功的结果。
66///
67/// 带 `Serialize` 是为了能直接从 Tauri Command 返回给「粘贴导入」表单。
68///
69/// 🔴 **它含 `api_key` 明文** —— 序列化后会经 IPC 到达前端。这是粘贴导入这个
70/// 功能本身的要求(表单要把密钥填进去),但因此:**不要把整个结构体写进日志**,
71/// 也不要把它存进任何缓存。
72#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
73#[serde(rename_all = "camelCase")]
74#[non_exhaustive]
75pub struct ParsedProfile {
76    /// 配置名;来源没给时为空串,调用方可用 provider 名兜底
77    pub name: String,
78    /// 推断出的协议 —— 决定走 `/v1/messages` 还是 `/v1/chat/completions`
79    pub protocol: Protocol,
80    /// 来源软件给的原始 provider 字符串(原样保留,便于 UI 让用户确认)
81    pub raw_provider: String,
82    /// 端点地址;空串 = 来源没给,走接收方的默认端点
83    pub base_url: String,
84    /// 明文密钥;空串 = 来源没给
85    pub api_key: String,
86    /// 模型 id
87    pub model: String,
88    /// 🔴 为真表示**来源没给 model**,当前值是接收方补的默认值。
89    ///
90    /// 调用方应当提示用户确认 —— 中转站暴露的模型名往往与官方不同,
91    /// 猜错要等第一次对话才报错。
92    pub model_fallback: bool,
93}
94
95/// 解析失败的原因。
96///
97/// 与 [`crate::VerifyError`] 分开:那个是"配置对不对",这个是"这段文本是不是配置"。
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, thiserror::Error)]
99#[serde(tag = "code", rename_all = "snake_case")]
100#[non_exhaustive]
101pub enum ParseError {
102    /// 输入为空
103    #[error("内容为空")]
104    Empty,
105    /// 不是合法 JSON
106    #[error("不是合法的 JSON:{detail}")]
107    InvalidJson {
108        /// serde_json 的报错摘要
109        detail: String,
110    },
111    /// `kind` 字段不是 `ai.profile`
112    #[error("这段内容不是 ai.profile(kind = {found})")]
113    NotAiProfile {
114        /// 实际读到的 kind;缺失时为空串
115        found: String,
116    },
117    /// 协议版本高于本实现所能理解的
118    #[error("协议版本 v{found} 高于当前支持的 v{supported}")]
119    UnsupportedVersion {
120        /// 来源声明的版本
121        found: u32,
122        /// 本实现支持的最高版本
123        supported: u32,
124    },
125    /// `data` 缺失或不是对象
126    #[error("缺少 data 对象")]
127    MissingData,
128    /// 多条打包里没有一条可导入的配置
129    #[error("打包里没有可导入的配置(跳过 {skipped} 条设备绑定的 OAuth 档案)")]
130    EmptyBundle {
131        /// 被跳过的条数(OAuth 档案)
132        skipped: usize,
133    },
134}
135
136/// [`parse_profiles`] 的结果:单条与多条统一成列表。
137///
138/// 🔴 同 [`ParsedProfile`],**含明文密钥**,别写日志、别缓存。
139#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
140#[serde(rename_all = "camelCase")]
141#[non_exhaustive]
142pub struct ParsedProfiles {
143    /// 解析出的配置,顺序与来源一致;单条信封时恰好一条
144    pub profiles: Vec<ParsedProfile>,
145    /// 跳过的条数(设备绑定的 OAuth 档案)。界面应当告诉用户,否则会以为漏导了
146    pub skipped: usize,
147    /// 来源是否为多条打包
148    pub bundle: bool,
149}
150
151/// 线格式:只用于 serde,公开 API 是 [`ParsedProfile`]。
152#[derive(Deserialize)]
153struct Envelope {
154    #[serde(default)]
155    kind: String,
156    #[serde(default = "default_version")]
157    v: u32,
158    /// 先收成 Value:单条与多条的 data 形状不同,按 kind 再解
159    #[serde(default)]
160    data: Option<serde_json::Value>,
161}
162
163/// 多条打包的 data。
164#[derive(Deserialize)]
165struct BundleData {
166    /// 规范写法 `profiles`;智码已发出去的写法是 `api_profiles`
167    #[serde(default, alias = "api_profiles")]
168    profiles: Vec<serde_json::Value>,
169}
170
171fn default_version() -> u32 {
172    AI_PROFILE_VERSION
173}
174
175#[derive(Deserialize)]
176struct Data {
177    #[serde(default)]
178    name: String,
179    #[serde(default)]
180    provider: String,
181    /// 🔴 三种命名都接受 —— 见模块文档
182    #[serde(default, rename = "baseURL", alias = "baseUrl", alias = "base_url")]
183    base_url: String,
184    #[serde(default, rename = "apiKey", alias = "api_key")]
185    api_key: String,
186    #[serde(default)]
187    model: String,
188    #[serde(default)]
189    hints: Option<Hints>,
190    /// 智码打包条目把 tool_id 放在顶层(不在 hints 里),作用相同
191    #[serde(default, rename = "toolId", alias = "tool_id")]
192    tool_id: String,
193    /// 智码档案的认证类型;`oauth` 与设备绑定,打包导入时跳过
194    #[serde(default, rename = "authType", alias = "auth_type")]
195    auth_type: String,
196}
197
198#[derive(Deserialize)]
199struct Hints {
200    #[serde(default, rename = "toolId", alias = "tool_id")]
201    tool_id: String,
202}
203
204/// 把来源的 provider 字符串映射回协议枚举。
205///
206/// 常见来源写法:
207/// - `anthropic` / `claude` / `claude_code` / `claude-code` → Anthropic 原生
208/// - `deepseek` / `openai` / `openrouter` / `google` / `custom` / … → OpenAI 兼容
209///
210/// **两条兜底**(都来自真实场景,不是防御性编程):
211///
212/// 1. provider 不明显但 `model` 以 `claude-` 开头 → Anthropic。
213///    密钥限定 `/v1/messages` 的中转卖家,model 通常就是 `claude-opus` / `claude-sonnet`。
214/// 2. `hints.toolId` 提到 claude → Anthropic。
215///    某些软件分享中转配置时是 `provider: "custom"` + 空 model + `toolId: "claude-code"`,
216///    不看 toolId 会误判成 OpenAI 兼容而请求错端点。
217fn map_protocol(raw_provider: &str, model: &str, tool_id: &str) -> Protocol {
218    let p = raw_provider.trim().to_ascii_lowercase();
219    if p.contains("anthropic") || p.contains("claude") {
220        return Protocol::Anthropic;
221    }
222    if model.trim().to_ascii_lowercase().starts_with("claude-") {
223        return Protocol::Anthropic;
224    }
225    let t = tool_id.trim().to_ascii_lowercase();
226    if t.contains("claude") || t.contains("anthropic") {
227        return Protocol::Anthropic;
228    }
229    Protocol::OpenAiCompatible
230}
231
232/// 解析一段文本为 `ai.profile`。
233///
234/// `default_model`:来源没给 model 时用它兜底(调用方按 provider 预置传入)。
235/// 用了兜底值时 [`ParsedProfile::model_fallback`] 为 `true`。
236pub fn parse_profile(text: &str, default_model: &str) -> Result<ParsedProfile, ParseError> {
237    let env = read_envelope(text)?;
238    if env.kind != AI_PROFILE_KIND {
239        return Err(ParseError::NotAiProfile { found: env.kind });
240    }
241    let data = env.data.ok_or(ParseError::MissingData)?;
242    Ok(build_profile(parse_data(data)?, default_model))
243}
244
245/// 解析单条或多条打包,统一返回列表。
246///
247/// - `ai.profile` → 一条
248/// - `ai.profile.bundle` → 多条;OAuth 档案跳过并计入 [`ParsedProfiles::skipped`],
249///   不是对象的条目静默丢弃
250/// - 打包里一条可导入的都没有 → [`ParseError::EmptyBundle`]
251///
252/// `default_model` 的含义同 [`parse_profile`],对每一条生效。
253pub fn parse_profiles(text: &str, default_model: &str) -> Result<ParsedProfiles, ParseError> {
254    let env = read_envelope(text)?;
255    // 🔴 先认 kind 再要 data:粘了一段别的 JSON(没有 data)时该说「不是 ai.profile」,
256    //    而不是「缺少 data」—— 后者会让用户以为自己粘的是一条残缺的配置
257    if env.kind != AI_PROFILE_KIND && env.kind != AI_PROFILE_BUNDLE_KIND {
258        return Err(ParseError::NotAiProfile { found: env.kind });
259    }
260    let data = env.data.ok_or(ParseError::MissingData)?;
261    match env.kind.as_str() {
262        AI_PROFILE_KIND => Ok(ParsedProfiles {
263            profiles: vec![build_profile(parse_data(data)?, default_model)],
264            skipped: 0,
265            bundle: false,
266        }),
267        AI_PROFILE_BUNDLE_KIND => {
268            let bundle: BundleData = serde_json::from_value(data).map_err(invalid_json)?;
269            let mut profiles = Vec::with_capacity(bundle.profiles.len());
270            let mut skipped = 0;
271            for item in bundle.profiles {
272                if !item.is_object() {
273                    continue;
274                }
275                let d = parse_data(item)?;
276                if d.auth_type.trim().eq_ignore_ascii_case("oauth") {
277                    skipped += 1;
278                    continue;
279                }
280                profiles.push(build_profile(d, default_model));
281            }
282            if profiles.is_empty() {
283                return Err(ParseError::EmptyBundle { skipped });
284            }
285            Ok(ParsedProfiles {
286                profiles,
287                skipped,
288                bundle: true,
289            })
290        }
291        _ => Err(ParseError::NotAiProfile { found: env.kind }),
292    }
293}
294
295/// 读信封并做与 kind 无关的检查(空、JSON、版本)。
296fn read_envelope(text: &str) -> Result<Envelope, ParseError> {
297    let trimmed = text.trim();
298    if trimmed.is_empty() {
299        return Err(ParseError::Empty);
300    }
301    let value: serde_json::Value = serde_json::from_str(trimmed).map_err(invalid_json)?;
302    // 🔴 信封必须是对象。直接反序列化成结构体的话,serde 会「按字段顺序」接受数组,
303    //    `["ai.profile",1,{…}]` 也能解析成功 —— 协议里没有这种写法
304    if !value.is_object() {
305        return Err(ParseError::InvalidJson {
306            detail: "顶层必须是 JSON 对象".to_string(),
307        });
308    }
309    let env: Envelope = serde_json::from_value(value).map_err(invalid_json)?;
310    // 🔴 只拒绝**更高**的版本:低版本能被高版本实现读懂(字段只增不改),
311    //    拒绝低版本会把老软件分享的配置挡在外面。单条与打包共用同一个版本号。
312    if env.v > AI_PROFILE_VERSION {
313        return Err(ParseError::UnsupportedVersion {
314            found: env.v,
315            supported: AI_PROFILE_VERSION,
316        });
317    }
318    Ok(env)
319}
320
321fn invalid_json(e: serde_json::Error) -> ParseError {
322    ParseError::InvalidJson {
323        detail: e.to_string(),
324    }
325}
326
327/// data 必须是对象;字段类型不对(如 name 是数字)按 JSON 错误处理。
328fn parse_data(v: serde_json::Value) -> Result<Data, ParseError> {
329    if !v.is_object() {
330        return Err(ParseError::MissingData);
331    }
332    serde_json::from_value(v).map_err(invalid_json)
333}
334
335fn build_profile(d: Data, default_model: &str) -> ParsedProfile {
336    // hints.toolId 优先;智码打包条目把它放在顶层
337    let tool_id = d
338        .hints
339        .map(|h| h.tool_id)
340        .filter(|t| !t.trim().is_empty())
341        .unwrap_or(d.tool_id);
342    let protocol = map_protocol(&d.provider, &d.model, &tool_id);
343
344    let model_given = !d.model.trim().is_empty();
345    let model = if model_given {
346        d.model.trim().to_string()
347    } else {
348        default_model.trim().to_string()
349    };
350
351    ParsedProfile {
352        name: d.name.trim().to_string(),
353        protocol,
354        raw_provider: d.provider.trim().to_string(),
355        base_url: d.base_url.trim().to_string(),
356        api_key: d.api_key.trim().to_string(),
357        model,
358        model_fallback: !model_given,
359    }
360}
361
362/// 生成 `ai.profile` JSON 文本。
363///
364/// 🔴 输出**一律用规范命名**(`baseURL` / `apiKey`)—— 宽进严出:
365/// 解析时接受各种别名,生成时只产出一种,别再给生态添新的变体。
366///
367/// # 安全
368///
369/// 产物含**明文密钥**。调用方必须自己决定它去哪(剪贴板 / 文件),
370/// 并且不要把它写进日志。本 crate 不做持久化。
371pub fn to_profile(
372    name: &str,
373    protocol: Protocol,
374    base_url: &str,
375    api_key: &str,
376    model: &str,
377) -> String {
378    // provider 用通用标签而非内部枚举名:接收方按这个字符串做映射,
379    // "openai" 比 "openai_compatible" 更容易被别家认出来。
380    let provider = match protocol {
381        Protocol::Anthropic => "anthropic",
382        _ => "openai",
383    };
384    let v = serde_json::json!({
385        "kind": AI_PROFILE_KIND,
386        "v": AI_PROFILE_VERSION,
387        "data": {
388            "name": name,
389            "provider": provider,
390            "baseURL": base_url,
391            "apiKey": api_key,
392            "model": model,
393        }
394    });
395    serde_json::to_string_pretty(&v).unwrap_or_default()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    /// 信封必须是 JSON 对象。serde 默认允许「按字段顺序」从数组反序列化结构体,
403    /// 不拦的话 `["ai.profile",1,{…}]` 会被当成合法配置 —— 协议里没有这种写法,
404    /// 写进多语言规范就等于逼别的语言去模仿一个库的副作用。
405    #[test]
406    fn envelope_must_be_an_object() {
407        let arr =
408            r#"["ai.profile",1,{"name":"x","baseURL":"https://a/v1","apiKey":"k","model":"m"}]"#;
409        assert!(matches!(
410            parse_profiles(arr, "m"),
411            Err(ParseError::InvalidJson { .. })
412        ));
413        assert!(matches!(
414            parse_profile(arr, "m"),
415            Err(ParseError::InvalidJson { .. })
416        ));
417    }
418
419    /// 🔴 `ParsedProfile` 的线格式是「粘贴导入」表单的契约。
420    #[test]
421    fn parsed_profile_serializes_camel_case() {
422        let p = parse_profile(
423            r#"{"kind":"ai.profile","v":1,"data":{"name":"x","baseURL":"https://a/v1","apiKey":"sk-1"}}"#,
424            "fallback-model",
425        )
426        .unwrap();
427        let j = serde_json::to_string(&p).unwrap();
428        assert!(j.contains(r#""baseUrl":"https://a/v1""#), "{j}");
429        assert!(j.contains(r#""apiKey":"sk-1""#), "{j}");
430        assert!(j.contains(r#""modelFallback":true"#), "来源没给 model:{j}");
431        assert!(j.contains(r#""rawProvider""#), "{j}");
432    }
433
434    const CANONICAL: &str = r#"{
435      "kind":"ai.profile","v":1,
436      "data":{"name":"我的 DeepSeek","provider":"deepseek",
437              "baseURL":"https://api.deepseek.com/v1","apiKey":"sk-x","model":"deepseek-flash"}
438    }"#;
439
440    #[test]
441    fn parses_canonical() {
442        let p = parse_profile(CANONICAL, "fallback").unwrap();
443        assert_eq!(p.name, "我的 DeepSeek");
444        assert_eq!(p.protocol, Protocol::OpenAiCompatible);
445        assert_eq!(p.base_url, "https://api.deepseek.com/v1");
446        assert_eq!(p.model, "deepseek-flash");
447        assert!(!p.model_fallback);
448    }
449
450    /// 🔴 三种 base_url 命名都要认 —— 调研中发现某实现漏了 `baseUrl`,
451    /// 导致另一个应用生成的配置它解析不了,而两边都自认为实现了本协议。
452    #[test]
453    fn accepts_all_three_base_url_spellings() {
454        for key in ["baseURL", "baseUrl", "base_url"] {
455            let json = format!(
456                r#"{{"kind":"ai.profile","v":1,"data":{{"{key}":"https://x.com/v1","apiKey":"k"}}}}"#
457            );
458            let p = parse_profile(&json, "m").unwrap_or_else(|e| panic!("{key} 应被接受:{e}"));
459            assert_eq!(p.base_url, "https://x.com/v1", "{key} 没解析出来");
460        }
461        for key in ["apiKey", "api_key"] {
462            let json = format!(r#"{{"kind":"ai.profile","v":1,"data":{{"{key}":"sk-secret"}}}}"#);
463            let p = parse_profile(&json, "m").unwrap();
464            assert_eq!(p.api_key, "sk-secret", "{key} 没解析出来");
465        }
466    }
467
468    /// 兜底 1:provider 不明显,但 model 以 claude- 开头。
469    #[test]
470    fn infers_anthropic_from_model_name() {
471        let json = r#"{"kind":"ai.profile","v":1,
472          "data":{"provider":"custom","model":"claude-opus-5","baseURL":"https://cc.x.cn/v1"}}"#;
473        let p = parse_profile(json, "m").unwrap();
474        assert_eq!(
475            p.protocol,
476            Protocol::Anthropic,
477            "model 名应触发 Anthropic 兜底"
478        );
479    }
480
481    /// 兜底 2:provider=custom + 空 model + hints.toolId 指向 claude-code。
482    /// 不看 toolId 会误判成 OpenAI 兼容而请求错端点。
483    #[test]
484    fn infers_anthropic_from_tool_id() {
485        let json = r#"{"kind":"ai.profile","v":1,
486          "data":{"provider":"custom","model":"","hints":{"toolId":"claude-code"}}}"#;
487        let p = parse_profile(json, "claude-opus-5").unwrap();
488        assert_eq!(p.protocol, Protocol::Anthropic);
489        assert!(p.model_fallback, "来源没给 model,应标记为兜底值");
490        assert_eq!(p.model, "claude-opus-5");
491    }
492
493    #[test]
494    fn rejects_non_profile_and_bad_json() {
495        assert!(matches!(parse_profile("", "m"), Err(ParseError::Empty)));
496        assert!(matches!(
497            parse_profile("{not json", "m"),
498            Err(ParseError::InvalidJson { .. })
499        ));
500        assert!(matches!(
501            parse_profile(r#"{"kind":"something.else"}"#, "m"),
502            Err(ParseError::NotAiProfile { .. })
503        ));
504        assert!(matches!(
505            parse_profile(r#"{"kind":"ai.profile","v":1}"#, "m"),
506            Err(ParseError::MissingData)
507        ));
508    }
509
510    /// 🔴 只拒绝更高版本 —— 拒绝低版本会把老软件分享的配置挡在外面。
511    #[test]
512    fn accepts_older_version_rejects_newer() {
513        let old = r#"{"kind":"ai.profile","v":1,"data":{"model":"m"}}"#;
514        assert!(parse_profile(old, "m").is_ok());
515
516        let future = r#"{"kind":"ai.profile","v":99,"data":{"model":"m"}}"#;
517        assert!(matches!(
518            parse_profile(future, "m"),
519            Err(ParseError::UnsupportedVersion { found: 99, .. })
520        ));
521    }
522
523    /// 生成 → 解析 → 再生成,字段不丢不变。
524    #[test]
525    fn protocol_roundtrip() {
526        let out = to_profile(
527            "我的 Claude",
528            Protocol::Anthropic,
529            "https://cc.example.cn/v1",
530            "sk-secret",
531            "claude-opus-5",
532        );
533        let p = parse_profile(&out, "fallback").unwrap();
534        assert_eq!(p.name, "我的 Claude");
535        assert_eq!(p.protocol, Protocol::Anthropic);
536        assert_eq!(p.base_url, "https://cc.example.cn/v1");
537        assert_eq!(p.api_key, "sk-secret");
538        assert_eq!(p.model, "claude-opus-5");
539        assert!(!p.model_fallback);
540
541        let again = to_profile("我的 Claude", p.protocol, &p.base_url, &p.api_key, &p.model);
542        assert_eq!(out, again, "两次生成应当一致");
543    }
544
545    /// 🔴 智码(tauri-cc)已经发出去的打包写法:`data.api_profiles` + snake_case 同步档案 +
546    /// 顶层 `tool_id` + `auth_type` + `manifest`。形状取自它的 `ApiProfile` 结构体。
547    const TAURI_CC_BUNDLE: &str = r#"{
548      "kind":"ai.profile.bundle","v":1,
549      "manifest":{"app":"tauri-cc","count":3},
550      "data":{"api_profiles":[
551        {"id":"a","name":"中转 Claude","provider":"custom","api_key":"sk-cc","base_url":"https://relay.example.cn",
552         "is_active":true,"tool_id":"claude-code","model":"","use_proxy":false,"key_auth_type":"auto",
553         "auth_type":"api_key","workspace_id":"local","created_at":"t","updated_at":"t"},
554        {"id":"b","name":"Codex 登录","provider":"openai","api_key":"","base_url":"","tool_id":"codex",
555         "model":"gpt-6","auth_type":"oauth","oauth_payload":"enc:v1:xx"},
556        {"id":"c","name":"DeepSeek","provider":"deepseek","api_key":"sk-ds","base_url":"https://api.deepseek.com/v1",
557         "tool_id":"codex","model":"deepseek-flash","auth_type":"api_key"}
558      ]}
559    }"#;
560
561    #[test]
562    fn parses_tauri_cc_bundle() {
563        let r = parse_profiles(TAURI_CC_BUNDLE, "claude-opus-5").unwrap();
564        assert!(r.bundle);
565        assert_eq!(r.skipped, 1, "OAuth 档案与设备绑定,应跳过并计数");
566        assert_eq!(r.profiles.len(), 2);
567
568        let relay = &r.profiles[0];
569        assert_eq!(relay.name, "中转 Claude");
570        assert_eq!(
571            relay.protocol,
572            Protocol::Anthropic,
573            "顶层 tool_id = claude-code 应触发 Anthropic 兜底"
574        );
575        assert_eq!(relay.base_url, "https://relay.example.cn");
576        assert_eq!(relay.api_key, "sk-cc");
577        assert!(relay.model_fallback);
578        assert_eq!(relay.model, "claude-opus-5");
579
580        let ds = &r.profiles[1];
581        assert_eq!(ds.protocol, Protocol::OpenAiCompatible);
582        assert_eq!(ds.model, "deepseek-flash");
583        assert!(!ds.model_fallback);
584    }
585
586    /// 规范写法 `data.profiles`,条目用单条的 camelCase 字段。
587    #[test]
588    fn parses_canonical_bundle() {
589        let json = r#"{"kind":"ai.profile.bundle","v":1,"data":{"profiles":[
590          {"name":"a","provider":"deepseek","baseURL":"https://api.deepseek.com/v1","apiKey":"k1","model":"deepseek-flash"},
591          {"name":"b","provider":"anthropic","baseUrl":"https://api.anthropic.com/v1","apiKey":"k2","model":"claude-opus-5"}
592        ]}}"#;
593        let r = parse_profiles(json, "m").unwrap();
594        assert_eq!(r.profiles.len(), 2);
595        assert_eq!(r.skipped, 0);
596        assert_eq!(r.profiles[1].protocol, Protocol::Anthropic);
597        assert_eq!(r.profiles[1].base_url, "https://api.anthropic.com/v1");
598    }
599
600    /// 单条信封走 `parse_profiles` 得到一条,结果与 `parse_profile` 一致。
601    #[test]
602    fn parse_profiles_accepts_single() {
603        let r = parse_profiles(CANONICAL, "fallback").unwrap();
604        assert!(!r.bundle);
605        assert_eq!(
606            r.profiles,
607            vec![parse_profile(CANONICAL, "fallback").unwrap()]
608        );
609    }
610
611    #[test]
612    fn bundle_edge_cases() {
613        // 全是 OAuth → 空打包,带跳过条数
614        let only_oauth = r#"{"kind":"ai.profile.bundle","v":1,"data":{"api_profiles":[
615          {"name":"x","auth_type":"oauth"}]}}"#;
616        assert_eq!(
617            parse_profiles(only_oauth, "m"),
618            Err(ParseError::EmptyBundle { skipped: 1 })
619        );
620        // 非对象条目丢弃,不连累其它条
621        let mixed = r#"{"kind":"ai.profile.bundle","v":1,"data":{"profiles":[1,"x",{"name":"ok","model":"m1"}]}}"#;
622        let r = parse_profiles(mixed, "m").unwrap();
623        assert_eq!(r.profiles.len(), 1);
624        assert_eq!(r.profiles[0].name, "ok");
625        // 更高版本的打包照样拒绝
626        let future = r#"{"kind":"ai.profile.bundle","v":99,"data":{"profiles":[]}}"#;
627        assert!(matches!(
628            parse_profiles(future, "m"),
629            Err(ParseError::UnsupportedVersion { found: 99, .. })
630        ));
631        // 🔴 不是 ai.profile 的 JSON(没有 data)要报 not_ai_profile,不能报 missing_data ——
632        //    reeve 的测试抓到过:先查 data 再认 kind,粘错东西时提示成了「缺少 data」
633        assert!(matches!(
634            parse_profiles(r#"{"kind":"other"}"#, "m"),
635            Err(ParseError::NotAiProfile { .. })
636        ));
637        assert!(matches!(
638            parse_profiles(r#"{"kind":"ai.profile.bundle","v":1}"#, "m"),
639            Err(ParseError::MissingData)
640        ));
641        // 🔴 只认单条的入口遇到打包要明确拒绝,而不是误读成一条
642        assert!(matches!(
643            parse_profile(TAURI_CC_BUNDLE, "m"),
644            Err(ParseError::NotAiProfile { .. })
645        ));
646    }
647
648    /// 生成时只产出规范命名 —— 宽进严出,别给生态添新变体。
649    #[test]
650    fn output_uses_canonical_spelling_only() {
651        let out = to_profile("n", Protocol::OpenAiCompatible, "https://x/v1", "k", "m");
652        assert!(out.contains("\"baseURL\""), "应输出规范的 baseURL");
653        assert!(!out.contains("\"base_url\""), "不该输出 snake_case 变体");
654        assert!(out.contains("\"apiKey\""));
655        assert!(!out.contains("\"api_key\""));
656    }
657}