usage

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Persistent cache for the rolling 30-day usage dashboard — the aggregate report AND the per-file incremental index, both on disk. This is the safe redesign:

  • Persisted (disk) = ~/.rmote/agent/usage_month.json. Holds the version, lastRefreshMs, and a per-file index: (path, mtime, size) → {that file's full-history (date,model) contribution}. A few hundred KB → low MB (paths + day×model cells dominate). Loaded on daemon start.
  • In-memory = the same index + the running aggregate, rebuilt from the persisted index on load. GET serves the finalized (windowed) report instantly.

Cold/full scans happen ONLY when the cache file is missing, corrupt, the schema version mismatches, or (rare) the aggregate is nil. A normal `brm` loads the index, so the first Refresh after restart is already incremental (~ms) — no multi-second scan. Unchanged files are skipped; changed/new files are re-parsed and their contribution REPLACED (subtract old, merge new); deleted/aged-out files (>30d mtime, never seen by the walk) are dropped.

The window is ROLLING 30 days (not calendar month), so there is no month-rollover cold reset — the window slides and finalize() re-applies it each refresh. Per-file contributions are the file's FULL history (window-independent), so subtract/merge stay exact as the window moves.

Safety vs. the old saga: this stores per-file (date,model)-bucketed contributions (one tiny struct per cell), never the 175k per-line records (~80MB), and loads once at startup with none of the eviction/reload churn that spiked to 300MB. No polling, no background tick — refresh only on explicit POST.

On-demand rolling 30-day usage — types + accumulator. The wire shape (monthReport) matches the old UsageReport Codable so the restored dashboard decodes it unchanged. Scanning/caching lives in month_cache.go; this file holds the pure data structures shared by the cache and the per-file parsers.

The window is a rolling 30 days (not calendar month), so the dashboard's Daily/Weekly/Monthly tabs can all slice one scan: Daily=today, Weekly=last 7d, Monthly=last 30d. byDay + byDayModel are keyed by ABSOLUTE date ("2006-01-02") — day-of-month keying would collide across months.

Per-file contributions are parsed as the file's FULL history (no since filter) so a contribution is window-independent: subtract/merge stay exact as the 30-day window slides, and finalize() applies the window. The walk bounds the index by skipping files with mtime older than the window (they carry no in-window turns).

Memory-safety invariant (the lesson from the dashboard's idle-memory saga): the persisted index holds one tiny (date,model)-bucketed struct per transcript file — never the ~175k per-line records that cost ~80MB, and never the 20MB blob that spiked to 300MB. No polling, no background tick.

Per-agent transcript parsers for the on-demand month view (month_total.go). Each parser streams one transcript file and folds its in-month tokens into a shared *monthAccum. None of them retain per-record state — the accum's buckets are the only thing that survives past a line loop.

Field names + paths reused verbatim from the deleted report.go / codex_usage.go / grok_usage.go (verified against on-host transcripts):

Claude ~/.claude/projects/**/*.jsonl
  type=="assistant", message.usage.{input,output,cache_read,cache_creation}_tokens
  message.model, timestamp (RFC3339Nano)

Codex  ~/.codex/sessions/**/*.jsonl   (ONE cumulative record per session)
  session_meta.payload.timestamp  → session date
  turn_context.payload.model      → model (last wins)
  event_msg payload.type=="token_count" → payload.info.total_token_usage
    {input_tokens, output_tokens, total_tokens(=in+out)}

Grok   ~/.grok/sessions/<cwd>/<uuid>/signals.json
  contextTokensUsed (context SNAPSHOT, not throughput) + primaryModelId
  dated by file mtime

Pi (earendil-works pi coding-agent) usage for the rolling 30-day dashboard. Each pi session is a JSONL tree at ~/.pi/agent/sessions/<dir>/<ISOts>_<uuid>.jsonl. Assistant messages carry a nested `message.usage` object:

{"type":"message","id":...,"timestamp":"2026-08-11T16:00:47.572Z",
 "message":{"role":"assistant","model":"glm-5.2",
           "usage":{"input":1612,"output":2269,"cacheRead":62336,
                    "cacheWrite":0,"reasoning":2047,"totalTokens":66217,
                    "cost":{...}}}}

totalTokens = input+output+cacheRead+cacheWrite (reasoning excluded — same convention codex uses for its reasoning tokens). The four buckets map onto the accum's input/output/cacheRead/cacheCreate (pi's cacheWrite = cache creation). cost is ignored: BYO-key providers report 0 and the daemon never prices. The line-level `timestamp` is ISO-8601 (the nested message.timestamp is epoch-ms; either resolves to the same day bucket — we use the line level for consistency with the Claude parser).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func FetchZaiUsage

func FetchZaiUsage() ([]byte, error)

FetchZaiUsage returns the raw z.ai quota JSON. Returns (nil, nil) when the host has no BYOK token configured — the handler maps that to 404. A non-nil error means the upstream call failed (timeout, non-2xx); the handler maps that to 502 so iOS keeps the last good value rather than retrying tightly.

func GetCachedMonth

func GetCachedMonth() *monthReport

GetCachedMonth returns the cached report WITHOUT scanning. Before the first refresh it is an empty report with lastRefreshMs=0 (iOS bootstraps a refresh when it sees that sentinel). Takes the lock only briefly.

func InvalidateClaudeQuotaCache

func InvalidateClaudeQuotaCache()

InvalidateClaudeQuotaCache clears the cached Claude quota (tests + future force-refresh entry point).

func InvalidateCodexQuotaCache

func InvalidateCodexQuotaCache()

InvalidateCodexQuotaCache clears the cached codex quota. For tests + future "rescan" entry points.

func InvalidateGrokQuotaCache

func InvalidateGrokQuotaCache()

InvalidateGrokQuotaCache clears the cached grok quota. For tests + future "rescan" entry points.

func InvalidateZaiCache

func InvalidateZaiCache()

InvalidateZaiCache clears the cached z.ai response. For tests + a future "force refresh" entry point.

func RefreshMonth

func RefreshMonth() *monthReport

RefreshMonth performs an incremental rescan and returns the fresh report. Cold (full) scan only when agg is nil — i.e. the cache file was missing, corrupt, or the schema version changed. Otherwise only changed/new files are re-parsed and files no longer seen (deleted/aged past the window) are dropped. The rolling window slides each call; finalize re-applies it. Holds the lock for the scan duration (acceptable: refresh is on-demand and rare; warm refreshes are ms).

func RegisterPaymentDetector

func RegisterPaymentDetector(agent string, d PaymentDetector)

RegisterPaymentDetector adds an agent's detector to the registry. Called from init(); idempotent.

func RegisterQuotaReader

func RegisterQuotaReader(agent string, r QuotaReader)

RegisterQuotaReader adds an agent's quota reader to the registry. Idempotent — late init() calls replace earlier registrations (useful in tests).

Types

type PaymentDetector

type PaymentDetector interface {
	Detect() PaymentModel
}

PaymentDetector returns the auto-detected (pre-override) model for one agent. Agents with no registered detector default to subscription.

type PaymentModel

type PaymentModel string

PaymentModel is an agent's billing classification. Values match iOS's PaymentModelWire.model / PaymentModel.rawValue decoders exactly.

const (
	PaymentByokZai      PaymentModel = "byokZai"      // Claude Code BYOK via z.ai — quota endpoint + cost
	PaymentByokProxy    PaymentModel = "byokProxy"    // BYOK via a paid proxy — cost only, no quota
	PaymentSubscription PaymentModel = "subscription" // flat-fee sub (Claude Max / ChatGPT / Grok)
	PaymentUnknown      PaymentModel = "unknown"
)

type PaymentModelWire

type PaymentModelWire struct {
	Agent       string `json:"agent"`
	Model       string `json:"model"`
	DisplayName string `json:"displayName"`
	Detected    string `json:"detected"`
	ShowsCost   bool   `json:"showsCost"`
	ShowsQuota  bool   `json:"showsQuota"`
}

PaymentModelWire is the per-agent row iOS's PaymentModelWire Codable reads. JSON tags mirror the Swift field names exactly (agent/model/displayName/ detected/showsCost/showsQuota). showsCost + showsQuota are concrete bools (no omitempty) so iOS always receives the column gates, matching the Mac server's wire shape.

func ResolvedPaymentModels

func ResolvedPaymentModels() []PaymentModelWire

ResolvedPaymentModels returns the wire list the iOS dashboard + chip read to decide which columns/chips to render. Order is stable (knownAgents order) so iOS's layout doesn't reshuffle between polls.

func SavePaymentOverride

func SavePaymentOverride(agent, model string) ([]PaymentModelWire, error)

SavePaymentOverride writes a single agent override atomically into ~/.rmote/usage-models.json (read-modify-write; tmp + rename in the same dir). Returns the resolved wire list so the PUT handler can echo it directly.

type Quota

type Quota struct {
	Percent      float64       `json:"percent"`
	ResetEpochMs int64         `json:"resetEpochMs"`
	Period       string        `json:"period,omitempty"`
	Windows      []QuotaWindow `json:"windows,omitempty"`

	// Source identifies where this reading came from so a fallback (e.g. the
	// OAuth→log-scrape path in codex/grok) is visible to the user rather than
	// indistinguishable from authoritative data. Values: "oauth" | "log-scrape"
	// | "cache". Omitted (empty) on legacy/z.ai paths that never fall back.
	Source string `json:"source,omitempty"`
}

Quota is the wire shape iOS's GrokQuotaResponse Codable decodes. Both /api/grok/quota and /api/codex/quota return this shape so iOS reuses one decoder (GrokQuotaService + CodexQuotaService both decode GrokQuotaResponse). All fields are optional on the iOS side, so zero-values are acceptable.

func ReadQuota

func ReadQuota(agent string) (*Quota, error)

ReadQuota looks up the agent's reader and invokes it. Returns (nil, nil) when the agent has no registered reader — the handler maps that to 404.

type QuotaReader

type QuotaReader interface {
	ReadQuota() (*Quota, error)
}

QuotaReader reads the subscription/quota state for one agent that exposes a quota endpoint. Returns (nil, nil) when no quota has been logged yet — the handler translates that into 404 ("none logged yet"), which iOS treats as an honest empty state rather than an error.

type QuotaWindow

type QuotaWindow struct {
	Label        string  `json:"label"`
	Percent      float64 `json:"percent"`
	ResetEpochMs int64   `json:"resetEpochMs"`
}

QuotaWindow is a single rate window (e.g. "5h", "1w"). iOS renders each window as its own row with % used + reset countdown. Codex exposes two (primary 5h + secondary 1w); grok exposes one (weekly).

Jump to

Keyboard shortcuts

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