Skip to main content

codex_helper_core/
doctor.rs

1use serde::Serialize;
2use serde_json::Value as JsonValue;
3use std::env;
4use std::fs::File;
5use std::io::BufReader;
6use std::path::PathBuf;
7
8use crate::config::{
9    codex_auth_path, codex_config_path, load_config, probe_codex_bootstrap_from_cli, proxy_home_dir,
10};
11use crate::logging::request_log_path;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DoctorLang {
15    Zh,
16    En,
17}
18
19fn pick(lang: DoctorLang, zh: &'static str, en: &'static str) -> &'static str {
20    match lang {
21        DoctorLang::Zh => zh,
22        DoctorLang::En => en,
23    }
24}
25
26#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
27#[serde(rename_all = "lowercase")]
28pub enum DoctorStatus {
29    Ok,
30    Info,
31    Warn,
32    Fail,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct DoctorCheck {
37    pub id: &'static str,
38    pub status: DoctorStatus,
39    pub message: String,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct DoctorReport {
44    pub checks: Vec<DoctorCheck>,
45}
46
47pub async fn run_doctor(lang: DoctorLang) -> DoctorReport {
48    let mut checks: Vec<DoctorCheck> = Vec::new();
49
50    // 1) codex-helper main config
51    match load_config().await {
52        Ok(cfg) => {
53            let codex_count = cfg.codex.station_count();
54            if codex_count == 0 {
55                checks.push(DoctorCheck {
56                    id: "proxy_config.codex",
57                    status: DoctorStatus::Warn,
58                    message: pick(
59                        lang,
60                        "检测到 ~/.codex-helper/config.toml/json 中尚无 Codex provider;建议先运行 `codex-helper config init`,再用 `codex-helper provider add` 手动添加,或运行 `codex-helper config import-from-codex` 从 Codex CLI 配置导入。",
61                        "No Codex providers found in ~/.codex-helper/config.toml/json; run `codex-helper config init`, then use `codex-helper provider add`, or run `codex-helper config import-from-codex` to import from Codex CLI.",
62                    )
63                    .to_string(),
64                });
65            } else {
66                checks.push(DoctorCheck {
67                    id: "proxy_config.codex",
68                    status: DoctorStatus::Ok,
69                    message: match lang {
70                        DoctorLang::Zh => format!(
71                            "已从 ~/.codex-helper/config.toml/json 读取到 {} 条 Codex 路由候选(runtime active_station = {:?})",
72                            codex_count, cfg.codex.active
73                        ),
74                        DoctorLang::En => format!(
75                            "Loaded {} Codex routing candidates from ~/.codex-helper/config.toml/json (runtime active_station = {:?})",
76                            codex_count, cfg.codex.active
77                        ),
78                    },
79                });
80            }
81
82            fn env_is_set(key: &str) -> bool {
83                env::var(key).ok().is_some_and(|v| !v.trim().is_empty())
84            }
85
86            for (svc_label, mgr) in [("Codex", &cfg.codex), ("Claude", &cfg.claude)] {
87                let Some(active_name) = mgr.active.as_deref() else {
88                    continue;
89                };
90                let Some(active_cfg) = mgr.active_station() else {
91                    continue;
92                };
93                for (idx, up) in active_cfg.upstreams.iter().enumerate() {
94                    if let Some(env_name) = up.auth.auth_token_env.as_deref()
95                        && !env_is_set(env_name)
96                    {
97                        checks.push(DoctorCheck {
98                            id: "proxy_config.auth.env_missing",
99                            status: DoctorStatus::Warn,
100                            message: match lang {
101                                DoctorLang::Zh => format!(
102                                    "{} active station '{}' upstream[{}] 缺少环境变量 {}(Bearer token);请在运行 codex-helper 前设置该变量",
103                                    svc_label, active_name, idx, env_name
104                                ),
105                                DoctorLang::En => format!(
106                                    "{} active station '{}' upstream[{}] is missing env var {} (Bearer token); set it before running codex-helper",
107                                    svc_label, active_name, idx, env_name
108                                ),
109                            },
110                        });
111                    }
112                    if let Some(env_name) = up.auth.api_key_env.as_deref()
113                        && !env_is_set(env_name)
114                    {
115                        checks.push(DoctorCheck {
116                            id: "proxy_config.auth.env_missing",
117                            status: DoctorStatus::Warn,
118                            message: match lang {
119                                DoctorLang::Zh => format!(
120                                    "{} active station '{}' upstream[{}] 缺少环境变量 {}(X-API-Key);请在运行 codex-helper 前设置该变量",
121                                    svc_label, active_name, idx, env_name
122                                ),
123                                DoctorLang::En => format!(
124                                    "{} active station '{}' upstream[{}] is missing env var {} (X-API-Key); set it before running codex-helper",
125                                    svc_label, active_name, idx, env_name
126                                ),
127                            },
128                        });
129                    }
130                    let has_plaintext = up
131                        .auth
132                        .auth_token
133                        .as_deref()
134                        .is_some_and(|s| !s.trim().is_empty())
135                        || up
136                            .auth
137                            .api_key
138                            .as_deref()
139                            .is_some_and(|s| !s.trim().is_empty());
140                    if has_plaintext {
141                        checks.push(DoctorCheck {
142                            id: "proxy_config.auth.plaintext",
143                            status: DoctorStatus::Warn,
144                            message: match lang {
145                                DoctorLang::Zh => format!(
146                                    "{} active station '{}' upstream[{}] 在 ~/.codex-helper/config.toml/json 中检测到明文密钥字段(建议改用 auth_token_env/api_key_env 以避免落盘泄露)",
147                                    svc_label, active_name, idx
148                                ),
149                                DoctorLang::En => format!(
150                                    "{} active station '{}' upstream[{}] contains plaintext secrets in ~/.codex-helper/config.toml/json (prefer auth_token_env/api_key_env)",
151                                    svc_label, active_name, idx
152                                ),
153                            },
154                        });
155                    }
156                }
157            }
158        }
159        Err(err) => {
160            checks.push(DoctorCheck {
161                id: "proxy_config.codex",
162                status: DoctorStatus::Fail,
163                message: match lang {
164                    DoctorLang::Zh => format!(
165                        "无法读取 ~/.codex-helper/config.toml/json:{};请检查该文件是否为有效 TOML/JSON,或尝试备份后重新初始化。",
166                        err
167                    ),
168                    DoctorLang::En => format!(
169                        "Failed to read ~/.codex-helper/config.toml/json: {err}; ensure it is valid TOML/JSON, or back it up and reinitialize.",
170                    ),
171                },
172            });
173        }
174    }
175
176    // 2) Codex CLI config/auth
177    let codex_cfg_path = codex_config_path();
178    let codex_auth_path = codex_auth_path();
179
180    if codex_cfg_path.exists() {
181        checks.push(DoctorCheck {
182            id: "codex.config.toml",
183            status: DoctorStatus::Ok,
184            message: match lang {
185                DoctorLang::Zh => format!("检测到 Codex 配置文件:{:?}", codex_cfg_path),
186                DoctorLang::En => format!("Found Codex config file: {:?}", codex_cfg_path),
187            },
188        });
189
190        match std::fs::read_to_string(&codex_cfg_path)
191            .ok()
192            .and_then(|s| s.parse::<toml::Value>().ok())
193        {
194            Some(value) => {
195                let provider = value
196                    .get("model_provider")
197                    .and_then(|v| v.as_str())
198                    .unwrap_or("openai");
199                checks.push(DoctorCheck {
200                    id: "codex.config.model_provider",
201                    status: DoctorStatus::Info,
202                    message: match lang {
203                        DoctorLang::Zh => format!(
204                            "当前 Codex model_provider = \"{}\"(doctor 仅做读取,不会修改该文件)",
205                            provider
206                        ),
207                        DoctorLang::En => format!(
208                            "Current Codex model_provider = \"{}\" (doctor is read-only)",
209                            provider
210                        ),
211                    },
212                });
213            }
214            None => checks.push(DoctorCheck {
215                id: "codex.config.toml",
216                status: DoctorStatus::Warn,
217                message: match lang {
218                    DoctorLang::Zh => format!(
219                        "无法解析 {:?} 为有效 TOML,codex-helper 将无法自动推导上游配置",
220                        codex_cfg_path
221                    ),
222                    DoctorLang::En => format!(
223                        "Failed to parse {:?} as TOML; codex-helper cannot infer upstreams from Codex CLI",
224                        codex_cfg_path
225                    ),
226                },
227            }),
228        }
229    } else {
230        checks.push(DoctorCheck {
231            id: "codex.config.toml",
232            status: DoctorStatus::Warn,
233            message: match lang {
234                DoctorLang::Zh => format!(
235                    "未找到 Codex 配置文件:{:?};建议先安装并运行 Codex CLI,完成登录和基础配置。",
236                    codex_cfg_path
237                ),
238                DoctorLang::En => format!(
239                    "Codex config file not found: {:?}; install/run Codex CLI and complete initial setup.",
240                    codex_cfg_path
241                ),
242            },
243        });
244    }
245
246    if codex_auth_path.exists() {
247        checks.push(DoctorCheck {
248            id: "codex.auth.json",
249            status: DoctorStatus::Ok,
250            message: match lang {
251                DoctorLang::Zh => format!("检测到 Codex 认证文件:{:?}", codex_auth_path),
252                DoctorLang::En => format!("Found Codex auth file: {:?}", codex_auth_path),
253            },
254        });
255
256        match File::open(&codex_auth_path).ok().and_then(|f| {
257            let reader = BufReader::new(f);
258            serde_json::from_reader::<_, JsonValue>(reader).ok()
259        }) {
260            Some(json_val) => {
261                if let Some(obj) = json_val.as_object() {
262                    let api_keys: Vec<_> = obj
263                        .iter()
264                        .filter_map(|(k, v)| {
265                            if k.ends_with("_API_KEY")
266                                && v.as_str().is_some_and(|s| !s.trim().is_empty())
267                            {
268                                Some(k.clone())
269                            } else {
270                                None
271                            }
272                        })
273                        .collect();
274                    if api_keys.is_empty() {
275                        checks.push(DoctorCheck {
276                            id: "codex.auth.api_key",
277                            status: DoctorStatus::Warn,
278                            message: pick(
279                                lang,
280                                "`~/.codex/auth.json` 中未找到任何 `*_API_KEY` 字段,可能尚未通过 API Key 方式配置 Codex",
281                                "No `*_API_KEY` fields found in ~/.codex/auth.json; Codex may not be configured via API key.",
282                            )
283                            .to_string(),
284                        });
285                    } else if api_keys.len() == 1 {
286                        checks.push(DoctorCheck {
287                            id: "codex.auth.api_key",
288                            status: DoctorStatus::Ok,
289                            message: match lang {
290                                DoctorLang::Zh => {
291                                    format!("检测到 Codex API key 字段:{:?}", api_keys)
292                                }
293                                DoctorLang::En => {
294                                    format!("Found Codex API key field: {:?}", api_keys)
295                                }
296                            },
297                        });
298                    } else {
299                        checks.push(DoctorCheck {
300                            id: "codex.auth.api_key",
301                            status: DoctorStatus::Warn,
302                            message: match lang {
303                                DoctorLang::Zh => format!(
304                                    "检测到多个 `*_API_KEY` 字段:{:?},自动推断 token 时可能需要手动指定 env_key",
305                                    api_keys
306                                ),
307                                DoctorLang::En => format!(
308                                    "Multiple `*_API_KEY` fields detected: {:?}; inference may require manual env_key selection",
309                                    api_keys
310                                ),
311                            },
312                        });
313                    }
314                } else {
315                    checks.push(DoctorCheck {
316                        id: "codex.auth.json",
317                        status: DoctorStatus::Warn,
318                        message: pick(
319                            lang,
320                            "`~/.codex/auth.json` 根节点不是 JSON 对象,可能不是 Codex CLI 生成的标准格式",
321                            "~/.codex/auth.json root is not a JSON object; it may not be in Codex CLI's standard format.",
322                        )
323                        .to_string(),
324                    });
325                }
326            }
327            None => checks.push(DoctorCheck {
328                id: "codex.auth.json",
329                status: DoctorStatus::Warn,
330                message: match lang {
331                    DoctorLang::Zh => format!(
332                        "无法解析 {:?} 为 JSON,codex-helper 将无法从中读取 token",
333                        codex_auth_path
334                    ),
335                    DoctorLang::En => format!(
336                        "Failed to parse {:?} as JSON; codex-helper cannot read tokens from it.",
337                        codex_auth_path
338                    ),
339                },
340            }),
341        }
342    } else {
343        checks.push(DoctorCheck {
344            id: "codex.auth.json",
345            status: DoctorStatus::Warn,
346            message: match lang {
347                DoctorLang::Zh => format!(
348                    "未找到 Codex 认证文件:{:?};建议运行 `codex login` 完成登录,或按照 Codex 文档手动创建 auth.json。",
349                    codex_auth_path
350                ),
351                DoctorLang::En => format!(
352                    "Codex auth file not found: {:?}; run `codex login` or create auth.json per Codex docs.",
353                    codex_auth_path
354                ),
355            },
356        });
357    }
358
359    // 3) bootstrap probe (no disk write)
360    match probe_codex_bootstrap_from_cli().await {
361        Ok(()) => checks.push(DoctorCheck {
362            id: "bootstrap.codex",
363            status: DoctorStatus::Ok,
364            message: pick(
365                lang,
366                "成功从 ~/.codex/config.toml 与 ~/.codex/auth.json 模拟推导 Codex 上游;如需导入,可运行 `codex-helper config import-from-codex`",
367                "Successfully inferred Codex upstreams from ~/.codex/config.toml and ~/.codex/auth.json; to import, run `codex-helper config import-from-codex`",
368            )
369            .to_string(),
370        }),
371        Err(err) => checks.push(DoctorCheck {
372            id: "bootstrap.codex",
373            status: DoctorStatus::Warn,
374            message: match lang {
375                DoctorLang::Zh => format!(
376                    "无法从 ~/.codex 自动推导 Codex 上游:{};这不会影响手动在 ~/.codex-helper/config.toml 中配置 provider/routing,但自动导入功能将不可用。",
377                    err
378                ),
379                DoctorLang::En => format!(
380                    "Failed to infer Codex upstreams from ~/.codex: {err}; manual ~/.codex-helper config.toml provider/routing config still works but auto-import won't.",
381                ),
382            },
383        }),
384    }
385
386    // 4) logs and usage_providers
387    let log_path = request_log_path();
388    if log_path.exists() {
389        checks.push(DoctorCheck {
390            id: "logs.requests",
391            status: DoctorStatus::Ok,
392            message: match lang {
393                DoctorLang::Zh => format!("检测到请求日志文件:{:?}", log_path),
394                DoctorLang::En => format!("Found request logs: {:?}", log_path),
395            },
396        });
397    } else {
398        checks.push(DoctorCheck {
399            id: "logs.requests",
400            status: DoctorStatus::Info,
401            message: match lang {
402                DoctorLang::Zh => format!(
403                    "尚未生成请求日志:{:?},可能尚未通过 codex-helper 代理发送请求",
404                    log_path
405                ),
406                DoctorLang::En => format!(
407                    "Request logs not found: {:?}; you may not have sent requests through codex-helper yet.",
408                    log_path
409                ),
410            },
411        });
412    }
413
414    let usage_path: PathBuf = proxy_home_dir().join("usage_providers.json");
415    if usage_path.exists() {
416        match std::fs::read_to_string(&usage_path)
417            .ok()
418            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
419        {
420            Some(_) => checks.push(DoctorCheck {
421                id: "usage_providers",
422                status: DoctorStatus::Ok,
423                message: match lang {
424                    DoctorLang::Zh => format!("检测到用量提供商配置:{:?}", usage_path),
425                    DoctorLang::En => format!("Found usage providers config: {:?}", usage_path),
426                },
427            }),
428            None => checks.push(DoctorCheck {
429                id: "usage_providers",
430                status: DoctorStatus::Warn,
431                message: match lang {
432                    DoctorLang::Zh => format!(
433                        "无法解析 {:?} 为 JSON,用量查询(如 Packy 额度)将不可用",
434                        usage_path
435                    ),
436                    DoctorLang::En => format!(
437                        "Failed to parse {:?} as JSON; usage queries (e.g. Packy quota) will be unavailable.",
438                        usage_path
439                    ),
440                },
441            }),
442        }
443    } else {
444        checks.push(DoctorCheck {
445            id: "usage_providers",
446            status: DoctorStatus::Info,
447            message: match lang {
448                DoctorLang::Zh => format!(
449                    "未找到 {:?},codex-helper 将在首次需要时写入一个默认示例(当前包含 packycode)",
450                    usage_path
451                ),
452                DoctorLang::En => format!(
453                    "{:?} not found; codex-helper will write a default example when needed (currently includes packycode).",
454                    usage_path
455                ),
456            },
457        });
458    }
459
460    DoctorReport { checks }
461}