diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index a496926d..b35eab72 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -13,9 +13,10 @@ // Rate-limit state persisted across probe() calls (module scope survives re-invocations). const MIN_USAGE_FETCH_INTERVAL_MS = 5 * 60 * 1000 // never poll more than once per 5 min const DEFAULT_RATE_LIMIT_BACKOFF_MS = 5 * 60 * 1000 // fallback when no Retry-After header - let rateLimitedUntilMs = 0 // epoch ms; 0 = not rate-limited - let lastUsageFetchMs = 0 // epoch ms of the most-recent API attempt - let cachedUsageData = null // last successful API response body (parsed JSON) + let rateLimitedUntilMs = 0 // epoch ms; 0 = not rate-limited + let lastUsageFetchMs = 0 // epoch ms of the most-recent API attempt + let cachedUsageData = null // last successful API response body (parsed JSON) + let cachedUsageOrgBillingOnly = false // true when last real API response was 403 (Enterprise) function utf8DecodeBytes(bytes) { // Prefer native TextDecoder when available (QuickJS may not expose it). @@ -629,6 +630,9 @@ let lines = [] let rateLimited = false let retryAfterSeconds = null + // orgBillingOnly is initialised from the module-level cache so the correct badge + // is shown even when the API call is skipped (min-interval or rate-limit reuse). + let orgBillingOnly = cachedUsageOrgBillingOnly if (canFetchLiveUsage) { if (nowMs < rateLimitedUntilMs) { // Still within a rate-limit window from a previous probe call — skip the @@ -636,6 +640,7 @@ rateLimited = true retryAfterSeconds = Math.ceil((rateLimitedUntilMs - nowMs) / 1000) data = cachedUsageData + orgBillingOnly = cachedUsageOrgBillingOnly ctx.host.log.info("usage fetch skipped: rate-limited for " + retryAfterSeconds + "s more") } else { // Rate-limit window has expired (or was never set). Check whether we were @@ -647,6 +652,7 @@ if (!wasRateLimited && nowMs - lastUsageFetchMs < MIN_USAGE_FETCH_INTERVAL_MS) { // Polled too recently in normal operation — reuse last cached response. data = cachedUsageData + orgBillingOnly = cachedUsageOrgBillingOnly ctx.host.log.info( "usage fetch skipped: last fetch was " + Math.round((nowMs - lastUsageFetchMs) / 1000) + "s ago (min interval " + @@ -692,12 +698,24 @@ throw "Usage request failed. Check your connection." } - if (ctx.util.isAuthStatus(resp.status)) { + if (resp.status === 403) { + // A 403 from the usage endpoint means the account type does not have access + // to personal quota data. Enterprise accounts are billed at the organisation + // level and consistently return 403 here. Treat it as "no personal quota data" + // so the card shows a helpful badge rather than the error state, which leaves + // it blank on first load. A 401 (truly expired token) falls through to the + // isAuthStatus handler below. + // Clear cachedUsageData so stale Session/Weekly lines from a previous account + // cannot hide the Enterprise badge on subsequent min-interval reuse probes. + ctx.host.log.info("usage API returned 403 — organisation-level billing; no personal quota data") + orgBillingOnly = true + cachedUsageOrgBillingOnly = true + cachedUsageData = null + data = null + } else if (ctx.util.isAuthStatus(resp.status)) { ctx.host.log.error("usage returned auth error after all retries: status=" + resp.status) throw "Token expired. Run `claude` to log in again." - } - - if (resp.status === 429) { + } else if (resp.status === 429) { rateLimited = true retryAfterSeconds = parseRetryAfterSeconds(resp.headers) const backoffMs = retryAfterSeconds !== null @@ -719,6 +737,7 @@ throw "Usage response invalid. Try again later." } cachedUsageData = data + cachedUsageOrgBillingOnly = false rateLimitedUntilMs = 0 } } // end fetch else-branch @@ -860,7 +879,16 @@ : "Live usage rate limited — data may be stale" lines.push(ctx.line.text({ label: "Note", value: noteText })) } else if (lines.length === 0) { - lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" })) + if (orgBillingOnly) { + // 403 from the personal usage endpoint — org-level Enterprise billing. + lines.push(ctx.line.badge({ label: "Status", text: "Enterprise — org-level billing", color: "#a3a3a3" })) + } else if (canFetchLiveUsage && data !== null) { + // Successfully connected to the usage API but the response contained no + // recognized quota fields (e.g. unsupported plan types). + lines.push(ctx.line.badge({ label: "Status", text: "Connected — no quota data", color: "#a3a3a3" })) + } else { + lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" })) + } } return { plan: plan, lines: lines } @@ -872,6 +900,7 @@ rateLimitedUntilMs = 0 lastUsageFetchMs = 0 cachedUsageData = null + cachedUsageOrgBillingOnly = false } globalThis.__openusage_plugin = { id: "claude", probe, _resetState } diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index 8bf0fcc9..da232175 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -828,7 +828,11 @@ describe("claude plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Usage request failed") }) - it("shows status badge when no usage data and ccusage is unavailable", async () => { + it("shows 'Connected — no quota data' when API returns no recognized fields", async () => { + // The usage API connected successfully but returned no fields that the plugin + // understands (e.g. Enterprise plans or future plan types). The badge must + // say "Connected — no quota data" to distinguish "reachable but unrecognized" + // from "never connected / inference-only token". const ctx = makeCtx() ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } }) ctx.host.fs.exists = () => true @@ -843,7 +847,44 @@ describe("claude plugin", () => { expect(result.lines.find((l) => l.label === "Last 30 Days")).toBeUndefined() const statusLine = result.lines.find((l) => l.label === "Status") expect(statusLine).toBeTruthy() + expect(statusLine.text).toBe("Connected — no quota data") + }) + + it("shows 'Enterprise — org-level billing' badge when API returns 403", async () => { + // Enterprise accounts have organisation-level billing. The personal usage API + // returns 403 for these accounts, which previously caused probe() to throw and + // left the provider card in a blank/error state on first load. The fix treats + // 403 as "no personal quota data" so a helpful badge is shown instead. + const ctx = makeCtx() + ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } }) + ctx.host.fs.exists = () => true + ctx.host.http.request.mockReturnValue({ status: 403, bodyText: "", headers: {} }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const statusLine = result.lines.find((l) => l.label === "Status") + expect(statusLine).toBeTruthy() + expect(statusLine.text).toBe("Enterprise — org-level billing") + // plan detection is independent of the API response + expect(result.lines.find((l) => l.label === "Today")).toBeUndefined() + }) + + it("shows 'No usage data' for inference-only token with no local ccusage", async () => { + // Inference-only tokens (CLAUDE_CODE_OAUTH_TOKEN env var) skip the live usage API + // entirely. When there is also no local ccusage data the badge should say + // "No usage data" — not "Connected — no quota data" — because no API call was made. + const ctx = makeCtx() + ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "stored-token" } }) + ctx.host.fs.exists = () => true + ctx.host.env.get.mockImplementation((name) => + name === "CLAUDE_CODE_OAUTH_TOKEN" ? "env-inference-token" : null + ) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const statusLine = result.lines.find((l) => l.label === "Status") + expect(statusLine).toBeTruthy() expect(statusLine.text).toBe("No usage data") + // The live usage API must not be called for inference-only tokens + expect(ctx.host.http.request).not.toHaveBeenCalled() }) it("passes resetsAt through as ISO when present", async () => { @@ -1056,7 +1097,11 @@ describe("claude plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Session expired") }) - it("throws token expired when usage remains unauthorized after refresh", async () => { + it("shows org-billing badge when usage returns 403 after token refresh", async () => { + // First call returns 401 → refresh → second call returns 403. + // 403 from the usage endpoint means the account type has no personal quota access + // (e.g. Enterprise org-level billing), even after a successful token refresh. + // Showing "Enterprise — org-level billing" is more accurate than "Token expired". const ctx = makeCtx() ctx.host.fs.exists = () => true ctx.host.fs.readText = () => @@ -1079,7 +1124,84 @@ describe("claude plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Token expired") + const result = plugin.probe(ctx) + const statusLine = result.lines.find((l) => l.label === "Status") + expect(statusLine).toBeTruthy() + expect(statusLine.text).toBe("Enterprise — org-level billing") + }) + + it("Enterprise badge persists across min-interval reuse after 403", async () => { + // cachedUsageOrgBillingOnly must survive the min-interval reuse path so that + // Enterprise accounts don't flip to "No usage data" on the second probe. + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-14T10:00:00.000Z")) + try { + const ctx = makeCtx() + ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } }) + ctx.host.fs.exists = () => true + ctx.host.http.request.mockReturnValue({ status: 403, bodyText: "", headers: {} }) + + const plugin = await loadPlugin() + + // First probe: API returns 403 → Enterprise badge + const result1 = plugin.probe(ctx) + expect(result1.lines.find((l) => l.label === "Status")?.text).toBe( + "Enterprise — org-level billing" + ) + expect(ctx.host.http.request).toHaveBeenCalledTimes(1) + + // Second probe within min-interval: API must NOT be called again (throttled) + const result2 = plugin.probe(ctx) + expect(ctx.host.http.request).toHaveBeenCalledTimes(1) // no new call + // Badge must still reflect org-billing, not fall back to "No usage data" + expect(result2.lines.find((l) => l.label === "Status")?.text).toBe( + "Enterprise — org-level billing" + ) + } finally { + vi.useRealTimers() + } + }) + + it("clears Enterprise badge after successful 2xx usage response", async () => { + // cachedUsageOrgBillingOnly should be reset to false when the API later returns + // a successful response (e.g. account type changed, or credentials refreshed). + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-14T10:00:00.000Z")) + try { + const ctx = makeCtx() + ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } }) + ctx.host.fs.exists = () => true + + const plugin = await loadPlugin() + + // First probe: 403 → Enterprise badge + ctx.host.http.request.mockReturnValueOnce({ status: 403, bodyText: "", headers: {} }) + const result1 = plugin.probe(ctx) + expect(result1.lines.find((l) => l.label === "Status")?.text).toBe( + "Enterprise — org-level billing" + ) + + // Second probe past min-interval: API now returns successful quota data + vi.setSystemTime(new Date("2026-04-14T10:05:01.000Z")) + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 30, resets_at: null }, + }), + headers: {}, + }) + const result2 = plugin.probe(ctx) + // Session progress line must appear and Enterprise badge must be gone + expect(result2.lines.find((l) => l.label === "Session")).toBeTruthy() + expect(result2.lines.find((l) => l.text === "Enterprise — org-level billing")).toBeUndefined() + + // Third probe within new min-interval: cached result keeps cachedUsageOrgBillingOnly = false + const result3 = plugin.probe(ctx) + expect(result3.lines.find((l) => l.label === "Session")).toBeTruthy() + expect(result3.lines.find((l) => l.text === "Enterprise — org-level billing")).toBeUndefined() + } finally { + vi.useRealTimers() + } }) it("throws token expired when refresh is unauthorized", async () => { diff --git a/src/components/provider-card.test.tsx b/src/components/provider-card.test.tsx index 5a1bc1fe..a3b3c368 100644 --- a/src/components/provider-card.test.tsx +++ b/src/components/provider-card.test.tsx @@ -868,6 +868,56 @@ describe("ProviderCard", () => { expect(document.querySelector('[data-slot="progress-refreshing"]')).toBeNull() }) + it("always shows badge lines in overview scope even when label is not in skeleton", () => { + // Regression test: status badges ("No usage data", "Rate limited") were previously + // filtered out in overview mode because their label ("Status") wasn't listed as an + // overview-scoped line in plugin.json, causing a silently blank card. + render( + + ) + expect(screen.getByText("Status")).toBeInTheDocument() + expect(screen.getByText("No usage data")).toBeInTheDocument() + }) + + it("does NOT show badge lines that are declared detail-only in the manifest", () => { + // Badges whose label appears in skeletonLines as scope=detail must be excluded from + // the overview compact view — only truly unmanifested badges (runtime status indicators) + // should pass through. This prevents detail-only badges from leaking into overview. + render( + + ) + // Overview-scoped line is shown + expect(screen.getByText("Session")).toBeInTheDocument() + // Detail-scoped badge must be hidden in overview + expect(screen.queryByText("Plan")).toBeNull() + expect(screen.queryByText("Pro")).toBeNull() + }) + it("shows inline warning with stale data on refresh error", () => { render( line.scope === "overview") .map(line => line.label) ) + // All labels declared in the manifest (any scope). Used to distinguish plugin-defined + // detail badges from ad-hoc status badges emitted at runtime (e.g. "Status"). + const skeletonLabels = new Set(skeletonLines.map(line => line.label)) const filteredSkeletonLines = scopeFilter === "all" ? skeletonLines : skeletonLines.filter(line => line.scope === "overview") + // In overview scope show: + // • lines whose label is explicitly marked overview in the manifest, AND + // • badge lines that are NOT declared in the manifest at all — these are runtime + // status indicators ("No usage data", "Rate limited", etc.) that must always be + // surfaced so the card is never silently blank. + // Badges declared as detail-only in the manifest are intentionally excluded. const filteredLines = scopeFilter === "all" ? lines - : lines.filter(line => overviewLabels.has(line.label)) + : lines.filter(line => + overviewLabels.has(line.label) || + (line.type === "badge" && !skeletonLabels.has(line.label)) + ) const hasResetCountdown = filteredLines.some( (line) => line.type === "progress" && Boolean(line.resetsAt)