From 6be4355e3fb9fd6fe61b8fe38430904acf02b64b Mon Sep 17 00:00:00 2001 From: Shubham Raghav Date: Fri, 22 May 2026 16:55:25 +0530 Subject: [PATCH 1/3] 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/3] 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 () => { From 797ed33c258a2212c0b3d9a73490e8f6cc21be7e Mon Sep 17 00:00:00 2001 From: Shubham Raghav Date: Mon, 25 May 2026 18:14:00 +0530 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?scope-filter=20precision=20and=20Enterprise=20badge=20persisten?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues raised by reviewers: 1. provider-card.tsx (zergzorg): The first fix let *all* badge-typed lines through the overview scope filter, including badges that are explicitly declared as detail-only in plugin.json. Tighten the rule: only pass through badge lines whose label is *absent* from skeletonLines entirely (i.e. runtime status indicators like "Status", "Rate limited"). Badges declared in the manifest at any scope follow the normal label-match path. Add a regression test confirming detail-manifest badges stay hidden in overview. 2. plugin.js (zergzorg): orgBillingOnly was a function-local variable, so on the second probe() call — when the min-interval guard skips the real API fetch — it reset to false and the Enterprise card fell back to "No usage data". Promote to module-level cachedUsageOrgBillingOnly (alongside cachedUsageData) and restore it in both the rate-limit and min-interval reuse paths. Clear it back to false on a successful 2xx response and in _resetState(). Add tests for badge persistence across min-interval reuse and for clearing after a subsequent successful response. All 1091 tests pass. --- plugins/claude/plugin.js | 21 ++++++-- plugins/claude/plugin.test.js | 74 +++++++++++++++++++++++++++ src/components/provider-card.test.tsx | 27 ++++++++++ src/components/provider-card.tsx | 16 ++++-- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index dc7d088b..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,7 +630,9 @@ let lines = [] let rateLimited = false let retryAfterSeconds = null - let orgBillingOnly = false // true when the API returned 403 (Enterprise org-level billing) + // 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 @@ -637,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 @@ -648,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 " + @@ -700,9 +705,13 @@ // 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 - data = cachedUsageData // keep previous cache if any, otherwise null + 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." @@ -728,6 +737,7 @@ throw "Usage response invalid. Try again later." } cachedUsageData = data + cachedUsageOrgBillingOnly = false rateLimitedUntilMs = 0 } } // end fetch else-branch @@ -890,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 ffbd144d..da232175 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -1130,6 +1130,80 @@ describe("claude plugin", () => { 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 () => { const ctx = makeCtx() ctx.host.fs.exists = () => true diff --git a/src/components/provider-card.test.tsx b/src/components/provider-card.test.tsx index c614191f..a3b3c368 100644 --- a/src/components/provider-card.test.tsx +++ b/src/components/provider-card.test.tsx @@ -891,6 +891,33 @@ describe("ProviderCard", () => { 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") - // 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. + // 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 => line.type === "badge" || 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)