feat: surface agent subscription usage in settings and chat tooltip (#1697)

* feat: surface agent subscription usage in settings and chat tooltip

Adds live subscription rate-limit visibility for Claude Code and Codex:

- GET /api/v1/agents/usage lists per-agent utilization windows (plan, percent,
  reset time) for host agents with subscription credentials; ?fresh=true
  bypasses the 5-minute cache with a 15s per-provider clamp.
- Fixes both existing usage clients: Claude parsed float utilization as int
  (decode failure) and now reads the richer limits[] array incl. per-model
  weekly windows; Codex read a nonexistent auth.json field and response
  headers - now reads tokens.access_token, parses the body windows, and
  refreshes OAuth tokens on 401. Token refresh persists without dropping
  unknown credential fields.
- Settings > Agents gains a Subscription Usage section; the chat context
  doughnut tooltip shows the session agent's windows, fetched fresh on hover.
- Shared UsageWindowRows component (bars, Good/High/Critical status,
  reset countdowns) keeps settings, tooltip, and office surfaces consistent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: recompute agent billing type on every read

Subscription billing detection for Claude and Codex was cached with
sync.OnceValue for the process lifetime, so logging in with OAuth after
the backend started never flipped billing badges or office usage checks
until restart. Detection now re-reads the credential files on each call
and reuses the usage clients' HasSubscriptionCredentials checks. Also
adds a regression assertion that fractional-second resets_at timestamps
parse under the RFC3339 layout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address subscription usage review feedback

- serialize empty utilization windows as arrays, not null
- sanitize per-agent usage errors in the API (raw provider errors are
  logged server-side; they can carry credential paths/response bodies)
- persist refreshed CLI credentials via atomic temp-file rename
- keep previous settings usage on transient refresh failure; drop stale
  tooltip usage (and its memo) when a fresh fetch fails
- add controller tests for SubscriptionUsage mapping and fresh propagation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: coalesce concurrent usage fetches and guard codex token refresh

- serialize usage-cache fetches per key with a double-checked lock so
  concurrent fresh requests make one provider call instead of a burst
- reject codex refresh responses with an empty access_token instead of
  persisting them and wiping working credentials
- pin the usage-rows reset fixture 30s under the day boundary so the
  formatted value is deterministic on fast runners

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: coalesce failed usage fetches with short negative caching

Concurrent lookups after a fetch error each retried the provider once
the per-key lock rotated. Failures are now cached for 15 seconds (never
longer than the caller's staleness bound), so bursts share one error
result and the provider gets a breather before the next retry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: keep stale usage through failed refreshes and skip caching cancellations

Track the last success and last failure independently in the usage
cache: a failed fresh refresh no longer evicts a still-valid success
(non-fresh callers keep the stale value while fresh callers share the
recent error), and a caller-cancelled context no longer records a
negative entry that would penalize other callers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: synchronize concurrent usage-cache tests with a channel barrier

Replaces the sleep-widened race window in the coalescing tests with a
started/release barrier per the backend testing guidelines, and dedupes
the two concurrent-caller tests into one helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-15 08:50:21 +01:00
committed by GitHub
parent 8b265d9b82
commit 55c8b461be
31 changed files with 2074 additions and 147 deletions
+16 -27
View File
@@ -1,10 +1,8 @@
package agents
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/kandev/kandev/internal/agent/usage"
)
@@ -16,44 +14,35 @@ func defaultBillingType() usage.BillingType {
}
// claudeBillingType detects whether the Claude agent is using OAuth
// subscription credentials. It reads ~/.claude/.credentials.json once and
// caches the result for the process lifetime.
var claudeBillingType = sync.OnceValue(func() usage.BillingType {
// subscription credentials. Computed on every call (the read is a small
// local file) so logging in after the backend started is picked up without
// a restart.
func claudeBillingType() usage.BillingType {
home, err := os.UserHomeDir()
if err != nil {
return usage.BillingTypeAPIKey
}
path := filepath.Join(home, ".claude", ".credentials.json")
data, err := os.ReadFile(path)
if err != nil {
return usage.BillingTypeAPIKey
}
var creds struct {
ClaudeAiOauth *struct{} `json:"claudeAiOauth"`
}
if err := json.Unmarshal(data, &creds); err != nil {
return usage.BillingTypeAPIKey
}
if creds.ClaudeAiOauth != nil {
client := usage.NewClaudeUsageClientWithPath(filepath.Join(home, ".claude", ".credentials.json"))
if client.HasSubscriptionCredentials() {
return usage.BillingTypeSubscription
}
return usage.BillingTypeAPIKey
})
}
// codexBillingType detects whether the Codex agent is using subscription
// credentials. It checks for ~/.codex/auth.json once per process — that
// path matches the SourceFiles / Runtime mounts in codex_acp.go, where
// the real Codex CLI persists OAuth tokens. The earlier ~/.config/codex
// path was an XDG-style guess and never matched a real install, so
// subscription billing was undetectable.
var codexBillingType = sync.OnceValue(func() usage.BillingType {
// credentials. It reads ~/.codex/auth.json — that path matches the
// SourceFiles / Runtime mounts in codex_acp.go, where the real Codex CLI
// persists OAuth tokens. An auth.json holding only OPENAI_API_KEY (no
// ChatGPT OAuth tokens) is API-key billing. Computed on every call so
// `codex login` after backend start flips billing without a restart.
func codexBillingType() usage.BillingType {
home, err := os.UserHomeDir()
if err != nil {
return usage.BillingTypeAPIKey
}
path := filepath.Join(home, ".codex", "auth.json")
if _, err := os.Stat(path); err == nil {
client := usage.NewCodexUsageClientWithPath(filepath.Join(home, ".codex", "auth.json"))
if client.HasSubscriptionCredentials() {
return usage.BillingTypeSubscription
}
return usage.BillingTypeAPIKey
})
}
@@ -55,6 +55,7 @@ type Controller struct {
mcpService *mcpconfig.Service
modelCache *modelfetcher.Cache
hostUtility *hostutility.Manager
hostUsage HostUsageLister
jobStore *JobStore
hub JobBroadcaster
logger *logger.Logger
@@ -0,0 +1,43 @@
package controller
import (
"context"
"github.com/kandev/kandev/internal/agent/settings/dto"
agentusage "github.com/kandev/kandev/internal/agent/usage"
)
// HostUsageLister lists subscription utilization for host-installed agent CLIs.
// Implemented by usage.HostService. fresh bypasses the 5-minute cache (still
// bounded by a short server-side clamp).
type HostUsageLister interface {
List(ctx context.Context, fresh bool) []agentusage.HostAgentUsage
}
// SetHostUsageLister wires in the host subscription-usage service. Optional —
// when unset SubscriptionUsage returns an empty listing.
func (c *Controller) SetHostUsageLister(l HostUsageLister) {
c.hostUsage = l
}
// SubscriptionUsage returns utilization for host agents authenticated with
// subscription (OAuth) credentials, enriched with registry display names.
func (c *Controller) SubscriptionUsage(ctx context.Context, fresh bool) *dto.AgentSubscriptionUsageResponse {
resp := &dto.AgentSubscriptionUsageResponse{Agents: []dto.AgentSubscriptionUsage{}}
if c.hostUsage == nil {
return resp
}
for _, entry := range c.hostUsage.List(ctx, fresh) {
displayName := entry.AgentID
if ag, ok := c.agentRegistry.Get(entry.AgentID); ok {
displayName = ag.DisplayName()
}
resp.Agents = append(resp.Agents, dto.AgentSubscriptionUsage{
AgentID: entry.AgentID,
DisplayName: displayName,
Usage: entry.Usage,
Error: entry.Error,
})
}
return resp
}
@@ -0,0 +1,91 @@
package controller
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/agents"
agentusage "github.com/kandev/kandev/internal/agent/usage"
)
type fakeHostUsageLister struct {
entries []agentusage.HostAgentUsage
lastFresh bool
calls int
}
func (f *fakeHostUsageLister) List(_ context.Context, fresh bool) []agentusage.HostAgentUsage {
f.calls++
f.lastFresh = fresh
return f.entries
}
func TestControllerSubscriptionUsage_NoLister(t *testing.T) {
ctrl := newTestController(map[string]agents.Agent{})
resp := ctrl.SubscriptionUsage(context.Background(), false)
if resp == nil || resp.Agents == nil {
t.Fatalf("expected non-nil response with empty agents, got %+v", resp)
}
if len(resp.Agents) != 0 {
t.Fatalf("expected empty listing without a lister, got %+v", resp.Agents)
}
}
func TestControllerSubscriptionUsage_MapsEntries(t *testing.T) {
ctrl := newTestController(map[string]agents.Agent{
"claude-acp": &testAgent{id: "claude-acp", name: "claude-acp", displayName: "Claude", enabled: true},
})
now := time.Now()
usage := &agentusage.ProviderUsage{
Provider: "anthropic",
Plan: "max",
Windows: []agentusage.UtilizationWindow{{Label: "5-hour", UtilizationPct: 42, ResetAt: now}},
FetchedAt: now,
}
lister := &fakeHostUsageLister{entries: []agentusage.HostAgentUsage{
{AgentID: "claude-acp", Usage: usage},
{AgentID: "unknown-acp", Error: "failed to fetch usage from provider"},
}}
ctrl.SetHostUsageLister(lister)
resp := ctrl.SubscriptionUsage(context.Background(), false)
if len(resp.Agents) != 2 {
t.Fatalf("agents = %+v, want 2 entries", resp.Agents)
}
// Registry display-name lookup for known agents.
if resp.Agents[0].AgentID != "claude-acp" || resp.Agents[0].DisplayName != "Claude" {
t.Errorf("entry[0] = %+v", resp.Agents[0])
}
if resp.Agents[0].Usage != usage || resp.Agents[0].Error != "" {
t.Errorf("entry[0] usage/error = %+v", resp.Agents[0])
}
// Unknown agents fall back to the agent ID and forward the error.
if resp.Agents[1].DisplayName != "unknown-acp" || resp.Agents[1].Error == "" {
t.Errorf("entry[1] = %+v", resp.Agents[1])
}
if resp.Agents[1].Usage != nil {
t.Errorf("entry[1] usage = %+v, want nil", resp.Agents[1].Usage)
}
}
func TestControllerSubscriptionUsage_PropagatesFresh(t *testing.T) {
ctrl := newTestController(map[string]agents.Agent{})
lister := &fakeHostUsageLister{}
ctrl.SetHostUsageLister(lister)
_ = ctrl.SubscriptionUsage(context.Background(), true)
if !lister.lastFresh {
t.Error("fresh=true was not propagated to the lister")
}
_ = ctrl.SubscriptionUsage(context.Background(), false)
if lister.lastFresh {
t.Error("fresh=false was not propagated to the lister")
}
if lister.calls != 2 {
t.Errorf("lister calls = %d, want 2", lister.calls)
}
}
@@ -4,8 +4,23 @@ import (
"time"
"github.com/kandev/kandev/internal/agent/mcpconfig"
agentusage "github.com/kandev/kandev/internal/agent/usage"
)
// AgentSubscriptionUsage is one subscription-billed host agent's utilization
// entry returned by GET /api/v1/agents/usage.
type AgentSubscriptionUsage struct {
AgentID string `json:"agent_id"`
DisplayName string `json:"display_name"`
Usage *agentusage.ProviderUsage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
}
// AgentSubscriptionUsageResponse is the GET /api/v1/agents/usage payload.
type AgentSubscriptionUsageResponse struct {
Agents []AgentSubscriptionUsage `json:"agents"`
}
type AgentProfileDTO struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
@@ -18,6 +18,8 @@ import (
"go.uber.org/zap"
)
const queryValueTrue = "true"
var availableAgentsBroadcastTimeout = 10 * time.Second
type Handlers struct {
@@ -50,6 +52,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/agents/discovery", h.httpDiscoverAgents)
api.GET("/agents/available", h.httpListAvailableAgents)
api.GET("/agents/usage", h.httpAgentSubscriptionUsage)
api.GET("/agents", h.httpListAgents)
api.POST("/agents", h.httpCreateAgent)
api.POST("/agents/tui", h.httpCreateCustomTUIAgent)
@@ -81,6 +84,11 @@ func (h *Handlers) httpDiscoverAgents(c *gin.Context) {
h.broadcastAvailableAgentsAsync()
}
func (h *Handlers) httpAgentSubscriptionUsage(c *gin.Context) {
fresh := c.Query("fresh") == queryValueTrue || c.Query("fresh") == "1"
c.JSON(http.StatusOK, h.controller.SubscriptionUsage(c.Request.Context(), fresh))
}
func (h *Handlers) httpListAvailableAgents(c *gin.Context) {
resp, err := h.controller.ListAvailableAgents(c.Request.Context())
if err != nil {
@@ -464,7 +472,7 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) {
}
func (h *Handlers) httpDeleteProfile(c *gin.Context) {
force := c.Query("force") == "true"
force := c.Query("force") == queryValueTrue
profile, err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id"), force)
if err != nil {
if err == controller.ErrAgentProfileNotFound {
@@ -566,7 +574,7 @@ func (h *Handlers) httpGetAgentModels(c *gin.Context) {
}
// Check for refresh query parameter
refresh := c.Query("refresh") == "true"
refresh := c.Query("refresh") == queryValueTrue
resp, err := h.controller.FetchDynamicModels(c.Request.Context(), agentName, refresh)
if err != nil {
+92 -16
View File
@@ -10,20 +10,38 @@ import (
const cacheTTL = 5 * time.Minute
// failureCacheTTL bounds negative caching: after a fetch error, lookups for
// the same key return the cached error instead of re-querying the provider,
// so bursts of concurrent callers coalesce failures as well as successes.
const failureCacheTTL = 15 * time.Second
// cachedEntry tracks the last success and the last failure independently, so
// a failed refresh never evicts a still-valid success: non-fresh callers keep
// being served the stale value while fresh callers share the recent error.
type cachedEntry struct {
usage *ProviderUsage
fetchedAt time.Time
successAt time.Time
err error
errAt time.Time
}
// UsageCache is a thread-safe in-memory cache for provider usage responses.
type UsageCache struct {
mu sync.RWMutex
entries map[string]*cachedEntry
// fetchLocks serializes fetches per cache key so concurrent misses
// coalesce into a single provider request instead of a burst.
fetchMu sync.Mutex
fetchLocks map[string]*sync.Mutex
}
// NewUsageCache creates an empty UsageCache.
func NewUsageCache() *UsageCache {
return &UsageCache{entries: make(map[string]*cachedEntry)}
return &UsageCache{
entries: make(map[string]*cachedEntry),
fetchLocks: make(map[string]*sync.Mutex),
}
}
// CacheKey builds a deterministic cache key from provider name and credential path.
@@ -40,34 +58,92 @@ func (c *UsageCache) GetOrFetch(
key string,
fetchFn func(ctx context.Context) (*ProviderUsage, error),
) (*ProviderUsage, error) {
if usage := c.get(key); usage != nil {
return usage, nil
return c.GetOrFetchWithin(ctx, key, cacheTTL, fetchFn)
}
// GetOrFetchWithin is GetOrFetch with a caller-chosen staleness bound: the
// cached entry is served only while younger than maxAge. Fetch failures are
// shared with concurrent and near-term callers via a short-lived negative
// entry, except cancellations, which only affect the cancelled caller.
func (c *UsageCache) GetOrFetchWithin(
ctx context.Context,
key string,
maxAge time.Duration,
fetchFn func(ctx context.Context) (*ProviderUsage, error),
) (*ProviderUsage, error) {
if usage, ok, err := c.lookup(key, maxAge); ok {
return usage, err
}
lock := c.keyLock(key)
lock.Lock()
defer lock.Unlock()
// Re-check after acquiring the per-key lock: a concurrent caller may have
// completed the fetch while this one was waiting.
if usage, ok, err := c.lookup(key, maxAge); ok {
return usage, err
}
usage, err := fetchFn(ctx)
if err != nil {
// Do not poison the cache when this caller's context was cancelled —
// the provider was not necessarily at fault.
if ctx.Err() == nil {
c.storeFailure(key, err)
}
return nil, err
}
c.set(key, usage)
c.storeSuccess(key, usage)
return usage, nil
}
func (c *UsageCache) get(key string) *ProviderUsage {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.entries[key]
func (c *UsageCache) keyLock(key string) *sync.Mutex {
c.fetchMu.Lock()
defer c.fetchMu.Unlock()
lock, ok := c.fetchLocks[key]
if !ok {
return nil
lock = &sync.Mutex{}
c.fetchLocks[key] = lock
}
if time.Since(e.fetchedAt) >= cacheTTL {
return nil
}
return e.usage
return lock
}
func (c *UsageCache) set(key string, usage *ProviderUsage) {
// lookup returns the cached value when still valid: successes live for
// maxAge, failures for at most failureCacheTTL (never longer than maxAge).
// A preserved stale success does not mask a newer failure for callers whose
// maxAge excludes it.
func (c *UsageCache) lookup(key string, maxAge time.Duration) (*ProviderUsage, bool, error) {
c.mu.RLock()
defer c.mu.RUnlock()
e, found := c.entries[key]
if !found {
return nil, false, nil
}
if !e.successAt.IsZero() && time.Since(e.successAt) < maxAge {
return e.usage, true, nil
}
if e.err != nil && time.Since(e.errAt) < min(failureCacheTTL, maxAge) {
return nil, true, e.err
}
return nil, false, nil
}
func (c *UsageCache) storeSuccess(key string, usage *ProviderUsage) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = &cachedEntry{usage: usage, fetchedAt: time.Now()}
c.entries[key] = &cachedEntry{usage: usage, successAt: time.Now()}
}
// storeFailure records the error alongside — not instead of — any previous
// success, so callers with a wider staleness bound keep the stale value.
func (c *UsageCache) storeFailure(key string, err error) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[key]
if !ok {
e = &cachedEntry{}
c.entries[key] = e
}
e.err = err
e.errAt = time.Now()
}
// Invalidate removes the cache entry for the given key.
@@ -15,11 +15,16 @@ const (
claudeUsageURL = "https://api.anthropic.com/api/oauth/usage"
claudeRefreshURL = "https://platform.claude.com/v1/oauth/token"
claudeBetaHeader = "oauth-2025-04-20"
claudeLabel5Hour = "5-hour"
claudeLabel7Day = "7-day"
)
// ClaudeUsageClient fetches utilization from the Anthropic OAuth usage API.
type ClaudeUsageClient struct {
credentialsPath string
usageURL string
refreshURL string
httpClient *http.Client
}
@@ -27,6 +32,8 @@ type ClaudeUsageClient struct {
func NewClaudeUsageClientWithPath(credentialsPath string) *ClaudeUsageClient {
return &ClaudeUsageClient{
credentialsPath: credentialsPath,
usageURL: claudeUsageURL,
refreshURL: claudeRefreshURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
@@ -36,35 +43,83 @@ func (c *ClaudeUsageClient) CredentialsPath() string {
return c.credentialsPath
}
// HasSubscriptionCredentials reports whether the credentials file exists and
// carries an OAuth (subscription) token.
func (c *ClaudeUsageClient) HasSubscriptionCredentials() bool {
creds, err := c.readCredentials()
return err == nil && creds.ClaudeAiOauth != nil && creds.ClaudeAiOauth.AccessToken != ""
}
type claudeCredentials struct {
ClaudeAiOauth *claudeOAuthToken `json:"claudeAiOauth"`
}
type claudeOAuthToken struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken,omitempty"`
ExpiresAt int64 `json:"expiresAt"` // Unix milliseconds
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken,omitempty"`
ExpiresAt int64 `json:"expiresAt"` // Unix milliseconds
SubscriptionType string `json:"subscriptionType,omitempty"`
}
type claudeUsageWindow struct {
Utilization float64 `json:"utilization"`
ResetsAt string `json:"resets_at,omitempty"`
}
type claudeLimitScope struct {
Model *struct {
DisplayName string `json:"display_name"`
} `json:"model"`
}
type claudeLimit struct {
Kind string `json:"kind"`
Percent float64 `json:"percent"`
ResetsAt string `json:"resets_at"`
Scope *claudeLimitScope `json:"scope"`
}
type claudeUsageResponse struct {
FiveHour struct {
Utilization int `json:"utilization"`
ResetsAt string `json:"resets_at,omitempty"`
} `json:"five_hour"`
SevenDay struct {
Utilization int `json:"utilization"`
ResetsAt string `json:"resets_at,omitempty"`
} `json:"seven_day"`
FiveHour *claudeUsageWindow `json:"five_hour"`
SevenDay *claudeUsageWindow `json:"seven_day"`
Limits []claudeLimit `json:"limits"`
}
// FetchUsage implements ProviderUsageClient.
func (c *ClaudeUsageClient) FetchUsage(ctx context.Context) (*ProviderUsage, error) {
token, err := c.accessToken(ctx)
creds, err := c.readCredentials()
if err != nil {
return nil, fmt.Errorf("claude usage: read token: %w", err)
return nil, fmt.Errorf("claude usage: read credentials: %w", err)
}
if creds.ClaudeAiOauth == nil {
return nil, fmt.Errorf("claude usage: no claudeAiOauth entry in %s", c.credentialsPath)
}
token, err := c.freshAccessToken(ctx, creds.ClaudeAiOauth)
if err != nil {
return nil, fmt.Errorf("claude usage: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeUsageURL, nil)
body, err := c.getUsage(ctx, token)
if err != nil {
return nil, err
}
var raw claudeUsageResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("claude usage: decode: %w", err)
}
now := time.Now()
return &ProviderUsage{
Provider: "anthropic",
Plan: creds.ClaudeAiOauth.SubscriptionType,
Windows: claudeWindows(raw, now),
FetchedAt: now,
}, nil
}
func (c *ClaudeUsageClient) getUsage(ctx context.Context, token string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.usageURL, nil)
if err != nil {
return nil, fmt.Errorf("claude usage: build request: %w", err)
}
@@ -84,48 +139,78 @@ func (c *ClaudeUsageClient) FetchUsage(ctx context.Context) (*ProviderUsage, err
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("claude usage: unexpected status %d: %s", resp.StatusCode, body)
}
var raw claudeUsageResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("claude usage: decode: %w", err)
}
now := time.Now()
windows := []UtilizationWindow{
{
Label: "5-hour",
UtilizationPct: float64(raw.FiveHour.Utilization),
ResetAt: parseResetAt(raw.FiveHour.ResetsAt, now, 5*time.Hour),
},
{
Label: "7-day",
UtilizationPct: float64(raw.SevenDay.Utilization),
ResetAt: parseResetAt(raw.SevenDay.ResetsAt, now, 7*24*time.Hour),
},
}
return &ProviderUsage{
Provider: "anthropic",
Windows: windows,
FetchedAt: now,
}, nil
return body, nil
}
// accessToken returns a valid access token, refreshing if expired.
func (c *ClaudeUsageClient) accessToken(ctx context.Context) (string, error) {
creds, err := c.readCredentials()
if err != nil {
return "", err
// claudeWindows prefers the richer limits[] array (session, weekly, per-model
// weekly) and falls back to the legacy five_hour/seven_day pair. Always
// returns a non-nil slice so the API serializes `windows` as an array.
func claudeWindows(raw claudeUsageResponse, now time.Time) []UtilizationWindow {
if windows := claudeLimitWindows(raw.Limits); len(windows) > 0 {
return windows
}
if creds.ClaudeAiOauth == nil {
return "", fmt.Errorf("no claudeAiOauth entry in credentials file")
windows := make([]UtilizationWindow, 0, 2)
if raw.FiveHour != nil {
windows = append(windows, UtilizationWindow{
Label: claudeLabel5Hour,
UtilizationPct: raw.FiveHour.Utilization,
ResetAt: parseResetAt(raw.FiveHour.ResetsAt, now, 5*time.Hour),
})
}
tok := creds.ClaudeAiOauth
if raw.SevenDay != nil {
windows = append(windows, UtilizationWindow{
Label: claudeLabel7Day,
UtilizationPct: raw.SevenDay.Utilization,
ResetAt: parseResetAt(raw.SevenDay.ResetsAt, now, 7*24*time.Hour),
})
}
return windows
}
func claudeLimitWindows(limits []claudeLimit) []UtilizationWindow {
var windows []UtilizationWindow
for _, l := range limits {
label := claudeLimitLabel(l)
if label == "" {
continue
}
resetAt, err := time.Parse(time.RFC3339, l.ResetsAt)
if err != nil {
continue
}
windows = append(windows, UtilizationWindow{
Label: label,
UtilizationPct: l.Percent,
ResetAt: resetAt,
})
}
return windows
}
func claudeLimitLabel(l claudeLimit) string {
switch l.Kind {
case "session":
return claudeLabel5Hour
case "weekly_all":
return claudeLabel7Day
case "weekly_scoped":
if l.Scope != nil && l.Scope.Model != nil && l.Scope.Model.DisplayName != "" {
return "7-day (" + l.Scope.Model.DisplayName + ")"
}
return "7-day (model)"
default:
// Unknown kinds are skipped rather than shown with a cryptic label.
return ""
}
}
// freshAccessToken returns a valid access token, refreshing if expired.
func (c *ClaudeUsageClient) freshAccessToken(ctx context.Context, tok *claudeOAuthToken) (string, error) {
// ExpiresAt is in milliseconds; treat as expired if within 60 s of now.
expiresAt := time.UnixMilli(tok.ExpiresAt)
if time.Until(expiresAt) > 60*time.Second {
return tok.AccessToken, nil
}
// Token is expired or near expiry — refresh it.
if tok.RefreshToken == "" {
return "", fmt.Errorf("claude token expired and no refresh token available")
}
@@ -135,8 +220,7 @@ func (c *ClaudeUsageClient) accessToken(ctx context.Context) (string, error) {
}
// Write new token back. Non-fatal — we have the new token in memory even if
// persistence fails, but log the error so it doesn't go unnoticed.
creds.ClaudeAiOauth = newTok
if writeErr := c.writeCredentials(creds); writeErr != nil {
if writeErr := c.persistRefreshedToken(newTok); writeErr != nil {
fmt.Fprintf(os.Stderr, "claude usage: persist refreshed token: %v\n", writeErr)
}
return newTok.AccessToken, nil
@@ -154,12 +238,30 @@ func (c *ClaudeUsageClient) readCredentials() (*claudeCredentials, error) {
return &creds, nil
}
func (c *ClaudeUsageClient) writeCredentials(creds *claudeCredentials) error {
data, err := json.MarshalIndent(creds, "", " ")
// persistRefreshedToken updates only the token fields inside claudeAiOauth,
// preserving unknown siblings (scopes, subscriptionType, rateLimitTier, ...).
func (c *ClaudeUsageClient) persistRefreshedToken(tok *claudeOAuthToken) error {
data, err := os.ReadFile(c.credentialsPath)
if err != nil {
return err
}
return os.WriteFile(c.credentialsPath, data, 0600)
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return err
}
oauth, _ := root["claudeAiOauth"].(map[string]any)
if oauth == nil {
oauth = map[string]any{}
}
oauth["accessToken"] = tok.AccessToken
oauth["refreshToken"] = tok.RefreshToken
oauth["expiresAt"] = tok.ExpiresAt
root["claudeAiOauth"] = oauth
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return err
}
return writeFileAtomic(c.credentialsPath, out, 0o600)
}
type claudeRefreshRequest struct {
@@ -179,7 +281,7 @@ func (c *ClaudeUsageClient) refreshToken(ctx context.Context, refreshToken strin
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, claudeRefreshURL, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.refreshURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
@@ -0,0 +1,195 @@
package usage
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func writeClaudeCreds(t *testing.T, dir string, expiresAt int64, extra map[string]any) string {
t.Helper()
oauth := map[string]any{
"accessToken": "live-token",
"refreshToken": "refresh-token",
"expiresAt": expiresAt,
"subscriptionType": "max",
"scopes": []string{"user:inference", "user:profile"},
"rateLimitTier": "default_claude_max_20x",
}
for k, v := range extra {
oauth[k] = v
}
path := filepath.Join(dir, ".credentials.json")
data, err := json.Marshal(map[string]any{"claudeAiOauth": oauth})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
return path
}
func futureExpiryMillis() int64 {
return time.Now().Add(time.Hour).UnixMilli()
}
// The live API returns float utilization values (e.g. 62.0) — the client must
// not parse them as int.
func TestClaudeFetchUsage_ParsesFloatsAndLimits(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer live-token" {
t.Errorf("Authorization = %q", got)
}
if got := r.Header.Get("anthropic-beta"); got != claudeBetaHeader {
t.Errorf("anthropic-beta = %q", got)
}
_, _ = w.Write([]byte(`{
"five_hour": {"utilization": 62.0, "resets_at": "2026-07-14T22:09:59.781046+00:00"},
"seven_day": {"utilization": 15.0, "resets_at": "2026-07-15T00:59:59.781070+00:00"},
"limits": [
{"kind": "session", "group": "session", "percent": 62, "resets_at": "2026-07-14T22:09:59.781046+00:00", "scope": null},
{"kind": "weekly_all", "group": "weekly", "percent": 15, "resets_at": "2026-07-15T00:59:59+00:00", "scope": null},
{"kind": "weekly_scoped", "group": "weekly", "percent": 20.5, "resets_at": "2026-07-15T00:59:59+00:00",
"scope": {"model": {"id": null, "display_name": "Opus"}}},
{"kind": "mystery_future_kind", "percent": 99, "resets_at": "2026-07-15T00:59:59+00:00"}
]
}`))
}))
defer srv.Close()
client := NewClaudeUsageClientWithPath(writeClaudeCreds(t, t.TempDir(), futureExpiryMillis(), nil))
client.usageURL = srv.URL
got, err := client.FetchUsage(context.Background())
if err != nil {
t.Fatalf("FetchUsage: %v", err)
}
if got.Provider != "anthropic" {
t.Errorf("Provider = %q", got.Provider)
}
if got.Plan != "max" {
t.Errorf("Plan = %q, want max", got.Plan)
}
wantLabels := []string{"5-hour", "7-day", "7-day (Opus)"}
if len(got.Windows) != len(wantLabels) {
t.Fatalf("Windows = %+v, want %d entries", got.Windows, len(wantLabels))
}
for i, label := range wantLabels {
if got.Windows[i].Label != label {
t.Errorf("Windows[%d].Label = %q, want %q", i, got.Windows[i].Label, label)
}
}
if got.Windows[0].UtilizationPct != 62 {
t.Errorf("session pct = %v", got.Windows[0].UtilizationPct)
}
if got.Windows[2].UtilizationPct != 20.5 {
t.Errorf("scoped pct = %v", got.Windows[2].UtilizationPct)
}
// The live API emits sub-second fractional timestamps; time.Parse accepts
// them with the RFC3339 layout even though the layout omits fractions.
wantReset := time.Date(2026, 7, 14, 22, 9, 59, 781046000, time.UTC)
if !got.Windows[0].ResetAt.Equal(wantReset) {
t.Errorf("session ResetAt = %v, want %v", got.Windows[0].ResetAt, wantReset)
}
}
func TestClaudeFetchUsage_FallbackWithoutLimits(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"five_hour": {"utilization": 33.3, "resets_at": "2026-07-14T22:09:59+00:00"},
"seven_day": {"utilization": 7.0, "resets_at": "2026-07-15T00:59:59+00:00"}
}`))
}))
defer srv.Close()
client := NewClaudeUsageClientWithPath(writeClaudeCreds(t, t.TempDir(), futureExpiryMillis(), nil))
client.usageURL = srv.URL
got, err := client.FetchUsage(context.Background())
if err != nil {
t.Fatalf("FetchUsage: %v", err)
}
if len(got.Windows) != 2 {
t.Fatalf("Windows = %+v, want 2", got.Windows)
}
if got.Windows[0].Label != "5-hour" || got.Windows[0].UtilizationPct != 33.3 {
t.Errorf("window[0] = %+v", got.Windows[0])
}
if got.Windows[1].Label != "7-day" || got.Windows[1].UtilizationPct != 7 {
t.Errorf("window[1] = %+v", got.Windows[1])
}
}
// Refreshing an expired token must not drop unrelated fields from the
// credentials file (scopes, subscriptionType, rateLimitTier, ...).
func TestClaudeFetchUsage_RefreshPreservesUnknownFields(t *testing.T) {
usageSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer new-access" {
t.Errorf("Authorization after refresh = %q", got)
}
_, _ = w.Write([]byte(`{"five_hour": {"utilization": 1.0}, "seven_day": {"utilization": 2.0}}`))
}))
defer usageSrv.Close()
refreshSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 3600}`))
}))
defer refreshSrv.Close()
expired := time.Now().Add(-time.Hour).UnixMilli()
path := writeClaudeCreds(t, t.TempDir(), expired, nil)
client := NewClaudeUsageClientWithPath(path)
client.usageURL = usageSrv.URL
client.refreshURL = refreshSrv.URL
if _, err := client.FetchUsage(context.Background()); err != nil {
t.Fatalf("FetchUsage: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]map[string]any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
oauth := root["claudeAiOauth"]
if oauth["accessToken"] != "new-access" || oauth["refreshToken"] != "new-refresh" {
t.Errorf("tokens not persisted: %+v", oauth)
}
if oauth["subscriptionType"] != "max" {
t.Errorf("subscriptionType dropped on refresh: %+v", oauth)
}
if oauth["rateLimitTier"] != "default_claude_max_20x" {
t.Errorf("rateLimitTier dropped on refresh: %+v", oauth)
}
if _, ok := oauth["scopes"]; !ok {
t.Errorf("scopes dropped on refresh: %+v", oauth)
}
}
func TestClaudeHasSubscriptionCredentials(t *testing.T) {
missing := NewClaudeUsageClientWithPath(filepath.Join(t.TempDir(), "nope.json"))
if missing.HasSubscriptionCredentials() {
t.Error("expected false for missing file")
}
apiKeyOnly := filepath.Join(t.TempDir(), ".credentials.json")
if err := os.WriteFile(apiKeyOnly, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if NewClaudeUsageClientWithPath(apiKeyOnly).HasSubscriptionCredentials() {
t.Error("expected false without claudeAiOauth")
}
withOAuth := NewClaudeUsageClientWithPath(writeClaudeCreds(t, t.TempDir(), futureExpiryMillis(), nil))
if !withOAuth.HasSubscriptionCredentials() {
t.Error("expected true with OAuth credentials")
}
}
+223 -41
View File
@@ -1,23 +1,29 @@
package usage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
const (
codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
codexRefreshURL = "https://auth.openai.com/oauth/token"
codexOAuthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
)
// CodexUsageClient fetches utilization from the OpenAI Codex usage API.
// It reads the bearer token from ~/.config/codex/auth.json and inspects
// response headers for utilization percentages. Fail-open: if the token is
// missing or the request fails, nil is returned (no utilization data).
// CodexUsageClient fetches utilization from the ChatGPT Codex usage API.
// It reads OAuth tokens from ~/.codex/auth.json (written by `codex login`)
// and parses the rate-limit windows from the response body.
type CodexUsageClient struct {
authPath string
usageURL string
refreshURL string
httpClient *http.Client
}
@@ -25,6 +31,8 @@ type CodexUsageClient struct {
func NewCodexUsageClientWithPath(authPath string) *CodexUsageClient {
return &CodexUsageClient{
authPath: authPath,
usageURL: codexUsageURL,
refreshURL: codexRefreshURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
@@ -34,73 +42,247 @@ func (c *CodexUsageClient) AuthPath() string {
return c.authPath
}
// HasSubscriptionCredentials reports whether auth.json exists and carries
// ChatGPT OAuth tokens (an auth.json with only OPENAI_API_KEY is API-key billing).
func (c *CodexUsageClient) HasSubscriptionCredentials() bool {
auth, err := c.readAuth()
return err == nil && auth.Tokens != nil && auth.Tokens.AccessToken != ""
}
type codexAuthTokens struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AccountID string `json:"account_id"`
}
type codexAuthJSON struct {
Token string `json:"token"`
Tokens *codexAuthTokens `json:"tokens"`
}
type codexWindow struct {
UsedPercent float64 `json:"used_percent"`
LimitWindowSeconds int64 `json:"limit_window_seconds"`
ResetAfterSeconds int64 `json:"reset_after_seconds"`
ResetAt int64 `json:"reset_at"` // Unix seconds
}
type codexUsageResponse struct {
PlanType string `json:"plan_type"`
RateLimit struct {
PrimaryWindow *codexWindow `json:"primary_window"`
SecondaryWindow *codexWindow `json:"secondary_window"`
} `json:"rate_limit"`
}
// FetchUsage implements ProviderUsageClient.
// Returns nil, nil if the auth file is missing or the token cannot be read —
// this is the fail-open path for Codex.
func (c *CodexUsageClient) FetchUsage(ctx context.Context) (*ProviderUsage, error) {
token, err := c.readToken()
auth, err := c.readAuth()
if err != nil {
// Fail-open: no Codex auth available.
return nil, nil //nolint:nilerr
return nil, fmt.Errorf("codex usage: %w", err)
}
if auth.Tokens == nil || auth.Tokens.AccessToken == "" {
return nil, fmt.Errorf("codex usage: no ChatGPT OAuth tokens in %s", c.authPath)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL, nil)
status, body, err := c.getUsage(ctx, auth.Tokens)
if err != nil {
return nil, nil //nolint:nilerr
return nil, err
}
if status == http.StatusUnauthorized && auth.Tokens.RefreshToken != "" {
refreshed, refreshErr := c.refreshTokens(ctx, auth.Tokens)
if refreshErr != nil {
return nil, fmt.Errorf("codex usage: refresh token: %w", refreshErr)
}
status, body, err = c.getUsage(ctx, refreshed)
if err != nil {
return nil, err
}
}
if status != http.StatusOK {
return nil, fmt.Errorf("codex usage: unexpected status %d: %s", status, body)
}
return parseCodexUsage(body, time.Now())
}
func (c *CodexUsageClient) getUsage(ctx context.Context, tokens *codexAuthTokens) (int, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.usageURL, nil)
if err != nil {
return 0, nil, fmt.Errorf("codex usage: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
if tokens.AccountID != "" {
req.Header.Set("chatgpt-account-id", tokens.AccountID)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil //nolint:nilerr
return 0, nil, fmt.Errorf("codex usage: http: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, nil
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, nil, fmt.Errorf("codex usage: read body: %w", err)
}
return resp.StatusCode, body, nil
}
primary := parseHeaderPercent(resp.Header.Get("x-codex-primary-used-percent"))
secondary := parseHeaderPercent(resp.Header.Get("x-codex-secondary-used-percent"))
now := time.Now()
windows := []UtilizationWindow{
{Label: "primary", UtilizationPct: primary, ResetAt: now.Add(24 * time.Hour)},
{Label: "secondary", UtilizationPct: secondary, ResetAt: now.Add(7 * 24 * time.Hour)},
func parseCodexUsage(body []byte, now time.Time) (*ProviderUsage, error) {
var raw codexUsageResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("codex usage: decode: %w", err)
}
// Non-nil so the API serializes `windows` as an array even when empty.
windows := make([]UtilizationWindow, 0, 2)
for _, w := range []*codexWindow{raw.RateLimit.PrimaryWindow, raw.RateLimit.SecondaryWindow} {
if w == nil {
continue
}
windows = append(windows, UtilizationWindow{
Label: codexWindowLabel(w.LimitWindowSeconds),
UtilizationPct: w.UsedPercent,
ResetAt: codexResetAt(w, now),
})
}
return &ProviderUsage{
Provider: "openai",
Plan: raw.PlanType,
Windows: windows,
FetchedAt: now,
}, nil
}
func (c *CodexUsageClient) readToken() (string, error) {
// codexWindowLabel renders a window duration like the Claude labels:
// 18000 s → "5-hour", 604800 s → "7-day", 2592000 s → "30-day".
func codexWindowLabel(seconds int64) string {
hours := seconds / 3600
switch {
case hours <= 0:
return "current"
case hours < 24:
return fmt.Sprintf("%d-hour", hours)
default:
return fmt.Sprintf("%d-day", hours/24)
}
}
func codexResetAt(w *codexWindow, now time.Time) time.Time {
if w.ResetAt > 0 {
return time.Unix(w.ResetAt, 0)
}
return now.Add(time.Duration(w.ResetAfterSeconds) * time.Second)
}
func (c *CodexUsageClient) readAuth() (*codexAuthJSON, error) {
data, err := os.ReadFile(c.authPath)
if err != nil {
return "", fmt.Errorf("read %s: %w", c.authPath, err)
return nil, fmt.Errorf("read %s: %w", c.authPath, err)
}
var auth codexAuthJSON
if err := json.Unmarshal(data, &auth); err != nil {
return "", fmt.Errorf("parse %s: %w", c.authPath, err)
return nil, fmt.Errorf("parse %s: %w", c.authPath, err)
}
if auth.Token == "" {
return "", fmt.Errorf("empty token in %s", c.authPath)
}
return auth.Token, nil
return &auth, nil
}
func parseHeaderPercent(v string) float64 {
if v == "" {
return 0
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0
}
return f
type codexRefreshRequest struct {
ClientID string `json:"client_id"`
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
type codexRefreshResponse struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
func (c *CodexUsageClient) refreshTokens(ctx context.Context, old *codexAuthTokens) (*codexAuthTokens, error) {
payload := codexRefreshRequest{
ClientID: codexOAuthClientID,
GrantType: "refresh_token",
RefreshToken: old.RefreshToken,
Scope: "openid profile email",
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.refreshURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("refresh: status %d: %s", resp.StatusCode, respBody)
}
var r codexRefreshResponse
if err := json.Unmarshal(respBody, &r); err != nil {
return nil, err
}
if r.AccessToken == "" {
// Never persist an empty access token: that would wipe working
// credentials and hide Codex usage until the next `codex login`.
return nil, fmt.Errorf("refresh: empty access_token in response")
}
updated := &codexAuthTokens{
IDToken: valueOr(r.IDToken, old.IDToken),
AccessToken: r.AccessToken,
RefreshToken: valueOr(r.RefreshToken, old.RefreshToken),
AccountID: old.AccountID,
}
// Non-fatal — we have the new tokens in memory even if persistence fails.
if writeErr := c.persistTokens(updated); writeErr != nil {
fmt.Fprintf(os.Stderr, "codex usage: persist refreshed tokens: %v\n", writeErr)
}
return updated, nil
}
// persistTokens updates only the tokens/last_refresh fields in auth.json,
// preserving unknown siblings (OPENAI_API_KEY, future fields).
func (c *CodexUsageClient) persistTokens(tokens *codexAuthTokens) error {
data, err := os.ReadFile(c.authPath)
if err != nil {
return err
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return err
}
toks, _ := root["tokens"].(map[string]any)
if toks == nil {
toks = map[string]any{}
}
toks["id_token"] = tokens.IDToken
toks["access_token"] = tokens.AccessToken
toks["refresh_token"] = tokens.RefreshToken
toks["account_id"] = tokens.AccountID
root["tokens"] = toks
root["last_refresh"] = time.Now().UTC().Format(time.RFC3339)
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return err
}
return writeFileAtomic(c.authPath, out, 0o600)
}
func valueOr(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
@@ -0,0 +1,236 @@
package usage
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func writeCodexAuth(t *testing.T, dir string, withTokens bool) string {
t.Helper()
root := map[string]any{"OPENAI_API_KEY": nil, "last_refresh": "2026-07-01T00:00:00Z"}
if withTokens {
root["tokens"] = map[string]any{
"id_token": "id-token",
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "acct-123",
}
}
path := filepath.Join(dir, "auth.json")
data, err := json.Marshal(root)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
return path
}
const codexUsageBody = `{
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {"used_percent": 42.5, "limit_window_seconds": 18000, "reset_after_seconds": 9000, "reset_at": 1786644793},
"secondary_window": {"used_percent": 7, "limit_window_seconds": 604800, "reset_after_seconds": 302400, "reset_at": 1786944793}
},
"credits": {"has_credits": false}
}`
func TestCodexFetchUsage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
t.Errorf("Authorization = %q", got)
}
if got := r.Header.Get("chatgpt-account-id"); got != "acct-123" {
t.Errorf("chatgpt-account-id = %q", got)
}
_, _ = w.Write([]byte(codexUsageBody))
}))
defer srv.Close()
client := NewCodexUsageClientWithPath(writeCodexAuth(t, t.TempDir(), true))
client.usageURL = srv.URL
got, err := client.FetchUsage(context.Background())
if err != nil {
t.Fatalf("FetchUsage: %v", err)
}
if got.Provider != "openai" {
t.Errorf("Provider = %q", got.Provider)
}
if got.Plan != "plus" {
t.Errorf("Plan = %q, want plus", got.Plan)
}
if len(got.Windows) != 2 {
t.Fatalf("Windows = %+v, want 2", got.Windows)
}
if got.Windows[0].Label != "5-hour" || got.Windows[0].UtilizationPct != 42.5 {
t.Errorf("window[0] = %+v", got.Windows[0])
}
if !got.Windows[0].ResetAt.Equal(time.Unix(1786644793, 0)) {
t.Errorf("window[0].ResetAt = %v", got.Windows[0].ResetAt)
}
if got.Windows[1].Label != "7-day" || got.Windows[1].UtilizationPct != 7 {
t.Errorf("window[1] = %+v", got.Windows[1])
}
}
func TestCodexFetchUsage_NullSecondaryWindow(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"plan_type": "free",
"rate_limit": {
"primary_window": {"used_percent": 5, "limit_window_seconds": 2592000, "reset_after_seconds": 2592000, "reset_at": 1786644793},
"secondary_window": null
}
}`))
}))
defer srv.Close()
client := NewCodexUsageClientWithPath(writeCodexAuth(t, t.TempDir(), true))
client.usageURL = srv.URL
got, err := client.FetchUsage(context.Background())
if err != nil {
t.Fatalf("FetchUsage: %v", err)
}
if len(got.Windows) != 1 {
t.Fatalf("Windows = %+v, want 1", got.Windows)
}
if got.Windows[0].Label != "30-day" {
t.Errorf("label = %q, want 30-day", got.Windows[0].Label)
}
}
func TestCodexFetchUsage_RefreshOn401PreservesUnknownFields(t *testing.T) {
var usageCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
usageCalls++
if usageCalls == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer new-access" {
t.Errorf("Authorization after refresh = %q", got)
}
_, _ = w.Write([]byte(codexUsageBody))
}))
defer srv.Close()
refreshSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req codexRefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode refresh request: %v", err)
}
if req.ClientID != codexOAuthClientID || req.GrantType != "refresh_token" || req.RefreshToken != "refresh-token" {
t.Errorf("refresh request = %+v", req)
}
_, _ = w.Write([]byte(`{"id_token": "new-id", "access_token": "new-access", "refresh_token": "new-refresh"}`))
}))
defer refreshSrv.Close()
path := writeCodexAuth(t, t.TempDir(), true)
client := NewCodexUsageClientWithPath(path)
client.usageURL = srv.URL
client.refreshURL = refreshSrv.URL
got, err := client.FetchUsage(context.Background())
if err != nil {
t.Fatalf("FetchUsage: %v", err)
}
if got.Plan != "plus" {
t.Errorf("Plan = %q", got.Plan)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["OPENAI_API_KEY"]; !ok {
t.Errorf("OPENAI_API_KEY dropped on refresh: %+v", root)
}
toks, _ := root["tokens"].(map[string]any)
if toks["access_token"] != "new-access" || toks["refresh_token"] != "new-refresh" {
t.Errorf("tokens not persisted: %+v", toks)
}
if toks["account_id"] != "acct-123" {
t.Errorf("account_id dropped on refresh: %+v", toks)
}
}
func TestCodexRefreshRejectsEmptyAccessToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
refreshSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"id_token": "new-id", "access_token": "", "refresh_token": "new-refresh"}`))
}))
defer refreshSrv.Close()
path := writeCodexAuth(t, t.TempDir(), true)
client := NewCodexUsageClientWithPath(path)
client.usageURL = srv.URL
client.refreshURL = refreshSrv.URL
if _, err := client.FetchUsage(context.Background()); err == nil {
t.Fatal("expected error for empty access_token in refresh response")
}
// The stored credentials must be untouched.
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
toks, _ := root["tokens"].(map[string]any)
if toks["access_token"] != "access-token" || toks["refresh_token"] != "refresh-token" {
t.Errorf("credentials were modified: %+v", toks)
}
}
func TestCodexHasSubscriptionCredentials(t *testing.T) {
missing := NewCodexUsageClientWithPath(filepath.Join(t.TempDir(), "nope.json"))
if missing.HasSubscriptionCredentials() {
t.Error("expected false for missing file")
}
apiKeyOnly := writeCodexAuth(t, t.TempDir(), false)
if NewCodexUsageClientWithPath(apiKeyOnly).HasSubscriptionCredentials() {
t.Error("expected false for API-key-only auth.json")
}
withTokens := writeCodexAuth(t, t.TempDir(), true)
if !NewCodexUsageClientWithPath(withTokens).HasSubscriptionCredentials() {
t.Error("expected true with OAuth tokens")
}
}
func TestCodexWindowLabel(t *testing.T) {
cases := map[int64]string{
0: "current",
18000: "5-hour",
86400: "1-day",
604800: "7-day",
2592000: "30-day",
}
for seconds, want := range cases {
if got := codexWindowLabel(seconds); got != want {
t.Errorf("codexWindowLabel(%d) = %q, want %q", seconds, got, want)
}
}
}
@@ -0,0 +1,39 @@
package usage
import (
"os"
"path/filepath"
)
// writeFileAtomic writes data through a same-directory temp file and rename,
// so concurrent readers of shared CLI credential files (the agent CLIs also
// read/write them) never observe a truncated or partially written file.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}
if _, err := tmp.Write(data); err != nil {
cleanup()
return err
}
if err := tmp.Chmod(perm); err != nil {
cleanup()
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return err
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package usage
import (
"context"
"os"
"path/filepath"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// freshMaxAge clamps fresh (hover-triggered) fetches: a provider is queried
// again at most every 15 s even when callers keep requesting fresh data.
const freshMaxAge = 15 * time.Second
// hostUsageFetchError is the sanitized per-agent error surfaced through the
// API. Raw provider errors can contain credential paths and response bodies,
// so those are only logged server-side.
const hostUsageFetchError = "failed to fetch usage from provider"
// HostAgentUsage is one host-installed subscription agent's usage entry.
type HostAgentUsage struct {
AgentID string `json:"agent_id"`
Usage *ProviderUsage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
}
// hostUsageClient is a ProviderUsageClient that can also report whether
// subscription credentials are present on disk.
type hostUsageClient interface {
ProviderUsageClient
HasSubscriptionCredentials() bool
}
type hostEntry struct {
agentID string
cacheKey string
client hostUsageClient
}
// HostService lists subscription utilization for agent CLIs installed on the
// kandev host (Claude Code, Codex). Agents without subscription credentials
// are omitted from List results.
type HostService struct {
cache *UsageCache
entries []hostEntry
logger *logger.Logger
}
// NewHostService builds a HostService reading credentials from the current
// user's home directory. The service is empty when the home dir is unavailable.
func NewHostService(log *logger.Logger) *HostService {
s := &HostService{cache: NewUsageCache(), logger: log}
home, err := os.UserHomeDir()
if err != nil {
return s
}
claudePath := filepath.Join(home, ".claude", ".credentials.json")
codexPath := filepath.Join(home, ".codex", "auth.json")
s.entries = []hostEntry{
{
agentID: "claude-acp",
cacheKey: CacheKey("anthropic", claudePath),
client: NewClaudeUsageClientWithPath(claudePath),
},
{
agentID: "codex-acp",
cacheKey: CacheKey("openai", codexPath),
client: NewCodexUsageClientWithPath(codexPath),
},
}
return s
}
// List returns usage for every host agent that has subscription credentials.
// With fresh=false cached results up to 5 minutes old are served; fresh=true
// re-queries the providers, bounded by freshMaxAge. Fetch failures are
// reported per-agent via the Error field rather than failing the whole listing.
func (s *HostService) List(ctx context.Context, fresh bool) []HostAgentUsage {
maxAge := cacheTTL
if fresh {
maxAge = freshMaxAge
}
out := make([]HostAgentUsage, 0, len(s.entries))
for _, e := range s.entries {
if !e.client.HasSubscriptionCredentials() {
continue
}
entry := HostAgentUsage{AgentID: e.agentID}
u, err := s.cache.GetOrFetchWithin(ctx, e.cacheKey, maxAge, e.client.FetchUsage)
switch {
case err != nil:
if s.logger != nil {
s.logger.Warn("subscription usage fetch failed",
zap.String("agent", e.agentID), zap.Error(err))
}
entry.Error = hostUsageFetchError
default:
entry.Usage = u
}
out = append(out, entry)
}
return out
}
@@ -0,0 +1,240 @@
package usage
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeHostClient struct {
hasCreds bool
usage *ProviderUsage
err error
calls int
}
func (f *fakeHostClient) FetchUsage(_ context.Context) (*ProviderUsage, error) {
f.calls++
return f.usage, f.err
}
func (f *fakeHostClient) HasSubscriptionCredentials() bool { return f.hasCreds }
func TestHostServiceList(t *testing.T) {
now := time.Now()
okUsage := &ProviderUsage{
Provider: "anthropic",
Plan: "max",
Windows: []UtilizationWindow{{Label: "5-hour", UtilizationPct: 42, ResetAt: now}},
FetchedAt: now,
}
noCreds := &fakeHostClient{hasCreds: false}
ok := &fakeHostClient{hasCreds: true, usage: okUsage}
failing := &fakeHostClient{hasCreds: true, err: errors.New("boom")}
svc := &HostService{
cache: NewUsageCache(),
entries: []hostEntry{
{agentID: "claude-acp", cacheKey: "k1", client: noCreds},
{agentID: "codex-acp", cacheKey: "k2", client: ok},
{agentID: "other-acp", cacheKey: "k3", client: failing},
},
}
got := svc.List(context.Background(), false)
if len(got) != 2 {
t.Fatalf("List = %+v, want 2 entries", got)
}
if got[0].AgentID != "codex-acp" || got[0].Usage != okUsage || got[0].Error != "" {
t.Errorf("entry[0] = %+v", got[0])
}
if got[1].AgentID != "other-acp" || got[1].Usage != nil || got[1].Error != hostUsageFetchError {
t.Errorf("entry[1] = %+v", got[1])
}
if noCreds.calls != 0 {
t.Errorf("client without creds was fetched %d times", noCreds.calls)
}
// Second List hits the cache for the successful entry.
_ = svc.List(context.Background(), false)
if ok.calls != 1 {
t.Errorf("expected cached fetch, got %d calls", ok.calls)
}
}
func TestHostServiceList_FreshBypassesStaleCache(t *testing.T) {
now := time.Now()
okUsage := &ProviderUsage{Provider: "anthropic", FetchedAt: now}
ok := &fakeHostClient{hasCreds: true, usage: okUsage}
svc := &HostService{
cache: NewUsageCache(),
entries: []hostEntry{{agentID: "claude-acp", cacheKey: "k1", client: ok}},
}
_ = svc.List(context.Background(), false)
if ok.calls != 1 {
t.Fatalf("initial fetch calls = %d", ok.calls)
}
// Entry is younger than freshMaxAge: fresh serves the cached value.
_ = svc.List(context.Background(), true)
if ok.calls != 1 {
t.Errorf("fresh within clamp should hit cache, calls = %d", ok.calls)
}
// Age the entry past freshMaxAge but below the 5-minute TTL: a normal List
// still serves the cache, a fresh List re-queries the provider.
svc.cache.mu.Lock()
svc.cache.entries["k1"].successAt = now.Add(-freshMaxAge - time.Second)
svc.cache.mu.Unlock()
_ = svc.List(context.Background(), false)
if ok.calls != 1 {
t.Errorf("stale-but-within-TTL cached List should not refetch, calls = %d", ok.calls)
}
_ = svc.List(context.Background(), true)
if ok.calls != 2 {
t.Errorf("fresh List should refetch, calls = %d", ok.calls)
}
}
func TestNewHostServiceRegistersHostAgents(t *testing.T) {
svc := NewHostService(nil)
if len(svc.entries) != 2 {
t.Fatalf("entries = %d, want 2", len(svc.entries))
}
if svc.entries[0].agentID != "claude-acp" || svc.entries[1].agentID != "codex-acp" {
t.Errorf("agent IDs = %q, %q", svc.entries[0].agentID, svc.entries[1].agentID)
}
}
// runCoalescedFetch launches n concurrent GetOrFetchWithin callers whose first
// fetch blocks until every goroutine has been started, maximizing contention
// without sleep-based synchronization. It returns the per-caller results.
func runCoalescedFetch(
t *testing.T,
cache *UsageCache,
n int,
result *ProviderUsage,
fetchErr error,
calls *atomic.Int32,
) {
t.Helper()
release := make(chan struct{})
fetch := func(_ context.Context) (*ProviderUsage, error) {
calls.Add(1)
<-release
return result, fetchErr
}
var started, wg sync.WaitGroup
for range n {
started.Add(1)
wg.Add(1)
go func() {
defer wg.Done()
started.Done()
_, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, fetch)
if fetchErr == nil && err != nil {
t.Errorf("GetOrFetchWithin: %v", err)
}
if fetchErr != nil && err == nil {
t.Error("expected error from coalesced failing fetch")
}
}()
}
started.Wait()
close(release)
wg.Wait()
}
func TestUsageCacheCoalescesConcurrentFetches(t *testing.T) {
cache := NewUsageCache()
var calls atomic.Int32
runCoalescedFetch(t, cache, 8, &ProviderUsage{Provider: "anthropic", FetchedAt: time.Now()}, nil, &calls)
if got := calls.Load(); got != 1 {
t.Errorf("concurrent misses fetched %d times, want 1", got)
}
}
func TestUsageCacheCoalescesFailures(t *testing.T) {
cache := NewUsageCache()
var calls atomic.Int32
runCoalescedFetch(t, cache, 8, nil, errors.New("boom"), &calls)
if got := calls.Load(); got != 1 {
t.Errorf("concurrent failing fetches called provider %d times, want 1", got)
}
fetch := func(_ context.Context) (*ProviderUsage, error) {
calls.Add(1)
return nil, errors.New("boom")
}
// Once the negative entry ages past failureCacheTTL, the provider is retried.
cache.mu.Lock()
cache.entries["k"].errAt = time.Now().Add(-failureCacheTTL - time.Second)
cache.mu.Unlock()
if _, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, fetch); err == nil {
t.Error("expected error on retry after TTL")
}
if got := calls.Load(); got != 2 {
t.Errorf("expired failure entry should refetch, calls = %d, want 2", got)
}
}
func TestUsageCacheFailureKeepsStaleSuccess(t *testing.T) {
cache := NewUsageCache()
okUsage := &ProviderUsage{Provider: "anthropic", FetchedAt: time.Now()}
okFetch := func(_ context.Context) (*ProviderUsage, error) { return okUsage, nil }
failFetch := func(_ context.Context) (*ProviderUsage, error) { return nil, errors.New("boom") }
if _, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, okFetch); err != nil {
t.Fatalf("seed fetch: %v", err)
}
// Age the success past the fresh clamp but within the regular TTL.
cache.mu.Lock()
cache.entries["k"].successAt = time.Now().Add(-freshMaxAge - time.Second)
cache.mu.Unlock()
// A failed fresh refresh returns the error to the fresh caller...
if _, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge, failFetch); err == nil {
t.Fatal("expected error from failed fresh fetch")
}
// ...but must not evict the stale-but-valid success for non-fresh callers.
got, err := cache.GetOrFetchWithin(context.Background(), "k", cacheTTL, failFetch)
if err != nil || got != okUsage {
t.Errorf("stale success was evicted: usage=%v err=%v", got, err)
}
}
func TestUsageCacheDoesNotCacheCancellation(t *testing.T) {
cache := NewUsageCache()
var calls atomic.Int32
fetch := func(ctx context.Context) (*ProviderUsage, error) {
calls.Add(1)
return nil, ctx.Err()
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := cache.GetOrFetchWithin(cancelled, "k", freshMaxAge, fetch); err == nil {
t.Fatal("expected cancellation error")
}
// The cancellation must not have been recorded as a provider failure.
okUsage := &ProviderUsage{Provider: "anthropic", FetchedAt: time.Now()}
got, err := cache.GetOrFetchWithin(context.Background(), "k", freshMaxAge,
func(_ context.Context) (*ProviderUsage, error) { return okUsage, nil })
if err != nil || got != okUsage {
t.Errorf("cancelled fetch poisoned the cache: usage=%v err=%v", got, err)
}
if calls.Load() != 1 {
t.Errorf("fetch calls = %d, want 1", calls.Load())
}
}
+2 -1
View File
@@ -27,7 +27,8 @@ type UtilizationWindow struct {
// ProviderUsage is the full utilization response for one provider credential.
type ProviderUsage struct {
Provider string `json:"provider"` // "anthropic", "openai"
Provider string `json:"provider"` // "anthropic", "openai"
Plan string `json:"plan,omitempty"` // e.g. "max", "pro", "plus", "free"
Windows []UtilizationWindow `json:"windows"`
FetchedAt time.Time `json:"fetched_at"`
}
@@ -11,6 +11,7 @@ import (
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/registry"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
agentusage "github.com/kandev/kandev/internal/agent/usage"
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/kandev/kandev/internal/automation"
"github.com/kandev/kandev/internal/common/config"
@@ -46,6 +47,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
return nil, nil, err
}
agentSettingsController := agentsettingscontroller.NewController(repos.AgentSettings, discoveryRegistry, agentRegistry, repos.Task, log)
agentSettingsController.SetHostUsageLister(agentusage.NewHostService(log))
userSvc := userservice.NewService(repos.User, eventBus, log)
editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc)
@@ -11,7 +11,7 @@ import { toast } from "sonner";
import { useAppStore } from "@/components/state-provider";
import { updateAgentProfile, getAgentUtilization } from "@/lib/api/domains/office-api";
import type { AgentProfile, AgentRole, ProviderUsage } from "@/lib/state/slices/office/types";
import { UtilizationBars } from "@/app/office/components/utilization-bars";
import { UtilizationBars } from "@/components/usage/utilization-bars";
type AgentOverviewTabProps = {
agent: AgentProfile;
+1 -1
View File
@@ -23,7 +23,7 @@ import { AgentCardsPanel } from "./components/agent-cards-panel";
import { ProviderHealthCard } from "./components/routing/provider-health-card";
import { timeAgo } from "@/lib/utils/time";
import { UtilizationBars } from "./components/utilization-bars";
import { UtilizationBars } from "@/components/usage/utilization-bars";
import { formatDollars } from "@/lib/utils";
// formatMonthSpend renders the subcents value from /office dashboard
+3
View File
@@ -31,6 +31,7 @@ import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { AgentLogo } from "@/components/agent-logo";
import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog";
import { AgentUsageSection } from "@/components/settings/agent-usage-section";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
import { InstallAgentCard } from "@/components/settings/install-agent-card";
import { InstalledAgentCard } from "@/components/settings/installed-agent-card";
@@ -575,6 +576,8 @@ export default function AgentsSettingsPage() {
handleRescan={handleRescan}
/>
<AgentUsageSection />
<SuggestInstallSection
notInstalledAgents={notInstalledAgents}
tools={tools}
@@ -0,0 +1,82 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { AgentUsageSection } from "@/components/settings/agent-usage-section";
import type { AgentSubscriptionUsageResponse } from "@/lib/types/http";
const listAgentSubscriptionUsage = vi.hoisted(() =>
vi.fn<[], Promise<AgentSubscriptionUsageResponse>>(),
);
vi.mock("@/lib/api", () => ({
listAgentSubscriptionUsage,
}));
vi.mock("@/components/agent-logo", () => ({
AgentLogo: ({ agentName }: { agentName: string }) => <span data-testid={`logo-${agentName}`} />,
}));
afterEach(() => {
cleanup();
listAgentSubscriptionUsage.mockReset();
});
describe("AgentUsageSection", () => {
it("renders nothing when no subscription agents are present", async () => {
listAgentSubscriptionUsage.mockResolvedValue({ agents: [] });
const { container } = render(<AgentUsageSection />);
await waitFor(() => expect(listAgentSubscriptionUsage).toHaveBeenCalled());
expect(container.querySelector('[data-testid="agent-usage-section"]')).toBeNull();
});
it("renders utilization windows, plan badge, and per-agent errors", async () => {
listAgentSubscriptionUsage.mockResolvedValue({
agents: [
{
agent_id: "claude-acp",
display_name: "Claude Code",
usage: {
provider: "anthropic",
plan: "max",
windows: [
{
label: "5-hour",
utilization_pct: 62,
reset_at: new Date(Date.now() + 3600_000).toISOString(),
},
{
label: "7-day",
utilization_pct: 15,
reset_at: new Date(Date.now() + 86400_000).toISOString(),
},
],
fetched_at: new Date().toISOString(),
},
},
{
agent_id: "codex-acp",
display_name: "Codex",
error: "codex usage: unexpected status 500",
},
],
});
render(
<TooltipProvider>
<AgentUsageSection />
</TooltipProvider>,
);
await screen.findByTestId("agent-usage-section");
expect(screen.getByText("Claude Code")).toBeDefined();
expect(screen.getByText("max")).toBeDefined();
expect(screen.getByText("Good")).toBeDefined(); // worst window 62% → Good
expect(screen.getByText("5h")).toBeDefined();
expect(screen.getByText("62%")).toBeDefined();
expect(screen.getByText("7d")).toBeDefined();
expect(screen.getByText("Codex")).toBeDefined();
expect(screen.getByText("Could not fetch usage data.")).toBeDefined();
});
});
@@ -0,0 +1,89 @@
"use client";
import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Separator } from "@kandev/ui/separator";
import { cn } from "@/lib/utils";
import { AgentLogo } from "@/components/agent-logo";
import { UsageWindowRows, usageStatus } from "@/components/usage/usage-window-rows";
import { useAgentSubscriptionUsage } from "@/hooks/domains/settings/use-agent-subscription-usage";
import type { AgentSubscriptionUsage } from "@/lib/types/http";
function AgentUsageCard({ item }: { item: AgentSubscriptionUsage }) {
const usage = item.usage;
const status = usage && usage.windows.length > 0 ? usageStatus(usage) : null;
return (
<Card data-testid={`agent-usage-card-${item.agent_id}`}>
<CardContent className="py-4 space-y-4">
<div className="flex items-center gap-2">
<AgentLogo agentName={item.agent_id} className="shrink-0" />
<h4 className="font-medium">{item.display_name}</h4>
{usage?.plan && (
<Badge variant="secondary" className="capitalize">
{usage.plan}
</Badge>
)}
{status && (
<span className={cn("ml-auto text-xs font-semibold", status.className)}>
{status.label}
</span>
)}
</div>
{item.error ? (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<IconAlertTriangle className="h-3.5 w-3.5 shrink-0" />
Could not fetch usage data.
</p>
) : (
usage && <UsageWindowRows usage={usage} />
)}
</CardContent>
</Card>
);
}
/**
* "Subscription Usage" section on Settings > Agents. Shows rate-limit window
* utilization for host agents authenticated with a subscription (OAuth) —
* Claude Code and Codex. Hidden when no subscription-billed agent is present.
*/
export function AgentUsageSection() {
const { items, loading, refresh } = useAgentSubscriptionUsage();
if (items.length === 0) return null;
return (
<div className="space-y-4" data-testid="agent-usage-section">
<Separator />
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold">Subscription Usage</h3>
<p className="text-sm text-muted-foreground">
Rate-limit utilization for agents signed in with a subscription plan.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void refresh()}
disabled={loading}
className="cursor-pointer"
>
{loading ? (
<IconLoader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<IconRefresh className="h-4 w-4 mr-2" />
)}
Refresh
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<AgentUsageCard key={item.agent_id} item={item} />
))}
</div>
</div>
);
}
@@ -1,12 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
import { useSessionAgentUsage } from "@/hooks/domains/session/use-session-agent-usage";
import { isContextWindowReliable, TokenUsageDisplay } from "./token-usage-display";
vi.mock("@/hooks/domains/session/use-session-context-window", () => ({
useSessionContextWindow: vi.fn(),
}));
vi.mock("@/hooks/domains/session/use-session-agent-usage", () => ({
useSessionAgentUsage: vi.fn(() => null),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -67,4 +72,62 @@ describe("TokenUsageDisplay", () => {
expect(container.querySelector(".cursor-help")).not.toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("shows subscription usage rows in the tooltip when the agent has them", () => {
vi.mocked(useSessionContextWindow).mockReturnValue({
size: 200_000,
used: 56_047,
remaining: 143_953,
efficiency: 28,
});
vi.mocked(useSessionAgentUsage).mockReturnValue({
agent_id: "claude-acp",
display_name: "Claude",
usage: {
provider: "anthropic",
plan: "max",
windows: [
{
label: "5-hour",
utilization_pct: 86,
reset_at: new Date(Date.now() + 3 * 3_600_000).toISOString(),
},
{
label: "7-day",
utilization_pct: 19,
reset_at: new Date(Date.now() + 30 * 3_600_000).toISOString(),
},
],
fetched_at: new Date().toISOString(),
},
});
const { container, getByText } = render(<TokenUsageDisplay sessionId="sess-1" />);
expect(container.querySelector('[data-testid="doughnut-subscription-usage"]')).not.toBeNull();
expect(getByText(/Subscription usage · max/i)).toBeDefined();
// Worst window is 86% → provider status "High".
expect(getByText("High")).toBeDefined();
expect(getByText("5h")).toBeDefined();
expect(getByText("86%")).toBeDefined();
expect(getByText("7d")).toBeDefined();
// Reset countdown column (≈3h out) and colored bars per window.
expect(getByText(/^(3h 0m|2h 59m)$/)).toBeDefined();
expect(container.querySelector(".bg-amber-500")).not.toBeNull(); // 86% bar
expect(container.querySelector(".bg-emerald-500")).not.toBeNull(); // 19% bar
});
it("omits the subscription block when the agent has no usage", () => {
vi.mocked(useSessionContextWindow).mockReturnValue({
size: 200_000,
used: 56_047,
remaining: 143_953,
efficiency: 28,
});
vi.mocked(useSessionAgentUsage).mockReturnValue(null);
const { container } = render(<TokenUsageDisplay sessionId="sess-1" />);
expect(container.querySelector('[data-testid="doughnut-subscription-usage"]')).toBeNull();
});
});
@@ -3,7 +3,9 @@
import { memo } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { UsageWindowRows, usageStatus } from "@/components/usage/usage-window-rows";
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
import { useSessionAgentUsage } from "@/hooks/domains/session/use-session-agent-usage";
type TokenUsageDisplayProps = {
sessionId: string | null;
@@ -39,6 +41,33 @@ function getCircleColor(efficiency: number): string {
return "text-blue-300";
}
/**
* Subscription utilization rows for the session's agent, rendered inside the
* doughnut tooltip. Mounted only while the tooltip is open, so each hover
* triggers a fresh provider fetch (server-clamped to one per 15 s).
*/
function SessionUsageRows({ sessionId }: { sessionId: string | null }) {
const agentUsage = useSessionAgentUsage(sessionId);
const usage = agentUsage?.usage;
if (!usage || usage.windows.length === 0) return null;
const status = usageStatus(usage);
return (
<div
className="pt-2 mt-2 border-t border-border/60 space-y-2 min-w-56"
data-testid="doughnut-subscription-usage"
>
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Subscription usage{usage.plan ? ` · ${usage.plan}` : ""}
</span>
<span className={cn("text-[10px] font-semibold", status.className)}>{status.label}</span>
</div>
<UsageWindowRows usage={usage} />
</div>
);
}
export const TokenUsageDisplay = memo(function TokenUsageDisplay({
sessionId,
className,
@@ -100,6 +129,7 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
<div className="font-medium">
{usagePercent.toFixed(0)}% ({formatNumber(used)} / {formatNumber(size)})
</div>
<SessionUsageRows sessionId={sessionId} />
</div>
</TooltipContent>
</Tooltip>
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import {
UsageWindowRows,
formatResetShort,
shortWindowLabel,
usageStatus,
} from "./usage-window-rows";
import type { ProviderUsage } from "@/lib/types/agent-profile";
afterEach(cleanup);
describe("shortWindowLabel", () => {
it("shortens hour and day windows", () => {
expect(shortWindowLabel("5-hour")).toBe("5h");
expect(shortWindowLabel("7-day")).toBe("7d");
expect(shortWindowLabel("30-day")).toBe("30d");
expect(shortWindowLabel("7-day (Opus)")).toBe("7d (Opus)");
});
it("leaves unknown labels untouched", () => {
expect(shortWindowLabel("primary")).toBe("primary");
});
});
describe("formatResetShort", () => {
const now = Date.UTC(2026, 6, 14, 12, 0, 0);
it("formats minutes", () => {
expect(formatResetShort(new Date(now + 42 * 60_000).toISOString(), now)).toBe("42m");
});
it("formats hours and minutes", () => {
expect(formatResetShort(new Date(now + 3 * 3_600_000 + 17 * 60_000).toISOString(), now)).toBe(
"3h 17m",
);
});
it("formats days and hours", () => {
expect(formatResetShort(new Date(now + 29 * 3_600_000).toISOString(), now)).toBe("1d 5h");
});
it("handles past reset times", () => {
expect(formatResetShort(new Date(now - 60_000).toISOString(), now)).toBe("soon");
});
it("handles invalid timestamps", () => {
expect(formatResetShort("not-a-date", now)).toBe("soon");
});
});
function makeUsage(pcts: number[]): ProviderUsage {
return {
provider: "anthropic",
plan: "max",
windows: pcts.map((pct, i) => ({
label: `${i + 1}-day`,
utilization_pct: pct,
reset_at: new Date(Date.now() + 3_600_000).toISOString(),
})),
fetched_at: new Date().toISOString(),
};
}
describe("usageStatus", () => {
it("is Good below 80%", () => {
expect(usageStatus(makeUsage([10, 79])).label).toBe("Good");
});
it("is High at 80–89%", () => {
expect(usageStatus(makeUsage([10, 85])).label).toBe("High");
});
it("is Critical at 90%+", () => {
expect(usageStatus(makeUsage([10, 94])).label).toBe("Critical");
});
});
describe("UsageWindowRows", () => {
it("renders a colored bar, percentage, and reset time per window", () => {
const usage: ProviderUsage = {
provider: "anthropic",
plan: "max",
windows: [
{
label: "5-hour",
utilization_pct: 94,
reset_at: new Date(Date.now() + 2 * 3_600_000).toISOString(),
},
{
label: "7-day",
utilization_pct: 39,
// 30s under 5 days so the elapsed render time cannot flip the
// formatted value between "5d 0h" and "4d 23h".
reset_at: new Date(Date.now() + 5 * 24 * 3_600_000 - 30_000).toISOString(),
},
],
fetched_at: new Date().toISOString(),
};
const { container, getByText } = render(<UsageWindowRows usage={usage} />);
expect(getByText("5h")).toBeDefined();
expect(getByText("94%")).toBeDefined();
expect(getByText("7d")).toBeDefined();
expect(getByText("39%")).toBeDefined();
expect(getByText("4d 23h")).toBeDefined();
expect(container.querySelector(".bg-red-500")).not.toBeNull();
expect(container.querySelector(".bg-emerald-500")).not.toBeNull();
});
});
@@ -0,0 +1,86 @@
"use client";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
import type { ProviderUsage, UtilizationWindow } from "@/lib/types/agent-profile";
import { getBarColor, getTextColor } from "./utilization-bars";
/** "5-hour" → "5h", "7-day (Opus)" → "7d (Opus)". */
export function shortWindowLabel(label: string): string {
return label.replace(/(\d+)-hour/i, "$1h").replace(/(\d+)-day/i, "$1d");
}
/** Compact time-until-reset: "3h 3m", "5d 12h", "42m", "soon". */
export function formatResetShort(resetAt: string, now = Date.now()): string {
const diffMs = new Date(resetAt).getTime() - now;
if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon";
const totalHours = Math.floor(diffMs / 3_600_000);
const minutes = Math.floor((diffMs % 3_600_000) / 60_000);
if (totalHours >= 24) return `${Math.floor(totalHours / 24)}d ${totalHours % 24}h`;
if (totalHours > 0) return `${totalHours}h ${minutes}m`;
return `${minutes}m`;
}
export type UsageStatus = {
label: "Good" | "High" | "Critical";
className: string;
};
/** Worst-window health for a provider, rendered like "Good" / "Critical". */
export function usageStatus(usage: ProviderUsage): UsageStatus {
const worst = Math.max(0, ...usage.windows.map((w) => w.utilization_pct));
if (worst >= 90) return { label: "Critical", className: "text-red-600 dark:text-red-400" };
if (worst >= 80) return { label: "High", className: "text-amber-600 dark:text-amber-400" };
return { label: "Good", className: "text-emerald-600 dark:text-emerald-400" };
}
function UsageWindowCells({ window: w }: { window: UtilizationWindow }) {
const pct = Math.min(100, Math.max(0, w.utilization_pct));
return (
<>
<span className="text-muted-foreground whitespace-nowrap">{shortWindowLabel(w.label)}</span>
<div className="h-1.5 min-w-20 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", getBarColor(pct))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn("font-semibold tabular-nums text-right", getTextColor(pct))}>
{Math.round(pct)}%
</span>
<span
className="text-muted-foreground/70 tabular-nums text-right whitespace-nowrap"
title={`Resets in ${formatResetShort(w.reset_at)}`}
>
{formatResetShort(w.reset_at)}
</span>
</>
);
}
/**
* Compact per-window utilization rows: `5h [==== ] 39% 3h 3m`.
* Shared by the settings usage cards and the chat doughnut tooltip.
*/
export function UsageWindowRows({
usage,
className,
}: {
usage: ProviderUsage;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-[auto_1fr_auto_auto] items-center gap-x-3 gap-y-2 text-xs",
className,
)}
>
{usage.windows.map((w) => (
<Fragment key={w.label}>
<UsageWindowCells window={w} />
</Fragment>
))}
</div>
);
}
@@ -1,22 +1,22 @@
"use client";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { ProviderUsage } from "@/lib/state/slices/office/types";
import type { ProviderUsage } from "@/lib/types/agent-profile";
type Props = {
usage: ProviderUsage;
};
function getBarColor(pct: number): string {
export function getBarColor(pct: number): string {
if (pct >= 90) return "bg-red-500";
if (pct >= 80) return "bg-amber-500";
return "bg-blue-500";
return "bg-emerald-500";
}
function getTextColor(pct: number): string {
export function getTextColor(pct: number): string {
if (pct >= 90) return "text-red-600 dark:text-red-400";
if (pct >= 80) return "text-amber-600 dark:text-amber-400";
return "text-muted-foreground";
return "text-emerald-600 dark:text-emerald-400";
}
function formatResetTime(resetAt: string): string {
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { listAgentSubscriptionUsage } from "@/lib/api";
import type { AgentSubscriptionUsage } from "@/lib/types/http";
// Module-level memo: the last usage listing, served instantly on the next
// tooltip open while a fresh fetch is in flight. One in-flight request is
// shared between concurrent consumers.
let lastAgents: AgentSubscriptionUsage[] | null = null;
let inflight: Promise<AgentSubscriptionUsage[]> | null = null;
function fetchFreshAgentUsage(): Promise<AgentSubscriptionUsage[]> {
if (!inflight) {
inflight = listAgentSubscriptionUsage({ cache: "no-store", fresh: true })
.then((response) => {
lastAgents = response.agents ?? [];
return lastAgents;
})
.finally(() => {
inflight = null;
});
}
return inflight;
}
/** Test-only: clear the module-level memo between tests. */
export function resetSessionAgentUsageCacheForTest() {
lastAgents = null;
inflight = null;
}
/** Resolves the session's agent name (e.g. "claude-acp") from the store. */
export function useSessionAgentName(sessionId: string | null): string | null {
const profileId = useAppStore((state) =>
sessionId ? state.taskSessions.items[sessionId]?.agent_profile_id : undefined,
);
const agentName = useAppStore((state) =>
profileId
? state.agentProfiles.items.find((profile) => profile.id === profileId)?.agent_name
: undefined,
);
return agentName ?? null;
}
/**
* Live subscription usage for the session's agent. Fetches fresh provider
* data on mount — mount the consuming component lazily (e.g. inside a tooltip
* content) so hovering triggers the fetch. The previous listing renders
* immediately while the fresh one is in flight. Returns null while the agent
* is unknown or has no subscription usage.
*/
export function useSessionAgentUsage(sessionId: string | null): AgentSubscriptionUsage | null {
const agentName = useSessionAgentName(sessionId);
const [agents, setAgents] = useState<AgentSubscriptionUsage[]>(lastAgents ?? []);
useEffect(() => {
if (!agentName) return;
let active = true;
fetchFreshAgentUsage()
.then((list) => {
if (active) setAgents(list);
})
.catch(() => {
// Don't keep presenting stale usage as live: drop the memo so the
// next open refetches, and hide the rows for this open.
lastAgents = null;
if (active) setAgents([]);
});
return () => {
active = false;
};
}, [agentName]);
if (!agentName) return null;
return agents.find((agent) => agent.agent_id === agentName) ?? null;
}
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { listAgentSubscriptionUsage } from "@/lib/api";
import type { AgentSubscriptionUsage } from "@/lib/types/http";
/**
* Fetches subscription utilization for host-installed agents (Claude Code,
* Codex). The initial load accepts the backend's 5-minute cache; manual
* refresh() requests fresh provider data (server-clamped to 15 s).
*/
export function useAgentSubscriptionUsage() {
const [items, setItems] = useState<AgentSubscriptionUsage[]>([]);
const [loading, setLoading] = useState(true);
const fetchingRef = useRef(false);
const fetchUsage = useCallback(async (fresh: boolean) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
setLoading(true);
try {
const response = await listAgentSubscriptionUsage({ cache: "no-store", fresh });
setItems(response.agents ?? []);
} catch {
// Keep the previous items: clearing them would hide the whole section
// (including the Refresh button) on a transient refresh failure.
} finally {
fetchingRef.current = false;
setLoading(false);
}
}, []);
const refresh = useCallback(() => fetchUsage(true), [fetchUsage]);
useEffect(() => {
void fetchUsage(false);
}, [fetchUsage]);
return { items, loading, refresh };
}
+9
View File
@@ -2,6 +2,7 @@ import { fetchJson, fetchJsonWithRetry, type ApiRequestOptions } from "../client
import { getBackendConfig } from "@/lib/config";
import type {
Agent,
AgentSubscriptionUsageResponse,
ListAgentsResponse,
ListAgentDiscoveryResponse,
ListAvailableAgentsResponse,
@@ -215,6 +216,14 @@ export async function listAvailableAgents(
return fetchJson<ListAvailableAgentsResponse>("/api/v1/agents/available", options);
}
export async function listAgentSubscriptionUsage(
options?: ApiRequestOptions & { fresh?: boolean },
): Promise<AgentSubscriptionUsageResponse> {
const { fresh, ...rest } = options ?? {};
const path = fresh ? "/api/v1/agents/usage?fresh=true" : "/api/v1/agents/usage";
return fetchJson<AgentSubscriptionUsageResponse>(path, rest);
}
export async function getAgentProfileMcpConfig(
profileId: string,
options?: ApiRequestOptions,
+12
View File
@@ -36,10 +36,22 @@ export type UtilizationWindow = {
export type ProviderUsage = {
provider: string;
plan?: string;
windows: UtilizationWindow[];
fetched_at: string;
};
export type AgentSubscriptionUsage = {
agent_id: string;
display_name: string;
usage?: ProviderUsage | null;
error?: string;
};
export type AgentSubscriptionUsageResponse = {
agents: AgentSubscriptionUsage[];
};
export type AgentRole =
| "ceo"
| "worker"
+2
View File
@@ -10,6 +10,8 @@ export type {
AgentProfilePayload,
AgentRole,
AgentStatus,
AgentSubscriptionUsage,
AgentSubscriptionUsageResponse,
BillingType,
CLIFlag,
UtilizationWindow,