1use crate::auth::{read_codex_auth_status, AuthCommandOptions};
2use crate::error::AppError;
3use crate::format::to_pretty_json;
4use crate::limits::{read_rate_limit_samples_report, RateLimitSamplesReadOptions};
5use crate::pricing::{
6 calculate_credit_cost, list_known_unpriced_models, list_model_pricing, normalize_model_name,
7 TokenUsage as PricingTokenUsage, CODEX_RATE_CARD_SOURCE,
8};
9use crate::stats::{read_usage_records_report, UsageRecordsReadOptions};
10use crate::storage::{resolve_storage_paths, StorageOptions};
11use chrono::{DateTime, Duration, SecondsFormat, Utc};
12use serde::Serialize;
13use std::collections::BTreeMap;
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19const MIN_NODE_VERSION: NodeVersion = NodeVersion {
20 major: 20,
21 minor: 12,
22 patch: 0,
23};
24const MIN_NODE_VERSION_LABEL: &str = ">=20.12.0";
25
26#[derive(Debug, Clone, Default, Eq, PartialEq)]
27pub struct DoctorOptions {
28 pub auth_file: Option<PathBuf>,
29 pub codex_home: Option<PathBuf>,
30 pub sessions_dir: Option<PathBuf>,
31}
32
33#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
34pub struct DoctorCheck {
35 pub name: String,
36 pub status: String,
37 pub message: String,
38 pub details: Vec<String>,
39}
40
41#[derive(Debug, Clone, Eq, PartialEq)]
42pub struct DoctorReport {
43 pub now: DateTime<Utc>,
44 pub codex_home: String,
45 pub auth_file: String,
46 pub sessions_dir: String,
47 pub helper_dir: String,
48 pub checks: Vec<DoctorCheck>,
49}
50
51#[derive(Debug, Clone, Default)]
52struct RecentUsageSummary {
53 read_files: usize,
54 token_count_events: usize,
55 included_usage_events: usize,
56 unpriced_models: BTreeMap<String, RecentUnpricedModel>,
57}
58
59#[derive(Debug, Clone, Default)]
60struct RecentRateLimitsSummary {
61 read_files: usize,
62 sample_count: usize,
63 five_hour_samples: usize,
64 seven_day_samples: usize,
65 latest_observed_at: Option<DateTime<Utc>>,
66}
67
68#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
69struct NodeVersion {
70 major: u32,
71 minor: u32,
72 patch: u32,
73}
74
75#[derive(Debug, Clone, Default)]
76struct RecentUnpricedModel {
77 model: String,
78 calls: usize,
79 total_tokens: i64,
80 note: Option<String>,
81}
82
83#[derive(Serialize)]
84#[serde(rename_all = "camelCase")]
85struct DoctorJson<'a> {
86 now: String,
87 codex_home: &'a str,
88 auth_file: &'a str,
89 sessions_dir: &'a str,
90 helper_dir: &'a str,
91 checks: &'a [DoctorCheck],
92 summary: DoctorSummary,
93}
94
95#[derive(Serialize)]
96struct DoctorSummary {
97 errors: usize,
98 warnings: usize,
99}
100
101pub fn read_doctor_report(options: &DoctorOptions, now: DateTime<Utc>) -> DoctorReport {
102 let storage = resolve_storage_paths(&StorageOptions {
103 codex_home: options.codex_home.clone(),
104 auth_file: options.auth_file.clone(),
105 sessions_dir: options.sessions_dir.clone(),
106 profile_store_dir: None,
107 account_history_file: None,
108 });
109
110 let checks = vec![
111 check_node_version(),
112 check_directory("Codex home", &storage.codex_home, false),
113 check_auth_file(&storage.auth_file, options, now),
114 check_directory("Sessions directory", &storage.sessions_dir, false),
115 check_helper_directory(&storage.helper_dir),
116 check_recent_usage(&storage.sessions_dir, now),
117 check_recent_rate_limits(&storage.sessions_dir, now),
118 check_pricing(),
119 ];
120
121 DoctorReport {
122 now,
123 codex_home: path_to_string(&storage.codex_home),
124 auth_file: path_to_string(&storage.auth_file),
125 sessions_dir: path_to_string(&storage.sessions_dir),
126 helper_dir: path_to_string(&storage.helper_dir),
127 checks,
128 }
129}
130
131pub fn format_doctor_report(report: &DoctorReport, json: bool) -> Result<String, AppError> {
132 if json {
133 let value = DoctorJson {
134 now: format_iso(report.now),
135 codex_home: &report.codex_home,
136 auth_file: &report.auth_file,
137 sessions_dir: &report.sessions_dir,
138 helper_dir: &report.helper_dir,
139 checks: &report.checks,
140 summary: DoctorSummary {
141 errors: report
142 .checks
143 .iter()
144 .filter(|check| check.status == "error")
145 .count(),
146 warnings: report
147 .checks
148 .iter()
149 .filter(|check| check.status == "warn")
150 .count(),
151 },
152 };
153
154 return Ok(format!(
155 "{}\n",
156 to_pretty_json(&value).map_err(|error| AppError::new(error.to_string()))?
157 ));
158 }
159
160 let mut lines = vec![
161 "Codex Ops doctor".to_string(),
162 format!("Codex home: {}", report.codex_home),
163 format!("Auth file: {}", report.auth_file),
164 format!("Sessions dir: {}", report.sessions_dir),
165 format!("Helper dir: {}", report.helper_dir),
166 String::new(),
167 ];
168
169 for check in &report.checks {
170 lines.push(format!(
171 "[{}] {}: {}",
172 check.status, check.name, check.message
173 ));
174 for detail in &check.details {
175 lines.push(format!(" {detail}"));
176 }
177 }
178
179 let errors = report
180 .checks
181 .iter()
182 .filter(|check| check.status == "error")
183 .count();
184 let warnings = report
185 .checks
186 .iter()
187 .filter(|check| check.status == "warn")
188 .count();
189 lines.push(String::new());
190 lines.push(format!("Result: {errors} error(s), {warnings} warning(s)"));
191 Ok(format!("{}\n", lines.join("\n")))
192}
193
194fn check_node_version() -> DoctorCheck {
195 match Command::new("node").arg("--version").output() {
196 Ok(output) if output.status.success() => {
197 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
198
199 if parse_node_version(&version).is_some_and(|parsed| parsed >= MIN_NODE_VERSION) {
200 ok(
201 "Node.js",
202 format!("{version} satisfies {MIN_NODE_VERSION_LABEL}"),
203 Vec::new(),
204 )
205 } else {
206 check_error(
207 "Node.js",
208 format!("{version} is below the required {MIN_NODE_VERSION_LABEL}"),
209 Vec::new(),
210 )
211 }
212 }
213 Ok(output) => check_error(
214 "Node.js",
215 String::from_utf8_lossy(&output.stderr).trim().to_string(),
216 Vec::new(),
217 ),
218 Err(error) => check_error("Node.js", error.to_string(), Vec::new()),
219 }
220}
221
222fn parse_node_version(version: &str) -> Option<NodeVersion> {
223 let mut parts = version.strip_prefix('v').unwrap_or(version).split('.');
224 let major = parts.next()?.parse::<u32>().ok()?;
225 let minor = parts.next()?.parse::<u32>().ok()?;
226 let patch_part = parts.next()?;
227 let patch_digits = patch_part
228 .chars()
229 .take_while(|ch| ch.is_ascii_digit())
230 .collect::<String>();
231 let patch = patch_digits.parse::<u32>().ok()?;
232
233 Some(NodeVersion {
234 major,
235 minor,
236 patch,
237 })
238}
239
240fn check_auth_file(auth_file: &Path, options: &DoctorOptions, now: DateTime<Utc>) -> DoctorCheck {
241 match read_codex_auth_status(
242 &AuthCommandOptions {
243 auth_file: Some(auth_file.to_path_buf()),
244 codex_home: options.codex_home.clone(),
245 store_dir: None,
246 account_history_file: None,
247 },
248 now,
249 ) {
250 Ok(report) => {
251 let summary = report.summary;
252 let label = summary
253 .email
254 .as_deref()
255 .or(summary.name.as_deref())
256 .or(summary.user_id.as_deref())
257 .unwrap_or("authenticated");
258 let mut details = vec![
259 format!(
260 "Account: {}",
261 summary
262 .chatgpt_account_id
263 .as_deref()
264 .or(summary.token_account_id.as_deref())
265 .unwrap_or("unknown")
266 ),
267 format!(
268 "Plan: {}",
269 summary.plan_type.as_deref().unwrap_or("unknown")
270 ),
271 ];
272 if let Some(expires_at) = summary.expires_at {
273 details.push(format!("Token expires: {expires_at}"));
274 }
275
276 if summary.is_expired == Some(true) {
277 warn(
278 "Auth file",
279 format!(
280 "Decoded {}, but the ID token is expired",
281 path_to_string(auth_file)
282 ),
283 details,
284 )
285 } else {
286 ok(
287 "Auth file",
288 format!("Decoded {} for {label}", path_to_string(auth_file)),
289 details,
290 )
291 }
292 }
293 Err(error) if error.message().starts_with("ENOENT:") => warn(
294 "Auth file",
295 format!("Missing auth.json at {}", path_to_string(auth_file)),
296 Vec::new(),
297 ),
298 Err(error) => check_error("Auth file", error.message().to_string(), Vec::new()),
299 }
300}
301
302fn check_directory(name: &str, path: &Path, writable: bool) -> DoctorCheck {
303 match fs::metadata(path) {
304 Ok(info) if !info.is_dir() => check_error(
305 name,
306 format!("{} exists but is not a directory", path_to_string(path)),
307 Vec::new(),
308 ),
309 Ok(_) => {
310 if let Err(error) = fs::read_dir(path) {
311 return check_error(name, error.to_string(), Vec::new());
312 }
313 if writable && is_readonly(path) {
314 return check_error(name, "permission denied".to_string(), Vec::new());
315 }
316 ok(
317 name,
318 format!("{} is accessible", path_to_string(path)),
319 Vec::new(),
320 )
321 }
322 Err(error) if error.kind() == io::ErrorKind::NotFound => warn(
323 name,
324 format!("{} does not exist", path_to_string(path)),
325 Vec::new(),
326 ),
327 Err(error) => check_error(name, error.to_string(), Vec::new()),
328 }
329}
330
331fn check_helper_directory(helper_dir: &Path) -> DoctorCheck {
332 match fs::metadata(helper_dir) {
333 Ok(info) if !info.is_dir() => check_error(
334 "Helper directory",
335 format!(
336 "{} exists but is not a directory",
337 path_to_string(helper_dir)
338 ),
339 Vec::new(),
340 ),
341 Ok(_) => {
342 if let Err(error) = fs::read_dir(helper_dir) {
343 return check_error("Helper directory", error.to_string(), Vec::new());
344 }
345 if is_readonly(helper_dir) {
346 return check_error(
347 "Helper directory",
348 "permission denied".to_string(),
349 Vec::new(),
350 );
351 }
352 ok(
353 "Helper directory",
354 format!("{} is readable and writable", path_to_string(helper_dir)),
355 Vec::new(),
356 )
357 }
358 Err(error) if error.kind() == io::ErrorKind::NotFound => ok(
359 "Helper directory",
360 format!(
361 "{} does not exist yet; helper commands will create it",
362 path_to_string(helper_dir)
363 ),
364 Vec::new(),
365 ),
366 Err(error) => check_error("Helper directory", error.to_string(), Vec::new()),
367 }
368}
369
370fn check_recent_usage(sessions_dir: &Path, now: DateTime<Utc>) -> DoctorCheck {
371 if !sessions_dir.exists() {
372 return warn(
373 "Recent usage",
374 format!(
375 "Cannot scan usage because {} does not exist",
376 path_to_string(sessions_dir)
377 ),
378 Vec::new(),
379 );
380 }
381
382 match read_recent_usage_summary(sessions_dir, now) {
383 Ok(summary) => {
384 let details = vec![
385 format!("Files read: {}", summary.read_files),
386 format!("Token events: {}", summary.token_count_events),
387 format!("Included usage events: {}", summary.included_usage_events),
388 ];
389
390 if !summary.unpriced_models.is_empty() {
391 let mut details = details;
392 for model in summary.unpriced_models.values() {
393 details.push(format!(
394 "{}: {} call(s), {} token(s){}",
395 model.model,
396 model.calls,
397 model.total_tokens,
398 model
399 .note
400 .as_ref()
401 .map(|note| format!(" ({note})"))
402 .unwrap_or_default()
403 ));
404 }
405 return warn(
406 "Recent usage",
407 format!(
408 "{} usage event(s), with unpriced model usage found",
409 summary.included_usage_events
410 ),
411 details,
412 );
413 }
414
415 if summary.included_usage_events == 0 {
416 return warn(
417 "Recent usage",
418 "No token_count usage events found in the last 7 days",
419 details,
420 );
421 }
422
423 ok(
424 "Recent usage",
425 format!(
426 "{} usage event(s) found in the last 7 days",
427 summary.included_usage_events
428 ),
429 details,
430 )
431 }
432 Err(error) => check_error("Recent usage", error, Vec::new()),
433 }
434}
435
436fn check_recent_rate_limits(sessions_dir: &Path, now: DateTime<Utc>) -> DoctorCheck {
437 if !sessions_dir.exists() {
438 return warn(
439 "Recent rate limits",
440 format!(
441 "Cannot scan rate limits because {} does not exist",
442 path_to_string(sessions_dir)
443 ),
444 Vec::new(),
445 );
446 }
447
448 match read_recent_rate_limits_summary(sessions_dir, now) {
449 Ok(summary) => {
450 let details = vec![
451 format!("Files read: {}", summary.read_files),
452 format!("Samples: {}", summary.sample_count),
453 format!("5h samples: {}", summary.five_hour_samples),
454 format!("7d samples: {}", summary.seven_day_samples),
455 format!(
456 "Latest observed at: {}",
457 summary
458 .latest_observed_at
459 .map(format_iso)
460 .unwrap_or_else(|| "none".to_string())
461 ),
462 ];
463
464 if summary.sample_count == 0 {
465 return warn(
466 "Recent rate limits",
467 "No observed rate limits found in the last 7 days",
468 details,
469 );
470 }
471
472 ok(
473 "Recent rate limits",
474 format!(
475 "{} rate-limit sample(s) found in the last 7 days",
476 summary.sample_count
477 ),
478 details,
479 )
480 }
481 Err(error) => check_error("Recent rate limits", error, Vec::new()),
482 }
483}
484
485fn check_pricing() -> DoctorCheck {
486 let priced = list_model_pricing();
487 let unpriced_count = list_known_unpriced_models().len();
488 let mut details = vec![
489 format!("Source: {}", CODEX_RATE_CARD_SOURCE.name),
490 format!("Checked: {}", CODEX_RATE_CARD_SOURCE.checked_at),
491 format!("Credits: {}", CODEX_RATE_CARD_SOURCE.credit_to_usd),
492 ];
493
494 for model in priced.iter().filter(|model| model.note.is_some()) {
495 details.push(format!(
496 "{}: {}",
497 model.label,
498 model.note.unwrap_or_default()
499 ));
500 }
501
502 ok(
503 "Pricing",
504 format!(
505 "{} priced model(s), {} known unpriced model(s)",
506 priced.len(),
507 unpriced_count
508 ),
509 details,
510 )
511}
512
513fn read_recent_usage_summary(
514 sessions_dir: &Path,
515 now: DateTime<Utc>,
516) -> Result<RecentUsageSummary, String> {
517 let start = now - Duration::days(7);
518 let report = read_usage_records_report(&UsageRecordsReadOptions {
519 start,
520 end: now,
521 sessions_dir: sessions_dir.to_path_buf(),
522 scan_all_files: false,
523 account_history_file: None,
524 account_id: None,
525 })
526 .map_err(|error| error.message().to_string())?;
527
528 let mut summary = RecentUsageSummary {
529 read_files: report.diagnostics.read_files.max(0) as usize,
530 token_count_events: report.diagnostics.token_count_events.max(0) as usize,
531 included_usage_events: report.diagnostics.included_usage_events.max(0) as usize,
532 ..RecentUsageSummary::default()
533 };
534
535 for record in report.records {
536 let cost = calculate_credit_cost(
537 &record.model,
538 PricingTokenUsage {
539 input_tokens: record.usage.input_tokens.max(0) as u64,
540 cached_input_tokens: record.usage.cached_input_tokens.max(0) as u64,
541 output_tokens: record.usage.output_tokens.max(0) as u64,
542 },
543 );
544 if !cost.priced {
545 let key = normalize_model_name(&record.model);
546 let entry = summary
547 .unpriced_models
548 .entry(key)
549 .or_insert_with(|| RecentUnpricedModel {
550 model: record.model.clone(),
551 calls: 0,
552 total_tokens: 0,
553 note: cost.unpriced_reason.clone(),
554 });
555 entry.calls += 1;
556 entry.total_tokens += record.usage.total_tokens;
557 }
558 }
559
560 Ok(summary)
561}
562
563fn read_recent_rate_limits_summary(
564 sessions_dir: &Path,
565 now: DateTime<Utc>,
566) -> Result<RecentRateLimitsSummary, String> {
567 let start = now - Duration::days(7);
568 let report = read_rate_limit_samples_report(&RateLimitSamplesReadOptions {
569 start,
570 end: now,
571 sessions_dir: sessions_dir.to_path_buf(),
572 scan_all_files: false,
573 account_history_file: None,
574 account_id: None,
575 plan_type: None,
576 window_minutes: None,
577 })
578 .map_err(|error| error.message().to_string())?;
579
580 Ok(RecentRateLimitsSummary {
581 read_files: report.diagnostics.read_files.max(0) as usize,
582 sample_count: report.samples.len(),
583 five_hour_samples: report
584 .samples
585 .iter()
586 .filter(|sample| sample.window_minutes == 300)
587 .count(),
588 seven_day_samples: report
589 .samples
590 .iter()
591 .filter(|sample| sample.window_minutes == 10_080)
592 .count(),
593 latest_observed_at: report.samples.iter().map(|sample| sample.timestamp).max(),
594 })
595}
596
597fn ok(name: &str, message: impl Into<String>, details: Vec<String>) -> DoctorCheck {
598 DoctorCheck {
599 name: name.to_string(),
600 status: "ok".to_string(),
601 message: message.into(),
602 details,
603 }
604}
605
606fn warn(name: &str, message: impl Into<String>, details: Vec<String>) -> DoctorCheck {
607 DoctorCheck {
608 name: name.to_string(),
609 status: "warn".to_string(),
610 message: message.into(),
611 details,
612 }
613}
614
615fn check_error(name: &str, message: impl Into<String>, details: Vec<String>) -> DoctorCheck {
616 DoctorCheck {
617 name: name.to_string(),
618 status: "error".to_string(),
619 message: message.into(),
620 details,
621 }
622}
623
624fn is_readonly(path: &Path) -> bool {
625 fs::metadata(path)
626 .map(|metadata| metadata.permissions().readonly())
627 .unwrap_or(false)
628}
629
630fn format_iso(date: DateTime<Utc>) -> String {
631 date.to_rfc3339_opts(SecondsFormat::Millis, true)
632}
633
634fn path_to_string(path: &Path) -> String {
635 path.to_string_lossy().to_string()
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn pricing_check_matches_typescript_summary() {
644 let check = check_pricing();
645
646 assert_eq!(check.name, "Pricing");
647 assert_eq!(check.status, "ok");
648 assert_eq!(
649 check.message,
650 "8 priced model(s), 0 known unpriced model(s)"
651 );
652 assert!(check
653 .details
654 .iter()
655 .any(|detail| detail.contains("GPT-5.3-Codex-Spark")));
656 }
657
658 #[test]
659 fn parses_node_version_with_major_minor_patch() {
660 assert_eq!(
661 parse_node_version("v20.12.0"),
662 Some(NodeVersion {
663 major: 20,
664 minor: 12,
665 patch: 0
666 })
667 );
668 assert_eq!(
669 parse_node_version("20.12.1"),
670 Some(NodeVersion {
671 major: 20,
672 minor: 12,
673 patch: 1
674 })
675 );
676 assert_eq!(
677 parse_node_version("v24.15.0-pre"),
678 Some(NodeVersion {
679 major: 24,
680 minor: 15,
681 patch: 0
682 })
683 );
684 assert_eq!(parse_node_version("v20"), None);
685 assert_eq!(parse_node_version("not-node"), None);
686 }
687
688 #[test]
689 fn node_version_minimum_uses_minor_and_patch() {
690 assert!(parse_node_version("v20.12.0").is_some_and(|version| version >= MIN_NODE_VERSION));
691 assert!(parse_node_version("v20.12.1").is_some_and(|version| version >= MIN_NODE_VERSION));
692 assert!(parse_node_version("v21.0.0").is_some_and(|version| version >= MIN_NODE_VERSION));
693 assert!(parse_node_version("v20.11.9").is_some_and(|version| version < MIN_NODE_VERSION));
694 assert!(parse_node_version("v19.99.99").is_some_and(|version| version < MIN_NODE_VERSION));
695 }
696}