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, write_text_file};
4use toml_edit::{
5    Document as EditableTomlDocument, Item as EditableTomlItem, Table as EditableTomlTable,
6    value as editable_toml_value,
7};
8
9fn config_dir() -> PathBuf {
10    proxy_home_dir()
11}
12
13fn config_path() -> PathBuf {
14    config_dir().join("config.json")
15}
16
17fn config_backup_path() -> PathBuf {
18    config_dir().join("config.json.bak")
19}
20
21fn config_toml_path() -> PathBuf {
22    config_dir().join("config.toml")
23}
24
25fn config_toml_backup_path() -> PathBuf {
26    config_dir().join("config.toml.bak")
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct CodexClientPatchConfig {
31    pub preset: crate::codex_integration::CodexPatchMode,
32    pub options: crate::codex_integration::CodexSwitchOptions,
33    pub translate_models: bool,
34    pub hosted_image_generation: crate::codex_integration::CodexHostedImageGenerationMode,
35}
36
37impl Default for CodexClientPatchConfig {
38    fn default() -> Self {
39        Self {
40            preset: crate::codex_integration::CodexPatchMode::Default,
41            options: crate::codex_integration::CodexSwitchOptions::default(),
42            translate_models: false,
43            hosted_image_generation: crate::codex_integration::CodexHostedImageGenerationMode::Auto,
44        }
45    }
46}
47
48fn parse_codex_client_patch_preset(
49    field_name: &str,
50    value: &str,
51) -> Result<crate::codex_integration::CodexPatchMode> {
52    match value.trim() {
53        "default" => Ok(crate::codex_integration::CodexPatchMode::Default),
54        "chatgpt-bridge" | "chatgpt_bridge" => {
55            Ok(crate::codex_integration::CodexPatchMode::ChatGptBridge)
56        }
57        "imagegen-bridge" | "imagegen_bridge" => {
58            Ok(crate::codex_integration::CodexPatchMode::ImagegenBridge)
59        }
60        "official-relay" | "official_relay" | "official-relay-bridge" | "official_relay_bridge" => {
61            Ok(crate::codex_integration::CodexPatchMode::OfficialRelayBridge)
62        }
63        "official-imagegen"
64        | "official_imagegen"
65        | "official-imagegen-bridge"
66        | "official_imagegen_bridge" => {
67            Ok(crate::codex_integration::CodexPatchMode::OfficialImagegenBridge)
68        }
69        other => anyhow::bail!(
70            "unsupported codex.client_patch.{} '{}'; expected 'default', 'chatgpt-bridge', 'imagegen-bridge', 'official-relay', or 'official-imagegen'. Legacy mode values are still accepted for reading. Use codex.client_patch.compaction or codex.client_patch.responses_websocket for orthogonal behavior instead of adding another preset.",
71            field_name,
72            other,
73        ),
74    }
75}
76
77fn parse_codex_compaction_strategy(
78    value: &str,
79) -> Result<crate::codex_integration::CodexCompactionStrategy> {
80    match value.trim() {
81        "" | "auto" => Ok(crate::codex_integration::CodexCompactionStrategy::Auto),
82        "local" => Ok(crate::codex_integration::CodexCompactionStrategy::Local),
83        "remote-v1" | "remote_v1" => {
84            Ok(crate::codex_integration::CodexCompactionStrategy::RemoteV1)
85        }
86        "remote-v2" | "remote_v2" => {
87            Ok(crate::codex_integration::CodexCompactionStrategy::RemoteV2)
88        }
89        other => anyhow::bail!(
90            "unsupported codex.client_patch.compaction '{}'; expected 'auto', 'local', 'remote-v1', or 'remote-v2'",
91            other,
92        ),
93    }
94}
95
96fn parse_codex_hosted_image_generation_mode(
97    value: &str,
98) -> Result<crate::codex_integration::CodexHostedImageGenerationMode> {
99    match value.trim() {
100        "" | "auto" => Ok(crate::codex_integration::CodexHostedImageGenerationMode::Auto),
101        "enabled" | "enable" | "on" | "true" => {
102            Ok(crate::codex_integration::CodexHostedImageGenerationMode::Enabled)
103        }
104        "disabled" | "disable" | "off" | "false" => {
105            Ok(crate::codex_integration::CodexHostedImageGenerationMode::Disabled)
106        }
107        other => anyhow::bail!(
108            "unsupported codex.client_patch.hosted_image_generation '{}'; expected 'auto', 'enabled', or 'disabled'",
109            other,
110        ),
111    }
112}
113
114fn codex_client_patch_preset_from_toml_value(
115    value: &TomlValue,
116) -> Result<crate::codex_integration::CodexPatchMode> {
117    let patch = value
118        .get("codex")
119        .and_then(|codex| codex.get("client_patch"));
120    let preset = patch
121        .and_then(|patch| patch.get("preset"))
122        .and_then(TomlValue::as_str)
123        .map(str::trim)
124        .filter(|preset| !preset.is_empty());
125    let legacy_mode = patch
126        .and_then(|patch| patch.get("mode"))
127        .and_then(TomlValue::as_str)
128        .map(str::trim)
129        .filter(|mode| !mode.is_empty());
130
131    match (preset, legacy_mode) {
132        (Some(preset), Some(mode)) => {
133            let preset = parse_codex_client_patch_preset("preset", preset)?;
134            let legacy_mode = parse_codex_client_patch_preset("mode", mode)?;
135            if preset != legacy_mode {
136                anyhow::bail!(
137                    "conflicting codex.client_patch preset/mode values; keep only preset = \"{}\"",
138                    preset.as_preset_str()
139                );
140            }
141            Ok(preset)
142        }
143        (Some(preset), None) => parse_codex_client_patch_preset("preset", preset),
144        (None, Some(mode)) => parse_codex_client_patch_preset("mode", mode),
145        (None, None) => Ok(crate::codex_integration::CodexPatchMode::Default),
146    }
147}
148
149fn codex_client_patch_config_from_toml_value(value: &TomlValue) -> Result<CodexClientPatchConfig> {
150    let preset = codex_client_patch_preset_from_toml_value(value)?;
151    let patch = value
152        .get("codex")
153        .and_then(|codex| codex.get("client_patch"));
154    let responses_websocket = patch
155        .and_then(|patch| patch.get("responses_websocket"))
156        .and_then(TomlValue::as_bool)
157        .unwrap_or(false);
158    let compaction = patch
159        .and_then(|patch| patch.get("compaction"))
160        .and_then(TomlValue::as_str)
161        .map(parse_codex_compaction_strategy)
162        .transpose()?
163        .unwrap_or_default();
164    let translate_models = patch
165        .and_then(|patch| patch.get("translate_models"))
166        .and_then(TomlValue::as_bool)
167        .unwrap_or(false);
168    let hosted_image_generation = patch
169        .and_then(|patch| patch.get("hosted_image_generation"))
170        .and_then(TomlValue::as_str)
171        .map(parse_codex_hosted_image_generation_mode)
172        .transpose()?
173        .unwrap_or_default();
174
175    Ok(CodexClientPatchConfig {
176        preset,
177        options: crate::codex_integration::CodexSwitchOptions {
178            responses_websocket,
179            compaction,
180        },
181        translate_models,
182        hosted_image_generation,
183    })
184}
185
186pub fn codex_client_patch_config_from_config_file() -> Result<CodexClientPatchConfig> {
187    let path = config_file_path();
188    if !path.exists() || path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
189        return Ok(CodexClientPatchConfig::default());
190    }
191
192    let text = stdfs::read_to_string(&path).with_context(|| format!("read {:?}", path))?;
193    let value: TomlValue = toml::from_str(&text).with_context(|| format!("parse {:?}", path))?;
194    codex_client_patch_config_from_toml_value(&value)
195}
196
197pub fn codex_client_patch_preset_from_config_file()
198-> Result<crate::codex_integration::CodexPatchMode> {
199    Ok(codex_client_patch_config_from_config_file()?.preset)
200}
201
202pub fn codex_client_patch_mode_from_config_file() -> Result<crate::codex_integration::CodexPatchMode>
203{
204    codex_client_patch_preset_from_config_file()
205}
206
207fn existing_codex_client_patch_item() -> Option<EditableTomlItem> {
208    let path = config_toml_path();
209    let text = stdfs::read_to_string(path).ok()?;
210    let doc = text.parse::<EditableTomlDocument>().ok()?;
211    doc.as_table()
212        .get("codex")
213        .and_then(EditableTomlItem::as_table)
214        .and_then(|codex| codex.get("client_patch"))
215        .cloned()
216        .map(normalize_existing_codex_client_patch_item)
217}
218
219fn normalize_existing_codex_client_patch_item(mut item: EditableTomlItem) -> EditableTomlItem {
220    let Some(table) = item.as_table_mut() else {
221        return item;
222    };
223    let preset = table
224        .get("preset")
225        .and_then(EditableTomlItem::as_str)
226        .map(str::trim)
227        .filter(|value| !value.is_empty())
228        .or_else(|| {
229            table
230                .get("mode")
231                .and_then(EditableTomlItem::as_str)
232                .map(str::trim)
233                .filter(|value| !value.is_empty())
234        })
235        .and_then(|value| parse_codex_client_patch_preset("preset", value).ok());
236
237    if let Some(preset) = preset {
238        table.remove("mode");
239        table.insert("preset", editable_toml_value(preset.as_preset_str()));
240    }
241
242    item
243}
244
245fn preserve_existing_codex_client_patch(text: String) -> String {
246    let Some(client_patch) = existing_codex_client_patch_item() else {
247        return text;
248    };
249    let Ok(mut doc) = text.parse::<EditableTomlDocument>() else {
250        return text;
251    };
252
253    let root = doc.as_table_mut();
254    if !root.contains_key("codex") {
255        root.insert("codex", EditableTomlItem::Table(EditableTomlTable::new()));
256    }
257    let Some(codex) = root
258        .get_mut("codex")
259        .and_then(EditableTomlItem::as_table_mut)
260    else {
261        return text;
262    };
263    codex.insert("client_patch", client_patch);
264    doc.to_string()
265}
266
267fn codex_client_patch_item_needs_normalization(item: &EditableTomlItem) -> bool {
268    let Some(table) = item.as_table() else {
269        return false;
270    };
271    let active_mode = table
272        .get("mode")
273        .and_then(EditableTomlItem::as_str)
274        .map(str::trim)
275        .filter(|value| !value.is_empty());
276    if active_mode.is_some() {
277        return true;
278    }
279
280    let active_preset = table
281        .get("preset")
282        .and_then(EditableTomlItem::as_str)
283        .map(str::trim)
284        .filter(|value| !value.is_empty());
285    active_preset
286        .and_then(|value| {
287            parse_codex_client_patch_preset("preset", value)
288                .ok()
289                .map(|preset| (value, preset))
290        })
291        .is_some_and(|(value, preset)| value != preset.as_preset_str())
292}
293
294fn normalize_codex_client_patch_doc(doc: &mut EditableTomlDocument) -> bool {
295    let Some(codex) = doc
296        .as_table_mut()
297        .get_mut("codex")
298        .and_then(EditableTomlItem::as_table_mut)
299    else {
300        return false;
301    };
302    let Some(existing) = codex.get("client_patch").cloned() else {
303        return false;
304    };
305    if !codex_client_patch_item_needs_normalization(&existing) {
306        return false;
307    }
308
309    let normalized = normalize_existing_codex_client_patch_item(existing);
310    codex.insert("client_patch", normalized);
311    true
312}
313
314fn normalize_route_graph_affinity_doc(
315    doc: &mut EditableTomlDocument,
316    schema_version: Option<u32>,
317) -> bool {
318    if !schema_version.is_some_and(is_supported_route_graph_config_version) {
319        return false;
320    }
321
322    let mut changed = false;
323    for service_name in ["codex", "claude"] {
324        let Some(service) = doc
325            .as_table_mut()
326            .get_mut(service_name)
327            .and_then(EditableTomlItem::as_table_mut)
328        else {
329            continue;
330        };
331        let Some(routing) = service
332            .get_mut("routing")
333            .and_then(EditableTomlItem::as_table_mut)
334        else {
335            continue;
336        };
337        if !routing.contains_key("affinity_policy") {
338            routing.insert("affinity_policy", editable_toml_value("fallback-sticky"));
339            changed = true;
340        }
341    }
342    changed
343}
344
345fn normalize_config_toml_authoring_text(text: &str) -> Result<Option<String>> {
346    let mut doc = text.parse::<EditableTomlDocument>()?;
347    let mut changed = normalize_codex_client_patch_doc(&mut doc);
348    changed |= normalize_route_graph_affinity_doc(&mut doc, toml_schema_version_or_shape(text));
349    if !changed {
350        return Ok(None);
351    }
352    let normalized_text = doc.to_string();
353    if normalized_text == text {
354        Ok(None)
355    } else {
356        Ok(Some(normalized_text))
357    }
358}
359
360pub fn normalize_config_toml_authoring() -> Result<Option<PathBuf>> {
361    let path = config_toml_path();
362    if !path.exists() {
363        return Ok(None);
364    }
365
366    let text = stdfs::read_to_string(&path).with_context(|| format!("read {:?}", path))?;
367    let Some(normalized) = normalize_config_toml_authoring_text(&text)? else {
368        return Ok(None);
369    };
370
371    let backup_path = config_toml_backup_path();
372    if let Err(err) = stdfs::copy(&path, &backup_path) {
373        warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
374    }
375    write_text_file(&path, &normalized)?;
376    Ok(Some(path))
377}
378
379pub fn normalize_config_toml_client_patch() -> Result<Option<PathBuf>> {
380    normalize_config_toml_authoring()
381}
382
383fn config_backup_source_and_path() -> (PathBuf, PathBuf) {
384    let toml_path = config_toml_path();
385    if toml_path.exists() {
386        return (toml_path, config_toml_backup_path());
387    }
388
389    let json_path = config_path();
390    if json_path.exists() {
391        return (json_path, config_backup_path());
392    }
393
394    (toml_path, config_toml_backup_path())
395}
396
397/// Return the primary config file path that will be used by `load_config()`.
398pub fn config_file_path() -> PathBuf {
399    let toml_path = config_toml_path();
400    if toml_path.exists() {
401        toml_path
402    } else if config_path().exists() {
403        config_path()
404    } else {
405        toml_path
406    }
407}
408
409const CONFIG_VERSION: u32 = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
410
411#[derive(Debug, Clone)]
412pub struct LoadedProxyConfig {
413    pub runtime: ProxyConfig,
414    pub v4: Option<ProxyConfigV4>,
415}
416
417fn ensure_config_version(cfg: &mut ProxyConfig) {
418    if cfg.version.is_none() {
419        cfg.version = Some(CONFIG_VERSION);
420    }
421}
422
423const CONFIG_TOML_DOC_HEADER: &str = r#"# codex-helper config.toml
424#
425# 本文件可选;如果存在,codex-helper 会优先使用它(而不是 config.json)。
426#
427# 常用命令:
428# - 生成带注释的模板:`codex-helper config init`
429#
430# 安全建议:
431# - 尽量用环境变量保存密钥(*_env 字段,例如 auth_token_env / api_key_env),不要把 token 明文写入文件。
432#
433# 备注:某些命令会重写此文件;会保留本段 header,方便把说明贴近配置。
434"#;
435
436const CONFIG_TOML_TEMPLATE: &str = r#"# codex-helper config.toml
437#
438# codex-helper 同时支持 config.json 与 config.toml:
439# - 如果 `config.toml` 存在,则优先使用它;
440# - 否则使用 `config.json`(兼容旧版本)。
441#
442# 本模板以“可发现性”为主:包含可直接抄的示例,以及每个字段的说明。
443#
444# 路径:
445# - Linux/macOS:`~/.codex-helper/config.toml`
446# - Windows:    `%USERPROFILE%\.codex-helper\config.toml`
447#
448# 小贴士:
449# - 生成/覆盖本模板:`codex-helper config init [--force]`
450# - 新安装时:首次写入配置默认会写 TOML。
451
452version = 5
453
454# 省略 --codex/--claude 时默认使用哪个服务。
455# default_service = "codex"
456# default_service = "claude"
457
458# --- Codex 客户端 patch 预设(可选) ---
459#
460# default:保持历史行为,只把 ~/.codex/config.toml 的 model_provider 指到本地代理。
461# chatgpt-bridge:保留 Codex/ChatGPT 登录态用于移动端/桌面端账号能力,同时模型请求进入 codex-helper。
462# imagegen-bridge:实验模式;把 auth.json 临时写成空对象 {},利用 Codex 默认 ChatGPT
463#                  auth 解析暴露 hosted image_generation;实际上游凭据仍来自 codex-helper routing / env key。
464# official-relay:实验模式;把本地 codex_proxy 声明为 OpenAI Responses provider,
465#                 默认让 Codex 使用远程压缩路径,例如 /responses/compact。
466#                 真实上游凭据仍来自 codex-helper routing / env key。
467# official-imagegen:实验模式;同时启用 official-relay 的 OpenAI provider
468#                    标识和 imagegen-bridge 的 {} auth facade;默认同时尝试
469#                    远程压缩与 hosted image_generation。
470# 启用 chatgpt-bridge 时,`switch on --preset chatgpt-bridge` 会把 ~/.codex/auth.json 的
471# auth_mode 改为 "chatgpt",OPENAI_API_KEY 改为 null,其它字段不动。
472# 启用 imagegen-bridge / official-imagegen 时,`switch on --preset ...` 会临时把 ~/.codex/auth.json
473# 改为 {} facade,并在 `switch off` 或切回 default 时安全恢复。
474# 该预设启用前会校验 Codex 至少有一个已启用上游,且当前进程能读到其上游凭据。
475# responses_websocket:正交传输开关;为 true 时会写 Codex provider 的
476#                      supports_websockets = true,让 Codex 可选择 Responses WebSocket v2。
477#                      只应与 official-relay / official-imagegen 搭配,
478#                      且仅在 helper 与所选中转都支持 WebSocket relay 时开启。
479# compaction:正交压缩策略。auto 保持 preset 默认:default / imagegen-bridge
480#             更偏向本地压缩,official-relay / official-imagegen 默认让 Codex
481#             走远程压缩路径;local 强制本地压缩;remote-v1 强制 /responses/compact;
482#             remote-v2 强制 remote_compaction_v2,并依赖 helper 的 v2->v1 降级兜底。
483# translate_models:默认 false。false 时 helper 只解码 /models 响应压缩体,
484#                   不把 OpenAI data 列表翻译成 Codex models catalog,让 Codex 使用
485#                   自带 models.json / fallback 元数据;true 仅用于确实需要 helper
486#                   合成模型目录的中转,因为 Codex 会把合成后的字段当成权威。
487# hosted_image_generation:默认 auto。disabled 会在 switch on 时写入
488#                          [features].image_generation = false,并在 helper
489#                          转发 Codex /responses 请求时移除 image_generation
490#                          工具,避免不支持生图的上游仅因 tools 声明失败;
491#                          enabled 会显式写入 true;auto 保持旧行为。
492# 请求体 Content-Encoding 默认自动归一化(zstd / gzip / br / deflate),并会把
493# body.prompt_cache_key 作为缺省 session affinity 信号。极少数中转若必须接收
494# 原始 Codex 压缩体,请在启动 helper 的环境里设置:
495# CODEX_HELPER_REQUEST_BODY_ENCODING=passthrough
496# 兼容性:旧配置键 mode 仍会被读取;保存/生成配置时统一写 preset。
497#
498# [codex.client_patch]
499# preset = "default"
500# preset = "chatgpt-bridge"
501# preset = "imagegen-bridge"
502# preset = "official-relay"
503# preset = "official-imagegen"
504# responses_websocket = false
505# compaction = "auto"
506# translate_models = false
507# hosted_image_generation = "auto"
508
509# --- Relay targets(可选,本机客户端入口) ---
510#
511# Relay target 是本机保存的本地/远端 helper runtime 书签,给 `ch relay ...` 使用。
512# 它不替代下面的 provider/routing;真正处理请求的 server runtime 仍使用自己的
513# provider/routing 配置。
514#
515# local 是内置 target:`ch relay local` 等同于显式选择本机前台 helper。
516# 命名 target 通常指 NAS、Tailscale 或 LAN 上的 codex-helper-server:
517#
518# [relay_targets.nas]
519# service = "codex"
520# proxy_url = "http://nas.local:3211"
521# admin_url = "http://nas.local:4211"
522# admin_token_env = "CODEX_HELPER_NAS_ADMIN_TOKEN"
523# client_preset = "official-relay"
524# responses_websocket = false
525#
526# 常用命令:
527#   ch relay add nas --proxy-url http://nas.local:3211 --admin-url http://nas.local:4211 --admin-token-env CODEX_HELPER_NAS_ADMIN_TOKEN --preset official-relay
528#   ch relay nas
529#   ch relay nas --no-tui
530#   ch relay nas --attach-only
531#   ch relay off
532
533# --- TUI 用量预测(可选) ---
534#
535# TUI Stats 页会按最近窗口的已计价请求估算 USD/h,并外推到下次配额刷新时间。
536# 如果你的中转站余额每天本地 0 点刷新,保留下面默认即可;如果按其它时区结算,改 reset_utc_offset。
537#
538# [ui.usage_forecast]
539# enabled = true
540# rate_window_minutes = 60
541# min_priced_requests = 2
542# reset_time = "00:00"
543# reset_utc_offset = "+08:00"
544
545# --- TUI 服务状态探针(可选,默认关闭) ---
546#
547# TUI 的 5 状态页可以对指定 provider 发起轻量模型请求,验证真实上游链路。
548# 注意:provider 探针会产生极少 token 消耗;只有 enabled=true 且显式配置 probes 时才会运行。
549#
550# [ui.service_status]
551# enabled = true
552# refresh_interval_secs = 300
553# timeout_ms = 3000
554# high_latency_ms = 3000
555# history_cells = 60
556#
557# [[ui.service_status.probes]]
558# id = "primary-relay"
559# provider = "openai"        # 对应 [codex.providers.openai] 或 [claude.providers.openai]
560# endpoint = "default"       # 可省略;多 endpoint provider 可指定
561# models = ["gpt-5.5"]       # 必填更稳妥;请求使用 max_tokens=1, stream=false
562# # timeout_ms = 3000
563# # high_latency_ms = 2500
564#
565# 兼容模式:也可以读取 UsageMonitor 风格只读 status JSON(不会消耗 token)。
566# { "all_ok": true, "generated_at": 1778762578, "services": [
567#   { "model": "gpt-5.5", "uptime_pct": "99.5", "last": { "ok": true, "latency_ms": 1200 },
568#     "history": [{ "ts": 1778762500, "ok": true, "latency_ms": 1200 }] }
569# ] }
570#
571# [[ui.service_status.probes]]
572# id = "relay-status-json"
573# url = "https://relay.example.com/api/status"
574# models = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
575# # headers = { "x-status-token" = "use-an-env-rendered-static-token-only-if-needed" }
576
577# --- 自动导入(可选) ---
578#
579# 如果你的机器上已配置 Codex CLI(存在 `~/.codex/config.toml`),`codex-helper config init`
580# 会尝试自动把 Codex providers / routing 导入到本文件中,避免你手动抄写 base_url/env_key。
581#
582# 如果你只想生成纯模板(不导入),请使用:
583#   codex-helper config init --no-import
584
585# --- 推荐:provider / routing 配置(v5 route graph) ---
586#
587# 大部分用户只需要改这一段。
588#
589# 说明:
590# - 优先使用环境变量方式保存密钥(`*_env`),避免写入磁盘。
591# - `providers` 负责账号、认证、endpoint 和标签。
592# - `routing.entry` 指向入口 route node。
593# - `routing.routes.*` 负责顺序、策略、分组和兜底行为。
594# - 单 endpoint provider 尽量直接写 `base_url`,不要再包一层 `endpoints.default`。
595#
596# [codex.providers.openai]
597# base_url = "https://api.openai.com/v1"
598# auth_token_env = "OPENAI_API_KEY"
599# tags = { vendor = "openai", region = "us" }
600#
601# [codex.providers.backup]
602# base_url = "https://your-backup-provider.example/v1"
603# auth_token_env = "BACKUP_API_KEY"
604# tags = { vendor = "backup", region = "hk" }
605#
606# 如果 Codex 请求的模型名和中转站要求的模型名不同,可按 provider 配置 model_mapping。
607# 例如 Codex 仍请求 `gpt-5.5`,但 relay 要求 `openai/gpt-5.5`:
608#
609# [codex.providers.relay]
610# base_url = "https://relay.example/v1"
611# auth_token_env = "RELAY_API_KEY"
612# supported_models = { "gpt-5.5" = true }
613# model_mapping = { "gpt-5.5" = "openai/gpt-5.5", "gpt-*" = "openai/gpt-*" }
614#
615# [codex.routing]
616# entry = "main"
617# affinity_policy = "fallback-sticky"
618# 默认 fallback-sticky:失败切到备用上游后,同一 session 会尽量粘住已成功的备用账号,
619# 对 official relay / remote compaction / encrypted conversation state 更安全。
620# 如果你想每次都优先回到最高优先级 provider,可以显式改为 "preferred-group"。
621# fallback_ttl_ms = 120000
622# reprobe_preferred_after_ms = 30000
623#
624# [codex.routing.routes.main]
625# strategy = "ordered-failover"
626# children = ["openai", "backup"]
627#
628# --- 会话控制模板(profiles,可选) ---
629#
630# Phase 1 先支持“定义 / 列出 / 应用到会话”,暂不自动把 default_profile 绑定到新会话。
631#
632# [codex]
633# default_profile = "daily"
634#
635# [codex.profiles.daily]
636# reasoning_effort = "medium"
637#
638# [codex.profiles.fast]
639# service_tier = "priority"
640# reasoning_effort = "low"
641#
642# [codex.profiles.deep]
643# model = "gpt-5.4"
644# reasoning_effort = "high"
645#
646# Claude 配置在 [claude] 下结构相同。
647#
648# ---
649#
650# --- 通知集成(Codex `notify` hook) ---
651#
652# 可选功能,默认关闭。
653# 设计目标:多 Codex 工作流下的低噪声通知(按耗时过滤 + 合并 + 限流)。
654#
655# 启用步骤:
656# 1) 在 Codex 配置 `~/.codex/config.toml` 中添加:
657#      notify = ["codex-helper", "notify", "codex"]
658# 2) 在本文件中开启:
659#      notify.enabled = true
660#      notify.system.enabled = true
661#
662[notify]
663# 通知总开关(system toast 与 exec 回调都受此控制)。
664enabled = false
665
666[notify.system]
667# 系统通知支持:
668# - Windows:toast(powershell.exe)
669# - macOS:`osascript`
670enabled = false
671
672[notify.policy]
673# D:按耗时过滤(毫秒)
674min_duration_ms = 60000
675
676# A:合并 + 限流(毫秒)
677merge_window_ms = 10000
678global_cooldown_ms = 60000
679per_thread_cooldown_ms = 180000
680
681# 在 proxy /__codex_helper/api/v1/status/recent 中向前回看多久(毫秒)。
682# codex-helper 会把 Codex 的 "thread-id" 匹配到 proxy 的 FinishedRequest.session_id。
683recent_search_window_ms = 300000
684# 访问 recent endpoint 的 HTTP 超时(毫秒)
685recent_endpoint_timeout_ms = 500
686
687[notify.exec]
688# 可选回调:执行一个命令,并把聚合后的 JSON 写到 stdin。
689enabled = false
690# command = ["python", "my_hook.py"]
691
692# ---
693#
694# --- 重试策略(代理侧) ---
695#
696# 控制 codex-helper 在返回给 Codex 之前进行的内部重试。
697# 注意:如果你同时开启了 Codex 自身的重试,可能会出现“双重重试”。
698#
699[retry]
700# 策略预设(推荐):
701# - "balanced"(默认)
702# - "same-upstream"(倾向同 upstream 重试,适合 CF/网络抖动)
703# - "aggressive-failover"(更激进:更多尝试次数,可能增加时延/成本)
704# - "cost-primary"(省钱主从:包月主线路 + 按量备选,支持回切探测)
705profile = "balanced"
706
707# 下面这些字段是“覆盖项”(在 profile 默认值之上进行覆盖)。
708#
709# 两层模型:
710# - retry.upstream:在当前 station 已选中的 provider/endpoint 内,对单个 upstream 的内部重试(默认更偏向同一 upstream)。
711# - retry.provider:当 upstream 层无法恢复时,决定是否切换到其他 upstream / 同一 station 可用的其他 provider 路径。
712#
713# 覆盖示例(可按需取消注释):
714#
715# [retry.upstream]
716# max_attempts = 2
717# strategy = "same_upstream"
718# backoff_ms = 200
719# backoff_max_ms = 2000
720# jitter_ms = 100
721# on_status = "429,500-502,504-528,530-599"
722# on_class = ["upstream_transport_error", "cloudflare_timeout", "cloudflare_challenge", "upstream_rate_limited", "upstream_overloaded"]
723#
724# [retry.provider]
725# max_attempts = 2
726# strategy = "failover"
727# on_status = "401,403,404,408,429,500-599,524"
728# on_class = ["upstream_transport_error", "upstream_rate_limited", "upstream_overloaded"]
729
730# 可选:Reasoning Guard,用于拦截 Codex 上游偶发的 reasoning_tokens 异常桶。
731# 默认关闭;开启后只基于上游 usage 元数据判断,不会判断答案文本是否正确。
732# 运行中修改本段配置会被新请求自动加载;已在途请求继续使用它启动时的配置快照。
733#
734# [retry.reasoning_guard]
735# enabled = true
736# reasoning_equals = [516, 1034, 1552]
737# action = "retry"              # retry | block | observe
738# stream_mode = "strict-buffer" # strict-buffer | observe | off
739# max_guard_retries = 1
740# paths = ["/v1/responses", "/responses", "/v1/chat/completions", "/chat/completions"]
741# log_matches = true
742
743# 明确禁止重试/切换的 HTTP 状态码/范围(字符串形式)。
744# 示例:"413,415,422"。
745# never_on_status = "413,415,422"
746
747# 明确禁止重试/切换的错误分类(来自 codex-helper 的 classify)。
748# 默认包含 "client_error_non_retryable"(常见请求格式/参数错误)。
749# never_on_class = ["client_error_non_retryable"]
750
751# 对某些失败类型施加冷却(秒)。
752# upstream_rate_limited / upstream_overloaded 会优先使用 Retry-After 或 Codex usage_limit_reached
753# body 中的 resets_at / resets_in_seconds;没有显式等待窗口时回落到 transport_cooldown_secs。
754# cloudflare_challenge_cooldown_secs = 300
755# cloudflare_timeout_cooldown_secs = 60
756# transport_cooldown_secs = 30
757
758# 可选:冷却的指数退避(主要用于“便宜主线路不稳 → 降级到备选 → 隔一段时间探测回切”)。
759#
760# 启用后:同一 upstream/config 连续失败次数越多,冷却越久:
761#   effective_cooldown = min(base_cooldown * factor^streak, cooldown_backoff_max_secs)
762#
763# factor=1 表示关闭退避(默认行为)。
764# cooldown_backoff_factor = 2
765# cooldown_backoff_max_secs = 600
766"#;
767
768fn insert_after_version_block(template: &str, insert: &str) -> String {
769    let needle = "version = 5\n\n";
770    if let Some(idx) = template.find(needle) {
771        let insert_pos = idx + needle.len();
772        let mut out = String::with_capacity(template.len() + insert.len() + 2);
773        out.push_str(&template[..insert_pos]);
774        out.push_str(insert);
775        out.push('\n');
776        out.push_str(&template[insert_pos..]);
777        return out;
778    }
779    format!("{template}\n\n{insert}\n")
780}
781
782fn toml_schema_version_or_shape(text: &str) -> Option<u32> {
783    let value = toml::from_str::<TomlValue>(text).ok()?;
784    if let Some(version) = value
785        .get("version")
786        .and_then(|v| v.as_integer())
787        .map(|value| value as u32)
788    {
789        return Some(version);
790    }
791
792    let has_v4_routing = ["codex", "claude"].iter().any(|service| {
793        value
794            .get(*service)
795            .and_then(|service| service.get("routing"))
796            .and_then(|routing| routing.get("entry").or_else(|| routing.get("routes")))
797            .is_some()
798    });
799    if has_v4_routing {
800        Some(4)
801    } else {
802        let has_legacy_routing = ["codex", "claude"].iter().any(|service| {
803            value
804                .get(*service)
805                .and_then(|service| service.get("routing"))
806                .is_some()
807        });
808        if has_legacy_routing { Some(3) } else { None }
809    }
810}
811
812fn codex_bootstrap_snippet() -> Result<Option<String>> {
813    #[derive(Serialize)]
814    struct CodexOnly<'a> {
815        codex: &'a ServiceViewV4,
816    }
817
818    let mut cfg = ProxyConfig::default();
819    ensure_config_version(&mut cfg);
820    if bootstrap_from_codex(&mut cfg).is_err() {
821        return Ok(None);
822    }
823    if !cfg.codex.has_stations() {
824        return Ok(None);
825    }
826
827    let migrated = migrate_legacy_to_v4(&cfg)?;
828    let mut migrated = migrated;
829    if let Some(routing) = migrated.codex.routing.as_mut() {
830        routing.affinity_policy = RoutingAffinityPolicyV5::FallbackSticky;
831    }
832    let body = toml::to_string_pretty(&CodexOnly {
833        codex: &migrated.codex,
834    })?;
835    Ok(Some(format!(
836        "# --- 自动导入:来自 ~/.codex/config.toml + auth.json ---\n{body}"
837    )))
838}
839
840pub async fn init_config_toml(force: bool, import_codex: bool) -> Result<PathBuf> {
841    let dir = config_dir();
842    fs::create_dir_all(&dir).await?;
843    let path = config_toml_path();
844    let backup_path = config_toml_backup_path();
845
846    if path.exists() && !force {
847        anyhow::bail!(
848            "config.toml already exists at {:?}; use --force to overwrite",
849            path
850        );
851    }
852
853    if path.exists()
854        && let Err(err) = fs::copy(&path, &backup_path).await
855    {
856        warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
857    }
858
859    let mut text = CONFIG_TOML_TEMPLATE.to_string();
860    if import_codex && let Some(snippet) = codex_bootstrap_snippet()? {
861        text = insert_after_version_block(&text, snippet.as_str());
862    }
863    write_bytes_file_async(&path, text.as_bytes()).await?;
864    Ok(path)
865}
866
867pub async fn load_config() -> Result<ProxyConfig> {
868    Ok(load_config_with_v4_source().await?.runtime)
869}
870
871pub async fn load_config_with_v4_source() -> Result<LoadedProxyConfig> {
872    let toml_path = config_toml_path();
873    if toml_path.exists() {
874        let text = fs::read_to_string(&toml_path).await?;
875        let version = toml_schema_version_or_shape(&text);
876
877        let mut loaded_v4 = None;
878        let mut cfg = if version.is_some_and(is_supported_route_graph_config_version) {
879            let cfg_v4 = toml::from_str::<ProxyConfigV4>(&text)?;
880            let runtime = compile_v4_to_runtime(&cfg_v4)?;
881            loaded_v4 = Some(cfg_v4);
882            runtime
883        } else if version == Some(3) {
884            let cfg_legacy = toml::from_str::<crate::config::legacy::ProxyConfigV3Legacy>(&text)?;
885            let migrated = crate::config::legacy::migrate_v3_legacy_to_v4(&cfg_legacy)?;
886            let runtime = compile_v4_to_runtime(&migrated.config)?;
887            loaded_v4 = Some(migrated.config);
888            runtime
889        } else if version == Some(2) {
890            let cfg_v2 = toml::from_str::<ProxyConfigV2>(&text)?;
891            compile_v2_to_runtime(&cfg_v2)?
892        } else {
893            let mut cfg = toml::from_str::<ProxyConfig>(&text)?;
894            ensure_config_version(&mut cfg);
895            cfg
896        };
897        normalize_proxy_config(&mut cfg);
898        validate_proxy_config(&cfg)?;
899        if version != Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION) {
900            if let Some(cfg_v4) = loaded_v4.as_mut() {
901                auto_migrate_loaded_v4_config(cfg_v4, "config.toml", version).await;
902                cfg_v4.version = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
903                cfg.version = Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION);
904            } else {
905                auto_migrate_loaded_config(&mut cfg, "config.toml", version).await;
906            }
907        } else if let Some(cfg_v4) = loaded_v4.as_ref() {
908            auto_compact_loaded_v4_config(cfg_v4, "config.toml").await;
909        }
910        auto_normalize_loaded_config_toml_authoring("config.toml").await;
911        return Ok(LoadedProxyConfig {
912            runtime: cfg,
913            v4: loaded_v4,
914        });
915    }
916
917    let json_path = config_path();
918    if json_path.exists() {
919        let bytes = fs::read(json_path).await?;
920        let mut cfg = serde_json::from_slice::<ProxyConfig>(&bytes)?;
921        let version = cfg.version;
922        ensure_config_version(&mut cfg);
923        normalize_proxy_config(&mut cfg);
924        validate_proxy_config(&cfg)?;
925        auto_migrate_loaded_config(&mut cfg, "config.json", version).await;
926        return Ok(LoadedProxyConfig {
927            runtime: cfg,
928            v4: None,
929        });
930    }
931
932    let mut cfg = ProxyConfig::default();
933    ensure_config_version(&mut cfg);
934    normalize_proxy_config(&mut cfg);
935    validate_proxy_config(&cfg)?;
936    Ok(LoadedProxyConfig {
937        runtime: cfg,
938        v4: None,
939    })
940}
941
942async fn auto_migrate_loaded_config(
943    cfg: &mut ProxyConfig,
944    source: &str,
945    source_version: Option<u32>,
946) {
947    match save_config(cfg).await {
948        Ok(()) => {
949            cfg.version = Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION);
950            info!(
951                "auto-migrated {} from version {:?} to version {}",
952                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION
953            );
954        }
955        Err(err) => {
956            warn!(
957                "failed to auto-migrate {} from version {:?} to version {}: {}",
958                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION, err
959            );
960        }
961    }
962}
963
964async fn auto_migrate_loaded_v4_config(
965    cfg: &ProxyConfigV4,
966    source: &str,
967    source_version: Option<u32>,
968) {
969    match save_config_v4(cfg).await {
970        Ok(_) => {
971            info!(
972                "auto-migrated {} from version {:?} to version {}",
973                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION
974            );
975        }
976        Err(err) => {
977            warn!(
978                "failed to auto-migrate {} from version {:?} to version {}: {}",
979                source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION, err
980            );
981        }
982    }
983}
984
985fn runtime_service_manager_value(mgr: &ServiceConfigManager) -> Result<JsonValue> {
986    serde_json::to_value(mgr).context("serialize runtime service manager")
987}
988
989fn v4_service_has_import_metadata(view: &ServiceViewV4) -> bool {
990    view.providers.values().any(|provider| {
991        provider.tags.contains_key("provider_id")
992            || provider.tags.contains_key("requires_openai_auth")
993            || provider
994                .tags
995                .get("source")
996                .is_some_and(|value| value == "codex-config")
997            || provider.endpoints.values().any(|endpoint| {
998                endpoint.tags.contains_key("provider_id")
999                    || endpoint.tags.contains_key("requires_openai_auth")
1000                    || endpoint
1001                        .tags
1002                        .get("source")
1003                        .is_some_and(|value| value == "codex-config")
1004            })
1005    })
1006}
1007
1008async fn auto_compact_loaded_v4_config(cfg: &ProxyConfigV4, source: &str) {
1009    if !v4_service_has_import_metadata(&cfg.codex) && !v4_service_has_import_metadata(&cfg.claude) {
1010        return;
1011    }
1012
1013    match save_config_v4(cfg).await {
1014        Ok(_) => {
1015            info!(
1016                "auto-compacted {} v4 provider config metadata for authoring format",
1017                source
1018            );
1019        }
1020        Err(err) => {
1021            warn!(
1022                "failed to auto-compact {} v4 provider config metadata: {}",
1023                source, err
1024            );
1025        }
1026    }
1027}
1028
1029async fn auto_normalize_loaded_config_toml_authoring(source: &str) {
1030    let path = config_toml_path();
1031    let text = match fs::read_to_string(&path).await {
1032        Ok(text) => text,
1033        Err(err) => {
1034            warn!(
1035                "failed to read {} while normalizing config authoring fields: {}",
1036                source, err
1037            );
1038            return;
1039        }
1040    };
1041    let normalized = match normalize_config_toml_authoring_text(&text) {
1042        Ok(Some(normalized)) => normalized,
1043        Ok(None) => return,
1044        Err(err) => {
1045            warn!(
1046                "failed to normalize {} config authoring fields: {}",
1047                source, err
1048            );
1049            return;
1050        }
1051    };
1052
1053    let backup_path = config_toml_backup_path();
1054    if path.exists()
1055        && let Err(err) = fs::copy(&path, &backup_path).await
1056    {
1057        warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
1058    }
1059
1060    match write_bytes_file_async(&path, normalized.as_bytes()).await {
1061        Ok(()) => {
1062            info!("auto-normalized {} config authoring fields", source);
1063        }
1064        Err(err) => {
1065            warn!(
1066                "failed to auto-normalize {} config authoring fields: {}",
1067                source, err
1068            );
1069        }
1070    }
1071}
1072
1073async fn save_existing_v4_if_only_runtime_metadata_changed(
1074    cfg: &ProxyConfig,
1075) -> Result<Option<PathBuf>> {
1076    let path = config_toml_path();
1077    if !path.exists() {
1078        return Ok(None);
1079    }
1080
1081    let text = fs::read_to_string(&path).await?;
1082    if !toml_schema_version_or_shape(&text).is_some_and(is_supported_route_graph_config_version) {
1083        return Ok(None);
1084    }
1085
1086    let mut requested = cfg.clone();
1087    normalize_proxy_config(&mut requested);
1088    validate_proxy_config(&requested)?;
1089
1090    let mut existing = toml::from_str::<ProxyConfigV4>(&text)?;
1091    let mut existing_runtime = compile_v4_to_runtime(&existing)?;
1092    normalize_proxy_config(&mut existing_runtime);
1093
1094    if runtime_service_manager_value(&existing_runtime.codex)?
1095        != runtime_service_manager_value(&requested.codex)?
1096        || runtime_service_manager_value(&existing_runtime.claude)?
1097            != runtime_service_manager_value(&requested.claude)?
1098    {
1099        return Ok(None);
1100    }
1101
1102    existing.retry = requested.retry;
1103    existing.notify = requested.notify;
1104    existing.default_service = requested.default_service;
1105    existing.relay_targets = requested.relay_targets;
1106    existing.fleet = requested.fleet;
1107    existing.ui = requested.ui;
1108    save_config_v4(&existing).await.map(Some)
1109}
1110
1111pub async fn save_config(cfg: &ProxyConfig) -> Result<()> {
1112    if cfg
1113        .version
1114        .is_some_and(is_supported_route_graph_config_version)
1115    {
1116        if save_existing_v4_if_only_runtime_metadata_changed(cfg)
1117            .await?
1118            .is_some()
1119        {
1120            return Ok(());
1121        }
1122        let migrated = migrate_legacy_to_v4(cfg)?;
1123        save_config_v4(&migrated).await?;
1124        return Ok(());
1125    }
1126
1127    let migrated = migrate_legacy_to_v4(cfg)?;
1128    save_config_v4(&migrated).await?;
1129    Ok(())
1130}
1131
1132pub async fn save_config_v2(cfg: &ProxyConfigV2) -> Result<PathBuf> {
1133    let mut normalized = compact_v2_config(cfg)?;
1134    let mut runtime = compile_v2_to_runtime(&normalized)?;
1135    normalize_proxy_config(&mut runtime);
1136    validate_proxy_config(&runtime)?;
1137    normalized.version = 2;
1138
1139    let dir = config_dir();
1140    fs::create_dir_all(&dir).await?;
1141    let path = config_toml_path();
1142    let (backup_source_path, backup_path) = config_backup_source_and_path();
1143    let body = toml::to_string_pretty(&normalized)?;
1144    let text = preserve_existing_codex_client_patch(format!(
1145        "{CONFIG_TOML_DOC_HEADER}
1146{body}"
1147    ));
1148    let data = text.into_bytes();
1149
1150    if backup_source_path.exists()
1151        && let Err(err) = fs::copy(&backup_source_path, &backup_path).await
1152    {
1153        warn!(
1154            "failed to backup {:?} to {:?}: {}",
1155            backup_source_path, backup_path, err
1156        );
1157    }
1158
1159    write_bytes_file_async(&path, &data).await?;
1160    Ok(path)
1161}
1162
1163pub async fn save_config_v4(cfg: &ProxyConfigV4) -> Result<PathBuf> {
1164    let mut normalized = cfg.clone();
1165    normalized.version = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
1166    compact_v4_config_for_write(&mut normalized);
1167    let mut runtime = compile_v4_to_runtime(&normalized)?;
1168    normalize_proxy_config(&mut runtime);
1169    validate_proxy_config(&runtime)?;
1170
1171    let dir = config_dir();
1172    fs::create_dir_all(&dir).await?;
1173    let path = config_toml_path();
1174    let (backup_source_path, backup_path) = config_backup_source_and_path();
1175    let body = toml::to_string_pretty(&normalized)?;
1176    let text = preserve_existing_codex_client_patch(format!(
1177        "{CONFIG_TOML_DOC_HEADER}
1178{body}"
1179    ));
1180    let data = text.into_bytes();
1181
1182    if backup_source_path.exists()
1183        && let Err(err) = fs::copy(&backup_source_path, &backup_path).await
1184    {
1185        warn!(
1186            "failed to backup {:?} to {:?}: {}",
1187            backup_source_path, backup_path, err
1188        );
1189    }
1190
1191    write_bytes_file_async(&path, &data).await?;
1192    Ok(path)
1193}
1194
1195fn normalize_proxy_config(cfg: &mut ProxyConfig) {
1196    fn normalize_mgr(mgr: &mut ServiceConfigManager) {
1197        fn select_default_active_name(configs: &HashMap<String, ServiceConfig>) -> Option<String> {
1198            let mut items = configs.iter().collect::<Vec<_>>();
1199            items.sort_by(|(name_a, svc_a), (name_b, svc_b)| {
1200                svc_a
1201                    .level
1202                    .cmp(&svc_b.level)
1203                    .then_with(|| name_a.cmp(name_b))
1204            });
1205            items
1206                .iter()
1207                .find(|(_, svc)| svc.enabled)
1208                .map(|(name, _)| (*name).clone())
1209                .or_else(|| items.first().map(|(name, _)| (*name).clone()))
1210        }
1211
1212        for (key, svc) in mgr.stations_mut() {
1213            if svc.name.trim().is_empty() {
1214                svc.name = key.clone();
1215            }
1216        }
1217        let normalized_active = mgr
1218            .active
1219            .as_ref()
1220            .map(|value| value.trim().to_string())
1221            .filter(|value| !value.is_empty());
1222        mgr.active = match normalized_active {
1223            Some(active) if mgr.contains_station(active.as_str()) => Some(active),
1224            Some(active) => match active.to_ascii_lowercase().as_str() {
1225                "true" | "1" | "yes" | "on" => select_default_active_name(mgr.stations()),
1226                "false" | "0" | "no" | "off" => None,
1227                _ => Some(active),
1228            },
1229            None => None,
1230        };
1231        mgr.default_profile = mgr
1232            .default_profile
1233            .as_ref()
1234            .map(|value| value.trim().to_string())
1235            .filter(|value| !value.is_empty());
1236        for profile in mgr.profiles.values_mut() {
1237            profile.extends = profile
1238                .extends
1239                .as_ref()
1240                .map(|value| value.trim().to_string())
1241                .filter(|value| !value.is_empty());
1242            profile.station = profile
1243                .station
1244                .as_ref()
1245                .map(|value| value.trim().to_string())
1246                .filter(|value| !value.is_empty());
1247            profile.model = profile
1248                .model
1249                .as_ref()
1250                .map(|value| value.trim().to_string())
1251                .filter(|value| !value.is_empty());
1252            profile.reasoning_effort = profile
1253                .reasoning_effort
1254                .as_ref()
1255                .map(|value| value.trim().to_string())
1256                .filter(|value| !value.is_empty());
1257            profile.service_tier = profile
1258                .service_tier
1259                .as_ref()
1260                .map(|value| value.trim().to_string())
1261                .filter(|value| !value.is_empty());
1262        }
1263    }
1264
1265    normalize_mgr(&mut cfg.codex);
1266    normalize_mgr(&mut cfg.claude);
1267}
1268
1269fn validate_proxy_config(cfg: &ProxyConfig) -> Result<()> {
1270    validate_service_profiles("codex", &cfg.codex)?;
1271    validate_service_profiles("claude", &cfg.claude)?;
1272    cfg.fleet.validate()?;
1273    Ok(())
1274}