Skip to main content

cc_switch_lib/
provider.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6// SSOT 模式:不再写供应商副本文件
7
8/// 供应商结构体
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Provider {
11    pub id: String,
12    pub name: String,
13    #[serde(rename = "settingsConfig")]
14    pub settings_config: Value,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    #[serde(rename = "websiteUrl")]
17    pub website_url: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub category: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    #[serde(rename = "createdAt")]
22    pub created_at: Option<i64>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    #[serde(rename = "sortIndex")]
25    pub sort_index: Option<usize>,
26    /// 备注信息
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub notes: Option<String>,
29    /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch-tui/config.json)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub meta: Option<ProviderMeta>,
32    /// 图标名称(如 "openai", "anthropic")
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub icon: Option<String>,
35    /// 图标颜色(Hex 格式,如 "#00A67E")
36    #[serde(skip_serializing_if = "Option::is_none")]
37    #[serde(rename = "iconColor")]
38    pub icon_color: Option<String>,
39    /// 是否加入故障转移队列
40    #[serde(default)]
41    #[serde(rename = "inFailoverQueue")]
42    pub in_failover_queue: bool,
43}
44
45impl Provider {
46    /// 从现有ID创建供应商
47    pub fn with_id(
48        id: String,
49        name: String,
50        settings_config: Value,
51        website_url: Option<String>,
52    ) -> Self {
53        Self {
54            id,
55            name,
56            settings_config,
57            website_url,
58            category: None,
59            created_at: None,
60            sort_index: None,
61            notes: None,
62            meta: None,
63            icon: None,
64            icon_color: None,
65            in_failover_queue: false,
66        }
67    }
68}
69
70/// 供应商管理器
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct ProviderManager {
73    pub providers: IndexMap<String, Provider>,
74    pub current: String,
75}
76
77/// 用量查询脚本配置
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct UsageScript {
80    pub enabled: bool,
81    pub language: String,
82    pub code: String,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub timeout: Option<u64>,
85    /// 用量查询专用的 API Key(通用模板使用)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    #[serde(rename = "apiKey")]
88    pub api_key: Option<String>,
89    /// 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
90    #[serde(skip_serializing_if = "Option::is_none")]
91    #[serde(rename = "baseUrl")]
92    pub base_url: Option<String>,
93    /// 访问令牌(用于需要登录的接口,NewAPI 模板使用)
94    #[serde(skip_serializing_if = "Option::is_none")]
95    #[serde(rename = "accessToken")]
96    pub access_token: Option<String>,
97    /// 用户ID(用于需要用户标识的接口,NewAPI 模板使用)
98    #[serde(skip_serializing_if = "Option::is_none")]
99    #[serde(rename = "userId")]
100    pub user_id: Option<String>,
101    /// 模板类型(用于后端判断验证规则)
102    #[serde(skip_serializing_if = "Option::is_none")]
103    #[serde(rename = "templateType")]
104    pub template_type: Option<String>,
105    /// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
106    #[serde(skip_serializing_if = "Option::is_none")]
107    #[serde(rename = "autoQueryInterval")]
108    pub auto_query_interval: Option<u64>,
109}
110
111/// 用量数据
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct UsageData {
114    #[serde(skip_serializing_if = "Option::is_none")]
115    #[serde(rename = "planName")]
116    pub plan_name: Option<String>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub extra: Option<String>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    #[serde(rename = "isValid")]
121    pub is_valid: Option<bool>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    #[serde(rename = "invalidMessage")]
124    pub invalid_message: Option<String>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub total: Option<f64>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub used: Option<f64>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub remaining: Option<f64>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub unit: Option<String>,
133}
134
135/// 用量查询结果(支持多套餐)
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct UsageResult {
138    pub success: bool,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub data: Option<Vec<UsageData>>, // 支持返回多个套餐
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub error: Option<String>,
143}
144
145/// 供应商单独的模型测试配置
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct ProviderTestConfig {
148    /// 是否启用单独配置(false 时使用全局配置)
149    #[serde(default)]
150    pub enabled: bool,
151    /// 测试用的模型名称(覆盖全局配置)
152    #[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
153    pub test_model: Option<String>,
154    /// 超时时间(秒)
155    #[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
156    pub timeout_secs: Option<u64>,
157    /// 测试提示词
158    #[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
159    pub test_prompt: Option<String>,
160    /// 降级阈值(毫秒)
161    #[serde(
162        rename = "degradedThresholdMs",
163        skip_serializing_if = "Option::is_none"
164    )]
165    pub degraded_threshold_ms: Option<u64>,
166    /// 最大重试次数
167    #[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
168    pub max_retries: Option<u32>,
169}
170
171/// 供应商单独的代理配置
172#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173pub struct ProviderProxyConfig {
174    /// 是否启用单独配置(false 时使用全局/系统代理)
175    #[serde(default)]
176    pub enabled: bool,
177    /// 代理类型:http, https, socks5
178    #[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
179    pub proxy_type: Option<String>,
180    /// 代理主机
181    #[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
182    pub proxy_host: Option<String>,
183    /// 代理端口
184    #[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
185    pub proxy_port: Option<u16>,
186    /// 代理用户名(可选)
187    #[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
188    pub proxy_username: Option<String>,
189    /// 代理密码(可选)
190    #[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
191    pub proxy_password: Option<String>,
192}
193
194/// 认证绑定来源
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
196#[serde(rename_all = "snake_case")]
197pub enum AuthBindingSource {
198    /// 从 provider 自身配置读取认证信息(默认)
199    #[default]
200    ProviderConfig,
201    /// 使用托管账号认证(如 GitHub Copilot OAuth)
202    ManagedAccount,
203}
204
205/// 通用认证绑定
206#[derive(Debug, Clone, Serialize, Deserialize, Default)]
207pub struct AuthBinding {
208    /// 认证来源
209    #[serde(default)]
210    pub source: AuthBindingSource,
211    /// 托管认证供应商标识(如 github_copilot)
212    #[serde(rename = "authProvider", skip_serializing_if = "Option::is_none")]
213    pub auth_provider: Option<String>,
214    /// 托管账号 ID;为空表示跟随该认证供应商的默认账号
215    #[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
216    pub account_id: Option<String>,
217}
218
219/// 供应商元数据
220#[derive(Debug, Clone, Serialize, Deserialize, Default)]
221pub struct ProviderMeta {
222    /// 是否在写入 live 配置时合并通用配置片段
223    #[serde(
224        rename = "commonConfigEnabled",
225        alias = "applyCommonConfig",
226        skip_serializing_if = "Option::is_none"
227    )]
228    pub apply_common_config: Option<bool>,
229    /// Codex 官方供应商标记(官方无需填写 API Key,使用 codex login 凭证)
230    #[serde(rename = "codexOfficial", skip_serializing_if = "Option::is_none")]
231    pub codex_official: Option<bool>,
232    /// 自定义端点列表(按 URL 去重存储)
233    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
234    pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
235    /// 用量查询脚本配置
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub usage_script: Option<UsageScript>,
238    /// 请求地址管理:测速后自动选择最佳端点
239    #[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
240    pub endpoint_auto_select: Option<bool>,
241    /// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
242    #[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
243    pub is_partner: Option<bool>,
244    /// 合作伙伴促销 key,用于识别 PackyCode 等特殊供应商
245    #[serde(
246        rename = "partnerPromotionKey",
247        skip_serializing_if = "Option::is_none"
248    )]
249    pub partner_promotion_key: Option<String>,
250    /// 成本倍数(用于计算实际成本)
251    #[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
252    pub cost_multiplier: Option<String>,
253    /// 计费模式来源(response/request)
254    #[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
255    pub pricing_model_source: Option<String>,
256    /// 每日消费限额(USD)
257    #[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
258    pub limit_daily_usd: Option<String>,
259    /// 每月消费限额(USD)
260    #[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
261    pub limit_monthly_usd: Option<String>,
262    /// 供应商单独的模型测试配置
263    #[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
264    pub test_config: Option<ProviderTestConfig>,
265    /// 供应商单独的代理配置
266    #[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
267    pub proxy_config: Option<ProviderProxyConfig>,
268    /// Claude API 格式(仅 Claude 供应商使用)
269    /// - "anthropic": 原生 Anthropic Messages API,直接透传
270    /// - "openai_chat": OpenAI Chat Completions 格式,需要转换
271    /// - "openai_responses": OpenAI Responses API 格式,需要转换
272    #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
273    pub api_format: Option<String>,
274    /// OpenAI 兼容端点使用的 prompt cache key。
275    #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
276    pub prompt_cache_key: Option<String>,
277    /// 通用认证绑定(provider_config / managed_account)
278    #[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")]
279    pub auth_binding: Option<AuthBinding>,
280    /// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY")
281    #[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
282    pub api_key_field: Option<String>,
283    /// 累加模式应用中,该 provider 是否已写入 live config。
284    /// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
285    #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
286    pub live_config_managed: Option<bool>,
287    /// Codex live config 中 `[model_providers.<key>]` 的稳定外部键。
288    #[serde(
289        rename = "codexModelProviderKey",
290        skip_serializing_if = "Option::is_none"
291    )]
292    pub codex_model_provider_key: Option<String>,
293    /// 供应商类型标识(用于特殊供应商检测)
294    /// - "github_copilot": GitHub Copilot 供应商
295    #[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
296    pub provider_type: Option<String>,
297    /// GitHub Copilot 关联账号 ID(仅 github_copilot 供应商使用)
298    /// 用于多账号支持,关联到特定的 GitHub 账号
299    #[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
300    pub github_account_id: Option<String>,
301}
302
303impl ProviderMeta {
304    pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
305        if let Some(binding) = self.auth_binding.as_ref() {
306            if binding.source == AuthBindingSource::ManagedAccount
307                && binding.auth_provider.as_deref() == Some(auth_provider)
308            {
309                return binding.account_id.clone();
310            }
311        }
312
313        if auth_provider == "github_copilot" {
314            return self.github_account_id.clone();
315        }
316
317        None
318    }
319}
320
321impl ProviderManager {
322    /// 获取所有供应商
323    pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {
324        &self.providers
325    }
326}
327
328/// OpenCode 供应商配置。
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct OpenCodeProviderConfig {
331    pub npm: String,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub name: Option<String>,
334    #[serde(default)]
335    pub options: OpenCodeProviderOptions,
336    #[serde(default)]
337    pub models: HashMap<String, OpenCodeModel>,
338}
339
340impl Default for OpenCodeProviderConfig {
341    fn default() -> Self {
342        Self {
343            npm: "@ai-sdk/openai-compatible".to_string(),
344            name: None,
345            options: OpenCodeProviderOptions::default(),
346            models: HashMap::new(),
347        }
348    }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, Default)]
352pub struct OpenCodeProviderOptions {
353    #[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
354    pub base_url: Option<String>,
355    #[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
356    pub api_key: Option<String>,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub headers: Option<HashMap<String, String>>,
359    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
360    pub extra: HashMap<String, Value>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct OpenCodeModel {
365    pub name: String,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub limit: Option<OpenCodeModelLimit>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub options: Option<HashMap<String, Value>>,
370    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
371    pub extra: HashMap<String, Value>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, Default)]
375pub struct OpenCodeModelLimit {
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub context: Option<u64>,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub output: Option<u64>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, Default)]
383#[serde(rename_all = "camelCase")]
384pub struct OpenClawProviderConfig {
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub base_url: Option<String>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub api_key: Option<String>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub api: Option<String>,
391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
392    pub models: Vec<OpenClawModelEntry>,
393    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
394    pub headers: HashMap<String, String>,
395    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
396    pub extra: HashMap<String, Value>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, Default)]
400#[serde(rename_all = "camelCase")]
401pub struct OpenClawModelEntry {
402    pub id: String,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub name: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub alias: Option<String>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub cost: Option<OpenClawModelCost>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub context_window: Option<u32>,
411    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
412    pub extra: HashMap<String, Value>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, Default)]
416pub struct OpenClawModelCost {
417    pub input: f64,
418    pub output: f64,
419    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
420    pub extra: HashMap<String, Value>,
421}
422
423#[cfg(test)]
424mod tests {
425    use super::{AuthBinding, AuthBindingSource, ProviderMeta};
426
427    #[test]
428    fn provider_meta_serializes_upstream_common_config_key_and_accepts_legacy_alias() {
429        let meta = ProviderMeta {
430            apply_common_config: Some(true),
431            ..Default::default()
432        };
433
434        let serialized = serde_json::to_value(&meta).expect("serialize provider meta");
435        assert_eq!(serialized["commonConfigEnabled"], true);
436        assert!(
437            serialized.get("applyCommonConfig").is_none(),
438            "serialization should prefer the upstream key name"
439        );
440
441        let deserialized: ProviderMeta = serde_json::from_value(serde_json::json!({
442            "applyCommonConfig": false
443        }))
444        .expect("deserialize legacy alias");
445        assert_eq!(deserialized.apply_common_config, Some(false));
446    }
447
448    #[test]
449    fn provider_meta_managed_account_id_for_prefers_binding_and_falls_back_to_legacy_github_id() {
450        let managed_meta = ProviderMeta {
451            auth_binding: Some(AuthBinding {
452                source: AuthBindingSource::ManagedAccount,
453                auth_provider: Some("github_copilot".to_string()),
454                account_id: Some("binding-account".to_string()),
455            }),
456            github_account_id: Some("legacy-account".to_string()),
457            ..Default::default()
458        };
459        assert_eq!(
460            managed_meta.managed_account_id_for("github_copilot"),
461            Some("binding-account".to_string())
462        );
463
464        let legacy_meta = ProviderMeta {
465            github_account_id: Some("legacy-account".to_string()),
466            ..Default::default()
467        };
468        assert_eq!(
469            legacy_meta.managed_account_id_for("github_copilot"),
470            Some("legacy-account".to_string())
471        );
472        assert_eq!(legacy_meta.managed_account_id_for("other_provider"), None);
473
474        let provider_config_meta = ProviderMeta {
475            auth_binding: Some(AuthBinding {
476                source: AuthBindingSource::ProviderConfig,
477                auth_provider: Some("github_copilot".to_string()),
478                account_id: Some("provider-config-account".to_string()),
479            }),
480            github_account_id: Some("legacy-account".to_string()),
481            ..Default::default()
482        };
483        assert_eq!(
484            provider_config_meta.managed_account_id_for("github_copilot"),
485            Some("legacy-account".to_string())
486        );
487    }
488}
489
490#[cfg(test)]
491mod issue_71_tests {
492    use super::*;
493    use serde_json::json;
494
495    #[test]
496    fn meta_with_both_common_config_keys_causes_duplicate_field_error() {
497        // When both commonConfigEnabled (rename) and applyCommonConfig (alias)
498        // are present, serde rejects as duplicate field.
499        let meta_json = json!({
500            "commonConfigEnabled": true,
501            "applyCommonConfig": false
502        });
503
504        let result: Result<ProviderMeta, _> = serde_json::from_value(meta_json);
505        assert!(
506            result.is_err(),
507            "serde should reject duplicate rename+alias keys"
508        );
509        assert!(
510            result.unwrap_err().to_string().contains("duplicate field"),
511            "error should mention duplicate field"
512        );
513    }
514
515    #[test]
516    fn meta_with_only_alias_key_deserializes_ok() {
517        // After the fix, to_provider_json_value removes commonConfigEnabled
518        // before inserting applyCommonConfig, so only one key remains.
519        let meta_json = json!({
520            "applyCommonConfig": false
521        });
522
523        let result: ProviderMeta =
524            serde_json::from_value(meta_json).expect("should deserialize with alias only");
525        assert_eq!(result.apply_common_config, Some(false));
526    }
527
528    #[test]
529    fn provider_with_only_alias_in_meta_deserializes_ok() {
530        let provider_json = json!({
531            "id": "test",
532            "name": "Test",
533            "settingsConfig": {},
534            "meta": {
535                "applyCommonConfig": true
536            }
537        });
538        let provider: Provider =
539            serde_json::from_value(provider_json).expect("should deserialize provider");
540        assert_eq!(provider.meta.unwrap().apply_common_config, Some(true));
541    }
542
543    #[test]
544    fn provider_roundtrip_preserves_upstream_meta_fields_for_auth_and_type() {
545        let provider_json = json!({
546            "id": "test",
547            "name": "Test",
548            "settingsConfig": {},
549            "meta": {
550                "authBinding": {
551                    "source": "managed_account",
552                    "authProvider": "github_copilot",
553                    "accountId": "acc-1"
554                },
555                "apiKeyField": "ANTHROPIC_AUTH_TOKEN",
556                "providerType": "github_copilot",
557                "githubAccountId": "gh-123"
558            }
559        });
560
561        let provider: Provider =
562            serde_json::from_value(provider_json).expect("provider should deserialize");
563        let serialized = serde_json::to_value(&provider).expect("provider should serialize");
564        let meta = serialized
565            .get("meta")
566            .and_then(|value| value.as_object())
567            .expect("meta should exist after roundtrip");
568
569        assert_eq!(
570            meta.get("authBinding")
571                .and_then(|value| value.get("source"))
572                .and_then(|value| value.as_str()),
573            Some("managed_account")
574        );
575        assert_eq!(
576            meta.get("authBinding")
577                .and_then(|value| value.get("authProvider"))
578                .and_then(|value| value.as_str()),
579            Some("github_copilot")
580        );
581        assert_eq!(
582            meta.get("authBinding")
583                .and_then(|value| value.get("accountId"))
584                .and_then(|value| value.as_str()),
585            Some("acc-1")
586        );
587        assert_eq!(
588            meta.get("apiKeyField").and_then(|value| value.as_str()),
589            Some("ANTHROPIC_AUTH_TOKEN")
590        );
591        assert_eq!(
592            meta.get("providerType").and_then(|value| value.as_str()),
593            Some("github_copilot")
594        );
595        assert_eq!(
596            meta.get("githubAccountId").and_then(|value| value.as_str()),
597            Some("gh-123")
598        );
599    }
600}