Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 61 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,17 +796,21 @@ estimate can still vary with reasoning effort, model mix, caching, prompt
shape, and backend quota weighting, so compare ranges and sample counts rather
than treating the midpoint as a fixed entitlement.

Samples remain process-local and are never written to the preferences file, so
evidence cannot leak from one login into another on a later run. During a run,
Codexometer requests the current account email from the same local app-server,
immediately reduces it to an in-memory one-way fingerprint, and uses that only
to separate account observations. The email and fingerprint are not persisted.
Estimator samples remain process-local and are never written to the preferences
or usage-history files, so learned price evidence cannot leak from one login
into another on a later run. Codexometer requests the current account email from
the same local app-server and immediately reduces it to a one-way fingerprint.
The email is never persisted; the fingerprint is stored only in the separate
numeric usage ledger so histories remain isolated across restarts. Cached data
is not selected until the current invocation verifies that same fingerprint.
If an older app-server cannot provide an account identity, the estimate fails
closed as `ACCOUNT ATTRIBUTION UNKNOWN` rather than mixing indistinguishable
accounts.

The privacy trade-off is that quitting Codexometer discards every learned
sample and quota anchor. On restart it can reconstruct cumulative priced usage
The estimator trade-off is that quitting Codexometer discards every learned
sample and its in-memory learning anchor. The separate ledger retains content-free
quota observations for future history views, but does not silently treat them as
complete price evidence. On restart it can reconstruct cumulative priced usage
from local rollout telemetry, but the current quota percentage and cost become
a new baseline: the display returns to `LEARNING` and needs another five clean
percentage points of movement before producing an estimate. Medium confidence
Expand Down Expand Up @@ -945,21 +949,30 @@ As in Codex's chart, the available display range is 52 Sunday-based weeks ending
in the current UTC week. Missing dates in a supplied history count as zero;
invalid dates, negative values, future dates, and dates outside that range are
ignored. Duplicate dates are summed. The compact summary shows the server's
separately reported lifetime tokens, peak daily tokens, and current streak when
available (`—` otherwise).
separately reported lifetime tokens, peak daily tokens, current and longest
streaks, and longest running turn when available (`—` otherwise).

History refreshes when you enter Usage, on the normal refresh interval while
Usage is selected, or with `r` / the Refresh button. These are server-side account
statistics, **not live Sessions telemetry**: updates may lag ongoing work.
Older CLI versions or unsupported accounts can return an unavailable/error state;
missing history is never silently presented as zero. A failed refresh labels
previously fetched data **STALE**, and a detected account change discards it.
Usage is selected, or with `r` / the Refresh button. OpenAI's account-wide daily
totals are authoritative and can fill days when Codexometer was not running.
Codexometer also rescans retained local Codex rollouts for content-free token
counts, including input, cached input, output and reasoning-token detail. Local
counts are shown as attribution against the OpenAI total and are never added to
it. A compact provenance line reports **OPENAI**, **RECOVERED**, or **PARTIAL**
and the percentage of the OpenAI total attributable to retained local history.

Older CLI versions or unsupported accounts can return an unavailable/error
state; unavailable history is never silently presented as an empty account. A
failed refresh uses the last verified persisted result and labels it **STALE**.
Account fingerprints keep histories isolated, and a newly verified account is
never shown another account's cached Usage data.

The endpoint currently exposes daily **total tokens**, not historical per-model,
input/output/cache splits, quota percentages, or dollar spend. Consequently this
tab does not infer historical API-equivalent cost or combine these totals with
the Sessions tab's local counters. History is held in memory only; restarting fetches it
again from Codex. `--demo` includes sample history for previewing the charts.
input/output/cache splits, quota percentages, or dollar spend. The finer token
breakdown is therefore local recovery and can be partial; activity from another
device, cloud task, or deleted rollout remains only in the OpenAI total. The tab
does not infer historical API-equivalent cost. `--demo` includes sample history
for previewing the charts.

### Other top-level views

Expand Down Expand Up @@ -1519,11 +1532,10 @@ any command; restart the demo to reset its approval.

### Saved presentation preferences

Codexometer stores only the selected theme, main tab, Quota view, benchmark filter,
benchmark ranking weight, and the Sessions context hide/show preference.
No quota estimate or snapshot, raw session telemetry,
benchmark result, message content, credential, session ID, email, account
fingerprint, or account ID is written. The small JSON file uses the
Codexometer's presentation preferences store only the selected theme, main tab,
Quota view, benchmark filter, benchmark ranking weight, and the Sessions context
hide/show preference. No benchmark result, message content, credential, session
ID, email, or account ID is written to that file. The small JSON file uses the
platform-standard user configuration directory:

- Linux: `$XDG_CONFIG_HOME/codexometer/preferences.json`, normally
Expand All @@ -1537,6 +1549,32 @@ tab and remembers your Quota view separately. First launch defaults to Quota →
Bars; older preferences without a main tab reopen the saved Quota view.
Restoring a tab does not resume a benchmark run or reopen an approval dialog.

