Skip to main content

codex_ops/
doctor.rs

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}