1use serde::{Deserialize, Serialize};
55
56use crate::kind::Protocol;
57
58pub const AI_PROFILE_KIND: &str = "ai.profile";
60pub const AI_PROFILE_BUNDLE_KIND: &str = "ai.profile.bundle";
62pub const AI_PROFILE_VERSION: u32 = 1;
64
65#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
73#[serde(rename_all = "camelCase")]
74#[non_exhaustive]
75pub struct ParsedProfile {
76 pub name: String,
78 pub protocol: Protocol,
80 pub raw_provider: String,
82 pub base_url: String,
84 pub api_key: String,
86 pub model: String,
88 pub model_fallback: bool,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, thiserror::Error)]
99#[serde(tag = "code", rename_all = "snake_case")]
100#[non_exhaustive]
101pub enum ParseError {
102 #[error("内容为空")]
104 Empty,
105 #[error("不是合法的 JSON:{detail}")]
107 InvalidJson {
108 detail: String,
110 },
111 #[error("这段内容不是 ai.profile(kind = {found})")]
113 NotAiProfile {
114 found: String,
116 },
117 #[error("协议版本 v{found} 高于当前支持的 v{supported}")]
119 UnsupportedVersion {
120 found: u32,
122 supported: u32,
124 },
125 #[error("缺少 data 对象")]
127 MissingData,
128 #[error("打包里没有可导入的配置(跳过 {skipped} 条设备绑定的 OAuth 档案)")]
130 EmptyBundle {
131 skipped: usize,
133 },
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
140#[serde(rename_all = "camelCase")]
141#[non_exhaustive]
142pub struct ParsedProfiles {
143 pub profiles: Vec<ParsedProfile>,
145 pub skipped: usize,
147 pub bundle: bool,
149}
150
151#[derive(Deserialize)]
153struct Envelope {
154 #[serde(default)]
155 kind: String,
156 #[serde(default = "default_version")]
157 v: u32,
158 #[serde(default)]
160 data: Option<serde_json::Value>,
161}
162
163#[derive(Deserialize)]
165struct BundleData {
166 #[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 #[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 #[serde(default, rename = "toolId", alias = "tool_id")]
192 tool_id: String,
193 #[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
204fn 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
232pub 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
245pub fn parse_profiles(text: &str, default_model: &str) -> Result<ParsedProfiles, ParseError> {
254 let env = read_envelope(text)?;
255 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
295fn 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 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 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
327fn 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 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
362pub fn to_profile(
372 name: &str,
373 protocol: Protocol,
374 base_url: &str,
375 api_key: &str,
376 model: &str,
377) -> String {
378 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 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 assert!(matches!(
643 parse_profile(TAURI_CC_BUNDLE, "m"),
644 Err(ParseError::NotAiProfile { .. })
645 ));
646 }
647
648 #[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}