Skip to content

Commit dac9b2a

Browse files
authored
Merge pull request #295 from shelltime/agent/fix-codex-usage-tracking
fix(daemon): restore Codex usage synchronization
2 parents a95274f + 70fc0fc commit dac9b2a

7 files changed

Lines changed: 264 additions & 119 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ shelltime codex install
5959
- Syncs your command history to ShellTime so you can search and analyze it.
6060
- Runs a background daemon for low-latency, non-blocking sync.
6161
- Forwards Claude Code and OpenAI Codex telemetry through OTEL.
62+
- Syncs the rate-limit windows and extra-credit status that Codex currently reports.
6263
- Shows a live Claude Code statusline with cost, quota, time, and context usage.
6364
- Syncs supported dotfiles to and from the ShellTime service.
6465

@@ -176,6 +177,17 @@ Example output:
176177

177178
For formatting details and platform notes, see [docs/CC_STATUSLINE.md](docs/CC_STATUSLINE.md).
178179

180+
## Codex Usage Tracking
181+
182+
ShellTime receives Codex data through two independent paths:
183+
184+
- `shelltime codex install` configures Codex OTEL export for sessions, tokens, tool activity, and cost telemetry.
185+
- 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.
186+
187+
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.
188+
189+
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.
190+
179191
## Security and Privacy
180192

181193
- **Data masking** redacts sensitive command content before it leaves your machine.

daemon/codex_ratelimit.go

Lines changed: 133 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
package daemon
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
78
"fmt"
89
"net/http"
910
"os"
1011
"path/filepath"
12+
"sort"
13+
"strings"
1114
"sync"
1215
"time"
1316
)
1417

1518
const codexUsageCacheTTL = 10 * time.Minute
1619

