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