Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions plugins/claude/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Enterprise/org-billing status badge is not stable across probes because orgBillingOnly is function-local and lost when requests are throttled via the min-interval guard.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/claude/plugin.js, line 632:

<comment>Enterprise/org-billing status badge is not stable across probes because `orgBillingOnly` is function-local and lost when requests are throttled via the min-interval guard.</comment>

<file context>
@@ -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) {
</file context>
Fix with Cubic

if (canFetchLiveUsage) {
if (nowMs < rateLimitedUntilMs) {
// Still within a rate-limit window from a previous probe call — skip the
Expand Down Expand Up @@ -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)) {
Comment on lines +696 to +706
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
Expand Down Expand Up @@ -860,7 +869,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 }
Expand Down
54 changes: 51 additions & 3 deletions plugins/claude/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 = () =>
Expand All @@ -1079,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 () => {
Expand Down
23 changes: 23 additions & 0 deletions src/components/provider-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ProviderCard
name="Claude"
displayMode="used"
scopeFilter="overview"
lastUpdatedAt={Date.now() - 60_000}
skeletonLines={[
{ type: "progress", label: "Session", scope: "overview" },
{ type: "progress", label: "Weekly", scope: "overview" },
]}
lines={[
{ type: "badge", label: "Status", text: "No usage data" },
]}
/>
)
expect(screen.getByText("Status")).toBeInTheDocument()
expect(screen.getByText("No usage data")).toBeInTheDocument()
})

it("shows inline warning with stale data on refresh error", () => {
render(
<ProviderCard
Expand Down
4 changes: 3 additions & 1 deletion src/components/provider-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ export function ProviderCard({
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.
const filteredLines = scopeFilter === "all"
? lines
: lines.filter(line => overviewLabels.has(line.label))
: lines.filter(line => line.type === "badge" || overviewLabels.has(line.label))

Comment on lines +128 to 133
const hasResetCountdown = filteredLines.some(
(line) => line.type === "progress" && Boolean(line.resetsAt)
Expand Down
Loading