Skip to main content

codex_helper_core/
config.rs

1use std::collections::HashMap;
2use std::env;
3use std::fs as stdfs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use dirs::home_dir;
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use tokio::fs;
11use toml::Value as TomlValue;
12use tracing::{info, warn};
13
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct UpstreamAuth {
16    /// Bearer token, e.g. OpenAI style
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub auth_token: Option<String>,
19    /// Environment variable name for bearer token (preferred over storing secrets on disk)
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub auth_token_env: Option<String>,
22    /// Optional API key header for some providers
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub api_key: Option<String>,
25    /// Environment variable name for API key header value
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub api_key_env: Option<String>,
28}
29
30impl UpstreamAuth {
31    pub fn resolve_auth_token(&self) -> Option<String> {
32        if let Some(token) = self.auth_token.as_deref()
33            && !token.trim().is_empty()
34        {
35            return Some(token.to_string());
36        }
37        if let Some(env_name) = self.auth_token_env.as_deref()
38            && let Ok(v) = env::var(env_name)
39            && !v.trim().is_empty()
40        {
41            return Some(v);
42        }
43        None
44    }
45
46    pub fn resolve_api_key(&self) -> Option<String> {
47        if let Some(key) = self.api_key.as_deref()
48            && !key.trim().is_empty()
49        {
50            return Some(key.to_string());
51        }
52        if let Some(env_name) = self.api_key_env.as_deref()
53            && let Ok(v) = env::var(env_name)
54            && !v.trim().is_empty()
55        {
56            return Some(v);
57        }
58        None
59    }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct UpstreamConfig {
64    pub base_url: String,
65    #[serde(default)]
66    pub auth: UpstreamAuth,
67    /// Optional free-form metadata, e.g. region / label
68    #[serde(default)]
69    pub tags: HashMap<String, String>,
70    /// Optional model whitelist for this upstream (exact or wildcard patterns like `gpt-*`).
71    #[serde(
72        default,
73        skip_serializing_if = "HashMap::is_empty",
74        alias = "supportedModels"
75    )]
76    pub supported_models: HashMap<String, bool>,
77    /// Optional model mapping: external model name -> upstream-specific model name (supports wildcards).
78    #[serde(
79        default,
80        skip_serializing_if = "HashMap::is_empty",
81        alias = "modelMapping"
82    )]
83    pub model_mapping: HashMap<String, String>,
84}
85
86pub fn model_routing_warnings(cfg: &ProxyConfig, service_name: &str) -> Vec<String> {
87    use crate::model_routing::match_wildcard;
88
89    fn validate_upstream(name: &str, upstream: &UpstreamConfig) -> Vec<String> {
90        let mut out = Vec::new();
91
92        if upstream.supported_models.is_empty() && upstream.model_mapping.is_empty() {
93            out.push(format!(
94                "[{name}] 未配置 supported_models 或 model_mapping,将假设支持所有模型(可能导致降级失败)"
95            ));
96            return out;
97        }
98
99        if !upstream.model_mapping.is_empty() && upstream.supported_models.is_empty() {
100            out.push(format!(
101                "[{name}] 配置了 model_mapping 但未配置 supported_models,映射目标将不做校验,请确认目标模型在供应商处可用"
102            ));
103        }
104
105        if upstream.model_mapping.is_empty() || upstream.supported_models.is_empty() {
106            return out;
107        }
108
109        for (external_model, internal_model) in upstream.model_mapping.iter() {
110            if internal_model.contains('*') {
111                continue;
112            }
113            let supported = if upstream
114                .supported_models
115                .get(internal_model)
116                .copied()
117                .unwrap_or(false)
118            {
119                true
120            } else {
121                upstream
122                    .supported_models
123                    .keys()
124                    .any(|p| match_wildcard(p, internal_model))
125            };
126            if !supported {
127                out.push(format!(
128                    "[{name}] 模型映射无效:'{external_model}' -> '{internal_model}',目标模型不在 supported_models 中"
129                ));
130            }
131        }
132        out
133    }
134
135    let mgr = match service_name {
136        "claude" => &cfg.claude,
137        "codex" => &cfg.codex,
138        _ => &cfg.codex,
139    };
140
141    let mut warnings = Vec::new();
142    for (cfg_name, svc) in mgr.configs.iter() {
143        for (idx, upstream) in svc.upstreams.iter().enumerate() {
144            let name = format!(
145                "{service_name}:{cfg_name} upstream[{idx}] ({})",
146                upstream.base_url
147            );
148            warnings.extend(validate_upstream(&name, upstream));
149        }
150    }
151    warnings
152}
153
154/// A logical config entry (roughly corresponds to cli_proxy 的一个配置名)
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ServiceConfig {
157    /// 配置标识(map key),保持稳定
158    #[serde(default)]
159    pub name: String,
160    /// 可选别名,便于展示/记忆
161    #[serde(default)]
162    pub alias: Option<String>,
163    /// Whether this config is eligible for automatic routing (defaults to true).
164    #[serde(default = "default_service_config_enabled")]
165    pub enabled: bool,
166    /// Priority group (1..=10, lower is higher priority). Default: 1.
167    #[serde(default = "default_service_config_level")]
168    pub level: u8,
169    #[serde(default)]
170    pub upstreams: Vec<UpstreamConfig>,
171}
172
173fn default_service_config_enabled() -> bool {
174    true
175}
176
177fn default_service_config_level() -> u8 {
178    1
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182pub struct ServiceConfigManager {
183    /// 当前激活配置名
184    #[serde(default)]
185    pub active: Option<String>,
186    /// 配置集合
187    #[serde(default)]
188    pub configs: HashMap<String, ServiceConfig>,
189}
190
191impl ServiceConfigManager {
192    pub fn active_config(&self) -> Option<&ServiceConfig> {
193        self.active
194            .as_ref()
195            .and_then(|name| self.configs.get(name))
196            // HashMap 的 values().next() 是非确定性的;这里用 key 排序后的最小项作为稳定兜底。
197            .or_else(|| self.configs.iter().min_by_key(|(k, _)| *k).map(|(_, v)| v))
198    }
199}
200
201#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
202#[serde(rename_all = "kebab-case")]
203pub enum RetryProfileName {
204    Balanced,
205    SameUpstream,
206    AggressiveFailover,
207    CostPrimary,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211pub struct ResolvedRetryLayerConfig {
212    pub max_attempts: u32,
213    pub backoff_ms: u64,
214    pub backoff_max_ms: u64,
215    pub jitter_ms: u64,
216    pub on_status: String,
217    pub on_class: Vec<String>,
218    pub strategy: RetryStrategy,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
222pub struct ResolvedRetryConfig {
223    pub upstream: ResolvedRetryLayerConfig,
224    pub provider: ResolvedRetryLayerConfig,
225    pub never_on_status: String,
226    pub never_on_class: Vec<String>,
227    pub cloudflare_challenge_cooldown_secs: u64,
228    pub cloudflare_timeout_cooldown_secs: u64,
229    pub transport_cooldown_secs: u64,
230    pub cooldown_backoff_factor: u64,
231    pub cooldown_backoff_max_secs: u64,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
235pub struct RetryLayerConfig {
236    #[serde(default)]
237    pub max_attempts: Option<u32>,
238    #[serde(default)]
239    pub backoff_ms: Option<u64>,
240    #[serde(default)]
241    pub backoff_max_ms: Option<u64>,
242    #[serde(default)]
243    pub jitter_ms: Option<u64>,
244    #[serde(default)]
245    pub on_status: Option<String>,
246    #[serde(default)]
247    pub on_class: Option<Vec<String>>,
248    #[serde(default)]
249    pub strategy: Option<RetryStrategy>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct RetryConfig {
254    /// Curated retry policy preset. When set, codex-helper starts from the profile defaults,
255    /// then applies any explicitly configured fields below as overrides.
256    #[serde(default)]
257    pub profile: Option<RetryProfileName>,
258    // Legacy (pre-v0.10.0) flat retry fields (kept for backward compatibility).
259    // Prefer the nested `upstream` / `provider` blocks for new configs.
260    #[serde(default)]
261    pub max_attempts: Option<u32>,
262    #[serde(default)]
263    pub backoff_ms: Option<u64>,
264    #[serde(default)]
265    pub backoff_max_ms: Option<u64>,
266    #[serde(default)]
267    pub jitter_ms: Option<u64>,
268    #[serde(default)]
269    pub on_status: Option<String>,
270    #[serde(default)]
271    pub on_class: Option<Vec<String>>,
272    #[serde(default)]
273    pub strategy: Option<RetryStrategy>,
274    #[serde(default)]
275    pub upstream: Option<RetryLayerConfig>,
276    #[serde(default)]
277    pub provider: Option<RetryLayerConfig>,
278    #[serde(default)]
279    pub never_on_status: Option<String>,
280    #[serde(default)]
281    pub never_on_class: Option<Vec<String>>,
282    #[serde(default)]
283    pub cloudflare_challenge_cooldown_secs: Option<u64>,
284    #[serde(default)]
285    pub cloudflare_timeout_cooldown_secs: Option<u64>,
286    #[serde(default)]
287    pub transport_cooldown_secs: Option<u64>,
288    /// Optional exponential backoff for cooldown penalties.
289    /// When factor > 1, repeated penalties will increase cooldown up to max_secs.
290    #[serde(default)]
291    pub cooldown_backoff_factor: Option<u64>,
292    #[serde(default)]
293    pub cooldown_backoff_max_secs: Option<u64>,
294}
295
296#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
297#[serde(rename_all = "snake_case")]
298pub enum RetryStrategy {
299    /// Prefer switching to another upstream on retry (default).
300    #[default]
301    Failover,
302    /// Prefer retrying the same upstream (opt-in).
303    SameUpstream,
304}
305
306impl Default for RetryConfig {
307    fn default() -> Self {
308        Self {
309            profile: Some(RetryProfileName::Balanced),
310            max_attempts: None,
311            backoff_ms: None,
312            backoff_max_ms: None,
313            jitter_ms: None,
314            on_status: None,
315            on_class: None,
316            strategy: None,
317            upstream: None,
318            provider: None,
319            never_on_status: None,
320            never_on_class: None,
321            cloudflare_challenge_cooldown_secs: None,
322            cloudflare_timeout_cooldown_secs: None,
323            transport_cooldown_secs: None,
324            cooldown_backoff_factor: None,
325            cooldown_backoff_max_secs: None,
326        }
327    }
328}
329
330impl RetryProfileName {
331    pub fn defaults(self) -> ResolvedRetryConfig {
332        match self {
333            RetryProfileName::Balanced => ResolvedRetryConfig {
334                upstream: ResolvedRetryLayerConfig {
335                    max_attempts: 2,
336                    backoff_ms: 200,
337                    backoff_max_ms: 2_000,
338                    jitter_ms: 100,
339                    on_status: "429,500-599,524".to_string(),
340                    on_class: vec![
341                        "upstream_transport_error".to_string(),
342                        "cloudflare_timeout".to_string(),
343                        "cloudflare_challenge".to_string(),
344                    ],
345                    strategy: RetryStrategy::SameUpstream,
346                },
347                provider: ResolvedRetryLayerConfig {
348                    max_attempts: 2,
349                    backoff_ms: 0,
350                    backoff_max_ms: 0,
351                    jitter_ms: 0,
352                    on_status: "401,403,404,408,429,500-599,524".to_string(),
353                    on_class: vec!["upstream_transport_error".to_string()],
354                    strategy: RetryStrategy::Failover,
355                },
356                never_on_status: "413,415,422".to_string(),
357                never_on_class: vec!["client_error_non_retryable".to_string()],
358                cloudflare_challenge_cooldown_secs: 300,
359                cloudflare_timeout_cooldown_secs: 60,
360                transport_cooldown_secs: 30,
361                cooldown_backoff_factor: 1,
362                cooldown_backoff_max_secs: 600,
363            },
364            RetryProfileName::SameUpstream => ResolvedRetryConfig {
365                upstream: ResolvedRetryLayerConfig {
366                    max_attempts: 3,
367                    ..RetryProfileName::Balanced.defaults().upstream
368                },
369                provider: ResolvedRetryLayerConfig {
370                    max_attempts: 1,
371                    ..RetryProfileName::Balanced.defaults().provider
372                },
373                ..RetryProfileName::Balanced.defaults()
374            },
375            RetryProfileName::AggressiveFailover => ResolvedRetryConfig {
376                upstream: ResolvedRetryLayerConfig {
377                    max_attempts: 2,
378                    backoff_ms: 200,
379                    backoff_max_ms: 2_500,
380                    jitter_ms: 150,
381                    on_status: "429,500-599,524".to_string(),
382                    on_class: vec![
383                        "upstream_transport_error".to_string(),
384                        "cloudflare_timeout".to_string(),
385                        "cloudflare_challenge".to_string(),
386                    ],
387                    strategy: RetryStrategy::SameUpstream,
388                },
389                provider: ResolvedRetryLayerConfig {
390                    max_attempts: 3,
391                    backoff_ms: 0,
392                    backoff_max_ms: 0,
393                    jitter_ms: 0,
394                    on_status: "401,403,404,408,429,500-599,524".to_string(),
395                    on_class: vec!["upstream_transport_error".to_string()],
396                    strategy: RetryStrategy::Failover,
397                },
398                ..RetryProfileName::Balanced.defaults()
399            },
400            RetryProfileName::CostPrimary => ResolvedRetryConfig {
401                provider: ResolvedRetryLayerConfig {
402                    max_attempts: 2,
403                    ..RetryProfileName::Balanced.defaults().provider
404                },
405                transport_cooldown_secs: 30,
406                cooldown_backoff_factor: 2,
407                cooldown_backoff_max_secs: 900,
408                ..RetryProfileName::Balanced.defaults()
409            },
410        }
411    }
412}
413
414impl RetryConfig {
415    pub fn resolve(&self) -> ResolvedRetryConfig {
416        let mut out = self
417            .profile
418            .unwrap_or(RetryProfileName::Balanced)
419            .defaults();
420
421        // Legacy flat fields map to the upstream layer by default, so existing configs that only
422        // tuned `max_attempts` / `on_status` keep a similar "retry the current upstream" behavior.
423        // If `upstream` is explicitly configured, it always takes precedence.
424        if self.upstream.is_none() {
425            if let Some(v) = self.max_attempts {
426                out.upstream.max_attempts = v;
427            }
428            if let Some(v) = self.backoff_ms {
429                out.upstream.backoff_ms = v;
430            }
431            if let Some(v) = self.backoff_max_ms {
432                out.upstream.backoff_max_ms = v;
433            }
434            if let Some(v) = self.jitter_ms {
435                out.upstream.jitter_ms = v;
436            }
437            if let Some(v) = self.on_status.as_deref() {
438                out.upstream.on_status = v.to_string();
439            }
440            if let Some(v) = self.on_class.as_ref() {
441                out.upstream.on_class = v.clone();
442            }
443            if let Some(v) = self.strategy {
444                out.upstream.strategy = v;
445            }
446        }
447
448        if let Some(layer) = self.upstream.as_ref() {
449            if let Some(v) = layer.max_attempts {
450                out.upstream.max_attempts = v;
451            }
452            if let Some(v) = layer.backoff_ms {
453                out.upstream.backoff_ms = v;
454            }
455            if let Some(v) = layer.backoff_max_ms {
456                out.upstream.backoff_max_ms = v;
457            }
458            if let Some(v) = layer.jitter_ms {
459                out.upstream.jitter_ms = v;
460            }
461            if let Some(v) = layer.on_status.as_deref() {
462                out.upstream.on_status = v.to_string();
463            }
464            if let Some(v) = layer.on_class.as_ref() {
465                out.upstream.on_class = v.clone();
466            }
467            if let Some(v) = layer.strategy {
468                out.upstream.strategy = v;
469            }
470        }
471        if let Some(layer) = self.provider.as_ref() {
472            if let Some(v) = layer.max_attempts {
473                out.provider.max_attempts = v;
474            }
475            if let Some(v) = layer.backoff_ms {
476                out.provider.backoff_ms = v;
477            }
478            if let Some(v) = layer.backoff_max_ms {
479                out.provider.backoff_max_ms = v;
480            }
481            if let Some(v) = layer.jitter_ms {
482                out.provider.jitter_ms = v;
483            }
484            if let Some(v) = layer.on_status.as_deref() {
485                out.provider.on_status = v.to_string();
486            }
487            if let Some(v) = layer.on_class.as_ref() {
488                out.provider.on_class = v.clone();
489            }
490            if let Some(v) = layer.strategy {
491                out.provider.strategy = v;
492            }
493        }
494        if let Some(v) = self.never_on_status.as_deref() {
495            out.never_on_status = v.to_string();
496        }
497        if let Some(v) = self.never_on_class.as_ref() {
498            out.never_on_class = v.clone();
499        }
500        if let Some(v) = self.cloudflare_challenge_cooldown_secs {
501            out.cloudflare_challenge_cooldown_secs = v;
502        }
503        if let Some(v) = self.cloudflare_timeout_cooldown_secs {
504            out.cloudflare_timeout_cooldown_secs = v;
505        }
506        if let Some(v) = self.transport_cooldown_secs {
507            out.transport_cooldown_secs = v;
508        }
509        if let Some(v) = self.cooldown_backoff_factor {
510            out.cooldown_backoff_factor = v;
511        }
512        if let Some(v) = self.cooldown_backoff_max_secs {
513            out.cooldown_backoff_max_secs = v;
514        }
515
516        out
517    }
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct NotifyPolicyConfig {
522    /// Only notify when proxy duration_ms is >= this threshold.
523    pub min_duration_ms: u64,
524    /// At most one notification per global_cooldown_ms.
525    pub global_cooldown_ms: u64,
526    /// Events within this window will be merged into one notification.
527    pub merge_window_ms: u64,
528    /// Suppress notifications for the same thread-id within this cooldown.
529    pub per_thread_cooldown_ms: u64,
530    /// How far back to look in proxy recent-finished list when matching a thread-id.
531    pub recent_search_window_ms: u64,
532    /// Timeout for calling proxy `status/recent` endpoint.
533    pub recent_endpoint_timeout_ms: u64,
534}
535
536impl Default for NotifyPolicyConfig {
537    fn default() -> Self {
538        Self {
539            min_duration_ms: 60_000,
540            global_cooldown_ms: 60_000,
541            merge_window_ms: 10_000,
542            per_thread_cooldown_ms: 180_000,
543            recent_search_window_ms: 5 * 60_000,
544            recent_endpoint_timeout_ms: 500,
545        }
546    }
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize, Default)]
550pub struct NotifySystemConfig {
551    /// Whether to show system notifications (toasts). Default: false.
552    pub enabled: bool,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, Default)]
556pub struct NotifyExecConfig {
557    /// Enable executing an external command for each aggregated notification.
558    pub enabled: bool,
559    /// Command to execute; the aggregated JSON is written to stdin.
560    /// Example: ["python", "my_script.py"].
561    #[serde(default)]
562    pub command: Vec<String>,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, Default)]
566pub struct NotifyConfig {
567    /// Whether notify processing is enabled at all (system toast and exec are both disabled by default).
568    pub enabled: bool,
569    #[serde(default)]
570    pub policy: NotifyPolicyConfig,
571    #[serde(default)]
572    pub system: NotifySystemConfig,
573    #[serde(default)]
574    pub exec: NotifyExecConfig,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, Default)]
578pub struct ProxyConfig {
579    /// Optional config schema version for future migrations
580    #[serde(default)]
581    pub version: Option<u32>,
582    /// Codex 服务配置
583    #[serde(default)]
584    pub codex: ServiceConfigManager,
585    /// Claude Code 等其他服务配置,后续扩展
586    #[serde(default)]
587    pub claude: ServiceConfigManager,
588    /// Global retry policy (proxy-side).
589    #[serde(default)]
590    pub retry: RetryConfig,
591    /// Notify integration settings (used by `codex-helper notify ...`).
592    #[serde(default)]
593    pub notify: NotifyConfig,
594    /// 默认目标服务(用于 CLI 默认选择 codex/claude)
595    #[serde(default)]
596    pub default_service: Option<ServiceKind>,
597    /// UI settings (mainly for the built-in TUI).
598    #[serde(default)]
599    pub ui: UiConfig,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize, Default)]
603pub struct UiConfig {
604    /// UI language: `en`, `zh`, or `auto` (default: unset).
605    ///
606    /// When unset, codex-helper will pick a default language based on system locale for the first run.
607    #[serde(default)]
608    pub language: Option<String>,
609}
610
611fn config_dir() -> PathBuf {
612    proxy_home_dir()
613}
614
615fn config_path() -> PathBuf {
616    config_dir().join("config.json")
617}
618
619fn config_backup_path() -> PathBuf {
620    config_dir().join("config.json.bak")
621}
622
623fn config_toml_path() -> PathBuf {
624    config_dir().join("config.toml")
625}
626
627fn config_toml_backup_path() -> PathBuf {
628    config_dir().join("config.toml.bak")
629}
630
631/// Return the primary config file path that will be used by `load_config()`.
632pub fn config_file_path() -> PathBuf {
633    let toml_path = config_toml_path();
634    if toml_path.exists() {
635        toml_path
636    } else if config_path().exists() {
637        config_path()
638    } else {
639        toml_path
640    }
641}
642
643const CONFIG_VERSION: u32 = 1;
644
645fn ensure_config_version(cfg: &mut ProxyConfig) {
646    if cfg.version.is_none() {
647        cfg.version = Some(CONFIG_VERSION);
648    }
649}
650
651const CONFIG_TOML_DOC_HEADER: &str = r#"# codex-helper config.toml
652#
653# 本文件可选;如果存在,codex-helper 会优先使用它(而不是 config.json)。
654#
655# 常用命令:
656# - 生成带注释的模板:`codex-helper config init`
657#
658# 安全建议:
659# - 尽量用环境变量保存密钥(*_env 字段,例如 auth_token_env / api_key_env),不要把 token 明文写入文件。
660#
661# 备注:某些命令会重写此文件;会保留本段 header,方便把说明贴近配置。
662"#;
663
664const CONFIG_TOML_TEMPLATE: &str = r#"# codex-helper config.toml
665#
666# codex-helper 同时支持 config.json 与 config.toml:
667# - 如果 `config.toml` 存在,则优先使用它;
668# - 否则使用 `config.json`(兼容旧版本)。
669#
670# 本模板以“可发现性”为主:包含可直接抄的示例,以及每个字段的说明。
671#
672# 路径:
673# - Linux/macOS:`~/.codex-helper/config.toml`
674# - Windows:    `%USERPROFILE%\.codex-helper\config.toml`
675#
676# 小贴士:
677# - 生成/覆盖本模板:`codex-helper config init [--force]`
678# - 新安装时:首次写入配置默认会写 TOML。
679
680version = 1
681
682# 省略 --codex/--claude 时默认使用哪个服务。
683# default_service = "codex"
684# default_service = "claude"
685
686# --- 自动导入(可选) ---
687#
688# 如果你的机器上已配置 Codex CLI(存在 `~/.codex/config.toml`),`codex-helper config init`
689# 会尝试自动把 Codex providers 导入到本文件中,避免你手动抄写 base_url/env_key。
690#
691# 如果你只想生成纯模板(不导入),请使用:
692#   codex-helper config init --no-import
693
694# --- 通用:上游配置(账号 / API Key) ---
695#
696# 大部分用户只需要改这一段。
697#
698# 说明:
699# - 优先使用环境变量方式保存密钥(`*_env`),避免写入磁盘。
700# - 单个 config 内可配置多个 `[[...upstreams]]`,用于“同账号多 endpoint 自动切换”。
701# - 可选:给每个 config 设置 `level`(1..=10)用于“按 level 分组跨配置降级”(只有存在多个不同 level 时才会生效)。
702#
703# [codex]
704# active = "codex-main"
705#
706# [codex.configs.codex-main]
707# name = "codex-main"
708# alias = "primary+backup"
709# # enabled = true
710# # level = 1
711#
712# # 主线路 upstream
713# [[codex.configs.codex-main.upstreams]]
714# base_url = "https://api.openai.com/v1"
715# [codex.configs.codex-main.upstreams.auth]
716# auth_token_env = "OPENAI_API_KEY"
717# # or: api_key_env = "OPENAI_API_KEY"
718# # (不推荐)auth_token = "sk-..."
719# [codex.configs.codex-main.upstreams.tags]
720# provider_id = "openai"
721#
722# # 备份线路 upstream
723# [[codex.configs.codex-main.upstreams]]
724# base_url = "https://your-backup-provider.example/v1"
725# [codex.configs.codex-main.upstreams.auth]
726# auth_token_env = "BACKUP_API_KEY"
727# [codex.configs.codex-main.upstreams.tags]
728# provider_id = "backup"
729#
730# Claude 配置在 [claude] 下结构相同。
731#
732# ---
733#
734# --- 通知集成(Codex `notify` hook) ---
735#
736# 可选功能,默认关闭。
737# 设计目标:多 Codex 工作流下的低噪声通知(按耗时过滤 + 合并 + 限流)。
738#
739# 启用步骤:
740# 1) 在 Codex 配置 `~/.codex/config.toml` 中添加:
741#      notify = ["codex-helper", "notify", "codex"]
742# 2) 在本文件中开启:
743#      notify.enabled = true
744#      notify.system.enabled = true
745#
746[notify]
747# 通知总开关(system toast 与 exec 回调都受此控制)。
748enabled = false
749
750[notify.system]
751# 系统通知支持:
752# - Windows:toast(powershell.exe)
753# - macOS:`osascript`
754enabled = false
755
756[notify.policy]
757# D:按耗时过滤(毫秒)
758min_duration_ms = 60000
759
760# A:合并 + 限流(毫秒)
761merge_window_ms = 10000
762global_cooldown_ms = 60000
763per_thread_cooldown_ms = 180000
764
765# 在 proxy /__codex_helper/status/recent 中向前回看多久(毫秒)。
766# codex-helper 会把 Codex 的 "thread-id" 匹配到 proxy 的 FinishedRequest.session_id。
767recent_search_window_ms = 300000
768# 访问 recent endpoint 的 HTTP 超时(毫秒)
769recent_endpoint_timeout_ms = 500
770
771[notify.exec]
772# 可选回调:执行一个命令,并把聚合后的 JSON 写到 stdin。
773enabled = false
774# command = ["python", "my_hook.py"]
775
776# ---
777#
778# --- 重试策略(代理侧) ---
779#
780# 控制 codex-helper 在返回给 Codex 之前进行的内部重试。
781# 注意:如果你同时开启了 Codex 自身的重试,可能会出现“双重重试”。
782#
783[retry]
784# 策略预设(推荐):
785# - "balanced"(默认)
786# - "same-upstream"(倾向同 upstream 重试,适合 CF/网络抖动)
787# - "aggressive-failover"(更激进:更多尝试次数,可能增加时延/成本)
788# - "cost-primary"(省钱主从:包月主线路 + 按量备选,支持回切探测)
789profile = "balanced"
790
791# 下面这些字段是“覆盖项”(在 profile 默认值之上进行覆盖)。
792#
793# 两层模型:
794# - retry.upstream:在当前 provider/config 内,对单个 upstream 的内部重试(默认更偏向同一 upstream)。
795# - retry.provider:当 upstream 层无法恢复时,决定是否切换到其他 upstream / 其他同级 config/provider。
796#
797# 覆盖示例(可按需取消注释):
798#
799# [retry.upstream]
800# max_attempts = 2
801# strategy = "same_upstream"
802# backoff_ms = 200
803# backoff_max_ms = 2000
804# jitter_ms = 100
805# on_status = "429,500-599,524"
806# on_class = ["upstream_transport_error", "cloudflare_timeout", "cloudflare_challenge"]
807#
808# [retry.provider]
809# max_attempts = 2
810# strategy = "failover"
811# on_status = "401,403,404,408,429,500-599,524"
812# on_class = ["upstream_transport_error"]
813
814# 明确禁止重试/切换的 HTTP 状态码/范围(字符串形式)。
815# 示例:"413,415,422"。
816# never_on_status = "413,415,422"
817
818# 明确禁止重试/切换的错误分类(来自 codex-helper 的 classify)。
819# 默认包含 "client_error_non_retryable"(常见请求格式/参数错误)。
820# never_on_class = ["client_error_non_retryable"]
821
822# 兼容说明:旧版扁平字段(max_attempts/on_status/strategy/...)仍可解析,默认映射到 retry.upstream.*。
823
824# 对某些失败类型施加冷却(秒)。
825# cloudflare_challenge_cooldown_secs = 300
826# cloudflare_timeout_cooldown_secs = 60
827# transport_cooldown_secs = 30
828
829# 可选:冷却的指数退避(主要用于“便宜主线路不稳 → 降级到备选 → 隔一段时间探测回切”)。
830#
831# 启用后:同一 upstream/config 连续失败次数越多,冷却越久:
832#   effective_cooldown = min(base_cooldown * factor^streak, cooldown_backoff_max_secs)
833#
834# factor=1 表示关闭退避(默认行为)。
835# cooldown_backoff_factor = 2
836# cooldown_backoff_max_secs = 600
837"#;
838
839fn insert_after_version_block(template: &str, insert: &str) -> String {
840    let needle = "version = 1\n\n";
841    if let Some(idx) = template.find(needle) {
842        let insert_pos = idx + needle.len();
843        let mut out = String::with_capacity(template.len() + insert.len() + 2);
844        out.push_str(&template[..insert_pos]);
845        out.push_str(insert);
846        out.push('\n');
847        out.push_str(&template[insert_pos..]);
848        return out;
849    }
850    format!("{template}\n\n{insert}\n")
851}
852
853fn codex_bootstrap_snippet() -> Result<Option<String>> {
854    #[derive(Serialize)]
855    struct CodexOnly<'a> {
856        codex: &'a ServiceConfigManager,
857    }
858
859    let mut cfg = ProxyConfig::default();
860    ensure_config_version(&mut cfg);
861    if bootstrap_from_codex(&mut cfg).is_err() {
862        return Ok(None);
863    }
864    if cfg.codex.configs.is_empty() {
865        return Ok(None);
866    }
867
868    let body = toml::to_string_pretty(&CodexOnly { codex: &cfg.codex })?;
869    Ok(Some(format!(
870        "# --- 自动导入:来自 ~/.codex/config.toml + auth.json ---\n{body}"
871    )))
872}
873
874pub async fn init_config_toml(force: bool, import_codex: bool) -> Result<PathBuf> {
875    let dir = config_dir();
876    fs::create_dir_all(&dir).await?;
877    let path = config_toml_path();
878    let backup_path = config_toml_backup_path();
879
880    if path.exists() && !force {
881        anyhow::bail!(
882            "config.toml already exists at {:?}; use --force to overwrite",
883            path
884        );
885    }
886
887    if path.exists()
888        && let Err(err) = fs::copy(&path, &backup_path).await
889    {
890        warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
891    }
892
893    let tmp_path = dir.join("config.toml.tmp");
894
895    let mut text = CONFIG_TOML_TEMPLATE.to_string();
896    if import_codex && let Some(snippet) = codex_bootstrap_snippet()? {
897        text = insert_after_version_block(&text, snippet.as_str());
898    }
899    fs::write(&tmp_path, text.as_bytes()).await?;
900    fs::rename(&tmp_path, &path).await?;
901    Ok(path)
902}
903
904pub async fn load_config() -> Result<ProxyConfig> {
905    let toml_path = config_toml_path();
906    if toml_path.exists() {
907        let text = fs::read_to_string(&toml_path).await?;
908        let mut cfg = toml::from_str::<ProxyConfig>(&text)?;
909        ensure_config_version(&mut cfg);
910        normalize_proxy_config(&mut cfg);
911        return Ok(cfg);
912    }
913
914    let json_path = config_path();
915    if json_path.exists() {
916        let bytes = fs::read(json_path).await?;
917        let mut cfg = serde_json::from_slice::<ProxyConfig>(&bytes)?;
918        ensure_config_version(&mut cfg);
919        normalize_proxy_config(&mut cfg);
920        return Ok(cfg);
921    }
922
923    let mut cfg = ProxyConfig::default();
924    ensure_config_version(&mut cfg);
925    normalize_proxy_config(&mut cfg);
926    Ok(cfg)
927}
928
929pub async fn save_config(cfg: &ProxyConfig) -> Result<()> {
930    let mut cfg = cfg.clone();
931    ensure_config_version(&mut cfg);
932    normalize_proxy_config(&mut cfg);
933
934    let dir = config_dir();
935    fs::create_dir_all(&dir).await?;
936    let toml_path = config_toml_path();
937    let json_path = config_path();
938    let (path, backup_path, data) = if toml_path.exists() || !json_path.exists() {
939        let body = toml::to_string_pretty(&cfg)?;
940        let text = format!("{CONFIG_TOML_DOC_HEADER}\n{body}");
941        (toml_path, config_toml_backup_path(), text.into_bytes())
942    } else {
943        (
944            json_path,
945            config_backup_path(),
946            serde_json::to_vec_pretty(&cfg)?,
947        )
948    };
949
950    // 先备份旧文件(若存在),再采用临时文件 + rename 方式原子写入,尽量避免配置损坏。
951    if path.exists()
952        && let Err(err) = fs::copy(&path, &backup_path).await
953    {
954        warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
955    }
956
957    let tmp_path = dir.join("config.tmp");
958    fs::write(&tmp_path, &data).await?;
959    fs::rename(&tmp_path, &path).await?;
960    Ok(())
961}
962
963fn normalize_proxy_config(cfg: &mut ProxyConfig) {
964    fn normalize_mgr(mgr: &mut ServiceConfigManager) {
965        for (key, svc) in mgr.configs.iter_mut() {
966            if svc.name.trim().is_empty() {
967                svc.name = key.clone();
968            }
969        }
970    }
971
972    normalize_mgr(&mut cfg.codex);
973    normalize_mgr(&mut cfg.claude);
974}
975
976/// 获取 codex-helper 的主目录(用于配置、日志等)
977pub fn proxy_home_dir() -> PathBuf {
978    if let Ok(dir) = env::var("CODEX_HELPER_HOME") {
979        let trimmed = dir.trim();
980        if !trimmed.is_empty() {
981            return PathBuf::from(trimmed);
982        }
983    }
984
985    #[cfg(test)]
986    {
987        static TEST_HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
988        TEST_HOME
989            .get_or_init(|| {
990                let mut dir = std::env::temp_dir();
991                let unique = format!(
992                    "codex-helper-test-{}-{}",
993                    std::process::id(),
994                    std::time::SystemTime::now()
995                        .duration_since(std::time::UNIX_EPOCH)
996                        .map(|d| d.as_nanos())
997                        .unwrap_or(0)
998                );
999                dir.push(unique);
1000                dir.push(".codex-helper");
1001                let _ = std::fs::create_dir_all(&dir);
1002                dir
1003            })
1004            .clone()
1005    }
1006
1007    #[cfg(not(test))]
1008    {
1009        home_dir()
1010            .unwrap_or_else(|| PathBuf::from("."))
1011            .join(".codex-helper")
1012    }
1013}
1014
1015fn codex_home() -> PathBuf {
1016    if let Ok(dir) = env::var("CODEX_HOME") {
1017        return PathBuf::from(dir);
1018    }
1019    home_dir()
1020        .unwrap_or_else(|| PathBuf::from("."))
1021        .join(".codex")
1022}
1023
1024pub fn codex_config_path() -> PathBuf {
1025    codex_home().join("config.toml")
1026}
1027
1028pub fn codex_backup_config_path() -> PathBuf {
1029    codex_home().join("config.toml.codex-helper-backup")
1030}
1031
1032pub fn codex_auth_path() -> PathBuf {
1033    codex_home().join("auth.json")
1034}
1035
1036fn claude_home() -> PathBuf {
1037    if let Ok(dir) = env::var("CLAUDE_HOME") {
1038        return PathBuf::from(dir);
1039    }
1040    home_dir()
1041        .unwrap_or_else(|| PathBuf::from("."))
1042        .join(".claude")
1043}
1044
1045pub fn claude_settings_path() -> PathBuf {
1046    let dir = claude_home();
1047    let settings = dir.join("settings.json");
1048    if settings.exists() {
1049        return settings;
1050    }
1051    let legacy = dir.join("claude.json");
1052    if legacy.exists() {
1053        return legacy;
1054    }
1055    settings
1056}
1057
1058pub fn claude_settings_backup_path() -> PathBuf {
1059    let mut path = claude_settings_path();
1060    let file_name = path
1061        .file_name()
1062        .map(|n| n.to_string_lossy().to_string())
1063        .unwrap_or_else(|| "settings.json".to_string());
1064    path.set_file_name(format!("{file_name}.codex-helper-backup"));
1065    path
1066}
1067
1068/// Directory where Codex stores conversation sessions: `~/.codex/sessions` (or `$CODEX_HOME/sessions`).
1069pub fn codex_sessions_dir() -> PathBuf {
1070    codex_home().join("sessions")
1071}
1072
1073/// 支持的上游服务类型:Codex / Claude。
1074#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1075#[serde(rename_all = "lowercase")]
1076pub enum ServiceKind {
1077    Codex,
1078    Claude,
1079}
1080
1081fn read_file_if_exists(path: &Path) -> Result<Option<String>> {
1082    if !path.exists() {
1083        return Ok(None);
1084    }
1085    let s = stdfs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
1086    Ok(Some(s))
1087}
1088
1089fn is_codex_absent_backup_sentinel(text: &str) -> bool {
1090    text.trim() == "# codex-helper-backup:absent"
1091}
1092
1093fn is_claude_absent_backup_sentinel(text: &str) -> bool {
1094    text.trim() == "{\"__codex_helper_backup_absent\":true}"
1095}
1096
1097/// Try to infer a unique API key from ~/.codex/auth.json when the provider
1098/// does not declare an explicit `env_key`.
1099///
1100/// This mirrors the common Codex CLI layout where `auth.json` contains a
1101/// single `*_API_KEY` field (e.g. `OPENAI_API_KEY`) plus metadata fields
1102/// like `tokens` / `last_refresh`. We only consider string values whose
1103/// key ends with `_API_KEY`, and only succeed when there is exactly one
1104/// such candidate; otherwise we return None and let the caller error out.
1105fn infer_env_key_from_auth_json(auth_json: &Option<JsonValue>) -> Option<(String, String)> {
1106    let json = auth_json.as_ref()?;
1107    let obj = json.as_object()?;
1108
1109    let mut candidates: Vec<(String, String)> = obj
1110        .iter()
1111        .filter_map(|(k, v)| v.as_str().map(|s| (k, s)))
1112        .filter(|(k, v)| k.ends_with("_API_KEY") && !v.trim().is_empty())
1113        .map(|(k, v)| (k.to_string(), v.to_string()))
1114        .collect();
1115
1116    if candidates.len() == 1 {
1117        candidates.pop()
1118    } else {
1119        None
1120    }
1121}
1122
1123fn bootstrap_from_codex(cfg: &mut ProxyConfig) -> Result<()> {
1124    if !cfg.codex.configs.is_empty() {
1125        return Ok(());
1126    }
1127
1128    // 优先从备份配置中推导原始上游,避免在 ~/.codex/config.toml 已被 codex-helper
1129    // 写成本地 provider(codex_proxy)时出现“自我转发”。
1130    let backup_path = codex_backup_config_path();
1131    let cfg_path = codex_config_path();
1132    let cfg_text_opt = if let Some(text) = read_file_if_exists(&backup_path)?
1133        && !is_codex_absent_backup_sentinel(&text)
1134    {
1135        Some(text)
1136    } else {
1137        read_file_if_exists(&cfg_path)?
1138    };
1139    let cfg_text = match cfg_text_opt {
1140        Some(s) if !s.trim().is_empty() => s,
1141        _ => {
1142            anyhow::bail!("未找到 ~/.codex/config.toml 或文件为空,无法自动推导 Codex 上游");
1143        }
1144    };
1145
1146    let value: TomlValue = cfg_text.parse()?;
1147    let table = value
1148        .as_table()
1149        .cloned()
1150        .ok_or_else(|| anyhow::anyhow!("Codex config root must be table"))?;
1151
1152    let current_provider_id = table
1153        .get("model_provider")
1154        .and_then(|v| v.as_str())
1155        .unwrap_or("openai")
1156        .to_string();
1157
1158    let providers_table = table
1159        .get("model_providers")
1160        .and_then(|v| v.as_table())
1161        .cloned()
1162        .unwrap_or_default();
1163
1164    let auth_json_path = codex_auth_path();
1165    let auth_json: Option<JsonValue> = match read_file_if_exists(&auth_json_path)? {
1166        Some(s) if !s.trim().is_empty() => serde_json::from_str(&s).ok(),
1167        _ => None,
1168    };
1169    let inferred_env_key = infer_env_key_from_auth_json(&auth_json).map(|(k, _)| k);
1170
1171    // 如当前 provider 看起来是本地 codex-helper 代理且没有备份(或备份无效),
1172    // 则无法安全推导原始上游,直接报错,避免将代理指向自身。
1173    if current_provider_id == "codex_proxy" && !backup_path.exists() {
1174        let provider_table = providers_table.get(&current_provider_id);
1175        let is_local_helper = provider_table
1176            .and_then(|t| t.get("base_url"))
1177            .and_then(|v| v.as_str())
1178            .map(|u| u.contains("127.0.0.1") || u.contains("localhost"))
1179            .unwrap_or(false);
1180        if is_local_helper {
1181            anyhow::bail!(
1182                "检测到 ~/.codex/config.toml 的当前 model_provider 指向本地代理 codex-helper,且未找到备份配置;\
1183无法自动推导原始 Codex 上游。请先恢复 ~/.codex/config.toml 后重试,或在 ~/.codex-helper/config.json 中手动添加 codex 上游配置。"
1184            );
1185        }
1186    }
1187
1188    let mut imported_any = false;
1189    let mut imported_active = false;
1190
1191    // Import all providers from [model_providers.*] as switchable configs.
1192    for (provider_id, provider_val) in providers_table.iter() {
1193        let Some(provider_table) = provider_val.as_table() else {
1194            continue;
1195        };
1196
1197        let requires_openai_auth = provider_table
1198            .get("requires_openai_auth")
1199            .and_then(|v| v.as_bool())
1200            .unwrap_or(provider_id == "openai");
1201
1202        let base_url_opt = provider_table
1203            .get("base_url")
1204            .and_then(|v| v.as_str())
1205            .map(|s| s.to_string());
1206
1207        let base_url = match base_url_opt {
1208            Some(u) if !u.trim().is_empty() => u,
1209            _ => {
1210                if provider_id == &current_provider_id {
1211                    anyhow::bail!(
1212                        "当前 model_provider '{}' 缺少 base_url,无法自动推导 Codex 上游",
1213                        provider_id
1214                    );
1215                }
1216                warn!(
1217                    "skip model_provider '{}' because base_url is missing",
1218                    provider_id
1219                );
1220                continue;
1221            }
1222        };
1223
1224        if provider_id == "codex_proxy"
1225            && (base_url.contains("127.0.0.1") || base_url.contains("localhost"))
1226        {
1227            if provider_id == &current_provider_id && !backup_path.exists() {
1228                anyhow::bail!(
1229                    "检测到 ~/.codex/config.toml 的当前 model_provider 指向本地代理 codex-helper,且未找到备份配置;\
1230无法自动推导原始 Codex 上游。请先恢复 ~/.codex/config.toml 后重试,或在 ~/.codex-helper/config.json 中手动添加 codex 上游配置。"
1231                );
1232            }
1233            warn!("skip model_provider 'codex_proxy' to avoid self-forwarding loop");
1234            continue;
1235        }
1236
1237        let env_key = provider_table
1238            .get("env_key")
1239            .and_then(|v| v.as_str())
1240            .map(|s| s.to_string())
1241            .filter(|s| !s.trim().is_empty());
1242
1243        let (auth_token, auth_token_env) = if requires_openai_auth {
1244            (None, None)
1245        } else {
1246            let effective_env_key = env_key.clone().or_else(|| inferred_env_key.clone());
1247            if effective_env_key.is_none() {
1248                if provider_id == &current_provider_id {
1249                    anyhow::bail!(
1250                        "当前 model_provider 未声明 env_key,且无法从 ~/.codex/auth.json 推断唯一的 `*_API_KEY` 字段;请为该 provider 配置 env_key"
1251                    );
1252                }
1253                warn!(
1254                    "skip model_provider '{}' because env_key is missing and auth.json can't infer a unique *_API_KEY",
1255                    provider_id
1256                );
1257                continue;
1258            }
1259            (None, effective_env_key)
1260        };
1261
1262        let alias = provider_table
1263            .get("name")
1264            .and_then(|v| v.as_str())
1265            .map(|s| s.to_string())
1266            .filter(|s| !s.trim().is_empty())
1267            .filter(|s| s != provider_id);
1268
1269        let mut tags = HashMap::new();
1270        tags.insert("source".into(), "codex-config".into());
1271        tags.insert("provider_id".into(), provider_id.to_string());
1272        tags.insert(
1273            "requires_openai_auth".into(),
1274            requires_openai_auth.to_string(),
1275        );
1276
1277        let upstream = UpstreamConfig {
1278            base_url: base_url.clone(),
1279            auth: UpstreamAuth {
1280                auth_token,
1281                auth_token_env,
1282                api_key: None,
1283                api_key_env: None,
1284            },
1285            tags,
1286            supported_models: HashMap::new(),
1287            model_mapping: HashMap::new(),
1288        };
1289
1290        let service = ServiceConfig {
1291            name: provider_id.to_string(),
1292            alias,
1293            enabled: true,
1294            level: 1,
1295            upstreams: vec![upstream],
1296        };
1297
1298        cfg.codex.configs.insert(provider_id.to_string(), service);
1299        imported_any = true;
1300        if provider_id == &current_provider_id {
1301            imported_active = true;
1302        }
1303    }
1304
1305    if !imported_any {
1306        anyhow::bail!("未能从 ~/.codex/config.toml 推导出任何可用的 Codex 上游配置");
1307    }
1308
1309    // Prefer the Codex CLI current provider as active.
1310    if imported_active && cfg.codex.configs.contains_key(&current_provider_id) {
1311        cfg.codex.active = Some(current_provider_id);
1312    } else {
1313        cfg.codex.active = cfg.codex.configs.keys().min().cloned();
1314    }
1315
1316    Ok(())
1317}
1318
1319fn bootstrap_from_claude(cfg: &mut ProxyConfig) -> Result<()> {
1320    if !cfg.claude.configs.is_empty() {
1321        return Ok(());
1322    }
1323
1324    let settings_path = claude_settings_path();
1325    let backup_path = claude_settings_backup_path();
1326    // Claude 配置同样优先从备份读取,避免将代理指向自身(本地 codex-helper)。
1327    let settings_text_opt = if let Some(text) = read_file_if_exists(&backup_path)?
1328        && !is_claude_absent_backup_sentinel(&text)
1329    {
1330        Some(text)
1331    } else {
1332        read_file_if_exists(&settings_path)?
1333    };
1334    let settings_text = match settings_text_opt {
1335        Some(s) if !s.trim().is_empty() => s,
1336        _ => {
1337            anyhow::bail!(
1338                "未找到 Claude Code 配置文件 {:?}(或文件为空),无法自动推导 Claude 上游;请先在 Claude Code 中完成配置,或手动在 ~/.codex-helper/config.json 中添加 claude 配置",
1339                settings_path
1340            );
1341        }
1342    };
1343
1344    let value: JsonValue = serde_json::from_str(&settings_text)
1345        .with_context(|| format!("解析 {:?} 失败,需为有效的 JSON", settings_path))?;
1346    let obj = value
1347        .as_object()
1348        .ok_or_else(|| anyhow::anyhow!("Claude settings 根节点必须是 JSON object"))?;
1349
1350    let env_obj = obj
1351        .get("env")
1352        .and_then(|v| v.as_object())
1353        .ok_or_else(|| anyhow::anyhow!("Claude settings 中缺少 env 对象"))?;
1354
1355    let api_key_env = if env_obj
1356        .get("ANTHROPIC_AUTH_TOKEN")
1357        .and_then(|v| v.as_str())
1358        .is_some()
1359    {
1360        Some("ANTHROPIC_AUTH_TOKEN".to_string())
1361    } else if env_obj
1362        .get("ANTHROPIC_API_KEY")
1363        .and_then(|v| v.as_str())
1364        .is_some()
1365    {
1366        Some("ANTHROPIC_API_KEY".to_string())
1367    } else {
1368        None
1369    }
1370    .ok_or_else(|| {
1371            anyhow::anyhow!(
1372                "Claude settings 中缺少 ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY;请先在 Claude Code 中完成登录或配置 API Key"
1373            )
1374        })?;
1375
1376    let base_url = env_obj
1377        .get("ANTHROPIC_BASE_URL")
1378        .and_then(|v| v.as_str())
1379        .unwrap_or("https://api.anthropic.com/v1")
1380        .to_string();
1381
1382    // 如当前 base_url 看起来是本地地址且没有备份,则无法安全推导真实上游,
1383    // 直接报错,避免将 Claude 代理指向自身。
1384    if !backup_path.exists() && (base_url.contains("127.0.0.1") || base_url.contains("localhost")) {
1385        anyhow::bail!(
1386            "检测到 Claude settings {:?} 的 ANTHROPIC_BASE_URL 指向本地地址 ({base_url}),且未找到备份配置;\
1387无法自动推导原始 Claude 上游。请先恢复 Claude 配置后重试,或在 ~/.codex-helper/config.json 中手动添加 claude 上游配置。",
1388            settings_path
1389        );
1390    }
1391
1392    let mut tags = HashMap::new();
1393    tags.insert("source".into(), "claude-settings".into());
1394    tags.insert("provider_id".into(), "anthropic".into());
1395
1396    let upstream = UpstreamConfig {
1397        base_url,
1398        auth: UpstreamAuth {
1399            auth_token: None,
1400            auth_token_env: None,
1401            api_key: None,
1402            api_key_env: Some(api_key_env),
1403        },
1404        tags,
1405        supported_models: HashMap::new(),
1406        model_mapping: HashMap::new(),
1407    };
1408
1409    let service = ServiceConfig {
1410        name: "default".to_string(),
1411        alias: Some("Claude default".to_string()),
1412        enabled: true,
1413        level: 1,
1414        upstreams: vec![upstream],
1415    };
1416
1417    cfg.claude.configs.insert("default".to_string(), service);
1418    cfg.claude.active = Some("default".to_string());
1419
1420    Ok(())
1421}
1422
1423/// 加载代理配置,如有必要从 ~/.codex 自动初始化 codex 配置。
1424pub async fn load_or_bootstrap_from_codex() -> Result<ProxyConfig> {
1425    let mut cfg = load_config().await?;
1426    if cfg.codex.configs.is_empty() {
1427        match bootstrap_from_codex(&mut cfg) {
1428            Ok(()) => {
1429                let _ = save_config(&cfg).await;
1430                info!(
1431                    "已根据 ~/.codex/config.toml 与 ~/.codex/auth.json 自动创建默认 Codex 上游配置"
1432                );
1433            }
1434            Err(err) => {
1435                warn!(
1436                    "无法从 ~/.codex 引导 Codex 配置: {err}; \
1437                     如果尚未安装或配置 Codex CLI 可以忽略,否则请检查 ~/.codex/config.toml 和 ~/.codex/auth.json,或使用 `codex-helper config add` 手动添加上游"
1438                );
1439            }
1440        }
1441    } else {
1442        // 已存在配置但没有 active,提示用户检查
1443        if cfg.codex.active.is_none() && !cfg.codex.configs.is_empty() {
1444            warn!(
1445                "检测到 Codex 配置但没有激活项,将使用任意一条配置作为默认;如需指定,请使用 `codex-helper config set-active <name>`"
1446            );
1447        }
1448    }
1449    Ok(cfg)
1450}
1451
1452/// 显式从 Codex CLI 的配置文件(~/.codex/config.toml + auth.json)导入/刷新 codex 段配置。
1453/// - 当 force = false 且当前已存在 codex 配置时,将返回错误,避免意外覆盖;
1454/// - 当 force = true 时,将清空现有 codex 段后重新基于 Codex 配置推导。
1455pub async fn import_codex_config_from_codex_cli(force: bool) -> Result<ProxyConfig> {
1456    let mut cfg = load_config().await?;
1457    if !cfg.codex.configs.is_empty() && !force {
1458        anyhow::bail!(
1459            "检测到 ~/.codex-helper/config.json 中已存在 Codex 配置;如需根据 ~/.codex/config.toml 重新导入,请使用 --force 覆盖"
1460        );
1461    }
1462
1463    cfg.codex = ServiceConfigManager::default();
1464    bootstrap_from_codex(&mut cfg)?;
1465    save_config(&cfg).await?;
1466    info!(
1467        "已根据 ~/.codex/config.toml 与 ~/.codex/auth.json 重新导入 Codex 上游配置(force = {})",
1468        force
1469    );
1470    Ok(cfg)
1471}
1472
1473/// Overwrite Codex configs from ~/.codex/config.toml + auth.json (in-place).
1474///
1475/// This resets the codex-helper Codex section back to Codex CLI defaults:
1476/// it clears existing configs (including grouping/level/enabled) and re-imports providers.
1477pub fn overwrite_codex_config_from_codex_cli_in_place(cfg: &mut ProxyConfig) -> Result<()> {
1478    cfg.codex = ServiceConfigManager::default();
1479    bootstrap_from_codex(cfg)
1480}
1481
1482#[allow(dead_code)]
1483#[derive(Debug, Clone, Copy)]
1484pub struct SyncCodexAuthFromCodexOptions {
1485    /// Add missing providers found in ~/.codex/config.toml into ~/.codex-helper/config.
1486    pub add_missing: bool,
1487    /// Also set codex-helper active config to match Codex CLI's current model_provider.
1488    pub set_active: bool,
1489    /// Override existing inline secrets and non-codex-source upstreams (use with care).
1490    pub force: bool,
1491}
1492
1493#[allow(dead_code)]
1494#[derive(Debug, Default)]
1495pub struct SyncCodexAuthFromCodexReport {
1496    pub updated: usize,
1497    pub added: usize,
1498    pub active_set: bool,
1499    pub warnings: Vec<String>,
1500}
1501
1502/// Sync Codex auth env vars from ~/.codex/config.toml + auth.json without changing routing config.
1503///
1504/// Default behavior:
1505/// - Only updates upstreams that are strongly associated with a Codex CLI provider:
1506///   - config key equals provider_id; or
1507///   - upstream.tags.provider_id equals provider_id.
1508/// - Does NOT change `active` / `enabled` / `level` unless `options.set_active = true`.
1509/// - Does NOT write secrets to disk; only syncs env var names (e.g. `OPENAI_API_KEY`).
1510#[allow(dead_code)]
1511pub fn sync_codex_auth_from_codex_cli(
1512    cfg: &mut ProxyConfig,
1513    options: SyncCodexAuthFromCodexOptions,
1514) -> Result<SyncCodexAuthFromCodexReport> {
1515    fn is_non_empty(s: &Option<String>) -> bool {
1516        s.as_deref().is_some_and(|v| !v.trim().is_empty())
1517    }
1518
1519    let backup_path = codex_backup_config_path();
1520    let cfg_path = codex_config_path();
1521    let cfg_text_opt = if let Some(text) = read_file_if_exists(&backup_path)?
1522        && !is_codex_absent_backup_sentinel(&text)
1523    {
1524        Some(text)
1525    } else {
1526        read_file_if_exists(&cfg_path)?
1527    };
1528    let cfg_text = match cfg_text_opt {
1529        Some(s) if !s.trim().is_empty() => s,
1530        _ => anyhow::bail!("未找到 ~/.codex/config.toml 或文件为空,无法同步 Codex 账号信息"),
1531    };
1532
1533    let value: TomlValue = cfg_text.parse()?;
1534    let table = value
1535        .as_table()
1536        .cloned()
1537        .ok_or_else(|| anyhow::anyhow!("Codex config root must be table"))?;
1538
1539    let current_provider_id = table
1540        .get("model_provider")
1541        .and_then(|v| v.as_str())
1542        .unwrap_or("openai")
1543        .to_string();
1544
1545    let providers_table = table
1546        .get("model_providers")
1547        .and_then(|v| v.as_table())
1548        .cloned()
1549        .unwrap_or_default();
1550
1551    let auth_json_path = codex_auth_path();
1552    let auth_json: Option<JsonValue> = match read_file_if_exists(&auth_json_path)? {
1553        Some(s) if !s.trim().is_empty() => serde_json::from_str(&s).ok(),
1554        _ => None,
1555    };
1556    let inferred_env_key = infer_env_key_from_auth_json(&auth_json).map(|(k, _)| k);
1557
1558    // Avoid syncing from a self-forwarding Codex config unless we have a valid backup.
1559    if current_provider_id == "codex_proxy" && !backup_path.exists() {
1560        let provider_table = providers_table.get(&current_provider_id);
1561        let is_local_helper = provider_table
1562            .and_then(|t| t.get("base_url"))
1563            .and_then(|v| v.as_str())
1564            .map(|u| u.contains("127.0.0.1") || u.contains("localhost"))
1565            .unwrap_or(false);
1566        if is_local_helper {
1567            anyhow::bail!(
1568                "检测到 ~/.codex/config.toml 的当前 model_provider 指向本地代理 codex-helper,且未找到备份配置;\
1569无法安全同步账号信息。请先恢复 ~/.codex/config.toml 后重试。"
1570            );
1571        }
1572    }
1573
1574    #[derive(Debug, Clone)]
1575    struct ProviderSpec {
1576        provider_id: String,
1577        requires_openai_auth: bool,
1578        base_url: Option<String>,
1579        env_key: Option<String>,
1580        alias: Option<String>,
1581    }
1582
1583    let mut providers = Vec::new();
1584    for (provider_id, provider_val) in providers_table.iter() {
1585        let Some(provider_table) = provider_val.as_table() else {
1586            continue;
1587        };
1588
1589        let requires_openai_auth = provider_table
1590            .get("requires_openai_auth")
1591            .and_then(|v| v.as_bool())
1592            .unwrap_or(provider_id == "openai");
1593
1594        let base_url = provider_table
1595            .get("base_url")
1596            .and_then(|v| v.as_str())
1597            .map(|s| s.to_string())
1598            .or_else(|| {
1599                if provider_id == "openai" {
1600                    Some("https://api.openai.com/v1".to_string())
1601                } else {
1602                    None
1603                }
1604            });
1605
1606        // Skip local codex-helper proxy entry to avoid accidental loops.
1607        if provider_id == "codex_proxy"
1608            && base_url
1609                .as_deref()
1610                .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"))
1611        {
1612            continue;
1613        }
1614
1615        let env_key = provider_table
1616            .get("env_key")
1617            .and_then(|v| v.as_str())
1618            .map(|s| s.to_string())
1619            .filter(|s| !s.trim().is_empty())
1620            .or_else(|| inferred_env_key.clone());
1621
1622        let alias = provider_table
1623            .get("name")
1624            .and_then(|v| v.as_str())
1625            .map(|s| s.to_string())
1626            .filter(|s| !s.trim().is_empty())
1627            .filter(|s| s != provider_id);
1628
1629        providers.push(ProviderSpec {
1630            provider_id: provider_id.to_string(),
1631            requires_openai_auth,
1632            base_url,
1633            env_key,
1634            alias,
1635        });
1636    }
1637
1638    let mut report = SyncCodexAuthFromCodexReport::default();
1639
1640    for pvd in providers.iter() {
1641        let pid = pvd.provider_id.as_str();
1642
1643        // Target configs:
1644        // 1) config key equals provider_id; 2) any upstream tagged with provider_id.
1645        let mut target_cfg_keys = Vec::new();
1646        if cfg.codex.configs.contains_key(pid) {
1647            target_cfg_keys.push(pid.to_string());
1648        }
1649
1650        for (cfg_key, svc) in cfg.codex.configs.iter() {
1651            if svc
1652                .upstreams
1653                .iter()
1654                .any(|u| u.tags.get("provider_id").map(|s| s.as_str()) == Some(pid))
1655                && !target_cfg_keys.iter().any(|k| k == cfg_key)
1656            {
1657                target_cfg_keys.push(cfg_key.clone());
1658            }
1659        }
1660
1661        if target_cfg_keys.is_empty() {
1662            if options.add_missing {
1663                let Some(base_url) = pvd.base_url.as_deref().filter(|s| !s.trim().is_empty())
1664                else {
1665                    report.warnings.push(format!(
1666                        "skip add provider '{pid}': base_url is missing in ~/.codex/config.toml"
1667                    ));
1668                    continue;
1669                };
1670
1671                let mut tags = HashMap::new();
1672                tags.insert("source".into(), "codex-config".into());
1673                tags.insert("provider_id".into(), pid.to_string());
1674                tags.insert(
1675                    "requires_openai_auth".into(),
1676                    pvd.requires_openai_auth.to_string(),
1677                );
1678
1679                let mut upstream = UpstreamConfig {
1680                    base_url: base_url.to_string(),
1681                    auth: UpstreamAuth::default(),
1682                    tags,
1683                    supported_models: HashMap::new(),
1684                    model_mapping: HashMap::new(),
1685                };
1686                if !pvd.requires_openai_auth {
1687                    if let Some(env_key) = pvd.env_key.as_deref().filter(|s| !s.trim().is_empty()) {
1688                        upstream.auth.auth_token_env = Some(env_key.to_string());
1689                    } else {
1690                        report.warnings.push(format!(
1691                            "added provider '{pid}' but auth env_key is missing (no env_key and auth.json can't infer a unique *_API_KEY)"
1692                        ));
1693                    }
1694                }
1695
1696                let service = ServiceConfig {
1697                    name: pid.to_string(),
1698                    alias: pvd.alias.clone(),
1699                    enabled: true,
1700                    level: 1,
1701                    upstreams: vec![upstream],
1702                };
1703
1704                cfg.codex.configs.insert(pid.to_string(), service);
1705                report.added += 1;
1706            }
1707            continue;
1708        }
1709
1710        // No secrets needed for providers that rely on the client Authorization.
1711        if pvd.requires_openai_auth {
1712            continue;
1713        }
1714
1715        let Some(desired_env) = pvd.env_key.as_deref().filter(|s| !s.trim().is_empty()) else {
1716            report.warnings.push(format!(
1717                "skip provider '{pid}': env_key is missing and auth.json can't infer a unique *_API_KEY"
1718            ));
1719            continue;
1720        };
1721
1722        for cfg_key in target_cfg_keys {
1723            let Some(service) = cfg.codex.configs.get_mut(&cfg_key) else {
1724                continue;
1725            };
1726
1727            let single_upstream = service.upstreams.len() == 1;
1728            let mut updated_in_this_config = false;
1729            for upstream in service.upstreams.iter_mut() {
1730                let tag_pid = upstream.tags.get("provider_id").map(|s| s.as_str());
1731                let should_touch = if tag_pid == Some(pid) {
1732                    true
1733                } else if cfg_key == pid {
1734                    // Strong signal: config key matches provider id.
1735                    // Touch upstreams that look like Codex-imported entries or single-upstream configs.
1736                    let src = upstream.tags.get("source").map(|s| s.as_str());
1737                    src == Some("codex-config") || single_upstream
1738                } else {
1739                    false
1740                };
1741
1742                if !should_touch && !options.force {
1743                    continue;
1744                }
1745
1746                if !options.force
1747                    && (is_non_empty(&upstream.auth.auth_token)
1748                        || is_non_empty(&upstream.auth.api_key))
1749                {
1750                    report.warnings.push(format!(
1751                        "skip '{cfg_key}': upstream has inline secret; use --force to override"
1752                    ));
1753                    continue;
1754                }
1755
1756                if upstream.auth.auth_token_env.as_deref() != Some(desired_env) {
1757                    upstream.auth.auth_token_env = Some(desired_env.to_string());
1758                    if options.force {
1759                        upstream.auth.auth_token = None;
1760                        upstream.auth.api_key = None;
1761                    }
1762                    report.updated += 1;
1763                    updated_in_this_config = true;
1764                }
1765            }
1766
1767            if !updated_in_this_config && cfg_key == pid {
1768                report.warnings.push(format!(
1769                    "no upstream updated for provider '{pid}' in config '{cfg_key}' (no matching upstream tags)"
1770                ));
1771            }
1772        }
1773    }
1774
1775    if options.set_active
1776        && current_provider_id != "codex_proxy"
1777        && cfg.codex.configs.contains_key(&current_provider_id)
1778        && cfg.codex.active.as_deref() != Some(current_provider_id.as_str())
1779    {
1780        cfg.codex.active = Some(current_provider_id);
1781        report.active_set = true;
1782    }
1783
1784    Ok(report)
1785}
1786
1787/// 加载代理配置,如有必要从 ~/.claude 初始化 Claude 配置。
1788pub async fn load_or_bootstrap_from_claude() -> Result<ProxyConfig> {
1789    let mut cfg = load_config().await?;
1790    if cfg.claude.configs.is_empty() {
1791        match bootstrap_from_claude(&mut cfg) {
1792            Ok(()) => {
1793                let _ = save_config(&cfg).await;
1794                info!("已根据 ~/.claude/settings.json 自动创建默认 Claude 上游配置");
1795            }
1796            Err(err) => {
1797                warn!(
1798                    "无法从 ~/.claude 引导 Claude 配置: {err}; \
1799                     如果尚未安装或配置 Claude Code 可以忽略,否则请检查 ~/.claude/settings.json,或在 ~/.codex-helper/config.json 中手动添加 claude 配置"
1800                );
1801            }
1802        }
1803    } else if cfg.claude.active.is_none() && !cfg.claude.configs.is_empty() {
1804        warn!(
1805            "检测到 Claude 配置但没有激活项,将使用任意一条配置作为默认;如需指定,请使用 `codex-helper config set-active <name>`(后续将扩展对 Claude 的专用子命令)"
1806        );
1807    }
1808    Ok(cfg)
1809}
1810
1811/// Unified entry to load proxy config and, if necessary, bootstrap upstreams
1812/// from the official Codex / Claude configuration files.
1813pub async fn load_or_bootstrap_for_service(kind: ServiceKind) -> Result<ProxyConfig> {
1814    match kind {
1815        ServiceKind::Codex => load_or_bootstrap_from_codex().await,
1816        ServiceKind::Claude => load_or_bootstrap_from_claude().await,
1817    }
1818}
1819
1820/// Probe whether we can successfully bootstrap Codex upstreams from
1821/// ~/.codex/config.toml and ~/.codex/auth.json without mutating any
1822/// codex-helper configs. Intended for diagnostics (`codex-helper doctor`).
1823pub async fn probe_codex_bootstrap_from_cli() -> Result<()> {
1824    let mut cfg = ProxyConfig::default();
1825    bootstrap_from_codex(&mut cfg)
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830    use super::*;
1831    use std::sync::{Mutex, OnceLock};
1832
1833    #[test]
1834    fn infer_env_key_from_auth_json_single_key() {
1835        let json = serde_json::json!({
1836            "OPENAI_API_KEY": "sk-test-123",
1837            "tokens": null
1838        });
1839        let auth = Some(json);
1840        let inferred = infer_env_key_from_auth_json(&auth);
1841        assert!(inferred.is_some());
1842        let (key, value) = inferred.unwrap();
1843        assert_eq!(key, "OPENAI_API_KEY");
1844        assert_eq!(value, "sk-test-123");
1845    }
1846
1847    #[test]
1848    fn infer_env_key_from_auth_json_multiple_keys() {
1849        let json = serde_json::json!({
1850            "OPENAI_API_KEY": "sk-test-1",
1851            "MISTRAL_API_KEY": "sk-test-2"
1852        });
1853        let auth = Some(json);
1854        let inferred = infer_env_key_from_auth_json(&auth);
1855        assert!(inferred.is_none());
1856    }
1857
1858    #[test]
1859    fn infer_env_key_from_auth_json_none() {
1860        let json = serde_json::json!({
1861            "tokens": {
1862                "id_token": "xxx"
1863            }
1864        });
1865        let auth = Some(json);
1866        let inferred = infer_env_key_from_auth_json(&auth);
1867        assert!(inferred.is_none());
1868    }
1869
1870    struct ScopedEnv {
1871        saved: Vec<(String, Option<String>)>,
1872    }
1873
1874    impl ScopedEnv {
1875        fn new() -> Self {
1876            Self { saved: Vec::new() }
1877        }
1878
1879        unsafe fn set(&mut self, key: &str, value: &Path) {
1880            self.saved.push((key.to_string(), std::env::var(key).ok()));
1881            unsafe { std::env::set_var(key, value) };
1882        }
1883
1884        unsafe fn set_str(&mut self, key: &str, value: &str) {
1885            self.saved.push((key.to_string(), std::env::var(key).ok()));
1886            unsafe { std::env::set_var(key, value) };
1887        }
1888    }
1889
1890    impl Drop for ScopedEnv {
1891        fn drop(&mut self) {
1892            for (key, old) in self.saved.drain(..).rev() {
1893                unsafe {
1894                    match old {
1895                        Some(v) => std::env::set_var(&key, v),
1896                        None => std::env::remove_var(&key),
1897                    }
1898                }
1899            }
1900        }
1901    }
1902
1903    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1904        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1905        match LOCK.get_or_init(|| Mutex::new(())).lock() {
1906            Ok(g) => g,
1907            Err(e) => e.into_inner(),
1908        }
1909    }
1910
1911    struct TestEnv {
1912        _lock: std::sync::MutexGuard<'static, ()>,
1913        _env: ScopedEnv,
1914        home: PathBuf,
1915    }
1916
1917    fn setup_temp_codex_home() -> TestEnv {
1918        let lock = env_lock();
1919        let mut dir = std::env::temp_dir();
1920        let suffix = format!("codex-helper-test-{}", uuid::Uuid::new_v4());
1921        dir.push(suffix);
1922        std::fs::create_dir_all(&dir).expect("create temp codex home");
1923        let mut scoped = ScopedEnv::new();
1924        let proxy_home = dir.join(".codex-helper");
1925        std::fs::create_dir_all(&proxy_home).expect("create temp proxy home");
1926        unsafe {
1927            scoped.set("CODEX_HELPER_HOME", &proxy_home);
1928            scoped.set("CODEX_HOME", &dir);
1929            // 将 HOME 也指向该目录,确保 proxy_home_dir()/config.json 也被隔离在测试目录中。
1930            scoped.set("HOME", &dir);
1931            // Windows: dirs::home_dir() prefers USERPROFILE.
1932            scoped.set("USERPROFILE", &dir);
1933            // 避免本机真实环境变量(例如 OPENAI_API_KEY)影响测试断言。
1934            scoped.set_str("OPENAI_API_KEY", "");
1935            scoped.set_str("MISTRAL_API_KEY", "");
1936            scoped.set_str("RIGHTCODE_API_KEY", "");
1937            scoped.set_str("PACKYAPI_API_KEY", "");
1938        }
1939        TestEnv {
1940            _lock: lock,
1941            _env: scoped,
1942            home: dir,
1943        }
1944    }
1945
1946    fn write_file(path: &Path, content: &str) {
1947        if let Some(parent) = path.parent() {
1948            std::fs::create_dir_all(parent).expect("create parent dirs");
1949        }
1950        std::fs::write(path, content).expect("write test file");
1951    }
1952
1953    #[test]
1954    fn load_config_prefers_toml_over_json() {
1955        let env = setup_temp_codex_home();
1956        let home = env.home.clone();
1957        let rt = tokio::runtime::Builder::new_current_thread()
1958            .enable_all()
1959            .build()
1960            .expect("build tokio runtime");
1961        rt.block_on(async move {
1962            let dir = super::proxy_home_dir();
1963            let json_path = dir.join("config.json");
1964            let toml_path = dir.join("config.toml");
1965
1966            // JSON sets notify.enabled=false
1967            write_file(&json_path, r#"{"version":1,"notify":{"enabled":false}}"#);
1968
1969            // TOML overrides notify.enabled=true
1970            write_file(
1971                &toml_path,
1972                r#"
1973version = 1
1974
1975[notify]
1976enabled = true
1977"#,
1978            );
1979
1980            let cfg = super::load_config().await.expect("load_config");
1981            assert!(
1982                cfg.notify.enabled,
1983                "expected config.toml to take precedence over config.json (home={:?})",
1984                home
1985            );
1986        });
1987    }
1988
1989    #[test]
1990    fn load_config_toml_allows_missing_service_name_and_infers_from_key() {
1991        let env = setup_temp_codex_home();
1992        let home = env.home.clone();
1993        let rt = tokio::runtime::Builder::new_current_thread()
1994            .enable_all()
1995            .build()
1996            .expect("build tokio runtime");
1997        rt.block_on(async move {
1998            let dir = super::proxy_home_dir();
1999            let toml_path = dir.join("config.toml");
2000            write_file(
2001                &toml_path,
2002                r#"
2003version = 1
2004
2005[codex]
2006active = "right"
2007
2008[codex.configs.right]
2009# name omitted on purpose
2010
2011[[codex.configs.right.upstreams]]
2012base_url = "https://www.right.codes/codex/v1"
2013[codex.configs.right.upstreams.auth]
2014auth_token_env = "RIGHTCODE_API_KEY"
2015"#,
2016            );
2017
2018            let cfg = super::load_config().await.expect("load_config");
2019            let svc = cfg
2020                .codex
2021                .configs
2022                .get("right")
2023                .expect("codex config 'right'");
2024            assert_eq!(
2025                svc.name, "right",
2026                "expected ServiceConfig.name to default to the map key (home={:?})",
2027                home
2028            );
2029        });
2030    }
2031
2032    #[test]
2033    fn init_config_toml_inserts_codex_bootstrap_when_available() {
2034        let env = setup_temp_codex_home();
2035        let home = env.home.clone();
2036
2037        // Provide a minimal Codex config that bootstrap_from_codex can parse.
2038        write_file(
2039            &home.join("config.toml"),
2040            r#"
2041model_provider = "right"
2042
2043[model_providers.right]
2044name = "right"
2045base_url = "https://www.right.codes/codex/v1"
2046env_key = "RIGHTCODE_API_KEY"
2047"#,
2048        );
2049        write_file(
2050            &home.join("auth.json"),
2051            r#"{ "RIGHTCODE_API_KEY": "sk-test-123" }"#,
2052        );
2053
2054        let rt = tokio::runtime::Builder::new_current_thread()
2055            .enable_all()
2056            .build()
2057            .expect("build tokio runtime");
2058        rt.block_on(async move {
2059            let path = super::init_config_toml(true, true)
2060                .await
2061                .expect("init_config_toml");
2062            let text = std::fs::read_to_string(&path).expect("read config.toml");
2063            assert!(
2064                text.contains("\n[codex]\n"),
2065                "expected init to insert a real [codex] block (path={:?})",
2066                path
2067            );
2068            assert!(
2069                text.contains("active = \"right\""),
2070                "expected imported active config to be present"
2071            );
2072            assert!(
2073                text.contains("\n[retry]\n") && text.contains("profile = \"balanced\""),
2074                "expected retry.profile default to be visible"
2075            );
2076        });
2077    }
2078
2079    #[test]
2080    fn init_config_toml_can_skip_codex_bootstrap_with_no_import() {
2081        let env = setup_temp_codex_home();
2082        let home = env.home.clone();
2083
2084        // Even if Codex config exists, no_import should not insert the real [codex] block.
2085        write_file(
2086            &home.join("config.toml"),
2087            r#"
2088model_provider = "right"
2089
2090[model_providers.right]
2091name = "right"
2092base_url = "https://www.right.codes/codex/v1"
2093env_key = "RIGHTCODE_API_KEY"
2094"#,
2095        );
2096        write_file(
2097            &home.join("auth.json"),
2098            r#"{ "RIGHTCODE_API_KEY": "sk-test-123" }"#,
2099        );
2100
2101        let rt = tokio::runtime::Builder::new_current_thread()
2102            .enable_all()
2103            .build()
2104            .expect("build tokio runtime");
2105        rt.block_on(async move {
2106            let path = super::init_config_toml(true, false)
2107                .await
2108                .expect("init_config_toml");
2109            let text = std::fs::read_to_string(&path).expect("read config.toml");
2110            assert!(
2111                !text.contains("\n[codex]\n"),
2112                "expected no_import to skip inserting a real [codex] block"
2113            );
2114            // But the template still contains the commented example.
2115            assert!(text.contains("# [codex]"));
2116        });
2117    }
2118
2119    #[test]
2120    fn retry_profile_defaults_to_balanced_when_unset() {
2121        let cfg = RetryConfig::default();
2122        let resolved = cfg.resolve();
2123        assert_eq!(resolved.upstream.strategy, RetryStrategy::SameUpstream);
2124        assert_eq!(resolved.upstream.max_attempts, 2);
2125        assert_eq!(resolved.upstream.backoff_ms, 200);
2126        assert_eq!(resolved.upstream.backoff_max_ms, 2_000);
2127        assert_eq!(resolved.upstream.jitter_ms, 100);
2128        assert_eq!(resolved.upstream.on_status, "429,500-599,524");
2129        assert!(
2130            resolved
2131                .upstream
2132                .on_class
2133                .iter()
2134                .any(|c| c == "upstream_transport_error")
2135        );
2136
2137        assert_eq!(resolved.provider.strategy, RetryStrategy::Failover);
2138        assert_eq!(resolved.provider.max_attempts, 2);
2139        assert_eq!(
2140            resolved.provider.on_status,
2141            "401,403,404,408,429,500-599,524"
2142        );
2143        assert_eq!(resolved.never_on_status, "413,415,422");
2144        assert!(
2145            resolved
2146                .never_on_class
2147                .iter()
2148                .any(|c| c == "client_error_non_retryable")
2149        );
2150        assert_eq!(resolved.cloudflare_challenge_cooldown_secs, 300);
2151        assert_eq!(resolved.cloudflare_timeout_cooldown_secs, 60);
2152        assert_eq!(resolved.transport_cooldown_secs, 30);
2153        assert_eq!(resolved.cooldown_backoff_factor, 1);
2154        assert_eq!(resolved.cooldown_backoff_max_secs, 600);
2155    }
2156
2157    #[test]
2158    fn retry_profile_cost_primary_sets_probe_back_defaults() {
2159        let cfg = RetryConfig {
2160            profile: Some(RetryProfileName::CostPrimary),
2161            ..RetryConfig::default()
2162        };
2163        let resolved = cfg.resolve();
2164        assert_eq!(resolved.provider.strategy, RetryStrategy::Failover);
2165        assert_eq!(resolved.cooldown_backoff_factor, 2);
2166        assert_eq!(resolved.cooldown_backoff_max_secs, 900);
2167        assert_eq!(resolved.transport_cooldown_secs, 30);
2168    }
2169
2170    #[test]
2171    fn retry_profile_aggressive_failover_enables_broader_failover_with_guardrails() {
2172        let cfg = RetryConfig {
2173            profile: Some(RetryProfileName::AggressiveFailover),
2174            ..RetryConfig::default()
2175        };
2176        let resolved = cfg.resolve();
2177        assert_eq!(resolved.provider.max_attempts, 3);
2178        assert_eq!(resolved.provider.strategy, RetryStrategy::Failover);
2179        assert_eq!(
2180            resolved.provider.on_status,
2181            "401,403,404,408,429,500-599,524"
2182        );
2183        assert_eq!(resolved.never_on_status, "413,415,422");
2184        assert!(
2185            resolved
2186                .never_on_class
2187                .iter()
2188                .any(|c| c == "client_error_non_retryable")
2189        );
2190    }
2191
2192    #[test]
2193    fn retry_profile_allows_explicit_overrides() {
2194        let cfg = RetryConfig {
2195            profile: Some(RetryProfileName::SameUpstream),
2196            // Override profile defaults:
2197            max_attempts: Some(5),
2198            strategy: Some(RetryStrategy::Failover),
2199            ..RetryConfig::default()
2200        };
2201        let resolved = cfg.resolve();
2202        assert_eq!(resolved.upstream.max_attempts, 5);
2203        assert_eq!(resolved.upstream.strategy, RetryStrategy::Failover);
2204    }
2205
2206    #[test]
2207    fn retry_profile_parses_from_toml_kebab_case() {
2208        let text = r#"
2209version = 1
2210
2211[retry]
2212profile = "cost-primary"
2213"#;
2214        let cfg = toml::from_str::<ProxyConfig>(text).expect("toml parse");
2215        assert_eq!(cfg.retry.profile, Some(RetryProfileName::CostPrimary));
2216    }
2217
2218    #[test]
2219    fn bootstrap_from_codex_with_env_key_and_auth_json() {
2220        let env = setup_temp_codex_home();
2221        let home = env.home.clone();
2222        // Write config.toml with explicit env_key
2223        let cfg_path = home.join("config.toml");
2224        let config_text = r#"
2225model_provider = "right"
2226
2227[model_providers.right]
2228name = "right"
2229base_url = "https://www.right.codes/codex/v1"
2230env_key = "RIGHTCODE_API_KEY"
2231"#;
2232        write_file(&cfg_path, config_text);
2233
2234        // Write auth.json with matching RIGHTCODE_API_KEY
2235        let auth_path = home.join("auth.json");
2236        let auth_text = r#"{ "RIGHTCODE_API_KEY": "sk-test-123" }"#;
2237        write_file(&auth_path, auth_text);
2238
2239        let mut cfg = ProxyConfig::default();
2240        bootstrap_from_codex(&mut cfg).expect("bootstrap_from_codex should succeed");
2241
2242        assert!(!cfg.codex.configs.is_empty());
2243        let svc = cfg.codex.active_config().expect("active codex config");
2244        assert_eq!(svc.name, "right");
2245        assert_eq!(svc.upstreams.len(), 1);
2246        let up = &svc.upstreams[0];
2247        assert_eq!(up.base_url, "https://www.right.codes/codex/v1");
2248        assert!(up.auth.auth_token.is_none());
2249        assert_eq!(up.auth.auth_token_env.as_deref(), Some("RIGHTCODE_API_KEY"));
2250    }
2251
2252    #[test]
2253    fn bootstrap_from_codex_infers_env_key_from_auth_json_when_missing() {
2254        let env = setup_temp_codex_home();
2255        let home = env.home.clone();
2256        // config.toml without env_key
2257        let cfg_path = home.join("config.toml");
2258        let config_text = r#"
2259model_provider = "right"
2260
2261[model_providers.right]
2262name = "right"
2263base_url = "https://www.right.codes/codex/v1"
2264"#;
2265        write_file(&cfg_path, config_text);
2266
2267        // auth.json with a single *_API_KEY field
2268        let auth_path = home.join("auth.json");
2269        let auth_text = r#"{ "RIGHTCODE_API_KEY": "sk-test-456" }"#;
2270        write_file(&auth_path, auth_text);
2271
2272        let mut cfg = ProxyConfig::default();
2273        bootstrap_from_codex(&mut cfg).expect("bootstrap_from_codex should infer env_key");
2274
2275        let svc = cfg.codex.active_config().expect("active codex config");
2276        assert_eq!(svc.name, "right");
2277        let up = &svc.upstreams[0];
2278        assert!(up.auth.auth_token.is_none());
2279        assert_eq!(up.auth.auth_token_env.as_deref(), Some("RIGHTCODE_API_KEY"));
2280    }
2281
2282    #[test]
2283    fn bootstrap_from_codex_fails_when_multiple_api_keys_without_env_key() {
2284        let env = setup_temp_codex_home();
2285        let home = env.home.clone();
2286        // config.toml still without env_key
2287        let cfg_path = home.join("config.toml");
2288        let config_text = r#"
2289model_provider = "right"
2290
2291[model_providers.right]
2292name = "right"
2293base_url = "https://www.right.codes/codex/v1"
2294"#;
2295        write_file(&cfg_path, config_text);
2296
2297        // auth.json with multiple *_API_KEY fields
2298        let auth_path = home.join("auth.json");
2299        let auth_text = r#"
2300{
2301  "RIGHTCODE_API_KEY": "sk-test-1",
2302  "PACKYAPI_API_KEY": "sk-test-2"
2303}
2304"#;
2305        write_file(&auth_path, auth_text);
2306
2307        let mut cfg = ProxyConfig::default();
2308        let err = bootstrap_from_codex(&mut cfg).expect_err("should fail to infer unique token");
2309        let msg = err.to_string();
2310        assert!(
2311            msg.contains("无法从 ~/.codex/auth.json 推断唯一的 `*_API_KEY` 字段"),
2312            "unexpected error message: {}",
2313            msg
2314        );
2315    }
2316
2317    #[test]
2318    fn load_or_bootstrap_for_service_writes_proxy_config() {
2319        let env = setup_temp_codex_home();
2320        let home = env.home.clone();
2321        let rt = tokio::runtime::Builder::new_current_thread()
2322            .enable_all()
2323            .build()
2324            .expect("build tokio runtime");
2325        rt.block_on(async move {
2326            // Prepare Codex CLI config and auth under CODEX_HOME/HOME
2327            let cfg_path = home.join("config.toml");
2328            let config_text = r#"
2329model_provider = "right"
2330
2331[model_providers.right]
2332name = "right"
2333base_url = "https://www.right.codes/codex/v1"
2334env_key = "RIGHTCODE_API_KEY"
2335"#;
2336            write_file(&cfg_path, config_text);
2337
2338            let auth_path = home.join("auth.json");
2339            let auth_text = r#"{ "RIGHTCODE_API_KEY": "sk-test-789" }"#;
2340            write_file(&auth_path, auth_text);
2341
2342            // 确保 proxy 配置文件起始不存在
2343            let proxy_cfg_path = super::proxy_home_dir().join("config.json");
2344            let proxy_cfg_toml_path = super::proxy_home_dir().join("config.toml");
2345            let _ = std::fs::remove_file(&proxy_cfg_path);
2346            let _ = std::fs::remove_file(&proxy_cfg_toml_path);
2347
2348            let cfg = super::load_or_bootstrap_for_service(ServiceKind::Codex)
2349                .await
2350                .expect("load_or_bootstrap_for_service should succeed");
2351
2352            // 内存中的配置应包含 right upstream 与正确的 token
2353            let svc = cfg.codex.active_config().expect("active codex config");
2354            assert_eq!(svc.name, "right");
2355            assert_eq!(svc.upstreams.len(), 1);
2356            assert!(svc.upstreams[0].auth.auth_token.is_none());
2357            assert_eq!(
2358                svc.upstreams[0].auth.auth_token_env.as_deref(),
2359                Some("RIGHTCODE_API_KEY")
2360            );
2361
2362            // 并且应已将配置写入到 proxy_home_dir()/config.toml(fresh install defaults to TOML)
2363            let text = std::fs::read_to_string(&proxy_cfg_toml_path)
2364                .expect("config.toml should be written by load_or_bootstrap");
2365            let text = text
2366                .lines()
2367                .filter(|l| !l.trim_start().starts_with('#'))
2368                .collect::<Vec<_>>()
2369                .join("\n");
2370            let loaded: ProxyConfig =
2371                toml::from_str(&text).expect("config.toml should be valid ProxyConfig");
2372            let svc2 = loaded.codex.active_config().expect("active codex config");
2373            assert_eq!(svc2.name, "right");
2374            assert!(svc2.upstreams[0].auth.auth_token.is_none());
2375            assert_eq!(
2376                svc2.upstreams[0].auth.auth_token_env.as_deref(),
2377                Some("RIGHTCODE_API_KEY")
2378            );
2379        });
2380    }
2381
2382    #[test]
2383    fn bootstrap_from_codex_openai_defaults_to_requires_openai_auth_and_allows_missing_token() {
2384        let env = setup_temp_codex_home();
2385        let home = env.home.clone();
2386        let cfg_path = home.join("config.toml");
2387        let config_text = r#"
2388model_provider = "openai"
2389
2390[model_providers.openai]
2391name = "openai"
2392base_url = "https://api.openai.com/v1"
2393"#;
2394        write_file(&cfg_path, config_text);
2395
2396        let mut cfg = ProxyConfig::default();
2397        bootstrap_from_codex(&mut cfg).expect("bootstrap_from_codex should succeed");
2398
2399        let svc = cfg.codex.active_config().expect("active codex config");
2400        assert_eq!(svc.name, "openai");
2401        let up = &svc.upstreams[0];
2402        assert_eq!(up.base_url, "https://api.openai.com/v1");
2403        assert!(
2404            up.auth.auth_token.is_none(),
2405            "openai default requires_openai_auth=true should not force a stored token"
2406        );
2407        assert_eq!(
2408            up.tags.get("requires_openai_auth").map(|s| s.as_str()),
2409            Some("true")
2410        );
2411    }
2412
2413    #[test]
2414    fn bootstrap_from_codex_allows_requires_openai_auth_true_for_custom_provider() {
2415        let env = setup_temp_codex_home();
2416        let home = env.home.clone();
2417        let cfg_path = home.join("config.toml");
2418        let config_text = r#"
2419model_provider = "packycode"
2420
2421[model_providers.packycode]
2422name = "packycode"
2423base_url = "https://codex-api.packycode.com/v1"
2424requires_openai_auth = true
2425wire_api = "responses"
2426"#;
2427        write_file(&cfg_path, config_text);
2428
2429        let mut cfg = ProxyConfig::default();
2430        bootstrap_from_codex(&mut cfg).expect("bootstrap_from_codex should succeed");
2431
2432        let svc = cfg.codex.active_config().expect("active codex config");
2433        assert_eq!(svc.name, "packycode");
2434        let up = &svc.upstreams[0];
2435        assert_eq!(up.base_url, "https://codex-api.packycode.com/v1");
2436        assert!(up.auth.auth_token.is_none());
2437        assert_eq!(
2438            up.tags.get("requires_openai_auth").map(|s| s.as_str()),
2439            Some("true")
2440        );
2441    }
2442
2443    #[test]
2444    fn probe_codex_bootstrap_detects_codex_proxy_without_backup() {
2445        let env = setup_temp_codex_home();
2446        let home = env.home.clone();
2447        let rt = tokio::runtime::Builder::new_current_thread()
2448            .enable_all()
2449            .build()
2450            .expect("build tokio runtime");
2451        rt.block_on(async move {
2452            let cfg_path = home.join("config.toml");
2453            let config_text = r#"
2454model_provider = "codex_proxy"
2455
2456[model_providers.codex_proxy]
2457name = "codex-helper"
2458base_url = "http://127.0.0.1:3211"
2459wire_api = "responses"
2460"#;
2461            write_file(&cfg_path, config_text);
2462
2463            // 不写备份文件,模拟“已经被本地代理接管且无原始备份”的场景
2464            let err = super::probe_codex_bootstrap_from_cli()
2465                .await
2466                .expect_err("probe should fail when model_provider is codex_proxy without backup");
2467            let msg = err.to_string();
2468            assert!(
2469                msg.contains("当前 model_provider 指向本地代理 codex-helper,且未找到备份配置"),
2470                "unexpected error message: {}",
2471                msg
2472            );
2473        });
2474    }
2475
2476    #[test]
2477    fn sync_codex_auth_updates_env_key_without_changing_routing_config() {
2478        let env = setup_temp_codex_home();
2479        let home = env.home.clone();
2480
2481        let cfg_path = home.join("config.toml");
2482        let config_text = r#"
2483model_provider = "right"
2484
2485[model_providers.right]
2486name = "right"
2487base_url = "https://www.right.codes/codex/v1"
2488env_key = "RIGHTCODE_API_KEY"
2489"#;
2490        write_file(&cfg_path, config_text);
2491
2492        let auth_path = home.join("auth.json");
2493        let auth_text = r#"{ "RIGHTCODE_API_KEY": "sk-test-123" }"#;
2494        write_file(&auth_path, auth_text);
2495
2496        let mut cfg = ProxyConfig::default();
2497        cfg.codex.active = Some("keep-active".to_string());
2498        cfg.codex.configs.insert(
2499            "right".to_string(),
2500            ServiceConfig {
2501                name: "right".to_string(),
2502                alias: None,
2503                enabled: false,
2504                level: 7,
2505                upstreams: vec![UpstreamConfig {
2506                    base_url: "https://www.right.codes/codex/v1".to_string(),
2507                    auth: UpstreamAuth {
2508                        auth_token: None,
2509                        auth_token_env: Some("OLD_KEY".to_string()),
2510                        api_key: None,
2511                        api_key_env: None,
2512                    },
2513                    tags: {
2514                        let mut t = HashMap::new();
2515                        t.insert("provider_id".into(), "right".into());
2516                        t.insert("source".into(), "codex-config".into());
2517                        t
2518                    },
2519                    supported_models: HashMap::new(),
2520                    model_mapping: HashMap::new(),
2521                }],
2522            },
2523        );
2524
2525        let report = sync_codex_auth_from_codex_cli(
2526            &mut cfg,
2527            SyncCodexAuthFromCodexOptions {
2528                add_missing: false,
2529                set_active: false,
2530                force: false,
2531            },
2532        )
2533        .expect("sync should succeed");
2534
2535        assert_eq!(report.updated, 1);
2536        assert_eq!(report.added, 0);
2537        assert!(!report.active_set);
2538
2539        let svc = cfg.codex.configs.get("right").expect("right config exists");
2540        assert_eq!(svc.level, 7);
2541        assert!(!svc.enabled, "enabled should not be changed by sync");
2542        assert_eq!(
2543            svc.upstreams[0].auth.auth_token_env.as_deref(),
2544            Some("RIGHTCODE_API_KEY")
2545        );
2546        assert_eq!(
2547            cfg.codex.active.as_deref(),
2548            Some("keep-active"),
2549            "active should not be changed by sync unless set_active is true"
2550        );
2551    }
2552
2553    #[test]
2554    fn sync_codex_auth_can_add_missing_provider_and_set_active() {
2555        let env = setup_temp_codex_home();
2556        let home = env.home.clone();
2557
2558        let cfg_path = home.join("config.toml");
2559        let config_text = r#"
2560model_provider = "right"
2561
2562[model_providers.right]
2563name = "right"
2564base_url = "https://www.right.codes/codex/v1"
2565env_key = "RIGHTCODE_API_KEY"
2566"#;
2567        write_file(&cfg_path, config_text);
2568
2569        let auth_path = home.join("auth.json");
2570        let auth_text = r#"{ "RIGHTCODE_API_KEY": "sk-test-123" }"#;
2571        write_file(&auth_path, auth_text);
2572
2573        let mut cfg = ProxyConfig::default();
2574        cfg.codex.active = Some("openai".to_string());
2575
2576        let report = sync_codex_auth_from_codex_cli(
2577            &mut cfg,
2578            SyncCodexAuthFromCodexOptions {
2579                add_missing: true,
2580                set_active: true,
2581                force: false,
2582            },
2583        )
2584        .expect("sync should succeed");
2585
2586        assert_eq!(report.added, 1);
2587        assert!(report.active_set);
2588        assert_eq!(cfg.codex.active.as_deref(), Some("right"));
2589
2590        let svc = cfg
2591            .codex
2592            .configs
2593            .get("right")
2594            .expect("right config should be added");
2595        assert!(svc.enabled);
2596        assert_eq!(svc.level, 1);
2597        assert_eq!(
2598            svc.upstreams[0].auth.auth_token_env.as_deref(),
2599            Some("RIGHTCODE_API_KEY")
2600        );
2601        assert_eq!(
2602            svc.upstreams[0].tags.get("source").map(|s| s.as_str()),
2603            Some("codex-config")
2604        );
2605    }
2606}