From 70fc0fc9c2913dac8d63c6d6ae359b993b571461 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Fri, 31 Jul 2026 08:35:05 +0000 Subject: [PATCH] fix(daemon): restore Codex usage synchronization --- README.md | 12 ++ daemon/codex_ratelimit.go | 168 ++++++++++++++++++++++------ daemon/codex_ratelimit_more_test.go | 2 +- daemon/codex_ratelimit_test.go | 152 +++++++++++++------------ daemon/codex_usage_sync.go | 14 ++- daemon/codex_usage_sync_test.go | 17 ++- docs/CONFIG.md | 18 +++ 7 files changed, 264 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 87e874d..092a6f5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ shelltime codex install - Syncs your command history to ShellTime so you can search and analyze it. - Runs a background daemon for low-latency, non-blocking sync. - Forwards Claude Code and OpenAI Codex telemetry through OTEL. +- Syncs the rate-limit windows and extra-credit status that Codex currently reports. - Shows a live Claude Code statusline with cost, quota, time, and context usage. - Syncs supported dotfiles to and from the ShellTime service. @@ -176,6 +177,17 @@ Example output: For formatting details and platform notes, see [docs/CC_STATUSLINE.md](docs/CC_STATUSLINE.md). +## Codex Usage Tracking + +ShellTime receives Codex data through two independent paths: + +- `shelltime codex install` configures Codex OTEL export for sessions, tokens, tool activity, and cost telemetry. +- The running `shelltime-daemon` reads your local Codex login, fetches the rate-limit windows and credit status currently returned by Codex, and syncs that summary when the daemon starts and every 10 minutes afterward. + +Quota sync requires both a ShellTime login (`shelltime auth`) and a ChatGPT-authenticated Codex installation. ShellTime reads the Codex access token from `~/.codex/auth.json` only for the direct request to Codex; the token stays on your machine, and only the returned plan, quota windows, percentages, reset times, and credit summary are sent to ShellTime. + +Codex decides which windows are present. ShellTime displays the windows returned by Codex instead of assuming that every account has a fixed 5-hour window. + ## Security and Privacy - **Data masking** redacts sensitive command content before it leaves your machine. diff --git a/daemon/codex_ratelimit.go b/daemon/codex_ratelimit.go index 64c9d07..00a6830 100644 --- a/daemon/codex_ratelimit.go +++ b/daemon/codex_ratelimit.go @@ -1,6 +1,7 @@ package daemon import ( + "bytes" "context" "encoding/json" "errors" @@ -8,12 +9,16 @@ import ( "net/http" "os" "path/filepath" + "sort" + "strings" "sync" "time" ) const codexUsageCacheTTL = 10 * time.Minute +const codexUsageEndpoint = "https://chatgpt.com/backend-api/wham/usage" + var ( loadCodexAuthFunc = loadCodexAuth fetchCodexUsageFunc = fetchCodexUsage @@ -31,11 +36,20 @@ var ( type CodexRateLimitData struct { Plan string Windows []CodexRateLimitWindow + Credits *CodexUsageCredits +} + +// CodexUsageCredits holds the extra-credit state returned by Codex. +type CodexUsageCredits struct { + HasCredits bool `json:"has_credits"` + Unlimited bool `json:"unlimited"` + Balance string `json:"balance"` } // CodexRateLimitWindow holds a single rate limit window from the Codex API type CodexRateLimitWindow struct { LimitID string + LimitName string UsagePercentage float64 ResetAt int64 // Unix timestamp WindowDurationMinutes int @@ -162,15 +176,28 @@ func loadCodexAuth() (*codexAuthData, error) { // whamUsageResponse maps the response from chatgpt.com/backend-api/wham/usage type whamUsageResponse struct { - PlanType string `json:"plan_type"` - RateLimit *whamRateLimitCategory `json:"rate_limit"` - CodeReviewRateLimit *whamRateLimitCategory `json:"code_review_rate_limit"` - AdditionalRateLimits map[string]*whamRateLimitCategory `json:"additional_rate_limits"` + PlanType string `json:"plan_type"` + RateLimit *whamRateLimitCategory `json:"rate_limit"` + CodeReviewRateLimit *whamRateLimitCategory `json:"code_review_rate_limit"` + AdditionalRateLimits json.RawMessage `json:"additional_rate_limits"` + Credits *whamCredits `json:"credits"` +} + +type whamAdditionalRateLimit struct { + LimitName string `json:"limit_name"` + MeteredFeature string `json:"metered_feature"` + RateLimit *whamRateLimitCategory `json:"rate_limit"` +} + +type whamCredits struct { + HasCredits bool `json:"has_credits"` + Unlimited bool `json:"unlimited"` + Balance string `json:"balance"` } type whamRateLimitCategory struct { - Allowed bool `json:"allowed"` - LimitReached bool `json:"limit_reached"` + Allowed bool `json:"allowed"` + LimitReached bool `json:"limit_reached"` PrimaryWindow *whamRateLimitWindow `json:"primary_window"` SecondaryWindow *whamRateLimitWindow `json:"secondary_window"` } @@ -184,15 +211,22 @@ type whamRateLimitWindow struct { // fetchCodexUsage calls the Codex usage API and returns rate limit data. func fetchCodexUsage(ctx context.Context, auth *codexAuthData) (*CodexRateLimitData, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://chatgpt.com/backend-api/wham/usage", nil) + client := &http.Client{Timeout: 5 * time.Second} + return fetchCodexUsageFromEndpoint(ctx, auth, codexUsageEndpoint, client) +} + +func fetchCodexUsageFromEndpoint(ctx context.Context, auth *codexAuthData, endpoint string, client *http.Client) (*CodexRateLimitData, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+auth.AccessToken) req.Header.Set("User-Agent", "shelltime-daemon") + if auth.AccountID != "" { + req.Header.Set("ChatGPT-Account-ID", auth.AccountID) + } - client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err @@ -211,47 +245,111 @@ func fetchCodexUsage(ctx context.Context, auth *codexAuthData) (*CodexRateLimitD return nil, fmt.Errorf("failed to decode codex usage response: %w", err) } - var windows []CodexRateLimitWindow - type categoryEntry struct { - name string - category *whamRateLimitCategory + return mapWhamUsageResponse(&usage) +} + +func mapWhamUsageResponse(usage *whamUsageResponse) (*CodexRateLimitData, error) { + windows := make([]CodexRateLimitWindow, 0, 4) + windows = appendWhamCategoryWindows(windows, "rate_limit", "", usage.RateLimit) + windows = appendWhamCategoryWindows(windows, "code_review_rate_limit", "", usage.CodeReviewRateLimit) + + additional, legacy, err := decodeAdditionalRateLimits(usage.AdditionalRateLimits) + if err != nil { + return nil, fmt.Errorf("failed to decode codex additional rate limits: %w", err) } - for _, cat := range []categoryEntry{ - {"rate_limit", usage.RateLimit}, - {"code_review_rate_limit", usage.CodeReviewRateLimit}, - } { - if cat.category == nil { - continue + for _, item := range additional { + identifier := normalizeAdditionalLimitID(item.MeteredFeature) + if identifier == "" { + identifier = normalizeAdditionalLimitID(item.LimitName) } - if w := cat.category.PrimaryWindow; w != nil { - windows = append(windows, mapWhamWindow(cat.name, "primary", w)) - } - if w := cat.category.SecondaryWindow; w != nil { - windows = append(windows, mapWhamWindow(cat.name, "secondary", w)) + if identifier == "" { + identifier = "unnamed" } + windows = appendWhamCategoryWindows(windows, "additional_rate_limit:"+identifier, item.LimitName, item.RateLimit) } - for name, cat := range usage.AdditionalRateLimits { - if cat == nil { - continue + legacyNames := make([]string, 0, len(legacy)) + for name := range legacy { + legacyNames = append(legacyNames, name) + } + sort.Strings(legacyNames) + for _, name := range legacyNames { + windows = appendWhamCategoryWindows(windows, name, "", legacy[name]) + } + + result := &CodexRateLimitData{ + Plan: usage.PlanType, + Windows: windows, + } + if usage.Credits != nil { + result.Credits = &CodexUsageCredits{ + HasCredits: usage.Credits.HasCredits, + Unlimited: usage.Credits.Unlimited, + Balance: usage.Credits.Balance, } - if w := cat.PrimaryWindow; w != nil { - windows = append(windows, mapWhamWindow(name, "primary", w)) + } + return result, nil +} + +func decodeAdditionalRateLimits(raw json.RawMessage) ([]whamAdditionalRateLimit, map[string]*whamRateLimitCategory, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, nil, nil + } + + switch trimmed[0] { + case '[': + var additional []whamAdditionalRateLimit + if err := json.Unmarshal(trimmed, &additional); err != nil { + return nil, nil, err } - if w := cat.SecondaryWindow; w != nil { - windows = append(windows, mapWhamWindow(name, "secondary", w)) + return additional, nil, nil + case '{': + var legacy map[string]*whamRateLimitCategory + if err := json.Unmarshal(trimmed, &legacy); err != nil { + return nil, nil, err } + return nil, legacy, nil + default: + return nil, nil, errors.New("expected an array or object") } +} - return &CodexRateLimitData{ - Plan: usage.PlanType, - Windows: windows, - }, nil +func appendWhamCategoryWindows(windows []CodexRateLimitWindow, category, limitName string, rateLimit *whamRateLimitCategory) []CodexRateLimitWindow { + if rateLimit == nil { + return windows + } + if rateLimit.PrimaryWindow != nil { + windows = append(windows, mapWhamWindow(category, limitName, "primary", rateLimit.PrimaryWindow)) + } + if rateLimit.SecondaryWindow != nil { + windows = append(windows, mapWhamWindow(category, limitName, "secondary", rateLimit.SecondaryWindow)) + } + return windows +} + +func normalizeAdditionalLimitID(value string) string { + var normalized strings.Builder + lastUnderscore := false + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + isAlphaNumeric := r >= 'a' && r <= 'z' || r >= '0' && r <= '9' + if isAlphaNumeric { + normalized.WriteRune(r) + lastUnderscore = false + continue + } + if !lastUnderscore && normalized.Len() > 0 { + normalized.WriteByte('_') + lastUnderscore = true + } + } + return strings.Trim(normalized.String(), "_") } -func mapWhamWindow(category, position string, w *whamRateLimitWindow) CodexRateLimitWindow { +func mapWhamWindow(category, limitName, position string, w *whamRateLimitWindow) CodexRateLimitWindow { return CodexRateLimitWindow{ LimitID: category + ":" + position, + LimitName: limitName, UsagePercentage: float64(w.UsedPercent), ResetAt: w.ResetAt, WindowDurationMinutes: w.LimitWindowSeconds / 60, diff --git a/daemon/codex_ratelimit_more_test.go b/daemon/codex_ratelimit_more_test.go index 4c0c87b..b060906 100644 --- a/daemon/codex_ratelimit_more_test.go +++ b/daemon/codex_ratelimit_more_test.go @@ -106,7 +106,7 @@ func TestMapWhamWindow_SubMinuteSecondary(t *testing.T) { LimitWindowSeconds: 30, // < 60 -> 0 minutes ResetAt: 999, } - got := mapWhamWindow("code_review_rate_limit", "secondary", w) + got := mapWhamWindow("code_review_rate_limit", "", "secondary", w) assert.Equal(t, "code_review_rate_limit:secondary", got.LimitID) assert.Equal(t, float64(5), got.UsagePercentage) assert.Equal(t, int64(999), got.ResetAt) diff --git a/daemon/codex_ratelimit_test.go b/daemon/codex_ratelimit_test.go index b7c4780..c227ac9 100644 --- a/daemon/codex_ratelimit_test.go +++ b/daemon/codex_ratelimit_test.go @@ -124,7 +124,7 @@ func TestMapWhamWindow(t *testing.T) { ResetAfterSeconds: 120, ResetAt: 1712400000, } - got := mapWhamWindow("rate_limit", "primary", w) + got := mapWhamWindow("rate_limit", "", "primary", w) assert.Equal(t, "rate_limit:primary", got.LimitID) assert.Equal(t, float64(85), got.UsagePercentage) assert.Equal(t, int64(1712400000), got.ResetAt) @@ -149,88 +149,86 @@ func TestShortenCodexAPIError(t *testing.T) { } } -// TestFetchCodexUsage_DecodeAndMapping exercises the decode/mapping logic of -// fetchCodexUsage by temporarily overriding the request to hit a test server. -// fetchCodexUsage hardcodes its URL, so we verify the mapping by directly -// decoding a representative response shape through the same struct and -// mapWhamWindow used by the function. -func TestFetchCodexUsage_ResponseMapping(t *testing.T) { - payload := whamUsageResponse{ - PlanType: "pro", - RateLimit: &whamRateLimitCategory{ - PrimaryWindow: &whamRateLimitWindow{UsedPercent: 10, LimitWindowSeconds: 300, ResetAt: 100}, - SecondaryWindow: &whamRateLimitWindow{UsedPercent: 20, LimitWindowSeconds: 600, ResetAt: 200}, - }, - CodeReviewRateLimit: &whamRateLimitCategory{ - PrimaryWindow: &whamRateLimitWindow{UsedPercent: 30, LimitWindowSeconds: 1200, ResetAt: 300}, - }, - AdditionalRateLimits: map[string]*whamRateLimitCategory{ - "extra": {PrimaryWindow: &whamRateLimitWindow{UsedPercent: 40, LimitWindowSeconds: 60, ResetAt: 400}}, - "nilcat": nil, - }, - } - raw, err := json.Marshal(payload) +func TestFetchCodexUsage_CurrentResponseShape(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer access-token", r.Header.Get("Authorization")) + assert.Equal(t, "account-1", r.Header.Get("ChatGPT-Account-ID")) + assert.Equal(t, "shelltime-daemon", r.Header.Get("User-Agent")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "plan_type": "prolite", + "rate_limit": map[string]any{ + "primary_window": map[string]any{"used_percent": 12, "limit_window_seconds": 604800, "reset_at": 1712400000}, + }, + "code_review_rate_limit": nil, + "additional_rate_limits": []any{ + map[string]any{ + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": map[string]any{ + "primary_window": map[string]any{"used_percent": 40, "limit_window_seconds": 18000, "reset_at": 1712400100}, + }, + }, + }, + "credits": map[string]any{"has_credits": false, "unlimited": false, "balance": "0"}, + }) + })) + defer server.Close() + + usage, err := fetchCodexUsageFromEndpoint(context.Background(), &codexAuthData{ + AccessToken: "access-token", + AccountID: "account-1", + }, server.URL, server.Client()) require.NoError(t, err) - var decoded whamUsageResponse - require.NoError(t, json.Unmarshal(raw, &decoded)) - - // Recreate the window aggregation the same way fetchCodexUsage does. - var windows []CodexRateLimitWindow - for _, c := range []struct { - name string - cat *whamRateLimitCategory - }{{"rate_limit", decoded.RateLimit}, {"code_review_rate_limit", decoded.CodeReviewRateLimit}} { - if c.cat == nil { - continue - } - if w := c.cat.PrimaryWindow; w != nil { - windows = append(windows, mapWhamWindow(c.name, "primary", w)) - } - if w := c.cat.SecondaryWindow; w != nil { - windows = append(windows, mapWhamWindow(c.name, "secondary", w)) - } - } + assert.Equal(t, "prolite", usage.Plan) + require.Len(t, usage.Windows, 2) + assert.Equal(t, "rate_limit:primary", usage.Windows[0].LimitID) + assert.Equal(t, 10080, usage.Windows[0].WindowDurationMinutes) + assert.Equal(t, "additional_rate_limit:codex_bengalfox:primary", usage.Windows[1].LimitID) + assert.Equal(t, "GPT-5.3-Codex-Spark", usage.Windows[1].LimitName) + require.NotNil(t, usage.Credits) + assert.False(t, usage.Credits.HasCredits) + assert.False(t, usage.Credits.Unlimited) + assert.Equal(t, "0", usage.Credits.Balance) +} + +func TestMapWhamUsageResponse_LegacyAdditionalRateLimits(t *testing.T) { + var response whamUsageResponse + require.NoError(t, json.Unmarshal([]byte(`{ + "plan_type":"pro", + "rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":300,"reset_at":100},"secondary_window":{"used_percent":20,"limit_window_seconds":600,"reset_at":200}}, + "code_review_rate_limit":{"primary_window":{"used_percent":30,"limit_window_seconds":1200,"reset_at":300}}, + "additional_rate_limits":{"z_extra":null,"extra":{"primary_window":{"used_percent":40,"limit_window_seconds":60,"reset_at":400}}} + }`), &response)) - assert.Equal(t, "pro", decoded.PlanType) - require.Len(t, windows, 3) - assert.Equal(t, "rate_limit:primary", windows[0].LimitID) - assert.Equal(t, "rate_limit:secondary", windows[1].LimitID) - assert.Equal(t, "code_review_rate_limit:primary", windows[2].LimitID) - assert.Equal(t, 5, windows[0].WindowDurationMinutes) - assert.Equal(t, 10, windows[1].WindowDurationMinutes) + usage, err := mapWhamUsageResponse(&response) + require.NoError(t, err) + require.Len(t, usage.Windows, 4) + assert.Equal(t, "rate_limit:primary", usage.Windows[0].LimitID) + assert.Equal(t, "rate_limit:secondary", usage.Windows[1].LimitID) + assert.Equal(t, "code_review_rate_limit:primary", usage.Windows[2].LimitID) + assert.Equal(t, "extra:primary", usage.Windows[3].LimitID) + assert.Empty(t, usage.Windows[3].LimitName) +} + +func TestMapWhamUsageResponse_RejectsInvalidAdditionalRateLimits(t *testing.T) { + response := &whamUsageResponse{AdditionalRateLimits: json.RawMessage(`"invalid"`)} + _, err := mapWhamUsageResponse(response) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected an array or object") } -// TestFetchCodexUsage_StatusHandling verifies fetchCodexUsage's status-code -// branches using a test server reachable through the same HTTP client pattern. -// Since fetchCodexUsage uses a hardcoded host, we replicate its status handling -// against a local server to assert the sentinel mapping it relies on. func TestFetchCodexUsage_StatusHandling(t *testing.T) { - t.Run("unauthorized -> token invalid sentinel via direct status check", func(t *testing.T) { - // Confirms that a 401/403 maps to errCodexTokenInvalid in the function's - // logic; we test the branch by reproducing the condition. - statuses := []int{http.StatusUnauthorized, http.StatusForbidden} - for _, sc := range statuses { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(sc) + w.WriteHeader(status) })) - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - resp.Body.Close() - server.Close() - - // Replicate fetchCodexUsage's status branch. - var got error - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - got = errCodexTokenInvalid - } else { - got = fmt.Errorf("codex usage API returned status %d", resp.StatusCode) - } - } - assert.ErrorIs(t, got, errCodexTokenInvalid) - } - }) + defer server.Close() + + usage, err := fetchCodexUsageFromEndpoint(context.Background(), &codexAuthData{AccessToken: "invalid"}, server.URL, server.Client()) + assert.Nil(t, usage) + assert.ErrorIs(t, err, errCodexTokenInvalid) + }) + } } diff --git a/daemon/codex_usage_sync.go b/daemon/codex_usage_sync.go index 7214dc9..c9980e9 100644 --- a/daemon/codex_usage_sync.go +++ b/daemon/codex_usage_sync.go @@ -110,19 +110,23 @@ func sendCodexUsageToServer(ctx context.Context, config model.ShellTimeConfig, u type usageWindow struct { LimitID string `json:"limit_id"` + LimitName string `json:"limit_name"` UsagePercentage float64 `json:"usage_percentage"` ResetsAt string `json:"resets_at"` WindowDurationMinutes int `json:"window_duration_minutes"` } type usagePayload struct { - Plan string `json:"plan"` - Windows []usageWindow `json:"windows"` + SchemaVersion int `json:"schema_version"` + Plan string `json:"plan"` + Credits *CodexUsageCredits `json:"credits,omitempty"` + Windows []usageWindow `json:"windows"` } windows := make([]usageWindow, len(usage.Windows)) for i, w := range usage.Windows { windows[i] = usageWindow{ LimitID: w.LimitID, + LimitName: w.LimitName, UsagePercentage: w.UsagePercentage, ResetsAt: time.Unix(w.ResetAt, 0).UTC().Format(time.RFC3339), WindowDurationMinutes: w.WindowDurationMinutes, @@ -130,8 +134,10 @@ func sendCodexUsageToServer(ctx context.Context, config model.ShellTimeConfig, u } payload := usagePayload{ - Plan: usage.Plan, - Windows: windows, + SchemaVersion: 2, + Plan: usage.Plan, + Credits: usage.Credits, + Windows: windows, } return model.SendHTTPRequestJSON(model.HTTPRequestOptions[usagePayload, any]{ diff --git a/daemon/codex_usage_sync_test.go b/daemon/codex_usage_sync_test.go index 4086a13..ac483b4 100644 --- a/daemon/codex_usage_sync_test.go +++ b/daemon/codex_usage_sync_test.go @@ -31,9 +31,14 @@ func TestSyncCodexUsage_SendsUsageToServer(t *testing.T) { fetchCodexUsageFunc = func(ctx context.Context, auth *codexAuthData) (*CodexRateLimitData, error) { return &CodexRateLimitData{ Plan: "pro", + Credits: &CodexUsageCredits{ + HasCredits: true, + Balance: "12.50", + }, Windows: []CodexRateLimitWindow{ { LimitID: "main", + LimitName: "Main window", UsagePercentage: 72.5, ResetAt: 1712400000, WindowDurationMinutes: 300, @@ -43,9 +48,12 @@ func TestSyncCodexUsage_SendsUsageToServer(t *testing.T) { } var captured struct { - Plan string `json:"plan"` - Windows []struct { + SchemaVersion int `json:"schema_version"` + Plan string `json:"plan"` + Credits *CodexUsageCredits `json:"credits"` + Windows []struct { LimitID string `json:"limit_id"` + LimitName string `json:"limit_name"` UsagePercentage float64 `json:"usage_percentage"` ResetsAt string `json:"resets_at"` WindowDurationMinutes int `json:"window_duration_minutes"` @@ -69,8 +77,13 @@ func TestSyncCodexUsage_SendsUsageToServer(t *testing.T) { require.NoError(t, err) require.Len(t, captured.Windows, 1) + assert.Equal(t, 2, captured.SchemaVersion) assert.Equal(t, "pro", captured.Plan) + require.NotNil(t, captured.Credits) + assert.True(t, captured.Credits.HasCredits) + assert.Equal(t, "12.50", captured.Credits.Balance) assert.Equal(t, "main", captured.Windows[0].LimitID) + assert.Equal(t, "Main window", captured.Windows[0].LimitName) assert.Equal(t, 72.5, captured.Windows[0].UsagePercentage) assert.Equal(t, "2024-04-06T10:40:00Z", captured.Windows[0].ResetsAt) assert.Equal(t, 300, captured.Windows[0].WindowDurationMinutes) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5dff1d2..cbe2ee5 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -11,6 +11,7 @@ This guide covers every configuration option in ShellTime CLI. ShellTime runs fi - [Command Filtering](#command-filtering) - [AI Features](#ai-features) - [Claude Code Integration](#claude-code-integration) +- [Codex Usage Tracking](#codex-usage-tracking) - [Advanced Settings](#advanced-settings) - [Complete Example](#complete-example) - [FAQ](#faq) @@ -270,6 +271,23 @@ aiCodeOtel: 3. ShellTime auto-detects the source from service.name attribute 4. Data is forwarded to shelltime.xyz for analysis +## Codex Usage Tracking + +Codex usage has two separate data paths: + +| Data | Source | Cadence | +|------|--------|---------| +| Sessions, tokens, tools, and cost telemetry | Codex OTEL export configured by `shelltime codex install` | As Codex emits telemetry | +| Rate-limit windows, reset times, plan, and extra-credit status | Codex usage API, authenticated with `~/.codex/auth.json` | Daemon startup and every 10 minutes | + +Quota sync is automatic and has no configuration switch. It runs only when: + +1. ShellTime has a token from `shelltime auth`. +2. Codex is signed in with ChatGPT and has written `~/.codex/auth.json`. +3. `shelltime-daemon` is running. + +The Codex access token never goes to ShellTime. The daemon uses it only for the direct Codex request, then sends ShellTime a summary containing the plan, the windows Codex returned, their usage percentages and reset times, and credit status. The set and duration of windows are dynamic; for example, an account may return only a weekly window, so ShellTime does not synthesize a 5-hour window. + ### CCUsage (Legacy) CLI-based collection (older method):