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