codex-cost 0.1.0

Local web dashboard for inspecting Codex token usage from session logs.
Documentation
use std::{fs, path::Path};

use axum::{
    body::{Body, to_bytes},
    http::{Request, StatusCode},
};
use codex_cost::{models::UsageSummary, provider::LocalCodexProvider, server::create_app};
use tempfile::tempdir;
use tower::util::ServiceExt;

fn write_session(path: &Path, session_id: &str, cwd: &str, tokens: u64) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create fixture directory");
    }

    let session_meta = serde_json::json!({
        "timestamp": "2026-07-06T08:00:00.000Z",
        "type": "session_meta",
        "payload": {
            "id": session_id,
            "cwd": cwd,
            "thread_name": format!("Thread {session_id}"),
        },
    });
    let turn_context = serde_json::json!({
        "timestamp": "2026-07-06T08:00:30.000Z",
        "type": "turn_context",
        "payload": {
            "cwd": cwd,
            "model": "gpt-5",
            "effort": "xhigh",
        },
    });
    let token_count = serde_json::json!({
        "timestamp": "2026-07-06T08:01:00.000Z",
        "type": "event_msg",
        "payload": {
            "type": "token_count",
            "info": {
                "last_token_usage": {
                    "input_tokens": tokens / 2,
                    "cached_input_tokens": 1,
                    "output_tokens": tokens / 4,
                    "reasoning_output_tokens": 2,
                    "total_tokens": tokens,
                },
                "model_context_window": 258400,
            },
        },
    });

    fs::write(
        path,
        format!("{session_meta}\n{turn_context}\n{token_count}\n"),
    )
    .expect("write fixture session");
}

async fn response_json(app: axum::Router, method: &str, uri: &str) -> (StatusCode, UsageSummary) {
    let response = app
        .oneshot(
            Request::builder()
                .method(method)
                .uri(uri)
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
    let status = response.status();
    let body = to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("read body");
    let summary = serde_json::from_slice::<UsageSummary>(&body).expect("summary json");
    (status, summary)
}

#[tokio::test]
async fn health_route_returns_ok() {
    let root = tempdir().expect("create codex home");
    let app = create_app(LocalCodexProvider::new(Some(root.path().to_path_buf())));

    let response = app
        .oneshot(
            Request::builder()
                .uri("/health")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn index_route_uses_codex_cost_brand() {
    let root = tempdir().expect("create codex home");
    let app = create_app(LocalCodexProvider::new(Some(root.path().to_path_buf())));

    let response = app
        .oneshot(
            Request::builder()
                .uri("/")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");
    let status = response.status();
    let body = to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("read body");
    let html = String::from_utf8(body.to_vec()).expect("utf-8 html");

    assert_eq!(status, StatusCode::OK);
    assert!(html.contains("<title>Codex Cost</title>"));
    assert!(html.contains("<h1>Codex Cost</h1>"));
}

#[tokio::test]
async fn summary_route_returns_local_codex_cost_usage() {
    let root = tempdir().expect("create codex home");
    write_session(
        &root.path().join("sessions/2026/07/06/rollout-active.jsonl"),
        "active",
        "C:\\work\\active",
        21,
    );
    write_session(
        &root.path().join("archived_sessions/rollout-archived.jsonl"),
        "archived",
        "C:\\work\\archived",
        9,
    );

    let app = create_app(LocalCodexProvider::new(Some(root.path().to_path_buf())));
    let (status, summary) = response_json(app, "GET", "/api/summary").await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(summary.totals.all_time_tokens, 30);
    assert_eq!(summary.totals.event_count, 2);
    assert_eq!(summary.totals.session_count, 2);
    assert_eq!(summary.distributions.models[0].label, "gpt-5");
    assert_eq!(summary.distributions.context_windows[0].label, "258400");
}

#[tokio::test]
async fn refresh_route_rescans_files() {
    let root = tempdir().expect("create codex home");
    let app = create_app(LocalCodexProvider::new(Some(root.path().to_path_buf())));

    let (_, before) = response_json(app.clone(), "GET", "/api/summary").await;
    assert_eq!(before.totals.all_time_tokens, 0);

    write_session(
        &root.path().join("sessions/2026/07/06/rollout-late.jsonl"),
        "late",
        "C:\\work\\late",
        13,
    );

    let (status, after) = response_json(app, "POST", "/api/refresh").await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(after.totals.all_time_tokens, 13);
    assert_eq!(after.totals.event_count, 1);
}