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