### Persistent usage history

Usage history is stored separately as `usage-history.json` in the same
platform-standard `codexometer` directory. This versioned ledger contains only:

- OpenAI daily account token totals and optional account summary metrics;
- locally recovered daily numeric token aggregates;
- current quota percentage observations, reset boundaries and window lengths;
- a one-way account fingerprint used to prevent histories from being mixed.

It never stores prompts, replies, commands, source content, working-directory
paths, session IDs, email addresses, credentials, or authentication tokens.
History is retained for approximately 400 days. Writes use a private temporary
file, an atomic replacement, and a cross-process lock so terminal and web
Codexometer instances cannot partially overwrite one another. A damaged or
unwritable ledger does not prevent current OpenAI data from being displayed;
it only disables persistence until the file is repaired or removed.

On startup Codexometer reconciles three layers: persisted history, retained
local rollout token events, and the newest OpenAI daily buckets. Repeated scans
replace the bounded local aggregate rather than incrementing it, so restarts and
concurrent invocations do not double-count usage. If Codexometer was closed,
OpenAI can backfill account totals and retained rollouts can restore local detail.
If neither source contains a missed period, Codexometer does not invent a
per-session, per-model, quota-percentage, or cost breakdown for it.

### Benchmark authentication and usage boundary

> [!IMPORTANT]
Expand Down
72 changes: 65 additions & 7 deletions internal/codex/account_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import (
// local session telemetry. Nil buckets mean unavailable; an empty list means
// the server returned no activity. Summary fields are independently optional.
type AccountUsage struct {
Summary AccountUsageSummary `json:"summary"`
DailyUsageBuckets []AccountUsageDay `json:"dailyUsageBuckets"`
AccountFingerprint string `json:"-"`
FetchedAt time.Time `json:"-"`
Summary AccountUsageSummary `json:"summary"`
DailyUsageBuckets []AccountUsageDay `json:"dailyUsageBuckets"`
Coverage AccountUsageCoverage `json:"coverage"`
AccountFingerprint string `json:"-"`
FetchedAt time.Time `json:"-"`
// Persisted means the result has been reconciled with Codexometer's local
// history. Stale means the live refresh failed and this is the most recent
// verified account cache rather than a fresh OpenAI response.
Persisted bool `json:"persisted"`
Stale bool `json:"stale"`
}

type AccountUsageSummary struct {
Expand All @@ -24,12 +30,64 @@ type AccountUsageSummary struct {
}

type AccountUsageDay struct {
StartDate string `json:"startDate"`
Tokens int64 `json:"tokens"`
StartDate string `json:"startDate"`
Tokens int64 `json:"tokens"`
LocalTokens int64 `json:"localTokens,omitempty"`
InputTokens int64 `json:"inputTokens,omitempty"`
CachedInputTokens int64 `json:"cachedInputTokens,omitempty"`
OutputTokens int64 `json:"outputTokens,omitempty"`
ReasoningTokens int64 `json:"reasoningTokens,omitempty"`
Provenance string `json:"provenance,omitempty"`
}

// AccountUsageCoverage explains how much of the account-wide OpenAI total can
// also be attributed to retained local Codex rollouts. LocalTokens is never
// added to OpenAITokens: it is a subset/comparison, not another usage source.
type AccountUsageCoverage struct {
Status string `json:"status,omitempty"`
OpenAITokens int64 `json:"openaiTokens,omitempty"`
LocalTokens int64 `json:"localTokens,omitempty"`
AttributedPct int `json:"attributedPercent,omitempty"`
OpenAIDays int `json:"openaiDays,omitempty"`
RecoveredDays int `json:"recoveredDays,omitempty"`
}

// RecoveredUsageDay is reconstructed from content-free token_count events in
// retained local Codex rollouts. Cached input is part of input, not additional
// usage, and TotalTokens remains the comparison value used against OpenAI.
type RecoveredUsageDay struct {
StartDate string `json:"startDate"`
TotalTokens int64 `json:"totalTokens"`
InputTokens int64 `json:"inputTokens,omitempty"`
CachedInputTokens int64 `json:"cachedInputTokens,omitempty"`
OutputTokens int64 `json:"outputTokens,omitempty"`
ReasoningTokens int64 `json:"reasoningTokens,omitempty"`
}

func (c Client) FetchAccountUsage(ctx context.Context) (AccountUsage, error) {
var history AccountUsage
_, err := c.fetch(ctx, nil, &history)
return history, err
if err != nil {
if c.History != nil {
if cached, cacheErr := c.History.Latest(); cacheErr == nil && cached.AccountFingerprint != "" {
cached.Stale = true
return cached, nil
}
}
return history, err
}
if c.History == nil {
return history, nil
}
var recovered []RecoveredUsageDay
if c.LiveUsage != nil {
recovered, _ = c.LiveUsage.RecoverDailyUsage(ctx, time.Now().UTC().AddDate(0, 0, -historyRetentionDays))
}
reconciled, reconcileErr := c.History.Reconcile(history, recovered)
if reconcileErr != nil {
// A local cache failure must not turn a valid OpenAI response into an
// unavailable Usage screen.
return history, nil
}
return reconciled, nil
}
20 changes: 20 additions & 0 deletions internal/codex/account_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package codex
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -45,6 +46,25 @@ func TestFetchAccountUsage(t *testing.T) {
}
}

func TestFetchAccountUsageFallsBackToVerifiedPersistedHistory(t *testing.T) {
t.Setenv("CODEXOMETER_FAKE_APP_SERVER", "1")
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
store := &HistoryStore{Path: filepath.Join(t.TempDir(), "usage.json")}
client := Client{Binary: exe, History: store}
fresh, err := client.FetchAccountUsage(context.Background())
if err != nil || !fresh.Persisted || fresh.Stale {
t.Fatalf("fresh history = %+v, %v", fresh, err)
}
t.Setenv("CODEXOMETER_FAKE_USAGE_ERROR", "1")
cached, err := client.FetchAccountUsage(context.Background())
if err != nil || !cached.Persisted || !cached.Stale || cached.AccountFingerprint != fresh.AccountFingerprint || len(cached.DailyUsageBuckets) != 1 {
t.Fatalf("cached history = %+v, %v", cached, err)
}
}

func TestAccountUsageRequiresVerifiedAccount(t *testing.T) {
t.Setenv("CODEXOMETER_FAKE_APP_SERVER", "1")
t.Setenv("CODEXOMETER_FAKE_ACCOUNT_ERROR", "1")
Expand Down
10 changes: 9 additions & 1 deletion internal/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const requestTimeout = 15 * time.Second
type Client struct {
Binary string
LiveUsage *LiveUsageReader
// History persists content-free account totals and quota observations so
// both terminal and web presentations survive process restarts.
History *HistoryStore
// QuotaSteps is an opt-in launch-time policy for lowering the model profile
// of loaded sessions as quota consumption crosses configured thresholds.
QuotaSteps []QuotaStep
Expand Down Expand Up @@ -66,7 +69,12 @@ type rpcResponse struct {
// Fetch starts a short-lived app-server, performs the initialization handshake,
// reads the authenticated account limits, and shuts the server down again.
func (c Client) Fetch(ctx context.Context) (Snapshot, error) {
return c.fetch(ctx, nil, nil)
snapshot, err := c.fetch(ctx, nil, nil)
if err == nil && c.History != nil {
// Persistence failure must not hide current authoritative quota data.
_ = c.History.RecordQuota(snapshot)
}
return snapshot, err
}

type resetAttempt struct {
Expand Down
11 changes: 10 additions & 1 deletion internal/codex/filelock_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

package codex

import "errors"
import (
"errors"
"os"
)

const fileLockSupported = false

func fileLockHeld(string) (bool, error) {
return false, errors.ErrUnsupported
}

func withHistoryFileLock(_ string, action func() error) error { return action() }

func replaceHistoryFile(from, to string) error { return os.Rename(from, to) }

func syncHistoryDirectory(string) error { return nil }
28 changes: 28 additions & 0 deletions internal/codex/filelock_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package codex
import (
"errors"
"os"
"path/filepath"

"golang.org/x/sys/unix"
)
Expand All @@ -28,3 +29,30 @@ func fileLockHeld(path string) (bool, error) {
}
return false, nil
}

func withHistoryFileLock(path string, action func() error) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer file.Close()
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil {
return err
}
defer unix.Flock(int(file.Fd()), unix.LOCK_UN) //nolint:errcheck -- best-effort after action
return action()
}

func replaceHistoryFile(from, to string) error { return os.Rename(from, to) }

func syncHistoryDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return err
}
defer directory.Close()
return directory.Sync()
}
42 changes: 42 additions & 0 deletions internal/codex/filelock_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package codex
import (
"errors"
"os"
"path/filepath"
"time"

"golang.org/x/sys/windows"
)
Expand Down Expand Up @@ -37,3 +39,43 @@ func fileLockHeld(path string) (bool, error) {
}
return false, nil
}

func withHistoryFileLock(path string, action func() error) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer file.Close()
overlapped := new(windows.Overlapped)
deadline := time.Now().Add(5 * time.Second)
for {
err = windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped)
if err == nil {
break
}
if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) || time.Now().After(deadline) {
return err
}
time.Sleep(10 * time.Millisecond)
}
defer windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, overlapped) //nolint:errcheck -- best-effort after action
return action()
}

func replaceHistoryFile(from, to string) error {
fromPath, err := windows.UTF16PtrFromString(from)
if err != nil {
return err
}
toPath, err := windows.UTF16PtrFromString(to)
if err != nil {
return err
}
return windows.MoveFileEx(fromPath, toPath, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
}

// MOVEFILE_WRITE_THROUGH flushes the replacement before returning.
func syncHistoryDirectory(string) error { return nil }
Loading
Loading