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
45 changes: 37 additions & 8 deletions plugins/claude/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -644,13 +645,17 @@
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
// API request entirely and surface the remaining wait time to the user.
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
Expand All @@ -662,6 +667,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 " +
Expand Down Expand Up @@ -707,12 +713,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)) {
Comment thread
RaghavShubham marked this conversation as resolved.
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 All @@ -734,6 +752,7 @@
throw "Usage response invalid. Try again later."
}
cachedUsageData = data
cachedUsageOrgBillingOnly = false
rateLimitedUntilMs = 0
}
} // end fetch else-branch
Expand Down Expand Up @@ -875,7 +894,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" }))
Comment on lines +897 to +899
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Show Enterprise badge even when local usage exists

When the usage API returns 403 but ccusage returns local token data, pushDayUsageLine adds detail-only Today/Yesterday lines before this fallback runs, so lines.length is nonzero and the Enterprise Status badge is skipped. In overview scope those local lines are filtered out by the manifest, leaving the Claude card blank for Enterprise users who have any local ccusage data—the same blank-card case this commit is meant to fix.

Useful? React with 👍 / 👎.

} 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 All @@ -887,6 +915,7 @@
rateLimitedUntilMs = 0
lastUsageFetchMs = 0
cachedUsageData = null
cachedUsageOrgBillingOnly = false
}

globalThis.__openusage_plugin = { id: "claude", probe, _resetState }
Expand Down
128 changes: 125 additions & 3 deletions plugins/claude/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,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 @@ -890,7 +894,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 @@ -1103,7 +1144,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 @@ -1126,7 +1171,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 () => {
Expand Down
50 changes: 50 additions & 0 deletions src/components/provider-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<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("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(
<ProviderCard
name="Claude"
displayMode="used"
scopeFilter="overview"
lastUpdatedAt={Date.now() - 60_000}
skeletonLines={[
{ type: "progress", label: "Session", scope: "overview" },
{ type: "badge", label: "Plan", scope: "detail" },
]}
lines={[
{ type: "progress", label: "Session", used: 50, limit: 100, format: { kind: "percent" } },
{ type: "badge", label: "Plan", text: "Pro" },
]}
/>
)
// 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(
<ProviderCard
Expand Down
14 changes: 13 additions & 1 deletion src/components/provider-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,24 @@ export function ProviderCard({
.filter(line => 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)
Expand Down
Loading