From 6be4355e3fb9fd6fe61b8fe38430904acf02b64b Mon Sep 17 00:00:00 2001 From: Shubham Raghav Date: Fri, 22 May 2026 16:55:25 +0530 Subject: [PATCH 1/2] fix: show status badges in overview when no quota data is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a plugin returns only a "Status" badge (e.g. "No usage data" or "Rate limited") and the card is rendered in overview scope, the badge was silently filtered out because its label was not listed as an overview-scoped line in plugin.json. This left the card visibly blank after the first successful probe set `lastUpdatedAt`, making `hasStaleData` true while `filteredLines` remained empty. Fix the scope filter in ProviderCard to always pass badge-type lines through regardless of scope — they are status indicators, not metric lines, and should always be visible. Also improve the Claude plugin's fallback message when the usage API responds successfully but returns no recognized quota fields (e.g. Enterprise plans or future plan types): show "Connected — no quota data" instead of the generic "No usage data" so users can distinguish a working connection from an authentication or network failure. Co-Authored-By: Claude Sonnet 4.6 --- plugins/claude/plugin.js | 8 +++++++- plugins/claude/plugin.test.js | 25 ++++++++++++++++++++++++- src/components/provider-card.test.tsx | 23 +++++++++++++++++++++++ src/components/provider-card.tsx | 4 +++- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index a496926d..40f4c42f 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -860,7 +860,13 @@ : "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 (canFetchLiveUsage && data !== null) { + // Successfully connected to the usage API but the response contained no + // recognized quota fields (e.g. Enterprise plans or 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 } diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index 8bf0fcc9..0cbf7dba 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,26 @@ 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 '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 () => { diff --git a/src/components/provider-card.test.tsx b/src/components/provider-card.test.tsx index 5a1bc1fe..c614191f 100644 --- a/src/components/provider-card.test.tsx +++ b/src/components/provider-card.test.tsx @@ -868,6 +868,29 @@ 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("shows inline warning with stale data on refresh error", () => { render( line.scope === "overview") + // Badge lines are status indicators (e.g. "No usage data", "Rate limited") and must + // always be shown regardless of scope so the overview card is never silently blank. const filteredLines = scopeFilter === "all" ? lines - : lines.filter(line => overviewLabels.has(line.label)) + : lines.filter(line => line.type === "badge" || overviewLabels.has(line.label)) const hasResetCountdown = filteredLines.some( (line) => line.type === "progress" && Boolean(line.resetsAt) From 48133190e56d8f99dd7d3a4a488200227f667441 Mon Sep 17 00:00:00 2001 From: Shubham Raghav Date: Fri, 22 May 2026 17:25:32 +0530 Subject: [PATCH 2/2] fix: handle Enterprise 403 on usage API without throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The personal usage endpoint (/api/oauth/usage) returns 403 for Enterprise accounts because their billing is tracked at the organisation level, not per-user. Previously the plugin treated any 403 as a token-expired error, which caused the JS probe to throw, the Rust runtime to emit an Error badge, and the state management to set data=null. On the first load this left the provider card completely blank because hasStaleData was false and no PluginError was prominently surfaced. Fix: intercept 403 from the usage endpoint before the generic isAuthStatus check and treat it as "no personal quota data" instead. The probe no longer throws, returns a "Status: Enterprise — org-level billing" badge, and the card renders meaningful content on the first load. A three-way fallback is now emitted when lines is empty: • 403 response → "Enterprise — org-level billing" • 200 but no recognized quota fields → "Connected — no quota data" • Inference-only / no API call → "No usage data" Co-Authored-By: Claude Sonnet 4.6 --- plugins/claude/plugin.js | 24 ++++++++++++++++++------ plugins/claude/plugin.test.js | 29 +++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 40f4c42f..dc7d088b 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -629,6 +629,7 @@ let lines = [] let rateLimited = false let retryAfterSeconds = null + let orgBillingOnly = false // true when the API returned 403 (Enterprise org-level billing) if (canFetchLiveUsage) { if (nowMs < rateLimitedUntilMs) { // Still within a rate-limit window from a previous probe call — skip the @@ -692,12 +693,20 @@ 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. + ctx.host.log.info("usage API returned 403 — organisation-level billing; no personal quota data") + orgBillingOnly = true + data = cachedUsageData // keep previous cache if any, otherwise 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 @@ -860,9 +869,12 @@ : "Live usage rate limited — data may be stale" lines.push(ctx.line.text({ label: "Note", value: noteText })) } else if (lines.length === 0) { - if (canFetchLiveUsage && data !== null) { + 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. Enterprise plans or unsupported plan types). + // 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" })) diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index 0cbf7dba..ffbd144d 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -850,6 +850,24 @@ describe("claude plugin", () => { 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 @@ -1079,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 = () => @@ -1102,7 +1124,10 @@ 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("throws token expired when refresh is unauthorized", async () => {