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)