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
344pub 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# --- TUI 用量预测(可选) ---
446#
447# TUI Stats 页会按最近窗口的已计价请求估算 USD/h,并外推到下次配额刷新时间。
448# 如果你的中转站余额每天本地 0 点刷新,保留下面默认即可;如果按其它时区结算,改 reset_utc_offset。
449#
450# [ui.usage_forecast]
451# enabled = true
452# rate_window_minutes = 60
453# min_priced_requests = 2
454# reset_time = "00:00"
455# reset_utc_offset = "+08:00"
456
457# --- 自动导入(可选) ---
458#
459# 如果你的机器上已配置 Codex CLI(存在 `~/.codex/config.toml`),`codex-helper config init`
460# 会尝试自动把 Codex providers / routing 导入到本文件中,避免你手动抄写 base_url/env_key。
461#
462# 如果你只想生成纯模板(不导入),请使用:
463# codex-helper config init --no-import
464
465# --- 推荐:provider / routing 配置(v5 route graph) ---
466#
467# 大部分用户只需要改这一段。
468#
469# 说明:
470# - 优先使用环境变量方式保存密钥(`*_env`),避免写入磁盘。
471# - `providers` 负责账号、认证、endpoint 和标签。
472# - `routing.entry` 指向入口 route node。
473# - `routing.routes.*` 负责顺序、策略、分组和兜底行为。
474# - 单 endpoint provider 尽量直接写 `base_url`,不要再包一层 `endpoints.default`。
475#
476# [codex.providers.openai]
477# base_url = "https://api.openai.com/v1"
478# auth_token_env = "OPENAI_API_KEY"
479# tags = { vendor = "openai", region = "us" }
480#
481# [codex.providers.backup]
482# base_url = "https://your-backup-provider.example/v1"
483# auth_token_env = "BACKUP_API_KEY"
484# tags = { vendor = "backup", region = "hk" }
485#
486# 如果 Codex 请求的模型名和中转站要求的模型名不同,可按 provider 配置 model_mapping。
487# 例如 Codex 仍请求 `gpt-5.5`,但 relay 要求 `openai/gpt-5.5`:
488#
489# [codex.providers.relay]
490# base_url = "https://relay.example/v1"
491# auth_token_env = "RELAY_API_KEY"
492# supported_models = { "gpt-5.5" = true }
493# model_mapping = { "gpt-5.5" = "openai/gpt-5.5", "gpt-*" = "openai/gpt-*" }
494#
495# [codex.routing]
496# entry = "main"
497# affinity_policy = "fallback-sticky"
498# 默认 fallback-sticky:失败切到备用上游后,同一 session 会尽量粘住已成功的备用账号,
499# 对 official relay / remote compaction / encrypted conversation state 更安全。
500# 如果你想每次都优先回到最高优先级 provider,可以显式改为 "preferred-group"。
501# fallback_ttl_ms = 120000
502# reprobe_preferred_after_ms = 30000
503#
504# [codex.routing.routes.main]
505# strategy = "ordered-failover"
506# children = ["openai", "backup"]
507#
508# --- 会话控制模板(profiles,可选) ---
509#
510# Phase 1 先支持“定义 / 列出 / 应用到会话”,暂不自动把 default_profile 绑定到新会话。
511#
512# [codex]
513# default_profile = "daily"
514#
515# [codex.profiles.daily]
516# reasoning_effort = "medium"
517#
518# [codex.profiles.fast]
519# service_tier = "priority"
520# reasoning_effort = "low"
521#
522# [codex.profiles.deep]
523# model = "gpt-5.4"
524# reasoning_effort = "high"
525#
526# Claude 配置在 [claude] 下结构相同。
527#
528# ---
529#
530# --- 通知集成(Codex `notify` hook) ---
531#
532# 可选功能,默认关闭。
533# 设计目标:多 Codex 工作流下的低噪声通知(按耗时过滤 + 合并 + 限流)。
534#
535# 启用步骤:
536# 1) 在 Codex 配置 `~/.codex/config.toml` 中添加:
537# notify = ["codex-helper", "notify", "codex"]
538# 2) 在本文件中开启:
539# notify.enabled = true
540# notify.system.enabled = true
541#
542[notify]
543# 通知总开关(system toast 与 exec 回调都受此控制)。
544enabled = false
545
546[notify.system]
547# 系统通知支持:
548# - Windows:toast(powershell.exe)
549# - macOS:`osascript`
550enabled = false
551
552[notify.policy]
553# D:按耗时过滤(毫秒)
554min_duration_ms = 60000
555
556# A:合并 + 限流(毫秒)
557merge_window_ms = 10000
558global_cooldown_ms = 60000
559per_thread_cooldown_ms = 180000
560
561# 在 proxy /__codex_helper/api/v1/status/recent 中向前回看多久(毫秒)。
562# codex-helper 会把 Codex 的 "thread-id" 匹配到 proxy 的 FinishedRequest.session_id。
563recent_search_window_ms = 300000
564# 访问 recent endpoint 的 HTTP 超时(毫秒)
565recent_endpoint_timeout_ms = 500
566
567[notify.exec]
568# 可选回调:执行一个命令,并把聚合后的 JSON 写到 stdin。
569enabled = false
570# command = ["python", "my_hook.py"]
571
572# ---
573#
574# --- 重试策略(代理侧) ---
575#
576# 控制 codex-helper 在返回给 Codex 之前进行的内部重试。
577# 注意:如果你同时开启了 Codex 自身的重试,可能会出现“双重重试”。
578#
579[retry]
580# 策略预设(推荐):
581# - "balanced"(默认)
582# - "same-upstream"(倾向同 upstream 重试,适合 CF/网络抖动)
583# - "aggressive-failover"(更激进:更多尝试次数,可能增加时延/成本)
584# - "cost-primary"(省钱主从:包月主线路 + 按量备选,支持回切探测)
585profile = "balanced"
586
587# 下面这些字段是“覆盖项”(在 profile 默认值之上进行覆盖)。
588#
589# 两层模型:
590# - retry.upstream:在当前 station 已选中的 provider/endpoint 内,对单个 upstream 的内部重试(默认更偏向同一 upstream)。
591# - retry.provider:当 upstream 层无法恢复时,决定是否切换到其他 upstream / 同一 station 可用的其他 provider 路径。
592#
593# 覆盖示例(可按需取消注释):
594#
595# [retry.upstream]
596# max_attempts = 2
597# strategy = "same_upstream"
598# backoff_ms = 200
599# backoff_max_ms = 2000
600# jitter_ms = 100
601# on_status = "429,500-599,524"
602# on_class = ["upstream_transport_error", "cloudflare_timeout", "cloudflare_challenge"]
603#
604# [retry.provider]
605# max_attempts = 2
606# strategy = "failover"
607# on_status = "401,403,404,408,429,500-599,524"
608# on_class = ["upstream_transport_error"]
609
610# 明确禁止重试/切换的 HTTP 状态码/范围(字符串形式)。
611# 示例:"413,415,422"。
612# never_on_status = "413,415,422"
613
614# 明确禁止重试/切换的错误分类(来自 codex-helper 的 classify)。
615# 默认包含 "client_error_non_retryable"(常见请求格式/参数错误)。
616# never_on_class = ["client_error_non_retryable"]
617
618# 对某些失败类型施加冷却(秒)。
619# cloudflare_challenge_cooldown_secs = 300
620# cloudflare_timeout_cooldown_secs = 60
621# transport_cooldown_secs = 30
622
623# 可选:冷却的指数退避(主要用于“便宜主线路不稳 → 降级到备选 → 隔一段时间探测回切”)。
624#
625# 启用后:同一 upstream/config 连续失败次数越多,冷却越久:
626# effective_cooldown = min(base_cooldown * factor^streak, cooldown_backoff_max_secs)
627#
628# factor=1 表示关闭退避(默认行为)。
629# cooldown_backoff_factor = 2
630# cooldown_backoff_max_secs = 600
631"#;
632
633fn insert_after_version_block(template: &str, insert: &str) -> String {
634 let needle = "version = 5\n\n";
635 if let Some(idx) = template.find(needle) {
636 let insert_pos = idx + needle.len();
637 let mut out = String::with_capacity(template.len() + insert.len() + 2);
638 out.push_str(&template[..insert_pos]);
639 out.push_str(insert);
640 out.push('\n');
641 out.push_str(&template[insert_pos..]);
642 return out;
643 }
644 format!("{template}\n\n{insert}\n")
645}
646
647fn toml_schema_version_or_shape(text: &str) -> Option<u32> {
648 let value = toml::from_str::<TomlValue>(text).ok()?;
649 if let Some(version) = value
650 .get("version")
651 .and_then(|v| v.as_integer())
652 .map(|value| value as u32)
653 {
654 return Some(version);
655 }
656
657 let has_v4_routing = ["codex", "claude"].iter().any(|service| {
658 value
659 .get(*service)
660 .and_then(|service| service.get("routing"))
661 .and_then(|routing| routing.get("entry").or_else(|| routing.get("routes")))
662 .is_some()
663 });
664 if has_v4_routing {
665 Some(4)
666 } else {
667 let has_legacy_routing = ["codex", "claude"].iter().any(|service| {
668 value
669 .get(*service)
670 .and_then(|service| service.get("routing"))
671 .is_some()
672 });
673 if has_legacy_routing { Some(3) } else { None }
674 }
675}
676
677fn codex_bootstrap_snippet() -> Result<Option<String>> {
678 #[derive(Serialize)]
679 struct CodexOnly<'a> {
680 codex: &'a ServiceViewV4,
681 }
682
683 let mut cfg = ProxyConfig::default();
684 ensure_config_version(&mut cfg);
685 if bootstrap_from_codex(&mut cfg).is_err() {
686 return Ok(None);
687 }
688 if !cfg.codex.has_stations() {
689 return Ok(None);
690 }
691
692 let migrated = migrate_legacy_to_v4(&cfg)?;
693 let mut migrated = migrated;
694 if let Some(routing) = migrated.codex.routing.as_mut() {
695 routing.affinity_policy = RoutingAffinityPolicyV5::FallbackSticky;
696 }
697 let body = toml::to_string_pretty(&CodexOnly {
698 codex: &migrated.codex,
699 })?;
700 Ok(Some(format!(
701 "# --- 自动导入:来自 ~/.codex/config.toml + auth.json ---\n{body}"
702 )))
703}
704
705pub async fn init_config_toml(force: bool, import_codex: bool) -> Result<PathBuf> {
706 let dir = config_dir();
707 fs::create_dir_all(&dir).await?;
708 let path = config_toml_path();
709 let backup_path = config_toml_backup_path();
710
711 if path.exists() && !force {
712 anyhow::bail!(
713 "config.toml already exists at {:?}; use --force to overwrite",
714 path
715 );
716 }
717
718 if path.exists()
719 && let Err(err) = fs::copy(&path, &backup_path).await
720 {
721 warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
722 }
723
724 let mut text = CONFIG_TOML_TEMPLATE.to_string();
725 if import_codex && let Some(snippet) = codex_bootstrap_snippet()? {
726 text = insert_after_version_block(&text, snippet.as_str());
727 }
728 write_bytes_file_async(&path, text.as_bytes()).await?;
729 Ok(path)
730}
731
732pub async fn load_config() -> Result<ProxyConfig> {
733 Ok(load_config_with_v4_source().await?.runtime)
734}
735
736pub async fn load_config_with_v4_source() -> Result<LoadedProxyConfig> {
737 let toml_path = config_toml_path();
738 if toml_path.exists() {
739 let text = fs::read_to_string(&toml_path).await?;
740 let version = toml_schema_version_or_shape(&text);
741
742 let mut loaded_v4 = None;
743 let mut cfg = if version.is_some_and(is_supported_route_graph_config_version) {
744 let cfg_v4 = toml::from_str::<ProxyConfigV4>(&text)?;
745 let runtime = compile_v4_to_runtime(&cfg_v4)?;
746 loaded_v4 = Some(cfg_v4);
747 runtime
748 } else if version == Some(3) {
749 let cfg_legacy = toml::from_str::<crate::config::legacy::ProxyConfigV3Legacy>(&text)?;
750 let migrated = crate::config::legacy::migrate_v3_legacy_to_v4(&cfg_legacy)?;
751 let runtime = compile_v4_to_runtime(&migrated.config)?;
752 loaded_v4 = Some(migrated.config);
753 runtime
754 } else if version == Some(2) {
755 let cfg_v2 = toml::from_str::<ProxyConfigV2>(&text)?;
756 compile_v2_to_runtime(&cfg_v2)?
757 } else {
758 let mut cfg = toml::from_str::<ProxyConfig>(&text)?;
759 ensure_config_version(&mut cfg);
760 cfg
761 };
762 normalize_proxy_config(&mut cfg);
763 validate_proxy_config(&cfg)?;
764 if version != Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION) {
765 if let Some(cfg_v4) = loaded_v4.as_mut() {
766 auto_migrate_loaded_v4_config(cfg_v4, "config.toml", version).await;
767 cfg_v4.version = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
768 cfg.version = Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION);
769 } else {
770 auto_migrate_loaded_config(&mut cfg, "config.toml", version).await;
771 }
772 } else if let Some(cfg_v4) = loaded_v4.as_ref() {
773 auto_compact_loaded_v4_config(cfg_v4, "config.toml").await;
774 }
775 auto_normalize_loaded_config_toml_authoring("config.toml").await;
776 return Ok(LoadedProxyConfig {
777 runtime: cfg,
778 v4: loaded_v4,
779 });
780 }
781
782 let json_path = config_path();
783 if json_path.exists() {
784 let bytes = fs::read(json_path).await?;
785 let mut cfg = serde_json::from_slice::<ProxyConfig>(&bytes)?;
786 let version = cfg.version;
787 ensure_config_version(&mut cfg);
788 normalize_proxy_config(&mut cfg);
789 validate_proxy_config(&cfg)?;
790 auto_migrate_loaded_config(&mut cfg, "config.json", version).await;
791 return Ok(LoadedProxyConfig {
792 runtime: cfg,
793 v4: None,
794 });
795 }
796
797 let mut cfg = ProxyConfig::default();
798 ensure_config_version(&mut cfg);
799 normalize_proxy_config(&mut cfg);
800 validate_proxy_config(&cfg)?;
801 Ok(LoadedProxyConfig {
802 runtime: cfg,
803 v4: None,
804 })
805}
806
807async fn auto_migrate_loaded_config(
808 cfg: &mut ProxyConfig,
809 source: &str,
810 source_version: Option<u32>,
811) {
812 match save_config(cfg).await {
813 Ok(()) => {
814 cfg.version = Some(CURRENT_ROUTE_GRAPH_CONFIG_VERSION);
815 info!(
816 "auto-migrated {} from version {:?} to version {}",
817 source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION
818 );
819 }
820 Err(err) => {
821 warn!(
822 "failed to auto-migrate {} from version {:?} to version {}: {}",
823 source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION, err
824 );
825 }
826 }
827}
828
829async fn auto_migrate_loaded_v4_config(
830 cfg: &ProxyConfigV4,
831 source: &str,
832 source_version: Option<u32>,
833) {
834 match save_config_v4(cfg).await {
835 Ok(_) => {
836 info!(
837 "auto-migrated {} from version {:?} to version {}",
838 source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION
839 );
840 }
841 Err(err) => {
842 warn!(
843 "failed to auto-migrate {} from version {:?} to version {}: {}",
844 source, source_version, CURRENT_ROUTE_GRAPH_CONFIG_VERSION, err
845 );
846 }
847 }
848}
849
850fn runtime_service_manager_value(mgr: &ServiceConfigManager) -> Result<JsonValue> {
851 serde_json::to_value(mgr).context("serialize runtime service manager")
852}
853
854fn v4_service_has_import_metadata(view: &ServiceViewV4) -> bool {
855 view.providers.values().any(|provider| {
856 provider.tags.contains_key("provider_id")
857 || provider.tags.contains_key("requires_openai_auth")
858 || provider
859 .tags
860 .get("source")
861 .is_some_and(|value| value == "codex-config")
862 || provider.endpoints.values().any(|endpoint| {
863 endpoint.tags.contains_key("provider_id")
864 || endpoint.tags.contains_key("requires_openai_auth")
865 || endpoint
866 .tags
867 .get("source")
868 .is_some_and(|value| value == "codex-config")
869 })
870 })
871}
872
873async fn auto_compact_loaded_v4_config(cfg: &ProxyConfigV4, source: &str) {
874 if !v4_service_has_import_metadata(&cfg.codex) && !v4_service_has_import_metadata(&cfg.claude) {
875 return;
876 }
877
878 match save_config_v4(cfg).await {
879 Ok(_) => {
880 info!(
881 "auto-compacted {} v4 provider config metadata for authoring format",
882 source
883 );
884 }
885 Err(err) => {
886 warn!(
887 "failed to auto-compact {} v4 provider config metadata: {}",
888 source, err
889 );
890 }
891 }
892}
893
894async fn auto_normalize_loaded_config_toml_authoring(source: &str) {
895 let path = config_toml_path();
896 let text = match fs::read_to_string(&path).await {
897 Ok(text) => text,
898 Err(err) => {
899 warn!(
900 "failed to read {} while normalizing config authoring fields: {}",
901 source, err
902 );
903 return;
904 }
905 };
906 let normalized = match normalize_config_toml_authoring_text(&text) {
907 Ok(Some(normalized)) => normalized,
908 Ok(None) => return,
909 Err(err) => {
910 warn!(
911 "failed to normalize {} config authoring fields: {}",
912 source, err
913 );
914 return;
915 }
916 };
917
918 let backup_path = config_toml_backup_path();
919 if path.exists()
920 && let Err(err) = fs::copy(&path, &backup_path).await
921 {
922 warn!("failed to backup {:?} to {:?}: {}", path, backup_path, err);
923 }
924
925 match write_bytes_file_async(&path, normalized.as_bytes()).await {
926 Ok(()) => {
927 info!("auto-normalized {} config authoring fields", source);
928 }
929 Err(err) => {
930 warn!(
931 "failed to auto-normalize {} config authoring fields: {}",
932 source, err
933 );
934 }
935 }
936}
937
938async fn save_existing_v4_if_only_runtime_metadata_changed(
939 cfg: &ProxyConfig,
940) -> Result<Option<PathBuf>> {
941 let path = config_toml_path();
942 if !path.exists() {
943 return Ok(None);
944 }
945
946 let text = fs::read_to_string(&path).await?;
947 if !toml_schema_version_or_shape(&text).is_some_and(is_supported_route_graph_config_version) {
948 return Ok(None);
949 }
950
951 let mut requested = cfg.clone();
952 normalize_proxy_config(&mut requested);
953 validate_proxy_config(&requested)?;
954
955 let mut existing = toml::from_str::<ProxyConfigV4>(&text)?;
956 let mut existing_runtime = compile_v4_to_runtime(&existing)?;
957 normalize_proxy_config(&mut existing_runtime);
958
959 if runtime_service_manager_value(&existing_runtime.codex)?
960 != runtime_service_manager_value(&requested.codex)?
961 || runtime_service_manager_value(&existing_runtime.claude)?
962 != runtime_service_manager_value(&requested.claude)?
963 {
964 return Ok(None);
965 }
966
967 existing.retry = requested.retry;
968 existing.notify = requested.notify;
969 existing.default_service = requested.default_service;
970 existing.ui = requested.ui;
971 save_config_v4(&existing).await.map(Some)
972}
973
974pub async fn save_config(cfg: &ProxyConfig) -> Result<()> {
975 if cfg
976 .version
977 .is_some_and(is_supported_route_graph_config_version)
978 {
979 if save_existing_v4_if_only_runtime_metadata_changed(cfg)
980 .await?
981 .is_some()
982 {
983 return Ok(());
984 }
985 let migrated = migrate_legacy_to_v4(cfg)?;
986 save_config_v4(&migrated).await?;
987 return Ok(());
988 }
989
990 let migrated = migrate_legacy_to_v4(cfg)?;
991 save_config_v4(&migrated).await?;
992 Ok(())
993}
994
995pub async fn save_config_v2(cfg: &ProxyConfigV2) -> Result<PathBuf> {
996 let mut normalized = compact_v2_config(cfg)?;
997 let mut runtime = compile_v2_to_runtime(&normalized)?;
998 normalize_proxy_config(&mut runtime);
999 validate_proxy_config(&runtime)?;
1000 normalized.version = 2;
1001
1002 let dir = config_dir();
1003 fs::create_dir_all(&dir).await?;
1004 let path = config_toml_path();
1005 let (backup_source_path, backup_path) = config_backup_source_and_path();
1006 let body = toml::to_string_pretty(&normalized)?;
1007 let text = preserve_existing_codex_client_patch(format!(
1008 "{CONFIG_TOML_DOC_HEADER}
1009{body}"
1010 ));
1011 let data = text.into_bytes();
1012
1013 if backup_source_path.exists()
1014 && let Err(err) = fs::copy(&backup_source_path, &backup_path).await
1015 {
1016 warn!(
1017 "failed to backup {:?} to {:?}: {}",
1018 backup_source_path, backup_path, err
1019 );
1020 }
1021
1022 write_bytes_file_async(&path, &data).await?;
1023 Ok(path)
1024}
1025
1026pub async fn save_config_v4(cfg: &ProxyConfigV4) -> Result<PathBuf> {
1027 let mut normalized = cfg.clone();
1028 normalized.version = CURRENT_ROUTE_GRAPH_CONFIG_VERSION;
1029 compact_v4_config_for_write(&mut normalized);
1030 let mut runtime = compile_v4_to_runtime(&normalized)?;
1031 normalize_proxy_config(&mut runtime);
1032 validate_proxy_config(&runtime)?;
1033
1034 let dir = config_dir();
1035 fs::create_dir_all(&dir).await?;
1036 let path = config_toml_path();
1037 let (backup_source_path, backup_path) = config_backup_source_and_path();
1038 let body = toml::to_string_pretty(&normalized)?;
1039 let text = preserve_existing_codex_client_patch(format!(
1040 "{CONFIG_TOML_DOC_HEADER}
1041{body}"
1042 ));
1043 let data = text.into_bytes();
1044
1045 if backup_source_path.exists()
1046 && let Err(err) = fs::copy(&backup_source_path, &backup_path).await
1047 {
1048 warn!(
1049 "failed to backup {:?} to {:?}: {}",
1050 backup_source_path, backup_path, err
1051 );
1052 }
1053
1054 write_bytes_file_async(&path, &data).await?;
1055 Ok(path)
1056}
1057
1058fn normalize_proxy_config(cfg: &mut ProxyConfig) {
1059 fn normalize_mgr(mgr: &mut ServiceConfigManager) {
1060 fn select_default_active_name(configs: &HashMap<String, ServiceConfig>) -> Option<String> {
1061 let mut items = configs.iter().collect::<Vec<_>>();
1062 items.sort_by(|(name_a, svc_a), (name_b, svc_b)| {
1063 svc_a
1064 .level
1065 .cmp(&svc_b.level)
1066 .then_with(|| name_a.cmp(name_b))
1067 });
1068 items
1069 .iter()
1070 .find(|(_, svc)| svc.enabled)
1071 .map(|(name, _)| (*name).clone())
1072 .or_else(|| items.first().map(|(name, _)| (*name).clone()))
1073 }
1074
1075 for (key, svc) in mgr.stations_mut() {
1076 if svc.name.trim().is_empty() {
1077 svc.name = key.clone();
1078 }
1079 }
1080 let normalized_active = mgr
1081 .active
1082 .as_ref()
1083 .map(|value| value.trim().to_string())
1084 .filter(|value| !value.is_empty());
1085 mgr.active = match normalized_active {
1086 Some(active) if mgr.contains_station(active.as_str()) => Some(active),
1087 Some(active) => match active.to_ascii_lowercase().as_str() {
1088 "true" | "1" | "yes" | "on" => select_default_active_name(mgr.stations()),
1089 "false" | "0" | "no" | "off" => None,
1090 _ => Some(active),
1091 },
1092 None => None,
1093 };
1094 mgr.default_profile = mgr
1095 .default_profile
1096 .as_ref()
1097 .map(|value| value.trim().to_string())
1098 .filter(|value| !value.is_empty());
1099 for profile in mgr.profiles.values_mut() {
1100 profile.extends = profile
1101 .extends
1102 .as_ref()
1103 .map(|value| value.trim().to_string())
1104 .filter(|value| !value.is_empty());
1105 profile.station = profile
1106 .station
1107 .as_ref()
1108 .map(|value| value.trim().to_string())
1109 .filter(|value| !value.is_empty());
1110 profile.model = profile
1111 .model
1112 .as_ref()
1113 .map(|value| value.trim().to_string())
1114 .filter(|value| !value.is_empty());
1115 profile.reasoning_effort = profile
1116 .reasoning_effort
1117 .as_ref()
1118 .map(|value| value.trim().to_string())
1119 .filter(|value| !value.is_empty());
1120 profile.service_tier = profile
1121 .service_tier
1122 .as_ref()
1123 .map(|value| value.trim().to_string())
1124 .filter(|value| !value.is_empty());
1125 }
1126 }
1127
1128 normalize_mgr(&mut cfg.codex);
1129 normalize_mgr(&mut cfg.claude);
1130}
1131
1132fn validate_proxy_config(cfg: &ProxyConfig) -> Result<()> {
1133 validate_service_profiles("codex", &cfg.codex)?;
1134 validate_service_profiles("claude", &cfg.claude)?;
1135 Ok(())
1136}