Skip to main content

codex_helper_core/
config_storage.rs

1use super::bootstrap_impl::bootstrap_from_codex;
2use super::*;
3use crate::file_replace::write_bytes_file_async;
4
5fn config_dir() -> PathBuf {
6    proxy_home_dir()
7}
8
9fn config_path() -> PathBuf {
10    config_dir().join("config.json")
11}
12
13fn config_backup_path() -> PathBuf {
14    config_dir().join("config.json.bak")
15}
16
17fn config_toml_path() -> PathBuf {
18    config_dir().join("config.toml")
19}
20
21fn config_toml_backup_path() -> PathBuf {
22    config_dir().join("config.toml.bak")
23}
24
25fn config_backup_source_and_path() -> (PathBuf, PathBuf) {
26    let toml_path = config_toml_path();
27    if toml_path.exists() {
28        return (toml_path, config_toml_backup_path());
29    }
30
31    let json_path = config_path();
32    if json_path.exists() {
33        return (json_path, config_backup_path());
34    }
35
36    (toml_path, config_toml_backup_path())
37}
38
39/// Return the primary config file path that will be used by `load_config()`.
40pub fn config_file_path() -> PathBuf {
41    let toml_path = config_toml_path();
42    if toml_path.exists() {
43        toml_path
44    } else if config_path().exists() {
45        config_path()
46    } else {
47        toml_path
48    }
49}
50
51const CONFIG_VERSION: u32 = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
52
53#[derive(Debug, Clone)]
54pub struct LoadedProxyConfig {
55    pub runtime: ProxyConfig,
56    pub v4: Option<ProxyConfigV4>,
57}
58
59fn ensure_config_version(cfg: &mut ProxyConfig) {
60    if cfg.version.is_none() {
61        cfg.version = Some(CONFIG_VERSION);
62    }
63}
64
65const CONFIG_TOML_DOC_HEADER: &str = r#"# codex-helper config.toml
66#
67# 本文件可选;如果存在,codex-helper 会优先使用它(而不是 config.json)。
68#
69# 常用命令:
70# - 生成带注释的模板:`codex-helper config init`
71#
72# 安全建议:
73# - 尽量用环境变量保存密钥(*_env 字段,例如 auth_token_env / api_key_env),不要把 token 明文写入文件。
74#
75# 备注:某些命令会重写此文件;会保留本段 header,方便把说明贴近配置。
76"#;
77
78const CONFIG_TOML_TEMPLATE: &str = r#"# codex-helper config.toml
79#
80# codex-helper 同时支持 config.json 与 config.toml:
81# - 如果 `config.toml` 存在,则优先使用它;
82# - 否则使用 `config.json`(兼容旧版本)。
83#
84# 本模板以“可发现性”为主:包含可直接抄的示例,以及每个字段的说明。
85#
86# 路径:
87# - Linux/macOS:`~/.codex-helper/config.toml`
88# - Windows:    `%USERPROFILE%\.codex-helper\config.toml`
89#
90# 小贴士:
91# - 生成/覆盖本模板:`codex-helper config init [--force]`
92# - 新安装时:首次写入配置默认会写 TOML。
93
94version = 5
95
96# 省略 --codex/--claude 时默认使用哪个服务。
97# default_service = "codex"
98# default_service = "claude"
99
100# --- 自动导入(可选) ---
101#
102# 如果你的机器上已配置 Codex CLI(存在 `~/.codex/config.toml`),`codex-helper config init`
103# 会尝试自动把 Codex providers / routing 导入到本文件中,避免你手动抄写 base_url/env_key。
104#
105# 如果你只想生成纯模板(不导入),请使用:
106#   codex-helper config init --no-import
107
108# --- 推荐:provider / routing 配置(v5 route graph) ---
109#
110# 大部分用户只需要改这一段。
111#
112# 说明:
113# - 优先使用环境变量方式保存密钥(`*_env`),避免写入磁盘。
114# - `providers` 负责账号、认证、endpoint 和标签。
115# - `routing.entry` 指向入口 route node。
116# - `routing.routes.*` 负责顺序、策略、分组和兜底行为。
117# - 单 endpoint provider 尽量直接写 `base_url`,不要再包一层 `endpoints.default`。
118#
119# [codex.providers.openai]
120# base_url = "https://api.openai.com/v1"
121# auth_token_env = "OPENAI_API_KEY"
122# tags = { vendor = "openai", region = "us" }
123#
124# [codex.providers.backup]
125# base_url = "https://your-backup-provider.example/v1"
126# auth_token_env = "BACKUP_API_KEY"
127# tags = { vendor = "backup", region = "hk" }
128#
129# [codex.routing]
130# entry = "main"
131# affinity_policy = "preferred-group"
132# fallback_ttl_ms = 120000
133# reprobe_preferred_after_ms = 30000
134#
135# [codex.routing.routes.main]
136# strategy = "ordered-failover"
137# children = ["openai", "backup"]
138#
139# --- 会话控制模板(profiles,可选) ---
140#
141# Phase 1 先支持“定义 / 列出 / 应用到会话”,暂不自动把 default_profile 绑定到新会话。
142#
143# [codex]
144# default_profile = "daily"
145#
146# [codex.profiles.daily]
147# reasoning_effort = "medium"
148#
149# [codex.profiles.fast]
150# service_tier = "priority"
151# reasoning_effort = "low"
152#
153# [codex.profiles.deep]
154# model = "gpt-5.4"
155# reasoning_effort = "high"
156#
157# Claude 配置在 [claude] 下结构相同。
158#
159# ---
160#
161# --- 通知集成(Codex `notify` hook) ---
162#
163# 可选功能,默认关闭。
164# 设计目标:多 Codex 工作流下的低噪声通知(按耗时过滤 + 合并 + 限流)。
165#
166# 启用步骤:
167# 1) 在 Codex 配置 `~/.codex/config.toml` 中添加:
168#      notify = ["codex-helper", "notify", "codex"]
169# 2) 在本文件中开启:
170#      notify.enabled = true
171#      notify.system.enabled = true
172#
173[notify]
174# 通知总开关(system toast 与 exec 回调都受此控制)。
175enabled = false
176
177[notify.system]
178# 系统通知支持:
179# - Windows:toast(powershell.exe)
180# - macOS:`osascript`
181enabled = false
182
183[notify.policy]
184# D:按耗时过滤(毫秒)
185min_duration_ms = 60000
186
187# A:合并 + 限流(毫秒)
188merge_window_ms = 10000
189global_cooldown_ms = 60000
190per_thread_cooldown_ms = 180000
191
192# 在 proxy /__codex_helper/api/v1/status/recent 中向前回看多久(毫秒)。
193# codex-helper 会把 Codex 的 "thread-id" 匹配到 proxy 的 FinishedRequest.session_id。
194recent_search_window_ms = 300000
195# 访问 recent endpoint 的 HTTP 超时(毫秒)
196recent_endpoint_timeout_ms = 500
197
198[notify.exec]
199# 可选回调:执行一个命令,并把聚合后的 JSON 写到 stdin。
200enabled = false
201# command = ["python", "my_hook.py"]
202
203# ---
204#
205# --- 重试策略(代理侧) ---
206#
207# 控制 codex-helper 在返回给 Codex 之前进行的内部重试。
208# 注意:如果你同时开启了 Codex 自身的重试,可能会出现“双重重试”。
209#
210[retry]
211# 策略预设(推荐):
212# - "balanced"(默认)
213# - "same-upstream"(倾向同 upstream 重试,适合 CF/网络抖动)
214# - "aggressive-failover"(更激进:更多尝试次数,可能增加时延/成本)
215# - "cost-primary"(省钱主从:包月主线路 + 按量备选,支持回切探测)
216profile = "balanced"
217
218# 下面这些字段是“覆盖项”(在 profile 默认值之上进行覆盖)。
219#
220# 两层模型:
221# - retry.upstream:在当前 station 已选中的 provider/endpoint 内,对单个 upstream 的内部重试(默认更偏向同一 upstream)。
222# - retry.provider:当 upstream 层无法恢复时,决定是否切换到其他 upstream / 同一 station 可用的其他 provider 路径。
223#
224# 覆盖示例(可按需取消注释):
225#
226# [retry.upstream]
227# max_attempts = 2
228# strategy = "same_upstream"
229# backoff_ms = 200
230# backoff_max_ms = 2000
231# jitter_ms = 100
232# on_status = "429,500-599,524"
233# on_class = ["upstream_transport_error", "cloudflare_timeout", "cloudflare_challenge"]
234#
235# [retry.provider]
236# max_attempts = 2
237# strategy = "failover"
238# on_status = "401,403,404,408,429,500-599,524"
239# on_class = ["upstream_transport_error"]
240
241# 明确禁止重试/切换的 HTTP 状态码/范围(字符串形式)。
242# 示例:"413,415,422"。
243# never_on_status = "413,415,422"
244
245# 明确禁止重试/切换的错误分类(来自 codex-helper 的 classify)。
246# 默认包含 "client_error_non_retryable"(常见请求格式/参数错误)。
247# never_on_class = ["client_error_non_retryable"]
248
249# 对某些失败类型施加冷却(秒)。
250# cloudflare_challenge_cooldown_secs = 300
251# cloudflare_timeout_cooldown_secs = 60
252# transport_cooldown_secs = 30
253
254# 可选:冷却的指数退避(主要用于“便宜主线路不稳 → 降级到备选 → 隔一段时间探测回切”)。
255#
256# 启用后:同一 upstream/config 连续失败次数越多,冷却越久:
257#   effective_cooldown = min(base_cooldown * factor^streak, cooldown_backoff_max_secs)
258#
259# factor=1 表示关闭退避(默认行为)。
260# cooldown_backoff_factor = 2
261# cooldown_backoff_max_secs = 600
262"#;
263
264fn insert_after_version_block(template: &str, insert: &str) -> String {
265    let needle = "version = 5\n\n";
266    if let Some(idx) = template.find(needle) {
267        let insert_pos = idx + needle.len();
268        let mut out = String::with_capacity(template.len() + insert.len() + 2);
269        out.push_str(&template[..insert_pos]);
270        out.push_str(insert);
271        out.push('\n');
272        out.push_str(&template[insert_pos..]);
273        return out;
274    }
275    format!("{template}\n\n{insert}\n")
276}
277
278fn toml_schema_version_or_shape(text: &str) -> Option<u32> {
279    let value = toml::from_str::<TomlValue>(text).ok()?;
280    if let Some(version) = value
281        .get("version")
282        .and_then(|v| v.as_integer())
283        .map(|value| value as u32)
284    {
285        return Some(version);
286    }
287
288    let has_v4_routing = ["codex", "claude"].iter().any(|service| {
289        value
290            .get(*service)
291            .and_then(|service| service.get("routing"))
292            .and_then(|routing| routing.get("entry").or_else(|| routing.get("routes")))
293            .is_some()
294    });
295    if has_v4_routing {
296        Some(4)
297    } else {
298        let has_legacy_routing = ["codex", "claude"].iter().any(|service| {
299            value
300                .get(*service)
301                .and_then(|service| service.get("routing"))
302                .is_some()
303        });
304        if has_legacy_routing { Some(3) } else { None }
305    }
306}
307
308fn codex_bootstrap_snippet() -> Result<Option<String>> {
309    #[derive(Serialize)]
310    struct CodexOnly<'a> {
311        codex: &'a ServiceViewV4,
312    }
313
314    let mut cfg = ProxyConfig::default();
315    ensure_config_version(&mut cfg);
316    if bootstrap_from_codex(&mut cfg).is_err() {
317        return Ok(None);
318    }
319    if !cfg.codex.has_stations() {
320        return Ok(None);
321    }
322
323    let migrated = migrate_legacy_to_v4(&cfg)?;
324    let body = toml::to_string_pretty(&CodexOnly {
325        codex: &migrated.codex,
326    })?;
327    Ok(Some(format!(
328        "# --- 自动导入:来自 ~/.codex/config.toml + auth.json ---\n{body}"
329    )))
330}
331
332pub async fn init_config_toml(force: bool, import_codex: bool) -> Result<PathBuf> {
333    let dir = config_dir();
334    fs::create_dir_all(&dir).await?;
335    let path = config_toml_path();
336    let backup_path = config_toml_backup_path();
337
338    if path.exists() && !force {
339        anyhow::bail!(
340            "config.toml already exists at {:?}; use --force to overwrite",
341            path
342        );
343    }
344
345    if path.exists()
346        && let Err(err) = fs::copy(&path, &backup_path).await
347    {
348        warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
349    }
350
351    let mut text = CONFIG_TOML_TEMPLATE.to_string();
352    if import_codex && let Some(snippet) = codex_bootstrap_snippet()? {
353        text = insert_after_version_block(&text, snippet.as_str());
354    }
355    write_bytes_file_async(&path, text.as_bytes()).await?;
356    Ok(path)
357}
358
359pub async fn load_config() -> Result<ProxyConfig> {
360    Ok(load_config_with_v4_source().await?.runtime)
361}
362
363pub async fn load_config_with_v4_source() -> Result<LoadedProxyConfig> {
364    let toml_path = config_toml_path();
365    if toml_path.exists() {
366        let text = fs::read_to_string(&toml_path).await?;
367        let version = toml_schema_version_or_shape(&text);
368
369        let mut loaded_v4 = None;
370        let mut cfg = if version.is_some_and(is_supported_route_graph_config_version) {
371            let cfg_v4 = toml::from_str::<ProxyConfigV4>(&text)?;
372            let runtime = compile_v4_to_runtime(&cfg_v4)?;
373            loaded_v4 = Some(cfg_v4);
374            runtime
375        } else if version == Some(3) {
376            let cfg_legacy = toml::from_str::<crate::config::legacy::ProxyConfigV3Legacy>(&text)?;
377            let migrated = crate::config::legacy::migrate_v3_legacy_to_v4(&cfg_legacy)?;
378            let runtime = compile_v4_to_runtime(&migrated.config)?;
379            loaded_v4 = Some(migrated.config);
380            runtime
381        } else if version == Some(2) {
382            let cfg_v2 = toml::from_str::<ProxyConfigV2>(&text)?;
383            compile_v2_to_runtime(&cfg_v2)?
384        } else {
385            let mut cfg = toml::from_str::<ProxyConfig>(&text)?;
386            ensure_config_version(&mut cfg);
387            cfg
388        };
389        normalize_proxy_config(&mut cfg);
390        validate_proxy_config(&cfg)?;
391        if version != Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION) {
392            if let Some(cfg_v4) = loaded_v4.as_mut() {
393                auto_migrate_loaded_v4_config(cfg_v4, "config.toml", version).await;
394                cfg_v4.version = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
395                cfg.version = Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION);
396            } else {
397                auto_migrate_loaded_config(&mut cfg, "config.toml", version).await;
398            }
399        } else if let Some(cfg_v4) = loaded_v4.as_ref() {
400            auto_compact_loaded_v4_config(cfg_v4, "config.toml").await;
401        }
402        return Ok(LoadedProxyConfig {
403            runtime: cfg,
404            v4: loaded_v4,
405        });
406    }
407
408    let json_path = config_path();
409    if json_path.exists() {
410        let bytes = fs::read(json_path).await?;
411        let mut cfg = serde_json::from_slice::<ProxyConfig>(&bytes)?;
412        let version = cfg.version;
413        ensure_config_version(&mut cfg);
414        normalize_proxy_config(&mut cfg);
415        validate_proxy_config(&cfg)?;
416        auto_migrate_loaded_config(&mut cfg, "config.json", version).await;
417        return Ok(LoadedProxyConfig {
418            runtime: cfg,
419            v4: None,
420        });
421    }
422
423    let mut cfg = ProxyConfig::default();
424    ensure_config_version(&mut cfg);
425    normalize_proxy_config(&mut cfg);
426    validate_proxy_config(&cfg)?;
427    Ok(LoadedProxyConfig {
428        runtime: cfg,
429        v4: None,
430    })
431}
432
433async fn auto_migrate_loaded_config(
434    cfg: &mut ProxyConfig,
435    source: &str,
436    source_version: Option<u32>,
437) {
438    match save_config(cfg).await {
439        Ok(()) => {
440            cfg.version = Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION);
441            info!(
442                "auto-migrated {} from version {:?} to version {}",
443                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION
444            );
445        }
446        Err(err) => {
447            warn!(
448                "failed to auto-migrate {} from version {:?} to version {}: {}",
449                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION, err
450            );
451        }
452    }
453}
454
455async fn auto_migrate_loaded_v4_config(
456    cfg: &ProxyConfigV4,
457    source: &str,
458    source_version: Option<u32>,
459) {
460    match save_config_v4(cfg).await {
461        Ok(_) => {
462            info!(
463                "auto-migrated {} from version {:?} to version {}",
464                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION
465            );
466        }
467        Err(err) => {
468            warn!(
469                "failed to auto-migrate {} from version {:?} to version {}: {}",
470                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION, err
471            );
472        }
473    }
474}
475
476fn runtime_service_manager_value(mgr: &ServiceConfigManager) -> Result<JsonValue> {
477    serde_json::to_value(mgr).context("serialize runtime service manager")
478}
479
480fn v4_service_has_import_metadata(view: &ServiceViewV4) -> bool {
481    view.providers.values().any(|provider| {
482        provider.tags.contains_key("provider_id")
483            || provider.tags.contains_key("requires_openai_auth")
484            || provider
485                .tags
486                .get("source")
487                .is_some_and(|value| value == "codex-config")
488            || provider.endpoints.values().any(|endpoint| {
489                endpoint.tags.contains_key("provider_id")
490                    || endpoint.tags.contains_key("requires_openai_auth")
491                    || endpoint
492                        .tags
493                        .get("source")
494                        .is_some_and(|value| value == "codex-config")
495            })
496    })
497}
498
499async fn auto_compact_loaded_v4_config(cfg: &ProxyConfigV4, source: &str) {
500    if !v4_service_has_import_metadata(&cfg.codex) && !v4_service_has_import_metadata(&cfg.claude) {
501        return;
502    }
503
504    match save_config_v4(cfg).await {
505        Ok(_) => {
506            info!(
507                "auto-compacted {} v4 provider config metadata for authoring format",
508                source
509            );
510        }
511        Err(err) => {
512            warn!(
513                "failed to auto-compact {} v4 provider config metadata: {}",
514                source, err
515            );
516        }
517    }
518}
519
520async fn save_existing_v4_if_only_runtime_metadata_changed(
521    cfg: &ProxyConfig,
522) -> Result<Option<PathBuf>> {
523    let path = config_toml_path();
524    if !path.exists() {
525        return Ok(None);
526    }
527
528    let text = fs::read_to_string(&path).await?;
529    if !toml_schema_version_or_shape(&text).is_some_and(is_supported_route_graph_config_version) {
530        return Ok(None);
531    }
532
533    let mut requested = cfg.clone();
534    normalize_proxy_config(&mut requested);
535    validate_proxy_config(&requested)?;
536
537    let mut existing = toml::from_str::<ProxyConfigV4>(&text)?;
538    let mut existing_runtime = compile_v4_to_runtime(&existing)?;
539    normalize_proxy_config(&mut existing_runtime);
540
541    if runtime_service_manager_value(&existing_runtime.codex)?
542        != runtime_service_manager_value(&requested.codex)?
543        || runtime_service_manager_value(&existing_runtime.claude)?
544            != runtime_service_manager_value(&requested.claude)?
545    {
546        return Ok(None);
547    }
548
549    existing.retry = requested.retry;
550    existing.notify = requested.notify;
551    existing.default_service = requested.default_service;
552    existing.ui = requested.ui;
553    save_config_v4(&existing).await.map(Some)
554}
555
556pub async fn save_config(cfg: &ProxyConfig) -> Result<()> {
557    if cfg
558        .version
559        .is_some_and(is_supported_route_graph_config_version)
560    {
561        if save_existing_v4_if_only_runtime_metadata_changed(cfg)
562            .await?
563            .is_some()
564        {
565            return Ok(());
566        }
567        let migrated = migrate_legacy_to_v4(cfg)?;
568        save_config_v4(&migrated).await?;
569        return Ok(());
570    }
571
572    let migrated = migrate_legacy_to_v4(cfg)?;
573    save_config_v4(&migrated).await?;
574    Ok(())
575}
576
577pub async fn save_config_v2(cfg: &ProxyConfigV2) -> Result<PathBuf> {
578    let mut normalized = compact_v2_config(cfg)?;
579    let mut runtime = compile_v2_to_runtime(&normalized)?;
580    normalize_proxy_config(&mut runtime);
581    validate_proxy_config(&runtime)?;
582    normalized.version = 2;
583
584    let dir = config_dir();
585    fs::create_dir_all(&dir).await?;
586    let path = config_toml_path();
587    let (backup_source_path, backup_path) = config_backup_source_and_path();
588    let body = toml::to_string_pretty(&normalized)?;
589    let text = format!(
590        "{CONFIG_TOML_DOC_HEADER}
591{body}"
592    );
593    let data = text.into_bytes();
594
595    if backup_source_path.exists()
596        && let Err(err) = fs::copy(&backup_source_path, &backup_path).await
597    {
598        warn!(
599            "failed to backup {:?} to {:?}: {}",
600            backup_source_path, backup_path, err
601        );
602    }
603
604    write_bytes_file_async(&path, &data).await?;
605    Ok(path)
606}
607
608pub async fn save_config_v4(cfg: &ProxyConfigV4) -> Result<PathBuf> {
609    let mut normalized = cfg.clone();
610    normalized.version = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
611    compact_v4_config_for_write(&mut normalized);
612    let mut runtime = compile_v4_to_runtime(&normalized)?;
613    normalize_proxy_config(&mut runtime);
614    validate_proxy_config(&runtime)?;
615
616    let dir = config_dir();
617    fs::create_dir_all(&dir).await?;
618    let path = config_toml_path();
619    let (backup_source_path, backup_path) = config_backup_source_and_path();
620    let body = toml::to_string_pretty(&normalized)?;
621    let text = format!(
622        "{CONFIG_TOML_DOC_HEADER}
623{body}"
624    );
625    let data = text.into_bytes();
626
627    if backup_source_path.exists()
628        && let Err(err) = fs::copy(&backup_source_path, &backup_path).await
629    {
630        warn!(
631            "failed to backup {:?} to {:?}: {}",
632            backup_source_path, backup_path, err
633        );
634    }
635
636    write_bytes_file_async(&path, &data).await?;
637    Ok(path)
638}
639
640fn normalize_proxy_config(cfg: &mut ProxyConfig) {
641    fn normalize_mgr(mgr: &mut ServiceConfigManager) {
642        fn select_default_active_name(configs: &HashMap<String, ServiceConfig>) -> Option<String> {
643            let mut items = configs.iter().collect::<Vec<_>>();
644            items.sort_by(|(name_a, svc_a), (name_b, svc_b)| {
645                svc_a
646                    .level
647                    .cmp(&svc_b.level)
648                    .then_with(|| name_a.cmp(name_b))
649            });
650            items
651                .iter()
652                .find(|(_, svc)| svc.enabled)
653                .map(|(name, _)| (*name).clone())
654                .or_else(|| items.first().map(|(name, _)| (*name).clone()))
655        }
656
657        for (key, svc) in mgr.stations_mut() {
658            if svc.name.trim().is_empty() {
659                svc.name = key.clone();
660            }
661        }
662        let normalized_active = mgr
663            .active
664            .as_ref()
665            .map(|value| value.trim().to_string())
666            .filter(|value| !value.is_empty());
667        mgr.active = match normalized_active {
668            Some(active) if mgr.contains_station(active.as_str()) => Some(active),
669            Some(active) => match active.to_ascii_lowercase().as_str() {
670                "true" | "1" | "yes" | "on" => select_default_active_name(mgr.stations()),
671                "false" | "0" | "no" | "off" => None,
672                _ => Some(active),
673            },
674            None => None,
675        };
676        mgr.default_profile = mgr
677            .default_profile
678            .as_ref()
679            .map(|value| value.trim().to_string())
680            .filter(|value| !value.is_empty());
681        for profile in mgr.profiles.values_mut() {
682            profile.extends = profile
683                .extends
684                .as_ref()
685                .map(|value| value.trim().to_string())
686                .filter(|value| !value.is_empty());
687            profile.station = profile
688                .station
689                .as_ref()
690                .map(|value| value.trim().to_string())
691                .filter(|value| !value.is_empty());
692            profile.model = profile
693                .model
694                .as_ref()
695                .map(|value| value.trim().to_string())
696                .filter(|value| !value.is_empty());
697            profile.reasoning_effort = profile
698                .reasoning_effort
699                .as_ref()
700                .map(|value| value.trim().to_string())
701                .filter(|value| !value.is_empty());
702            profile.service_tier = profile
703                .service_tier
704                .as_ref()
705                .map(|value| value.trim().to_string())
706                .filter(|value| !value.is_empty());
707        }
708    }
709
710    normalize_mgr(&mut cfg.codex);
711    normalize_mgr(&mut cfg.claude);
712}
713
714fn validate_proxy_config(cfg: &ProxyConfig) -> Result<()> {
715    validate_service_profiles("codex", &cfg.codex)?;
716    validate_service_profiles("claude", &cfg.claude)?;
717    Ok(())
718}