use serde::{Deserialize, Serialize};
use crate::kind::Protocol;
pub const AI_PROFILE_KIND: &str = "ai.profile";
pub const AI_PROFILE_BUNDLE_KIND: &str = "ai.profile.bundle";
pub const AI_PROFILE_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ParsedProfile {
pub name: String,
pub protocol: Protocol,
pub raw_provider: String,
pub base_url: String,
pub api_key: String,
pub model: String,
pub model_fallback: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, thiserror::Error)]
#[serde(tag = "code", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ParseError {
#[error("内容为空")]
Empty,
#[error("不是合法的 JSON:{detail}")]
InvalidJson {
detail: String,
},
#[error("这段内容不是 ai.profile(kind = {found})")]
NotAiProfile {
found: String,
},
#[error("协议版本 v{found} 高于当前支持的 v{supported}")]
UnsupportedVersion {
found: u32,
supported: u32,
},
#[error("缺少 data 对象")]
MissingData,
#[error("打包里没有可导入的配置(跳过 {skipped} 条设备绑定的 OAuth 档案)")]
EmptyBundle {
skipped: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ParsedProfiles {
pub profiles: Vec<ParsedProfile>,
pub skipped: usize,
pub bundle: bool,
}
#[derive(Deserialize)]
struct Envelope {
#[serde(default)]
kind: String,
#[serde(default = "default_version")]
v: u32,
#[serde(default)]
data: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct BundleData {
#[serde(default, alias = "api_profiles")]
profiles: Vec<serde_json::Value>,
}
fn default_version() -> u32 {
AI_PROFILE_VERSION
}
#[derive(Deserialize)]
struct Data {
#[serde(default)]
name: String,
#[serde(default)]
provider: String,
#[serde(default, rename = "baseURL", alias = "baseUrl", alias = "base_url")]
base_url: String,
#[serde(default, rename = "apiKey", alias = "api_key")]
api_key: String,
#[serde(default)]
model: String,
#[serde(default)]
hints: Option<Hints>,
#[serde(default, rename = "toolId", alias = "tool_id")]
tool_id: String,
#[serde(default, rename = "authType", alias = "auth_type")]
auth_type: String,
}
#[derive(Deserialize)]
struct Hints {
#[serde(default, rename = "toolId", alias = "tool_id")]
tool_id: String,
}
fn map_protocol(raw_provider: &str, model: &str, tool_id: &str) -> Protocol {
let p = raw_provider.trim().to_ascii_lowercase();
if p.contains("anthropic") || p.contains("claude") {
return Protocol::Anthropic;
}
if model.trim().to_ascii_lowercase().starts_with("claude-") {
return Protocol::Anthropic;
}
let t = tool_id.trim().to_ascii_lowercase();
if t.contains("claude") || t.contains("anthropic") {
return Protocol::Anthropic;
}
Protocol::OpenAiCompatible
}
pub fn parse_profile(text: &str, default_model: &str) -> Result<ParsedProfile, ParseError> {
let env = read_envelope(text)?;
if env.kind != AI_PROFILE_KIND {
return Err(ParseError::NotAiProfile { found: env.kind });
}
let data = env.data.ok_or(ParseError::MissingData)?;
Ok(build_profile(parse_data(data)?, default_model))
}
pub fn parse_profiles(text: &str, default_model: &str) -> Result<ParsedProfiles, ParseError> {
let env = read_envelope(text)?;
if env.kind != AI_PROFILE_KIND && env.kind != AI_PROFILE_BUNDLE_KIND {
return Err(ParseError::NotAiProfile { found: env.kind });
}
let data = env.data.ok_or(ParseError::MissingData)?;
match env.kind.as_str() {
AI_PROFILE_KIND => Ok(ParsedProfiles {
profiles: vec![build_profile(parse_data(data)?, default_model)],
skipped: 0,
bundle: false,
}),
AI_PROFILE_BUNDLE_KIND => {
let bundle: BundleData = serde_json::from_value(data).map_err(invalid_json)?;
let mut profiles = Vec::with_capacity(bundle.profiles.len());
let mut skipped = 0;
for item in bundle.profiles {
if !item.is_object() {
continue;
}
let d = parse_data(item)?;
if d.auth_type.trim().eq_ignore_ascii_case("oauth") {
skipped += 1;
continue;
}
profiles.push(build_profile(d, default_model));
}
if profiles.is_empty() {
return Err(ParseError::EmptyBundle { skipped });
}
Ok(ParsedProfiles {
profiles,
skipped,
bundle: true,
})
}
_ => Err(ParseError::NotAiProfile { found: env.kind }),
}
}
fn read_envelope(text: &str) -> Result<Envelope, ParseError> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
let value: serde_json::Value = serde_json::from_str(trimmed).map_err(invalid_json)?;
if !value.is_object() {
return Err(ParseError::InvalidJson {
detail: "顶层必须是 JSON 对象".to_string(),
});
}
let env: Envelope = serde_json::from_value(value).map_err(invalid_json)?;
if env.v > AI_PROFILE_VERSION {
return Err(ParseError::UnsupportedVersion {
found: env.v,
supported: AI_PROFILE_VERSION,
});
}
Ok(env)
}
fn invalid_json(e: serde_json::Error) -> ParseError {
ParseError::InvalidJson {
detail: e.to_string(),
}
}
fn parse_data(v: serde_json::Value) -> Result<Data, ParseError> {
if !v.is_object() {
return Err(ParseError::MissingData);
}
serde_json::from_value(v).map_err(invalid_json)
}
fn build_profile(d: Data, default_model: &str) -> ParsedProfile {
let tool_id = d
.hints
.map(|h| h.tool_id)
.filter(|t| !t.trim().is_empty())
.unwrap_or(d.tool_id);
let protocol = map_protocol(&d.provider, &d.model, &tool_id);
let model_given = !d.model.trim().is_empty();
let model = if model_given {
d.model.trim().to_string()
} else {
default_model.trim().to_string()
};
ParsedProfile {
name: d.name.trim().to_string(),
protocol,
raw_provider: d.provider.trim().to_string(),
base_url: d.base_url.trim().to_string(),
api_key: d.api_key.trim().to_string(),
model,
model_fallback: !model_given,
}
}
pub fn to_profile(
name: &str,
protocol: Protocol,
base_url: &str,
api_key: &str,
model: &str,
) -> String {
let provider = match protocol {
Protocol::Anthropic => "anthropic",
_ => "openai",
};
let v = serde_json::json!({
"kind": AI_PROFILE_KIND,
"v": AI_PROFILE_VERSION,
"data": {
"name": name,
"provider": provider,
"baseURL": base_url,
"apiKey": api_key,
"model": model,
}
});
serde_json::to_string_pretty(&v).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_must_be_an_object() {
let arr =
r#"["ai.profile",1,{"name":"x","baseURL":"https://a/v1","apiKey":"k","model":"m"}]"#;
assert!(matches!(
parse_profiles(arr, "m"),
Err(ParseError::InvalidJson { .. })
));
assert!(matches!(
parse_profile(arr, "m"),
Err(ParseError::InvalidJson { .. })
));
}
#[test]
fn parsed_profile_serializes_camel_case() {
let p = parse_profile(
r#"{"kind":"ai.profile","v":1,"data":{"name":"x","baseURL":"https://a/v1","apiKey":"sk-1"}}"#,
"fallback-model",
)
.unwrap();
let j = serde_json::to_string(&p).unwrap();
assert!(j.contains(r#""baseUrl":"https://a/v1""#), "{j}");
assert!(j.contains(r#""apiKey":"sk-1""#), "{j}");
assert!(j.contains(r#""modelFallback":true"#), "来源没给 model:{j}");
assert!(j.contains(r#""rawProvider""#), "{j}");
}
const CANONICAL: &str = r#"{
"kind":"ai.profile","v":1,
"data":{"name":"我的 DeepSeek","provider":"deepseek",
"baseURL":"https://api.deepseek.com/v1","apiKey":"sk-x","model":"deepseek-flash"}
}"#;
#[test]
fn parses_canonical() {
let p = parse_profile(CANONICAL, "fallback").unwrap();
assert_eq!(p.name, "我的 DeepSeek");
assert_eq!(p.protocol, Protocol::OpenAiCompatible);
assert_eq!(p.base_url, "https://api.deepseek.com/v1");
assert_eq!(p.model, "deepseek-flash");
assert!(!p.model_fallback);
}
#[test]
fn accepts_all_three_base_url_spellings() {
for key in ["baseURL", "baseUrl", "base_url"] {
let json = format!(
r#"{{"kind":"ai.profile","v":1,"data":{{"{key}":"https://x.com/v1","apiKey":"k"}}}}"#
);
let p = parse_profile(&json, "m").unwrap_or_else(|e| panic!("{key} 应被接受:{e}"));
assert_eq!(p.base_url, "https://x.com/v1", "{key} 没解析出来");
}
for key in ["apiKey", "api_key"] {
let json = format!(r#"{{"kind":"ai.profile","v":1,"data":{{"{key}":"sk-secret"}}}}"#);
let p = parse_profile(&json, "m").unwrap();
assert_eq!(p.api_key, "sk-secret", "{key} 没解析出来");
}
}
#[test]
fn infers_anthropic_from_model_name() {
let json = r#"{"kind":"ai.profile","v":1,
"data":{"provider":"custom","model":"claude-opus-5","baseURL":"https://cc.x.cn/v1"}}"#;
let p = parse_profile(json, "m").unwrap();
assert_eq!(
p.protocol,
Protocol::Anthropic,
"model 名应触发 Anthropic 兜底"
);
}
#[test]
fn infers_anthropic_from_tool_id() {
let json = r#"{"kind":"ai.profile","v":1,
"data":{"provider":"custom","model":"","hints":{"toolId":"claude-code"}}}"#;
let p = parse_profile(json, "claude-opus-5").unwrap();
assert_eq!(p.protocol, Protocol::Anthropic);
assert!(p.model_fallback, "来源没给 model,应标记为兜底值");
assert_eq!(p.model, "claude-opus-5");
}
#[test]
fn rejects_non_profile_and_bad_json() {
assert!(matches!(parse_profile("", "m"), Err(ParseError::Empty)));
assert!(matches!(
parse_profile("{not json", "m"),
Err(ParseError::InvalidJson { .. })
));
assert!(matches!(
parse_profile(r#"{"kind":"something.else"}"#, "m"),
Err(ParseError::NotAiProfile { .. })
));
assert!(matches!(
parse_profile(r#"{"kind":"ai.profile","v":1}"#, "m"),
Err(ParseError::MissingData)
));
}
#[test]
fn accepts_older_version_rejects_newer() {
let old = r#"{"kind":"ai.profile","v":1,"data":{"model":"m"}}"#;
assert!(parse_profile(old, "m").is_ok());
let future = r#"{"kind":"ai.profile","v":99,"data":{"model":"m"}}"#;
assert!(matches!(
parse_profile(future, "m"),
Err(ParseError::UnsupportedVersion { found: 99, .. })
));
}
#[test]
fn protocol_roundtrip() {
let out = to_profile(
"我的 Claude",
Protocol::Anthropic,
"https://cc.example.cn/v1",
"sk-secret",
"claude-opus-5",
);
let p = parse_profile(&out, "fallback").unwrap();
assert_eq!(p.name, "我的 Claude");
assert_eq!(p.protocol, Protocol::Anthropic);
assert_eq!(p.base_url, "https://cc.example.cn/v1");
assert_eq!(p.api_key, "sk-secret");
assert_eq!(p.model, "claude-opus-5");
assert!(!p.model_fallback);
let again = to_profile("我的 Claude", p.protocol, &p.base_url, &p.api_key, &p.model);
assert_eq!(out, again, "两次生成应当一致");
}
const TAURI_CC_BUNDLE: &str = r#"{
"kind":"ai.profile.bundle","v":1,
"manifest":{"app":"tauri-cc","count":3},
"data":{"api_profiles":[
{"id":"a","name":"中转 Claude","provider":"custom","api_key":"sk-cc","base_url":"https://relay.example.cn",
"is_active":true,"tool_id":"claude-code","model":"","use_proxy":false,"key_auth_type":"auto",
"auth_type":"api_key","workspace_id":"local","created_at":"t","updated_at":"t"},
{"id":"b","name":"Codex 登录","provider":"openai","api_key":"","base_url":"","tool_id":"codex",
"model":"gpt-6","auth_type":"oauth","oauth_payload":"enc:v1:xx"},
{"id":"c","name":"DeepSeek","provider":"deepseek","api_key":"sk-ds","base_url":"https://api.deepseek.com/v1",
"tool_id":"codex","model":"deepseek-flash","auth_type":"api_key"}
]}
}"#;
#[test]
fn parses_tauri_cc_bundle() {
let r = parse_profiles(TAURI_CC_BUNDLE, "claude-opus-5").unwrap();
assert!(r.bundle);
assert_eq!(r.skipped, 1, "OAuth 档案与设备绑定,应跳过并计数");
assert_eq!(r.profiles.len(), 2);
let relay = &r.profiles[0];
assert_eq!(relay.name, "中转 Claude");
assert_eq!(
relay.protocol,
Protocol::Anthropic,
"顶层 tool_id = claude-code 应触发 Anthropic 兜底"
);
assert_eq!(relay.base_url, "https://relay.example.cn");
assert_eq!(relay.api_key, "sk-cc");
assert!(relay.model_fallback);
assert_eq!(relay.model, "claude-opus-5");
let ds = &r.profiles[1];
assert_eq!(ds.protocol, Protocol::OpenAiCompatible);
assert_eq!(ds.model, "deepseek-flash");
assert!(!ds.model_fallback);
}
#[test]
fn parses_canonical_bundle() {
let json = r#"{"kind":"ai.profile.bundle","v":1,"data":{"profiles":[
{"name":"a","provider":"deepseek","baseURL":"https://api.deepseek.com/v1","apiKey":"k1","model":"deepseek-flash"},
{"name":"b","provider":"anthropic","baseUrl":"https://api.anthropic.com/v1","apiKey":"k2","model":"claude-opus-5"}
]}}"#;
let r = parse_profiles(json, "m").unwrap();
assert_eq!(r.profiles.len(), 2);
assert_eq!(r.skipped, 0);
assert_eq!(r.profiles[1].protocol, Protocol::Anthropic);
assert_eq!(r.profiles[1].base_url, "https://api.anthropic.com/v1");
}
#[test]
fn parse_profiles_accepts_single() {
let r = parse_profiles(CANONICAL, "fallback").unwrap();
assert!(!r.bundle);
assert_eq!(
r.profiles,
vec![parse_profile(CANONICAL, "fallback").unwrap()]
);
}
#[test]
fn bundle_edge_cases() {
let only_oauth = r#"{"kind":"ai.profile.bundle","v":1,"data":{"api_profiles":[
{"name":"x","auth_type":"oauth"}]}}"#;
assert_eq!(
parse_profiles(only_oauth, "m"),
Err(ParseError::EmptyBundle { skipped: 1 })
);
let mixed = r#"{"kind":"ai.profile.bundle","v":1,"data":{"profiles":[1,"x",{"name":"ok","model":"m1"}]}}"#;
let r = parse_profiles(mixed, "m").unwrap();
assert_eq!(r.profiles.len(), 1);
assert_eq!(r.profiles[0].name, "ok");
let future = r#"{"kind":"ai.profile.bundle","v":99,"data":{"profiles":[]}}"#;
assert!(matches!(
parse_profiles(future, "m"),
Err(ParseError::UnsupportedVersion { found: 99, .. })
));
assert!(matches!(
parse_profiles(r#"{"kind":"other"}"#, "m"),
Err(ParseError::NotAiProfile { .. })
));
assert!(matches!(
parse_profiles(r#"{"kind":"ai.profile.bundle","v":1}"#, "m"),
Err(ParseError::MissingData)
));
assert!(matches!(
parse_profile(TAURI_CC_BUNDLE, "m"),
Err(ParseError::NotAiProfile { .. })
));
}
#[test]
fn output_uses_canonical_spelling_only() {
let out = to_profile("n", Protocol::OpenAiCompatible, "https://x/v1", "k", "m");
assert!(out.contains("\"baseURL\""), "应输出规范的 baseURL");
assert!(!out.contains("\"base_url\""), "不该输出 snake_case 变体");
assert!(out.contains("\"apiKey\""));
assert!(!out.contains("\"api_key\""));
}
}