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