diff --git a/apps/backend/internal/agent/agents/billing.go b/apps/backend/internal/agent/agents/billing.go index 0ac37975d4..bfe9166305 100644 --- a/apps/backend/internal/agent/agents/billing.go +++ b/apps/backend/internal/agent/agents/billing.go @@ -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 -}) +} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 1eddd23550..6239832a63 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/subscription_usage.go b/apps/backend/internal/agent/settings/controller/subscription_usage.go new file mode 100644 index 0000000000..feafa324c7 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/subscription_usage.go @@ -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 +} diff --git a/apps/backend/internal/agent/settings/controller/subscription_usage_test.go b/apps/backend/internal/agent/settings/controller/subscription_usage_test.go new file mode 100644 index 0000000000..2c2cf20cd4 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/subscription_usage_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index a8deb36d6f..78025cb824 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -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"` diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index b2ea31683d..f5c93dfffc 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -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 { diff --git a/apps/backend/internal/agent/usage/cache.go b/apps/backend/internal/agent/usage/cache.go index 62e79cd75c..1d1168b9df 100644 --- a/apps/backend/internal/agent/usage/cache.go +++ b/apps/backend/internal/agent/usage/cache.go @@ -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. diff --git a/apps/backend/internal/agent/usage/client_claude.go b/apps/backend/internal/agent/usage/client_claude.go index 05a6381416..092291a30d 100644 --- a/apps/backend/internal/agent/usage/client_claude.go +++ b/apps/backend/internal/agent/usage/client_claude.go @@ -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 } diff --git a/apps/backend/internal/agent/usage/client_claude_internal_test.go b/apps/backend/internal/agent/usage/client_claude_internal_test.go new file mode 100644 index 0000000000..b56e0db0c3 --- /dev/null +++ b/apps/backend/internal/agent/usage/client_claude_internal_test.go @@ -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") + } +} diff --git a/apps/backend/internal/agent/usage/client_codex.go b/apps/backend/internal/agent/usage/client_codex.go index 264d9a7625..118ad334b3 100644 --- a/apps/backend/internal/agent/usage/client_codex.go +++ b/apps/backend/internal/agent/usage/client_codex.go @@ -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 } diff --git a/apps/backend/internal/agent/usage/client_codex_internal_test.go b/apps/backend/internal/agent/usage/client_codex_internal_test.go new file mode 100644 index 0000000000..412d245e20 --- /dev/null +++ b/apps/backend/internal/agent/usage/client_codex_internal_test.go @@ -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) + } + } +} diff --git a/apps/backend/internal/agent/usage/fileutil.go b/apps/backend/internal/agent/usage/fileutil.go new file mode 100644 index 0000000000..f22f3b594b --- /dev/null +++ b/apps/backend/internal/agent/usage/fileutil.go @@ -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 +} diff --git a/apps/backend/internal/agent/usage/host.go b/apps/backend/internal/agent/usage/host.go new file mode 100644 index 0000000000..43349d40ca --- /dev/null +++ b/apps/backend/internal/agent/usage/host.go @@ -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 +} diff --git a/apps/backend/internal/agent/usage/host_internal_test.go b/apps/backend/internal/agent/usage/host_internal_test.go new file mode 100644 index 0000000000..ee39885121 --- /dev/null +++ b/apps/backend/internal/agent/usage/host_internal_test.go @@ -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()) + } +} diff --git a/apps/backend/internal/agent/usage/types.go b/apps/backend/internal/agent/usage/types.go index 23a0bcf4c3..94dd171977 100644 --- a/apps/backend/internal/agent/usage/types.go +++ b/apps/backend/internal/agent/usage/types.go @@ -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"` } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 2a691eb2fb..ea65742b13 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -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) diff --git a/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx index c28e755381..b0e57880c8 100644 --- a/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx @@ -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; diff --git a/apps/web/app/office/page-client.tsx b/apps/web/app/office/page-client.tsx index e7f1b4b617..8faa79114a 100644 --- a/apps/web/app/office/page-client.tsx +++ b/apps/web/app/office/page-client.tsx @@ -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 diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 8a63f07644..73d4e2ae5b 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -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} /> + + + vi.fn<[], Promise>(), +); + +vi.mock("@/lib/api", () => ({ + listAgentSubscriptionUsage, +})); + +vi.mock("@/components/agent-logo", () => ({ + AgentLogo: ({ agentName }: { agentName: string }) => , +})); + +afterEach(() => { + cleanup(); + listAgentSubscriptionUsage.mockReset(); +}); + +describe("AgentUsageSection", () => { + it("renders nothing when no subscription agents are present", async () => { + listAgentSubscriptionUsage.mockResolvedValue({ agents: [] }); + + const { container } = render(); + + 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( + + + , + ); + + 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(); + }); +}); diff --git a/apps/web/components/settings/agent-usage-section.tsx b/apps/web/components/settings/agent-usage-section.tsx new file mode 100644 index 0000000000..087c960c90 --- /dev/null +++ b/apps/web/components/settings/agent-usage-section.tsx @@ -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 ( + + +
+ +