20+
const codexUsageEndpoint = "https://chatgpt.com/backend-api/wham/usage"
21+
1722
var (
1823
loadCodexAuthFunc = loadCodexAuth
1924
fetchCodexUsageFunc = fetchCodexUsage
@@ -31,11 +36,20 @@ var (
3136
type CodexRateLimitData struct {
3237
Plan string
3338
Windows []CodexRateLimitWindow
39+
Credits *CodexUsageCredits
40+
}
41+
42+
// CodexUsageCredits holds the extra-credit state returned by Codex.
43+
type CodexUsageCredits struct {
44+
HasCredits bool `json:"has_credits"`
45+
Unlimited bool `json:"unlimited"`
46+
Balance string `json:"balance"`
3447
}
3548

3649
// CodexRateLimitWindow holds a single rate limit window from the Codex API
3750
type CodexRateLimitWindow struct {
3851
LimitID string
52+
LimitName string
3953
UsagePercentage float64
4054
ResetAt int64 // Unix timestamp
4155
WindowDurationMinutes int
@@ -162,15 +176,28 @@ func loadCodexAuth() (*codexAuthData, error) {
162176

163177
// whamUsageResponse maps the response from chatgpt.com/backend-api/wham/usage
164178
type whamUsageResponse struct {
165-
PlanType string `json:"plan_type"`
166-
RateLimit *whamRateLimitCategory `json:"rate_limit"`
167-
CodeReviewRateLimit *whamRateLimitCategory `json:"code_review_rate_limit"`
168-
AdditionalRateLimits map[string]*whamRateLimitCategory `json:"additional_rate_limits"`
179+
PlanType string `json:"plan_type"`
180+
RateLimit *whamRateLimitCategory `json:"rate_limit"`
181+
CodeReviewRateLimit *whamRateLimitCategory `json:"code_review_rate_limit"`
182+
AdditionalRateLimits json.RawMessage `json:"additional_rate_limits"`
183+
Credits *whamCredits `json:"credits"`
184+
}
185+
186+
type whamAdditionalRateLimit struct {
187+
LimitName string `json:"limit_name"`
188+
MeteredFeature string `json:"metered_feature"`
189+
RateLimit *whamRateLimitCategory `json:"rate_limit"`
190+
}
191+
192+
type whamCredits struct {
193+
HasCredits bool `json:"has_credits"`
194+
Unlimited bool `json:"unlimited"`
195+
Balance string `json:"balance"`
169196
}
170197

171198
type whamRateLimitCategory struct {
172-
Allowed bool `json:"allowed"`
173-
LimitReached bool `json:"limit_reached"`
199+
Allowed bool `json:"allowed"`
200+
LimitReached bool `json:"limit_reached"`
174201
PrimaryWindow *whamRateLimitWindow `json:"primary_window"`
175202
SecondaryWindow *whamRateLimitWindow `json:"secondary_window"`
176203
}
@@ -184,15 +211,22 @@ type whamRateLimitWindow struct {
184211

185212
// fetchCodexUsage calls the Codex usage API and returns rate limit data.
186213
func fetchCodexUsage(ctx context.Context, auth *codexAuthData) (*CodexRateLimitData, error) {
187-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://chatgpt.com/backend-api/wham/usage", nil)
214+
client := &http.Client{Timeout: 5 * time.Second}
215+
return fetchCodexUsageFromEndpoint(ctx, auth, codexUsageEndpoint, client)
216+
}
217+
218+
func fetchCodexUsageFromEndpoint(ctx context.Context, auth *codexAuthData, endpoint string, client *http.Client) (*CodexRateLimitData, error) {
219+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
188220
if err != nil {
189221
return nil, err
190222
}
191223

192224
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
193225
req.Header.Set("User-Agent", "shelltime-daemon")
226+
if auth.AccountID != "" {
227+
req.Header.Set("ChatGPT-Account-ID", auth.AccountID)
228+
}
194229

195-
client := &http.Client{Timeout: 5 * time.Second}
196230
resp, err := client.Do(req)
197231
if err != nil {
198232
return nil, err
@@ -211,47 +245,111 @@ func fetchCodexUsage(ctx context.Context, auth *codexAuthData) (*CodexRateLimitD
211245
return nil, fmt.Errorf("failed to decode codex usage response: %w", err)
212246
}
213247

214-
var windows []CodexRateLimitWindow
215-
type categoryEntry struct {
216-
name string
217-
category *whamRateLimitCategory
248+
return mapWhamUsageResponse(&usage)
249+
}
250+
251+
func mapWhamUsageResponse(usage *whamUsageResponse) (*CodexRateLimitData, error) {
252+
windows := make([]CodexRateLimitWindow, 0, 4)
253+
windows = appendWhamCategoryWindows(windows, "rate_limit", "", usage.RateLimit)
254+
windows = appendWhamCategoryWindows(windows, "code_review_rate_limit", "", usage.CodeReviewRateLimit)
255+
256+
additional, legacy, err := decodeAdditionalRateLimits(usage.AdditionalRateLimits)
257+
if err != nil {
258+
return nil, fmt.Errorf("failed to decode codex additional rate limits: %w", err)
218259
}
219-
for _, cat := range []categoryEntry{
220-
{"rate_limit", usage.RateLimit},
221-
{"code_review_rate_limit", usage.CodeReviewRateLimit},
222-
} {
223-
if cat.category == nil {
224-
continue
260+
for _, item := range additional {
261+
identifier := normalizeAdditionalLimitID(item.MeteredFeature)
262+
if identifier == "" {
263+
identifier = normalizeAdditionalLimitID(item.LimitName)
225264
}
226-
if w := cat.category.PrimaryWindow; w != nil {
227-
windows = append(windows, mapWhamWindow(cat.name, "primary", w))
228-
}
229-
if w := cat.category.SecondaryWindow; w != nil {
230-
windows = append(windows, mapWhamWindow(cat.name, "secondary", w))
265+
if identifier == "" {
266+
identifier = "unnamed"
231267
}
268+
windows = appendWhamCategoryWindows(windows, "additional_rate_limit:"+identifier, item.LimitName, item.RateLimit)
232269
}
233270

234-
for name, cat := range usage.AdditionalRateLimits {
235-
if cat == nil {
236-
continue
271+
legacyNames := make([]string, 0, len(legacy))
272+
for name := range legacy {
273+
legacyNames = append(legacyNames, name)
274+
}
275+
sort.Strings(legacyNames)
276+
for _, name := range legacyNames {
277+
windows = appendWhamCategoryWindows(windows, name, "", legacy[name])
278+
}
279+
280+
result := &CodexRateLimitData{
281+
Plan: usage.PlanType,
282+
Windows: windows,
283+
}
284+
if usage.Credits != nil {
285+
result.Credits = &CodexUsageCredits{
286+
HasCredits: usage.Credits.HasCredits,
287+
Unlimited: usage.Credits.Unlimited,
288+
Balance: usage.Credits.Balance,
237289
}
238-
if w := cat.PrimaryWindow; w != nil {
239-
windows = append(windows, mapWhamWindow(name, "primary", w))
290+
}
291+
return result, nil
292+
}
293+
294+
func decodeAdditionalRateLimits(raw json.RawMessage) ([]whamAdditionalRateLimit, map[string]*whamRateLimitCategory, error) {
295+
trimmed := bytes.TrimSpace(raw)
296+
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
297+
return nil, nil, nil
298+
}
299+
300+
switch trimmed[0] {
301+
case '[':
302+
var additional []whamAdditionalRateLimit
303+
if err := json.Unmarshal(trimmed, &additional); err != nil {
304+
return nil, nil, err
240305
}
241-
if w := cat.SecondaryWindow; w != nil {
242-
windows = append(windows, mapWhamWindow(name, "secondary", w))
306+
return additional, nil, nil
307+
case '{':
308+
var legacy map[string]*whamRateLimitCategory
309+
if err := json.Unmarshal(trimmed, &legacy); err != nil {
310+
return nil, nil, err
243311
}
312+
return nil, legacy, nil
313+
default:
314+
return nil, nil, errors.New("expected an array or object")
244315
}
316+
}
245317

246-
return &CodexRateLimitData{
247-
Plan: usage.PlanType,
248-
Windows: windows,
249-
}, nil
318+
func appendWhamCategoryWindows(windows []CodexRateLimitWindow, category, limitName string, rateLimit *whamRateLimitCategory) []CodexRateLimitWindow {
319+
if rateLimit == nil {
320+
return windows
321+
}
322+
if rateLimit.PrimaryWindow != nil {
323+
windows = append(windows, mapWhamWindow(category, limitName, "primary", rateLimit.PrimaryWindow))
324+
}
325+
if rateLimit.SecondaryWindow != nil {
326+
windows = append(windows, mapWhamWindow(category, limitName, "secondary", rateLimit.SecondaryWindow))
327+
}
328+
return windows
329+
}
330+
331+
func normalizeAdditionalLimitID(value string) string {
332+
var normalized strings.Builder
333+
lastUnderscore := false
334+
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
335+
isAlphaNumeric := r >= 'a' && r <= 'z' || r >= '0' && r <= '9'
336+
if isAlphaNumeric {
337+
normalized.WriteRune(r)
338+
lastUnderscore = false
339+
continue
340+
}
341+
if !lastUnderscore && normalized.Len() > 0 {
342+
normalized.WriteByte('_')
343+
lastUnderscore = true
344+
}
345+
}
346+
return strings.Trim(normalized.String(), "_")
250347
}
251348

252-
func mapWhamWindow(category, position string, w *whamRateLimitWindow) CodexRateLimitWindow {
349+
func mapWhamWindow(category, limitName, position string, w *whamRateLimitWindow) CodexRateLimitWindow {
253350
return CodexRateLimitWindow{
254351
LimitID: category + ":" + position,
352+
LimitName: limitName,
255353
UsagePercentage: float64(w.UsedPercent),
256354
ResetAt: w.ResetAt,
257355
WindowDurationMinutes: w.LimitWindowSeconds / 60,

daemon/codex_ratelimit_more_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func TestMapWhamWindow_SubMinuteSecondary(t *testing.T) {
106106
LimitWindowSeconds: 30, // < 60 -> 0 minutes
107107
ResetAt: 999,
108108
}
109-
got := mapWhamWindow("code_review_rate_limit", "secondary", w)
109+
got := mapWhamWindow("code_review_rate_limit", "", "secondary", w)
110110
assert.Equal(t, "code_review_rate_limit:secondary", got.LimitID)
111111
assert.Equal(t, float64(5), got.UsagePercentage)
112112
assert.Equal(t, int64(999), got.ResetAt)

0 commit comments

Comments
 (0)