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 #[serde(skip_serializing_if = "Option::is_none")]
18 pub auth_token: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub auth_token_env: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub api_key: Option<String>,
25 #[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 #[serde(default)]
69 pub tags: HashMap<String, String>,
70 #[serde(
72 default,
73 skip_serializing_if = "HashMap::is_empty",
74 alias = "supportedModels"
75 )]
76 pub supported_models: HashMap<String, bool>,
77 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ServiceConfig {
157 #[serde(default)]
159 pub name: String,
160 #[serde(default)]
162 pub alias: Option<String>,
163 #[serde(default = "default_service_config_enabled")]
165 pub enabled: bool,
166 #[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 #[serde(default)]
185 pub active: Option<String>,
186 #[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 .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 #[serde(default)]
257 pub profile: Option<RetryProfileName>,
258 #[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 #[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 #[default]
301 Failover,
302 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 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 pub min_duration_ms: u64,
524 pub global_cooldown_ms: u64,
526 pub merge_window_ms: u64,
528 pub per_thread_cooldown_ms: u64,
530 pub recent_search_window_ms: u64,
532 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 pub enabled: bool,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, Default)]
556pub struct NotifyExecConfig {
557 pub enabled: bool,
559 #[serde(default)]
562 pub command: Vec<String>,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, Default)]
566pub struct NotifyConfig {
567 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 #[serde(default)]
581 pub version: Option<u32>,
582 #[serde(default)]
584 pub codex: ServiceConfigManager,
585 #[serde(default)]
587 pub claude: ServiceConfigManager,
588 #[serde(default)]
590 pub retry: RetryConfig,
591 #[serde(default)]
593 pub notify: NotifyConfig,
594 #[serde(default)]
596 pub default_service: Option<ServiceKind>,
597 #[serde(default)]
599 pub ui: UiConfig,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize, Default)]
603pub struct UiConfig {
604 #[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
631pub 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 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
976pub 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
1068pub fn codex_sessions_dir() -> PathBuf {
1070 codex_home().join("sessions")
1071}
1072
1073#[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
1097fn 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 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 if current_provider_id == "codex_proxy" && !backup_path.exists() {
1174 let provider_table = providers_table.get(¤t_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 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 == ¤t_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 == ¤t_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 == ¤t_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 == ¤t_provider_id {
1301 imported_active = true;
1302 }
1303 }
1304
1305 if !imported_any {
1306 anyhow::bail!("未能从 ~/.codex/config.toml 推导出任何可用的 Codex 上游配置");
1307 }
1308
1309 if imported_active && cfg.codex.configs.contains_key(¤t_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 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 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
1423pub 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 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
1452pub 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
1473pub 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 pub add_missing: bool,
1487 pub set_active: bool,
1489 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#[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 if current_provider_id == "codex_proxy" && !backup_path.exists() {
1560 let provider_table = providers_table.get(¤t_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 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 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 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 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(¤t_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
1787pub 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
1811pub 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
1820pub 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 scoped.set("HOME", &dir);
1931 scoped.set("USERPROFILE", &dir);
1933 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 write_file(&json_path, r#"{"version":1,"notify":{"enabled":false}}"#);
1968
1969 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}