{item.display_name}

+ {usage?.plan && ( + + {usage.plan} + + )} + {status && ( + + {status.label} + + )} +
+ {item.error ? ( +

+ + Could not fetch usage data. +

+ ) : ( + usage && + )} +
+
+ ); +} + +/** + * "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 ( +
+ +
+
+

Subscription Usage

+

+ Rate-limit utilization for agents signed in with a subscription plan. +

+
+ +
+ +
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/components/task/chat/token-usage-display.test.tsx b/apps/web/components/task/chat/token-usage-display.test.tsx index f7a8d4066b..6c5dedd3c2 100644 --- a/apps/web/components/task/chat/token-usage-display.test.tsx +++ b/apps/web/components/task/chat/token-usage-display.test.tsx @@ -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(); + + 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(); + + expect(container.querySelector('[data-testid="doughnut-subscription-usage"]')).toBeNull(); + }); }); diff --git a/apps/web/components/task/chat/token-usage-display.tsx b/apps/web/components/task/chat/token-usage-display.tsx index 798c9ab759..0b373de30d 100644 --- a/apps/web/components/task/chat/token-usage-display.tsx +++ b/apps/web/components/task/chat/token-usage-display.tsx @@ -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 ( +
+
+ + Subscription usage{usage.plan ? ` · ${usage.plan}` : ""} + + {status.label} +
+ +
+ ); +} + export const TokenUsageDisplay = memo(function TokenUsageDisplay({ sessionId, className, @@ -100,6 +129,7 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
{usagePercent.toFixed(0)}% ({formatNumber(used)} / {formatNumber(size)})
+ diff --git a/apps/web/components/usage/usage-window-rows.test.tsx b/apps/web/components/usage/usage-window-rows.test.tsx new file mode 100644 index 0000000000..4764b59a7a --- /dev/null +++ b/apps/web/components/usage/usage-window-rows.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/apps/web/components/usage/usage-window-rows.tsx b/apps/web/components/usage/usage-window-rows.tsx new file mode 100644 index 0000000000..a1513df93a --- /dev/null +++ b/apps/web/components/usage/usage-window-rows.tsx @@ -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 ( + <> + {shortWindowLabel(w.label)} +
+
+
+ + {Math.round(pct)}% + + + {formatResetShort(w.reset_at)} + + + ); +} + +/** + * 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 ( +
+ {usage.windows.map((w) => ( + + + + ))} +
+ ); +} diff --git a/apps/web/app/office/components/utilization-bars.tsx b/apps/web/components/usage/utilization-bars.tsx similarity index 89% rename from apps/web/app/office/components/utilization-bars.tsx rename to apps/web/components/usage/utilization-bars.tsx index 7a814ae85f..a48b3c6a4c 100644 --- a/apps/web/app/office/components/utilization-bars.tsx +++ b/apps/web/components/usage/utilization-bars.tsx @@ -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 { diff --git a/apps/web/hooks/domains/session/use-session-agent-usage.ts b/apps/web/hooks/domains/session/use-session-agent-usage.ts new file mode 100644 index 0000000000..5709d0ece3 --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-agent-usage.ts @@ -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 | null = null; + +function fetchFreshAgentUsage(): Promise { + 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(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; +} diff --git a/apps/web/hooks/domains/settings/use-agent-subscription-usage.ts b/apps/web/hooks/domains/settings/use-agent-subscription-usage.ts new file mode 100644 index 0000000000..4a3a97897b --- /dev/null +++ b/apps/web/hooks/domains/settings/use-agent-subscription-usage.ts @@ -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([]); + 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 }; +} diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index f0e311511a..ac468bba77 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -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("/api/v1/agents/available", options); } +export async function listAgentSubscriptionUsage( + options?: ApiRequestOptions & { fresh?: boolean }, +): Promise { + const { fresh, ...rest } = options ?? {}; + const path = fresh ? "/api/v1/agents/usage?fresh=true" : "/api/v1/agents/usage"; + return fetchJson(path, rest); +} + export async function getAgentProfileMcpConfig( profileId: string, options?: ApiRequestOptions, diff --git a/apps/web/lib/types/agent-profile.ts b/apps/web/lib/types/agent-profile.ts index 355557a97f..27ef3509c7 100644 --- a/apps/web/lib/types/agent-profile.ts +++ b/apps/web/lib/types/agent-profile.ts @@ -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" diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 7089588b63..809ccfd40c 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -10,6 +10,8 @@ export type { AgentProfilePayload, AgentRole, AgentStatus, + AgentSubscriptionUsage, + AgentSubscriptionUsageResponse, BillingType, CLIFlag, UtilizationWindow,