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