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 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 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 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 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}