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