planusage

package
v0.2.0-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 20 Imported by: 0

README

Plan usage

internal/planusage tells the person how much of their Claude and Codex plans is left, so they can choose the harness for reviews knowing what each has to spare. The daemon serves it as GET /api/plan-usage; the app shows it in its sidebar, and the desktop tray in its menu and panel.

{"plans": [{"provider": "claude", "available": true, "plan": "",
  "windows": [{"name": "5-hour", "used_percent": 38,
               "resets_at": "2026-10-26T15:00:00Z"}],
  "fetched_at": "2026-10-26T12:00:00Z"},
 {"provider": "codex", "available": false,
  "reason": "No Codex sessions found yet. Run Codex once to see its usage.",
  "windows": [], "fetched_at": "2026-10-26T12:00:00Z"}]}

A plan that cannot be read has available: false and a reason in words. The reason is always a fixed sentence: no upstream body, error text, path or token is ever in it.

Codex

When Codex is signed in with ChatGPT, the reader asks the endpoint the Codex CLI's status line uses, GET https://chatgpt.com/backend-api/wham/usage, with the tokens.access_token and tokens.account_id of $CODEX_HOME/auth.json (Authorization: Bearer and ChatGPT-Account-Id). It is undocumented and may change. rate_limit.primary_window and secondary_window become windows named by limit_window_seconds, resetting at reset_at (or after reset_after_seconds), and fetched_at is now: the figures are live. The token is handled as Claude's is: held only for the request, never logged, stored, returned or put in an error, never refreshed (that would rotate Codex's sign-in), and not sent once the exp of the JWT has passed. The client refuses redirects.

Without that sign-in (an API key, no auth.json), with an expired token, or when the request fails or answers in a shape this reader does not know, it falls back to what Codex writes into its own session files. The reader finds $CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl (CODEX_HOME defaults to ~/.codex), looks at the eight newest files, newest first, and in each reads only the last 1 MiB, scanning lines backwards for an event_msg whose payload is a token_count carrying rate_limits. primary and secondary become windows named by window_minutes (300 is "5-hour", 10080 "weekly"). The reset is resets_at (unix seconds, newer Codex) or resets_in_seconds (older Codex, counted from the snapshot). fetched_at is the snapshot's own timestamp, so the figure is as old as the last Codex turn. A window whose reset has passed since the snapshot has reset: true and a null used_percent: the old figure no longer applies. plan_type becomes the plan's name as Codex shows it: "prolite", "pro" and "promax" are "Pro 100", "Pro 200" and "Pro 500" (the monthly price), "plus" is "Plus", and an unknown value is passed on unchanged (the endpoint's plan_type is named the same way). The fallback makes no network request and reads no credential.

Claude

The reader calls the endpoint Claude Code's own /usage command uses: GET https://api.anthropic.com/api/oauth/usage with Authorization: Bearer and anthropic-beta: oauth-2025-04-20. The endpoint is undocumented and may change or disappear; any failure is shown as unavailable, never as an error. five_hour, seven_day, seven_day_opus and seven_day_sonnet become windows from utilization (percent used) and resets_at; null or malformed windows and unknown keys are ignored.

The token is the one Claude Code stored: on macOS the Keychain item "Claude Code-credentials", and otherwise (or if the item is missing) $CLAUDE_CONFIG_DIR/.credentials.json (default ~/.claude/.credentials.json), field claudeAiOauth.accessToken. Its subscriptionType and rateLimitTier name the plan as Anthropic sells it: Pro, Max 5x or Max 20x for a person, Team Standard or Team Premium (the max_5x tier) for a Team seat, and Enterprise, which is billed at API prices, without a tier. The token is held only for the request. It is never logged, stored, cached, returned by the API or included in an error, and the HTTP client refuses redirects. baum never refreshes it: refreshing would rotate Claude Code's credential. An expired token, or a 401 or 403, reads "Open Claude Code to refresh its sign-in."

The endpoint limits how often it is asked, and answers 429 once that is passed; asking again keeps it limited. After a 429 the reader leaves it alone for its Retry-After, or without one for four minutes (just under the cache below, so the next reading after it asks again), doubling with each 429 in a row, and never more than an hour. While the endpoint is limited, down or out of reach, the last reading the running daemon made stands in for up to six hours with its own fetched_at, so its age shows; a window that reset since has reset: true and no figure. A sign-in failure is never covered this way.

Which plans are served

GET /api/plan-usage serves only the plans chosen in Settings, so the sidebar and tray show the same ones. The choice, GET/PUT /api/settings/plan-usage, is the plan_usage.show object of the registry's global preferences in app.sqlite: provider to true or false, only for plans the person switched. A plan with no entry follows the backends baum uses: those of the reviews, fixes and decision capture (Auto counts the first readable plan, Claude Code before Codex; the Anthropic API and Cursor have none), so an explicit choice survives a backend change and the default follows it. The history behind the Analytics chart samples every plan regardless.

Caching

Service keeps the result for five minutes. ?refresh=1 (the app sends it when a page opens) reads again, but never more than once a minute. One mutex covers a reading, so simultaneous requests share it.

History

History samples the plans every 15 minutes while the daemon runs, through Service's cache, so Claude's endpoint is asked at most that often when no page is open, and appends what is new to the state/plan-usage-history document in the shared app.sqlite application database: the snapshot time, provider, window name, percent used and reset time. No token, account or plan name is stored. A figure is kept when it changed, when its window's reset changed, or an hour after the last kept one; a reading no newer than the last (Codex's snapshot did not move) is not. Samples older than 90 days are dropped. The analytics page draws them (analytics).

Tests use fixture session files, an httptest server and an injected credential source and clock; they never read the real ~/.claude or ~/.codex.

Documentation

Overview

Package planusage reports how much of the Claude and Codex plans the person has left, for the app's settings. See README.md.

Index

Constants

View Source
const (
	// SampleInterval is how often History reads the plans while the daemon
	// runs.
	SampleInterval = 15 * time.Minute
	// HistoryRetention is how long a sample is kept.
	HistoryRetention = 90 * 24 * time.Hour
)
View Source
const (
	// CacheTTL is how long a result serves without asking again.
	CacheTTL = 5 * time.Minute
	// MinRefresh is the shortest time between two reads, even on demand.
	MinRefresh = 60 * time.Second
)

Variables

This section is empty.

Functions

This section is empty.

Types

type Claude

type Claude struct {
	// Credential finds Claude Code's token; nil reads its stored credential.
	Credential func(ctx context.Context) (Credential, error)
	// Client makes the request; nil uses a client that refuses redirects.
	Client *http.Client
	// URL overrides the endpoint in tests.
	URL string
	Now func() time.Time
	// contains filtered or unexported fields
}

Claude asks Anthropic for the usage of the plan Claude Code is signed in to.

func NewClaude

func NewClaude() *Claude

func (*Claude) Read

func (c *Claude) Read(ctx context.Context) Plan

Read never refreshes the token: that would rotate Claude Code's credential. A failure that should pass (the endpoint limits the rate, is down or cannot be reached) serves the last reading for up to staleFor, and after a 429 the endpoint is left alone for a while: asking again only keeps the limit.

type Codex

type Codex struct {
	// Home is CODEX_HOME; empty means $CODEX_HOME, else ~/.codex.
	Home string
	// Credential finds Codex's sign-in; nil reads Home's auth.json.
	Credential func(ctx context.Context) (CodexCredential, error)
	// Client makes the request; nil uses a client that refuses redirects.
	Client *http.Client
	// URL overrides the endpoint in tests.
	URL string
	Now func() time.Time
}

Codex asks Codex's usage endpoint with the ChatGPT sign-in Codex stored, and otherwise (an API key sign-in, an expired token, no network) reads the newest rate-limit snapshot Codex wrote to its session files.

func NewCodex

func NewCodex() *Codex

func (*Codex) Read

func (c *Codex) Read(ctx context.Context) Plan

type CodexCredential

type CodexCredential struct {
	Token     string
	AccountID string
	ExpiresAt time.Time // zero when unknown
}

CodexCredential is the ChatGPT sign-in Codex stored. Like Claude's token it never leaves this package: it is not logged, stored, serialised or put in an error.

func StoredCodexCredential

func StoredCodexCredential(home string) (CodexCredential, error)

StoredCodexCredential reads $CODEX_HOME/auth.json. A sign-in with an API key has no ChatGPT token, and so no plan to read.

type Credential

type Credential struct {
	Token     string
	ExpiresAt time.Time // zero when unknown
	// Plan is the plan's name as Anthropic sells it ("Pro", "Max 5x",
	// "Team Premium"), from what Claude Code recorded with the token; empty
	// when it did not say.
	Plan string
}

Credential is the access token Claude Code stored. It never leaves this package: it is not logged, stored, serialised or put in an error.

func StoredCredential

func StoredCredential(ctx context.Context) (Credential, error)

StoredCredential reads Claude Code's credential: on macOS the Keychain item first, then $CLAUDE_CONFIG_DIR/.credentials.json (default ~/.claude/.credentials.json).

type History

type History struct {
	Path    string
	Service *Service
	Now     func() time.Time
	// Interval defaults to SampleInterval.
	Interval time.Duration
	// contains filtered or unexported fields
}

History samples the plans periodically and keeps readings in the application database, for the analytics page's plan usage over time. Only figures are kept: no token, account or plan name. A reading is stored when its figure changed, when its window reset, or an hour after the last stored one; older than HistoryRetention it is dropped.

func (*History) Run

func (h *History) Run(ctx context.Context)

Run samples at once and then every Interval until ctx ends.

func (*History) Sample

func (h *History) Sample(ctx context.Context) error

Sample reads the plans (through the service's cache) and records what is new.

func (*History) Since

func (h *History) Since(t time.Time) []Sample

Since returns the readings taken at or after t, oldest first.

type Plan

type Plan struct {
	Provider  string    `json:"provider"`
	Available bool      `json:"available"`
	Reason    string    `json:"reason,omitempty"`
	Plan      string    `json:"plan,omitempty"`
	Windows   []Window  `json:"windows"`
	FetchedAt time.Time `json:"fetched_at"`
}

Plan is the usage of one provider's plan, or why it is unavailable.

type Sample

type Sample struct {
	At          time.Time  `json:"at"`
	Provider    string     `json:"provider"`
	Window      string     `json:"window"`
	UsedPercent float64    `json:"used_percent"`
	ResetsAt    *time.Time `json:"resets_at,omitempty"`
}

Sample is one reading of one window: when the figure was true (the snapshot's time, which for Codex is its last turn), and how much was used.

type Service

type Service struct {
	Sources []Source
	Now     func() time.Time
	// contains filtered or unexported fields
}

Service caches the plans of its sources.

func NewService

func NewService() *Service

NewService reads Claude Code's usage endpoint and Codex's session files.

func (*Service) Handler

func (s *Service) Handler() http.Handler

Handler serves GET /api/plan-usage; ?refresh=1 asks for a newer reading, which the cache still limits to one per MinRefresh.

func (*Service) Plans

func (s *Service) Plans(ctx context.Context, refresh bool) []Plan

Plans returns the cached plans, reading again when they are older than CacheTTL, or older than MinRefresh when refresh is set.

type Source

type Source interface {
	Read(ctx context.Context) Plan
}

Source reads one provider's usage. A failure is reported as an unavailable Plan whose Reason is a fixed sentence, never an error carrying upstream text.

type Window

type Window struct {
	// Name is "5-hour", "weekly", "weekly Opus", ... or the window length for
	// a Codex window of another size.
	Name string `json:"name"`
	// UsedPercent is nil when the window's reset passed after the snapshot,
	// so its old figure no longer applies.
	UsedPercent *float64 `json:"used_percent"`
	// ResetsAt is nil when the source did not say.
	ResetsAt *time.Time `json:"resets_at"`
	// Reset reports that the window reset after the snapshot was taken.
	Reset bool `json:"reset,omitempty"`
}

Window is one usage window of a plan.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL