From db2976536faadd71e97d44a63882e06f9256a8f2 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 21:46:09 +0000 Subject: [PATCH 1/8] feat(dashboard): add Claude Code subscription limits to sidebar --- src/anthropic/client.ts | 101 +++++++++++ src/api/router.ts | 2 + src/api/routers/claudeCodeLimits.ts | 40 ++++ src/db/repositories/credentialsRepository.ts | 31 ++++ tests/unit/anthropic/client.test.ts | 162 +++++++++++++++++ .../unit/api/routers/claudeCodeLimits.test.ts | 171 ++++++++++++++++++ vitest.config.ts | 1 + .../components/global/claude-code-limits.tsx | 63 +++++++ web/src/components/layout/sidebar.tsx | 2 + 9 files changed, 573 insertions(+) create mode 100644 src/anthropic/client.ts create mode 100644 src/api/routers/claudeCodeLimits.ts create mode 100644 tests/unit/anthropic/client.test.ts create mode 100644 tests/unit/api/routers/claudeCodeLimits.test.ts create mode 100644 web/src/components/global/claude-code-limits.tsx diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts new file mode 100644 index 00000000..4fbe7bf5 --- /dev/null +++ b/src/anthropic/client.ts @@ -0,0 +1,101 @@ +const ANTHROPIC_ACCOUNT_URL = 'https://api.anthropic.com/api/account'; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const FETCH_TIMEOUT_MS = 10_000; // 10 seconds + +export interface ClaudeSubscriptionLimits { + plan: string; + messagesUsed: number; + messagesLimit: number; + tokensUsed: number; + tokensLimit: number; + resetsAt: string; + tokenMasked: string; +} + +interface CacheEntry { + data: ClaudeSubscriptionLimits; + timestamp: number; +} + +/** + * Per-token cache. Keyed by masked token representation to avoid storing raw + * tokens as cache keys. Uses a Map keyed by full token for lookup; only the + * masked value is surfaced in returned data. + */ +const cacheByToken = new Map(); + +/** + * Masks a token, showing only the last 4 characters. + */ +function maskToken(token: string): string { + return `****${token.slice(-4)}`; +} + +/** + * Fetch Claude subscription limits for the given OAuth token. + * Returns null on any error (network, auth, unexpected shape, etc.). + * Results are cached in memory for 5 minutes per unique token. + */ +export async function fetchClaudeSubscriptionLimits( + oauthToken: string, +): Promise { + // Return cached result if still valid + const cached = cacheByToken.get(oauthToken); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.data; + } + + try { + const response = await fetch(ANTHROPIC_ACCOUNT_URL, { + headers: { + Authorization: `Bearer ${oauthToken}`, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + return null; + } + + const json = (await response.json()) as Record; + + // Parse defensively — return null if the shape doesn't match expectations + const usage = json.usage as Record | undefined; + + if (!usage) { + return null; + } + + const plan = typeof json.plan === 'string' ? json.plan : 'unknown'; + const messagesUsed = typeof usage.messages_used === 'number' ? usage.messages_used : 0; + const messagesLimit = typeof usage.messages_limit === 'number' ? usage.messages_limit : 0; + const tokensUsed = typeof usage.tokens_used === 'number' ? usage.tokens_used : 0; + const tokensLimit = typeof usage.tokens_limit === 'number' ? usage.tokens_limit : 0; + const resetsAt = typeof usage.resets_at === 'string' ? usage.resets_at : ''; + + const result: ClaudeSubscriptionLimits = { + plan, + messagesUsed, + messagesLimit, + tokensUsed, + tokensLimit, + resetsAt, + tokenMasked: maskToken(oauthToken), + }; + + cacheByToken.set(oauthToken, { data: result, timestamp: Date.now() }); + return result; + } catch { + // Return null on any failure (network error, timeout, parse error, etc.) + return null; + } +} + +/** + * Clear the in-memory limits cache (useful for testing). + */ +export function clearAnthropicLimitsCache(): void { + cacheByToken.clear(); +} diff --git a/src/api/router.ts b/src/api/router.ts index 8b3fedf7..afd5b1e7 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -2,6 +2,7 @@ import { agentConfigsRouter } from './routers/agentConfigs.js'; import { agentDefinitionsRouter } from './routers/agentDefinitions.js'; import { agentTriggerConfigsRouter } from './routers/agentTriggerConfigs.js'; import { authRouter } from './routers/auth.js'; +import { claudeCodeLimitsRouter } from './routers/claudeCodeLimits.js'; import { integrationsDiscoveryRouter } from './routers/integrationsDiscovery.js'; import { organizationRouter } from './routers/organization.js'; import { projectsRouter } from './routers/projects.js'; @@ -29,6 +30,7 @@ export const appRouter = router({ prs: prsRouter, workItems: workItemsRouter, users: usersRouter, + claudeCodeLimits: claudeCodeLimitsRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/claudeCodeLimits.ts b/src/api/routers/claudeCodeLimits.ts new file mode 100644 index 00000000..58ff3f7c --- /dev/null +++ b/src/api/routers/claudeCodeLimits.ts @@ -0,0 +1,40 @@ +import { fetchClaudeSubscriptionLimits } from '../../anthropic/client.js'; +import { listAllClaudeCodeCredentials } from '../../db/repositories/credentialsRepository.js'; +import { router, superAdminProcedure } from '../trpc.js'; + +export const claudeCodeLimitsRouter = router({ + /** + * Fetch Claude Code subscription limits for all unique OAuth tokens configured + * across org projects, plus the global env var if set. + * + * Superadmin only. Returns masked token + limits data — never raw tokens. + */ + query: superAdminProcedure.query(async ({ ctx }) => { + // Gather tokens from project credentials + const projectCredentials = await listAllClaudeCodeCredentials(ctx.effectiveOrgId); + + // Build a deduplicated set of tokens (value → first seen) + const tokenMap = new Map(); + const tokens: string[] = []; + + for (const cred of projectCredentials) { + if (!tokenMap.has(cred.value)) { + tokenMap.set(cred.value, true); + tokens.push(cred.value); + } + } + + // Also include the global env var if set + const globalToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + if (globalToken && !tokenMap.has(globalToken)) { + tokenMap.set(globalToken, true); + tokens.push(globalToken); + } + + // Fetch limits for each unique token in parallel + const results = await Promise.all(tokens.map((token) => fetchClaudeSubscriptionLimits(token))); + + // Filter nulls (API errors / unavailable) + return results.filter((r) => r !== null); + }), +}); diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 75079b98..58ca53f2 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -159,6 +159,37 @@ export async function listProjectCredentialsMeta( .where(eq(projectCredentials.projectId, projectId)); } +// ============================================================================ +// Cross-project credential queries +// ============================================================================ + +/** + * List all CLAUDE_CODE_OAUTH_TOKEN credentials across all projects in an org. + * Returns decrypted values for use in server-side API calls only. + * Never expose raw tokens to the client. + */ +export async function listAllClaudeCodeCredentials( + orgId: string, +): Promise<{ projectId: string; value: string }[]> { + const db = getDb(); + + const rows = await db + .select({ + projectId: projectCredentials.projectId, + value: projectCredentials.value, + }) + .from(projectCredentials) + .innerJoin(projects, eq(projectCredentials.projectId, projects.id)) + .where( + and(eq(projects.orgId, orgId), eq(projectCredentials.envVarKey, 'CLAUDE_CODE_OAUTH_TOKEN')), + ); + + return rows.map((row) => ({ + projectId: row.projectId, + value: decryptCredential(row.value, row.projectId), + })); +} + // ============================================================================ // Integration metadata queries // ============================================================================ diff --git a/tests/unit/anthropic/client.test.ts b/tests/unit/anthropic/client.test.ts new file mode 100644 index 00000000..d15cf608 --- /dev/null +++ b/tests/unit/anthropic/client.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAnthropicLimitsCache, + fetchClaudeSubscriptionLimits, +} from '../../../src/anthropic/client.js'; + +describe('fetchClaudeSubscriptionLimits', () => { + beforeEach(() => { + clearAnthropicLimitsCache(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearAnthropicLimitsCache(); + }); + + function makeFetchResponse(data: unknown, ok = true, status = 200) { + return Promise.resolve({ + ok, + status, + statusText: ok ? 'OK' : 'Unauthorized', + json: () => Promise.resolve(data), + }); + } + + const sampleResponse = { + plan: 'claude_max', + usage: { + messages_used: 1234, + messages_limit: 20000, + tokens_used: 500000, + tokens_limit: 10000000, + resets_at: '2026-05-01T00:00:00Z', + }, + }; + + it('returns limits data on success', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-oauth-token'); + + expect(result).not.toBeNull(); + expect(result?.plan).toBe('claude_max'); + expect(result?.messagesUsed).toBe(1234); + expect(result?.messagesLimit).toBe(20000); + expect(result?.tokensUsed).toBe(500000); + expect(result?.tokensLimit).toBe(10000000); + expect(result?.resetsAt).toBe('2026-05-01T00:00:00Z'); + }); + + it('masks the token showing only last 4 chars', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('sk-ant-oauth-abcd1234'); + + expect(result?.tokenMasked).toBe('****1234'); + }); + + it('sends Authorization header with Bearer token', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-oauth-token'); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-oauth-token', + }), + }), + ); + }); + + it('returns null on 4xx response', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({}, false, 401) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('bad-token'); + + expect(result).toBeNull(); + }); + + it('returns null on 5xx response', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({}, false, 500) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null on network error', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null on timeout', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new DOMException('Timeout', 'AbortError')); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null when response has no usage field', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({ plan: 'claude_max' }) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('caches results for subsequent calls with the same token', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-token'); + // Second call should use cache (fetch called only once) + await fetchClaudeSubscriptionLimits('my-token'); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not share cache between different tokens', async () => { + vi.mocked(fetch) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType); + + await fetchClaudeSubscriptionLimits('token-a'); + await fetchClaudeSubscriptionLimits('token-b'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('clearAnthropicLimitsCache allows re-fetching', async () => { + vi.mocked(fetch) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType); + + await fetchClaudeSubscriptionLimits('my-token'); + clearAnthropicLimitsCache(); + await fetchClaudeSubscriptionLimits('my-token'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts new file mode 100644 index 00000000..19a43405 --- /dev/null +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createMockContext, + createMockSuperAdmin, + createMockUser, +} from '../../../helpers/factories.js'; +import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; + +const { mockListAllClaudeCodeCredentials, mockFetchClaudeSubscriptionLimits } = vi.hoisted(() => ({ + mockListAllClaudeCodeCredentials: vi.fn(), + mockFetchClaudeSubscriptionLimits: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + listAllClaudeCodeCredentials: mockListAllClaudeCodeCredentials, +})); + +vi.mock('../../../../src/anthropic/client.js', () => ({ + fetchClaudeSubscriptionLimits: mockFetchClaudeSubscriptionLimits, +})); + +import { claudeCodeLimitsRouter } from '../../../../src/api/routers/claudeCodeLimits.js'; + +const createCaller = createCallerFor(claudeCodeLimitsRouter); + +const sampleLimits = { + plan: 'claude_max', + messagesUsed: 1000, + messagesLimit: 20000, + tokensUsed: 500000, + tokensLimit: 10000000, + resetsAt: '2026-05-01T00:00:00Z', + tokenMasked: '****abcd', +}; + +describe('claudeCodeLimitsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Clear the global env var between tests + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + }); + + describe('query', () => { + it('requires superadmin role — rejects regular users', async () => { + const caller = createCaller(createMockContext({ role: 'member' })); + await expectTRPCError(caller.query(), 'FORBIDDEN'); + }); + + it('requires superadmin role — rejects admin users', async () => { + const caller = createCaller(createMockContext({ role: 'admin' })); + await expectTRPCError(caller.query(), 'FORBIDDEN'); + }); + + it('returns empty array when no credentials and no env var', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([]); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toEqual([]); + }); + + it('fetches limits for credentials found in DB', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'token-aaa' }, + ]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(sampleLimits); + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('token-aaa'); + }); + + it('deduplicates tokens from multiple projects', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'shared-token' }, + { projectId: 'proj-2', value: 'shared-token' }, + { projectId: 'proj-3', value: 'other-token' }, + ]); + mockFetchClaudeSubscriptionLimits + .mockResolvedValueOnce({ ...sampleLimits, tokenMasked: '****oken' }) + .mockResolvedValueOnce({ ...sampleLimits, tokenMasked: '****oken2' }); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + // Should only call fetch twice (once per unique token) + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + }); + + it('includes global env var token', async () => { + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'global-env-token'; + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('global-env-token'); + expect(result).toHaveLength(1); + }); + + it('deduplicates global env var against project credentials', async () => { + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'shared-token'; + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'shared-token' }, + ]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(sampleLimits); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + // Even though token appears in both DB and env, fetch only once + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + + it('filters out null results from failed API calls', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'token-good' }, + { projectId: 'proj-2', value: 'token-bad' }, + ]); + mockFetchClaudeSubscriptionLimits + .mockResolvedValueOnce(sampleLimits) // token-good succeeds + .mockResolvedValueOnce(null); // token-bad fails + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(sampleLimits); + }); + + it('returns empty array when all API calls return null', async () => { + mockListAllClaudeCodeCredentials.mockResolvedValueOnce([ + { projectId: 'proj-1', value: 'token-bad' }, + ]); + mockFetchClaudeSubscriptionLimits.mockResolvedValueOnce(null); + + const caller = createCaller({ + user: createMockSuperAdmin(), + effectiveOrgId: 'org-1', + }); + const result = await caller.query(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8ba2d0aa..1b9e3c31 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -126,6 +126,7 @@ export default defineConfig({ 'tests/unit/tools/**/*.test.ts', 'tests/unit/openrouter/**/*.test.ts', 'tests/unit/sentry/**/*.test.ts', + 'tests/unit/anthropic/**/*.test.ts', 'tests/unit/*.test.ts', ], ...sharedTest, diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx new file mode 100644 index 00000000..6a8e4422 --- /dev/null +++ b/web/src/components/global/claude-code-limits.tsx @@ -0,0 +1,63 @@ +import { useQuery } from '@tanstack/react-query'; +import { trpc } from '@/lib/trpc.js'; + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +function formatResetDate(resetsAt: string): string { + if (!resetsAt) return ''; + try { + const date = new Date(resetsAt); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return resetsAt; + } +} + +/** + * Displays Claude Code subscription limits for all unique tokens configured + * across org projects. Shown only to superadmins; auto-hides when no data. + */ +export function ClaudeCodeLimitsSection() { + const { data } = useQuery({ + ...trpc.claudeCodeLimits.query.queryOptions(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + // Hide if no data returned (no tokens configured or API unavailable) + if (!data || data.length === 0) { + return null; + } + + return ( +
+
+ Limits +
+
+ {data.map((limits) => ( +
+
+ {limits.tokenMasked} +
+
{limits.plan}
+ {limits.messagesLimit > 0 && ( +
+ Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} +
+ )} + {limits.tokensLimit > 0 && ( +
+ Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} +
+ )} + {limits.resetsAt && ( +
Resets {formatResetDate(limits.resetsAt)}
+ )} +
+ ))} +
+
+ ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 1e1dcde7..13803cd7 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -14,6 +14,7 @@ import { Zap, } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { ClaudeCodeLimitsSection } from '@/components/global/claude-code-limits.js'; import { ProjectFormDialog } from '@/components/projects/project-form-dialog.js'; import { Select, @@ -238,6 +239,7 @@ export function Sidebar({ user }: SidebarProps) { {globalNav.map((item) => ( ))} + )} From 890d2243f3fb4a75971043f8fd9cf64e2afc674e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 21:55:55 +0000 Subject: [PATCH 2/8] fix(deps): resolve critical axios vulnerability and update vulnerable packages Runs npm audit fix to update axios (critical SSRF vulnerability GHSA-3p68-rc4w-qgx5) and hono/@hono/node-server (moderate vulnerabilities), resolving CI audit failure. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 46157bcb..dc2f4a03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,9 +96,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.91", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.91.tgz", - "integrity": "sha512-DCd5Ad5XKBbIIOMZ73L+c+e9azM6NtZzOtdKQAzykzRG/KxSCMraMAsMMQrJrIUMH3oTtHY7QuQimAiElVVVpA==", + "version": "0.2.98", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.98.tgz", + "integrity": "sha512-pWUx+xY21rKy5wvX0eBZja7p8J5ykOYaHsykvdj9nkTbAVXmP1WusI1mP6jbBByJ8uBJeBc4beAPSZIFcdIpTA==", "license": "SEE LICENSE IN README.md", "dependencies": { "@anthropic-ai/sdk": "^0.80.0", @@ -2137,9 +2137,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.12", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", - "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -7558,9 +7558,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", "engines": { "node": ">=16.9.0" From 3e7bf89d10434222cbe37d262fd2ab3c06301e82 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 22:08:05 +0000 Subject: [PATCH 3/8] fix: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use array index as React key in claude-code-limits.tsx to avoid reconciliation bugs when tokens share the same trailing 4 chars; suppress noArrayIndexKey lint rule with explanation - Fix contradictory JSDoc on cacheByToken — remove false claim that raw tokens are not stored as cache keys - Remove unused createMockUser import in claudeCodeLimits.test.ts Co-Authored-By: Claude Sonnet 4.6 --- src/anthropic/client.ts | 5 +-- .../unit/api/routers/claudeCodeLimits.test.ts | 6 +-- .../components/global/claude-code-limits.tsx | 45 ++++++++++--------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index 4fbe7bf5..6a526610 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -18,9 +18,8 @@ interface CacheEntry { } /** - * Per-token cache. Keyed by masked token representation to avoid storing raw - * tokens as cache keys. Uses a Map keyed by full token for lookup; only the - * masked value is surfaced in returned data. + * Per-token cache. Keyed by full token for lookup; only the masked value is + * surfaced in returned data. */ const cacheByToken = new Map(); diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts index 19a43405..83df5503 100644 --- a/tests/unit/api/routers/claudeCodeLimits.test.ts +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -1,9 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - createMockContext, - createMockSuperAdmin, - createMockUser, -} from '../../../helpers/factories.js'; +import { createMockContext, createMockSuperAdmin } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; const { mockListAllClaudeCodeCredentials, mockFetchClaudeSubscriptionLimits } = vi.hoisted(() => ({ diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 6a8e4422..842eb0f6 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -36,27 +36,32 @@ export function ClaudeCodeLimitsSection() { Limits
- {data.map((limits) => ( -
-
- {limits.tokenMasked} -
-
{limits.plan}
- {limits.messagesLimit > 0 && ( -
- Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} -
- )} - {limits.tokensLimit > 0 && ( -
- Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} + {data.map((limits, i) => { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable +
+
+ {limits.tokenMasked}
- )} - {limits.resetsAt && ( -
Resets {formatResetDate(limits.resetsAt)}
- )} -
- ))} +
{limits.plan}
+ {limits.messagesLimit > 0 && ( +
+ Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} +
+ )} + {limits.tokensLimit > 0 && ( +
+ Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} +
+ )} + {limits.resetsAt && ( +
+ Resets {formatResetDate(limits.resetsAt)} +
+ )} +
+ ); + })}
); From 564285f774b737fa8ee3f5a6d8550b3da4fa59aa Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 15:53:06 +0000 Subject: [PATCH 4/8] fix(anthropic): use correct oauth/profile endpoint for subscription info The previous implementation called https://api.anthropic.com/api/account which does not exist in Anthropic's API for OAuth tokens. The Claude Code CLI actually uses https://api.anthropic.com/api/oauth/profile to fetch subscription/organization info. Update fetchClaudeSubscriptionLimits to call the correct endpoint and parse the organization.organization_type field for the plan name. Per-token usage stats (messages/tokens used vs. limit) are not available from this endpoint, so those fields return 0 and the UI hides them automatically. Co-Authored-By: Claude Sonnet 4.6 --- src/anthropic/client.ts | 47 +++++++++++++-------- tests/unit/anthropic/client.test.ts | 63 ++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index 6a526610..b3e93c42 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -1,4 +1,10 @@ -const ANTHROPIC_ACCOUNT_URL = 'https://api.anthropic.com/api/account'; +/** + * OAuth profile endpoint used by the Claude Code CLI to fetch subscription info. + * Returns organization type (plan), rate limit tier, and account display name. + * Note: per-token usage stats (messages/tokens used) are not available via this + * endpoint — they are only surfaced via rate-limit response headers during API calls. + */ +const ANTHROPIC_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const FETCH_TIMEOUT_MS = 10_000; // 10 seconds @@ -31,9 +37,14 @@ function maskToken(token: string): string { } /** - * Fetch Claude subscription limits for the given OAuth token. + * Fetch Claude subscription info for the given OAuth token via the oauth/profile endpoint. * Returns null on any error (network, auth, unexpected shape, etc.). * Results are cached in memory for 5 minutes per unique token. + * + * Note: per-token usage stats (messages/tokens used vs. limit) are not available + * from this endpoint. The returned `messagesUsed`, `messagesLimit`, `tokensUsed`, + * `tokensLimit`, and `resetsAt` fields will always be 0/"" — the UI hides them + * when the limit is 0. */ export async function fetchClaudeSubscriptionLimits( oauthToken: string, @@ -45,7 +56,7 @@ export async function fetchClaudeSubscriptionLimits( } try { - const response = await fetch(ANTHROPIC_ACCOUNT_URL, { + const response = await fetch(ANTHROPIC_PROFILE_URL, { headers: { Authorization: `Bearer ${oauthToken}`, 'anthropic-version': '2023-06-01', @@ -60,27 +71,29 @@ export async function fetchClaudeSubscriptionLimits( const json = (await response.json()) as Record; - // Parse defensively — return null if the shape doesn't match expectations - const usage = json.usage as Record | undefined; + // Parse defensively — return null if the shape doesn't match expectations. + // The profile response contains: { organization: { organization_type, rate_limit_tier, ... }, account: { ... } } + const organization = json.organization as Record | undefined; - if (!usage) { + if (!organization) { return null; } - const plan = typeof json.plan === 'string' ? json.plan : 'unknown'; - const messagesUsed = typeof usage.messages_used === 'number' ? usage.messages_used : 0; - const messagesLimit = typeof usage.messages_limit === 'number' ? usage.messages_limit : 0; - const tokensUsed = typeof usage.tokens_used === 'number' ? usage.tokens_used : 0; - const tokensLimit = typeof usage.tokens_limit === 'number' ? usage.tokens_limit : 0; - const resetsAt = typeof usage.resets_at === 'string' ? usage.resets_at : ''; + // organization_type is e.g. "claude_max", "claude_pro", "claude_enterprise", "claude_team" + const plan = + typeof organization.organization_type === 'string' + ? organization.organization_type + : 'unknown'; const result: ClaudeSubscriptionLimits = { plan, - messagesUsed, - messagesLimit, - tokensUsed, - tokensLimit, - resetsAt, + // Usage stats (messages/tokens) are not available from this endpoint; + // the UI hides these fields when limit is 0. + messagesUsed: 0, + messagesLimit: 0, + tokensUsed: 0, + tokensLimit: 0, + resetsAt: '', tokenMasked: maskToken(oauthToken), }; diff --git a/tests/unit/anthropic/client.test.ts b/tests/unit/anthropic/client.test.ts index d15cf608..a5653a00 100644 --- a/tests/unit/anthropic/client.test.ts +++ b/tests/unit/anthropic/client.test.ts @@ -25,18 +25,23 @@ describe('fetchClaudeSubscriptionLimits', () => { }); } + // Reflects the actual api/oauth/profile response shape used by the Claude Code CLI const sampleResponse = { - plan: 'claude_max', - usage: { - messages_used: 1234, - messages_limit: 20000, - tokens_used: 500000, - tokens_limit: 10000000, - resets_at: '2026-05-01T00:00:00Z', + account: { + display_name: 'Test User', + created_at: '2025-01-01T00:00:00Z', + }, + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_5x', + has_extra_usage_enabled: false, + billing_type: 'subscription', + subscription_created_at: '2025-01-01T00:00:00Z', + uuid: 'org-uuid-123', }, }; - it('returns limits data on success', async () => { + it('returns subscription info on success', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, ); @@ -45,11 +50,12 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).not.toBeNull(); expect(result?.plan).toBe('claude_max'); - expect(result?.messagesUsed).toBe(1234); - expect(result?.messagesLimit).toBe(20000); - expect(result?.tokensUsed).toBe(500000); - expect(result?.tokensLimit).toBe(10000000); - expect(result?.resetsAt).toBe('2026-05-01T00:00:00Z'); + // Usage stats are not available from the profile endpoint; always 0 + expect(result?.messagesUsed).toBe(0); + expect(result?.messagesLimit).toBe(0); + expect(result?.tokensUsed).toBe(0); + expect(result?.tokensLimit).toBe(0); + expect(result?.resetsAt).toBe(''); }); it('masks the token showing only last 4 chars', async () => { @@ -79,6 +85,19 @@ describe('fetchClaudeSubscriptionLimits', () => { ); }); + it('calls the oauth/profile endpoint', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-oauth-token'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/profile', + expect.any(Object), + ); + }); + it('returns null on 4xx response', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse({}, false, 401) as ReturnType, @@ -115,9 +134,10 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).toBeNull(); }); - it('returns null when response has no usage field', async () => { + it('returns null when response has no organization field', async () => { vi.mocked(fetch).mockReturnValueOnce( - makeFetchResponse({ plan: 'claude_max' }) as ReturnType, + // Response missing the organization field (invalid shape) + makeFetchResponse({ account: { display_name: 'Test' } }) as ReturnType, ); const result = await fetchClaudeSubscriptionLimits('some-token'); @@ -125,6 +145,19 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).toBeNull(); }); + it('returns unknown plan when organization_type is missing', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({ + organization: { rate_limit_tier: 'default' }, + }) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).not.toBeNull(); + expect(result?.plan).toBe('unknown'); + }); + it('caches results for subsequent calls with the same token', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, From 79467395f09bb773b0aee410c29380609bbce2ab Mon Sep 17 00:00:00 2001 From: Wojtek Siudzinski Date: Fri, 10 Apr 2026 18:43:16 +0200 Subject: [PATCH 5/8] fix(anthropic): switch from profile to usage endpoint for per-bucket utilization --- src/anthropic/client.ts | 94 +++++++----- tests/unit/anthropic/client.test.ts | 134 +++++++++++++----- .../unit/api/routers/claudeCodeLimits.test.ts | 11 +- .../components/global/claude-code-limits.tsx | 74 ++++++---- 4 files changed, 201 insertions(+), 112 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index b3e93c42..3e7e879e 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -1,21 +1,30 @@ /** - * OAuth profile endpoint used by the Claude Code CLI to fetch subscription info. - * Returns organization type (plan), rate limit tier, and account display name. - * Note: per-token usage stats (messages/tokens used) are not available via this - * endpoint — they are only surfaced via rate-limit response headers during API calls. + * OAuth usage endpoint — returns per-bucket utilization percentages and reset times + * for the authenticated subscription. */ -const ANTHROPIC_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'; +const ANTHROPIC_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const FETCH_TIMEOUT_MS = 10_000; // 10 seconds -export interface ClaudeSubscriptionLimits { - plan: string; - messagesUsed: number; - messagesLimit: number; - tokensUsed: number; - tokensLimit: number; +/** A single rate-limit bucket from the usage API. */ +export interface UsageBucket { + /** Human-readable label (e.g. "5-Hour Window", "Sonnet 7-Day") */ + label: string; + /** Utilization percentage 0–100 */ + utilization: number; + /** ISO-8601 reset timestamp */ resetsAt: string; +} + +export interface ClaudeSubscriptionLimits { tokenMasked: string; + buckets: UsageBucket[]; + extraUsage: { + isEnabled: boolean; + monthlyLimit: number | null; + usedCredits: number | null; + utilization: number | null; + } | null; } interface CacheEntry { @@ -36,15 +45,21 @@ function maskToken(token: string): string { return `****${token.slice(-4)}`; } +/** Maps API response keys to human-readable labels. */ +const BUCKET_LABELS: Record = { + five_hour: '5-Hour Window', + seven_day: '7-Day Overall', + seven_day_oauth_apps: '7-Day OAuth Apps', + seven_day_opus: '7-Day Opus', + seven_day_sonnet: '7-Day Sonnet', + seven_day_cowork: '7-Day Cowork', + iguana_necktie: 'Iguana Necktie', +}; + /** - * Fetch Claude subscription info for the given OAuth token via the oauth/profile endpoint. + * Fetch Claude subscription usage for the given OAuth token via the /api/oauth/usage endpoint. * Returns null on any error (network, auth, unexpected shape, etc.). * Results are cached in memory for 5 minutes per unique token. - * - * Note: per-token usage stats (messages/tokens used vs. limit) are not available - * from this endpoint. The returned `messagesUsed`, `messagesLimit`, `tokensUsed`, - * `tokensLimit`, and `resetsAt` fields will always be 0/"" — the UI hides them - * when the limit is 0. */ export async function fetchClaudeSubscriptionLimits( oauthToken: string, @@ -56,11 +71,12 @@ export async function fetchClaudeSubscriptionLimits( } try { - const response = await fetch(ANTHROPIC_PROFILE_URL, { + const response = await fetch(ANTHROPIC_USAGE_URL, { headers: { Authorization: `Bearer ${oauthToken}`, - 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'oauth-2025-04-20', 'Content-Type': 'application/json', + 'User-Agent': 'claude-code/2.1.87', }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); @@ -71,30 +87,32 @@ export async function fetchClaudeSubscriptionLimits( const json = (await response.json()) as Record; - // Parse defensively — return null if the shape doesn't match expectations. - // The profile response contains: { organization: { organization_type, rate_limit_tier, ... }, account: { ... } } - const organization = json.organization as Record | undefined; - - if (!organization) { - return null; + // Parse usage buckets — each key (except extra_usage) is either null or + // { utilization: number, resets_at: string } + const buckets: UsageBucket[] = []; + for (const [key, label] of Object.entries(BUCKET_LABELS)) { + const raw = json[key] as { utilization?: number; resets_at?: string } | null | undefined; + if (raw && typeof raw.utilization === 'number' && typeof raw.resets_at === 'string') { + buckets.push({ label, utilization: raw.utilization, resetsAt: raw.resets_at }); + } } - // organization_type is e.g. "claude_max", "claude_pro", "claude_enterprise", "claude_team" - const plan = - typeof organization.organization_type === 'string' - ? organization.organization_type - : 'unknown'; + // Parse extra_usage block + let extraUsage: ClaudeSubscriptionLimits['extraUsage'] = null; + const rawExtra = json.extra_usage as Record | null | undefined; + if (rawExtra && typeof rawExtra.is_enabled === 'boolean') { + extraUsage = { + isEnabled: rawExtra.is_enabled, + monthlyLimit: typeof rawExtra.monthly_limit === 'number' ? rawExtra.monthly_limit : null, + usedCredits: typeof rawExtra.used_credits === 'number' ? rawExtra.used_credits : null, + utilization: typeof rawExtra.utilization === 'number' ? rawExtra.utilization : null, + }; + } const result: ClaudeSubscriptionLimits = { - plan, - // Usage stats (messages/tokens) are not available from this endpoint; - // the UI hides these fields when limit is 0. - messagesUsed: 0, - messagesLimit: 0, - tokensUsed: 0, - tokensLimit: 0, - resetsAt: '', tokenMasked: maskToken(oauthToken), + buckets, + extraUsage, }; cacheByToken.set(oauthToken, { data: result, timestamp: Date.now() }); diff --git a/tests/unit/anthropic/client.test.ts b/tests/unit/anthropic/client.test.ts index a5653a00..d83fd3ed 100644 --- a/tests/unit/anthropic/client.test.ts +++ b/tests/unit/anthropic/client.test.ts @@ -25,23 +25,24 @@ describe('fetchClaudeSubscriptionLimits', () => { }); } - // Reflects the actual api/oauth/profile response shape used by the Claude Code CLI + // Reflects the actual /api/oauth/usage response shape const sampleResponse = { - account: { - display_name: 'Test User', - created_at: '2025-01-01T00:00:00Z', - }, - organization: { - organization_type: 'claude_max', - rate_limit_tier: 'default_claude_max_5x', - has_extra_usage_enabled: false, - billing_type: 'subscription', - subscription_created_at: '2025-01-01T00:00:00Z', - uuid: 'org-uuid-123', + five_hour: { utilization: 33, resets_at: '2026-04-10T19:00:00.772723+00:00' }, + seven_day: { utilization: 3, resets_at: '2026-04-17T09:59:59.772747+00:00' }, + seven_day_oauth_apps: null, + seven_day_opus: null, + seven_day_sonnet: { utilization: 44, resets_at: '2026-04-10T16:59:59.772755+00:00' }, + seven_day_cowork: null, + iguana_necktie: null, + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, }, }; - it('returns subscription info on success', async () => { + it('returns usage buckets on success', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, ); @@ -49,13 +50,82 @@ describe('fetchClaudeSubscriptionLimits', () => { const result = await fetchClaudeSubscriptionLimits('test-oauth-token'); expect(result).not.toBeNull(); - expect(result?.plan).toBe('claude_max'); - // Usage stats are not available from the profile endpoint; always 0 - expect(result?.messagesUsed).toBe(0); - expect(result?.messagesLimit).toBe(0); - expect(result?.tokensUsed).toBe(0); - expect(result?.tokensLimit).toBe(0); - expect(result?.resetsAt).toBe(''); + expect(result?.buckets).toHaveLength(3); + expect(result?.buckets[0]).toEqual({ + label: '5-Hour Window', + utilization: 33, + resetsAt: '2026-04-10T19:00:00.772723+00:00', + }); + expect(result?.buckets[1]).toEqual({ + label: '7-Day Overall', + utilization: 3, + resetsAt: '2026-04-17T09:59:59.772747+00:00', + }); + expect(result?.buckets[2]).toEqual({ + label: '7-Day Sonnet', + utilization: 44, + resetsAt: '2026-04-10T16:59:59.772755+00:00', + }); + }); + + it('parses extra_usage when enabled with values', async () => { + const responseWithExtra = { + ...sampleResponse, + extra_usage: { + is_enabled: true, + monthly_limit: 100, + used_credits: 42.5, + utilization: 42, + }, + }; + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(responseWithExtra) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.extraUsage).toEqual({ + isEnabled: true, + monthlyLimit: 100, + usedCredits: 42.5, + utilization: 42, + }); + }); + + it('returns extra_usage as non-enabled when disabled', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.extraUsage).toEqual({ + isEnabled: false, + monthlyLimit: null, + usedCredits: null, + utilization: null, + }); + }); + + it('skips null buckets', async () => { + const sparseResponse = { + five_hour: null, + seven_day: { utilization: 10, resets_at: '2026-04-17T00:00:00Z' }, + seven_day_oauth_apps: null, + seven_day_opus: null, + seven_day_sonnet: null, + seven_day_cowork: null, + iguana_necktie: null, + extra_usage: null, + }; + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sparseResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.buckets).toHaveLength(1); + expect(result?.buckets[0]?.label).toBe('7-Day Overall'); }); it('masks the token showing only last 4 chars', async () => { @@ -85,7 +155,7 @@ describe('fetchClaudeSubscriptionLimits', () => { ); }); - it('calls the oauth/profile endpoint', async () => { + it('calls the oauth/usage endpoint', async () => { vi.mocked(fetch).mockReturnValueOnce( makeFetchResponse(sampleResponse) as ReturnType, ); @@ -93,7 +163,7 @@ describe('fetchClaudeSubscriptionLimits', () => { await fetchClaudeSubscriptionLimits('my-oauth-token'); expect(fetch).toHaveBeenCalledWith( - 'https://api.anthropic.com/api/oauth/profile', + 'https://api.anthropic.com/api/oauth/usage', expect.any(Object), ); }); @@ -134,28 +204,16 @@ describe('fetchClaudeSubscriptionLimits', () => { expect(result).toBeNull(); }); - it('returns null when response has no organization field', async () => { - vi.mocked(fetch).mockReturnValueOnce( - // Response missing the organization field (invalid shape) - makeFetchResponse({ account: { display_name: 'Test' } }) as ReturnType, - ); - - const result = await fetchClaudeSubscriptionLimits('some-token'); - - expect(result).toBeNull(); - }); - - it('returns unknown plan when organization_type is missing', async () => { + it('returns empty buckets when response has no recognized fields', async () => { vi.mocked(fetch).mockReturnValueOnce( - makeFetchResponse({ - organization: { rate_limit_tier: 'default' }, - }) as ReturnType, + makeFetchResponse({ something_unexpected: true }) as ReturnType, ); const result = await fetchClaudeSubscriptionLimits('some-token'); expect(result).not.toBeNull(); - expect(result?.plan).toBe('unknown'); + expect(result?.buckets).toEqual([]); + expect(result?.extraUsage).toBeNull(); }); it('caches results for subsequent calls with the same token', async () => { diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts index 83df5503..4abe58a5 100644 --- a/tests/unit/api/routers/claudeCodeLimits.test.ts +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -20,13 +20,12 @@ import { claudeCodeLimitsRouter } from '../../../../src/api/routers/claudeCodeLi const createCaller = createCallerFor(claudeCodeLimitsRouter); const sampleLimits = { - plan: 'claude_max', - messagesUsed: 1000, - messagesLimit: 20000, - tokensUsed: 500000, - tokensLimit: 10000000, - resetsAt: '2026-05-01T00:00:00Z', tokenMasked: '****abcd', + buckets: [ + { label: '5-Hour Window', utilization: 33, resetsAt: '2026-04-10T19:00:00Z' }, + { label: '7-Day Overall', utilization: 3, resetsAt: '2026-04-17T10:00:00Z' }, + ], + extraUsage: { isEnabled: false, monthlyLimit: null, usedCredits: null, utilization: null }, }; describe('claudeCodeLimitsRouter', () => { diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 842eb0f6..525e031b 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -1,22 +1,24 @@ import { useQuery } from '@tanstack/react-query'; import { trpc } from '@/lib/trpc.js'; -function formatNumber(n: number): string { - return n.toLocaleString(); -} - function formatResetDate(resetsAt: string): string { if (!resetsAt) return ''; try { const date = new Date(resetsAt); - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return resetsAt; } } +function utilizationColor(pct: number): string { + if (pct >= 90) return 'bg-red-500'; + if (pct >= 70) return 'bg-yellow-500'; + return 'bg-emerald-500'; +} + /** - * Displays Claude Code subscription limits for all unique tokens configured + * Displays Claude Code subscription usage for all unique tokens configured * across org projects. Shown only to superadmins; auto-hides when no data. */ export function ClaudeCodeLimitsSection() { @@ -33,35 +35,47 @@ export function ClaudeCodeLimitsSection() { return (
- Limits + Usage
- {data.map((limits, i) => { - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable -
-
- {limits.tokenMasked} -
-
{limits.plan}
- {limits.messagesLimit > 0 && ( -
- Msgs: {formatNumber(limits.messagesUsed)} / {formatNumber(limits.messagesLimit)} + {data.map((limits, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable +
+
+ {limits.tokenMasked} +
+ {limits.buckets.length === 0 && ( +
No usage data
+ )} + {limits.buckets.map((bucket) => ( +
+
+ {bucket.label} + {bucket.utilization}%
- )} - {limits.tokensLimit > 0 && ( -
- Tokens: {formatNumber(limits.tokensUsed)} / {formatNumber(limits.tokensLimit)} +
+
- )} - {limits.resetsAt && ( -
- Resets {formatResetDate(limits.resetsAt)} +
+ Resets {formatResetDate(bucket.resetsAt)}
- )} -
- ); - })} +
+ ))} + {limits.extraUsage?.isEnabled && ( +
+ Extra usage enabled + {limits.extraUsage.usedCredits != null && limits.extraUsage.monthlyLimit != null && ( + + {' '}— ${limits.extraUsage.usedCredits.toFixed(2)} / ${limits.extraUsage.monthlyLimit.toFixed(2)} + + )} +
+ )} +
+ ))}
); From ccc697a2c874feab5f7f8c795f1e30d97ee7ee17 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 16:50:51 +0000 Subject: [PATCH 6/8] fix(lint): apply biome formatter to claude-code-limits component Split long lines in formatResetDate and extraUsage rendering to satisfy biome's line-length formatting rules. Co-Authored-By: Claude Sonnet 4.6 --- .../components/global/claude-code-limits.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 525e031b..12e4b358 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -5,7 +5,12 @@ function formatResetDate(resetsAt: string): string { if (!resetsAt) return ''; try { const date = new Date(resetsAt); - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); } catch { return resetsAt; } @@ -67,11 +72,14 @@ export function ClaudeCodeLimitsSection() { {limits.extraUsage?.isEnabled && (
Extra usage enabled - {limits.extraUsage.usedCredits != null && limits.extraUsage.monthlyLimit != null && ( - - {' '}— ${limits.extraUsage.usedCredits.toFixed(2)} / ${limits.extraUsage.monthlyLimit.toFixed(2)} - - )} + {limits.extraUsage.usedCredits != null && + limits.extraUsage.monthlyLimit != null && ( + + {' '} + — ${limits.extraUsage.usedCredits.toFixed(2)} / $ + {limits.extraUsage.monthlyLimit.toFixed(2)} + + )}
)}
From 65b263879f144b0cf072718ed2026d7058688dcc Mon Sep 17 00:00:00 2001 From: Wojtek Siudzinski Date: Fri, 10 Apr 2026 18:57:17 +0200 Subject: [PATCH 7/8] fix(anthropic): add separator above Claude code limits usage section --- web/src/components/global/claude-code-limits.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index 12e4b358..c573ad72 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { trpc } from '@/lib/trpc.js'; +import { Separator } from '@/components/ui/separator.js'; function formatResetDate(resetsAt: string): string { if (!resetsAt) return ''; @@ -38,14 +39,15 @@ export function ClaudeCodeLimitsSection() { } return ( -
+
+
Usage
{data.map((limits, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: tokenMasked is not guaranteed unique (tokens may share trailing 4 chars); index is safe here as list order is server-determined and stable -
+
{limits.tokenMasked}
From 7766fdc25a27bae82bc2bedd73f4df46869724b1 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 17:03:11 +0000 Subject: [PATCH 8/8] fix(lint): reduce cognitive complexity and fix import order - Extract parseBuckets() and parseExtraUsage() helpers from fetchClaudeSubscriptionLimits() to reduce cognitive complexity from 16 to below the max of 15 - Fix import order in claude-code-limits.tsx (Separator before trpc) Co-Authored-By: Claude Sonnet 4.6 --- src/anthropic/client.ts | 53 ++++++++++--------- .../components/global/claude-code-limits.tsx | 2 +- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts index 3e7e879e..21277da4 100644 --- a/src/anthropic/client.ts +++ b/src/anthropic/client.ts @@ -56,6 +56,32 @@ const BUCKET_LABELS: Record = { iguana_necktie: 'Iguana Necktie', }; +/** Parse usage buckets from the API response JSON. */ +function parseBuckets(json: Record): UsageBucket[] { + const buckets: UsageBucket[] = []; + for (const [key, label] of Object.entries(BUCKET_LABELS)) { + const raw = json[key] as { utilization?: number; resets_at?: string } | null | undefined; + if (raw && typeof raw.utilization === 'number' && typeof raw.resets_at === 'string') { + buckets.push({ label, utilization: raw.utilization, resetsAt: raw.resets_at }); + } + } + return buckets; +} + +/** Parse the extra_usage block from the API response JSON. */ +function parseExtraUsage(json: Record): ClaudeSubscriptionLimits['extraUsage'] { + const rawExtra = json.extra_usage as Record | null | undefined; + if (!rawExtra || typeof rawExtra.is_enabled !== 'boolean') { + return null; + } + return { + isEnabled: rawExtra.is_enabled, + monthlyLimit: typeof rawExtra.monthly_limit === 'number' ? rawExtra.monthly_limit : null, + usedCredits: typeof rawExtra.used_credits === 'number' ? rawExtra.used_credits : null, + utilization: typeof rawExtra.utilization === 'number' ? rawExtra.utilization : null, + }; +} + /** * Fetch Claude subscription usage for the given OAuth token via the /api/oauth/usage endpoint. * Returns null on any error (network, auth, unexpected shape, etc.). @@ -86,33 +112,10 @@ export async function fetchClaudeSubscriptionLimits( } const json = (await response.json()) as Record; - - // Parse usage buckets — each key (except extra_usage) is either null or - // { utilization: number, resets_at: string } - const buckets: UsageBucket[] = []; - for (const [key, label] of Object.entries(BUCKET_LABELS)) { - const raw = json[key] as { utilization?: number; resets_at?: string } | null | undefined; - if (raw && typeof raw.utilization === 'number' && typeof raw.resets_at === 'string') { - buckets.push({ label, utilization: raw.utilization, resetsAt: raw.resets_at }); - } - } - - // Parse extra_usage block - let extraUsage: ClaudeSubscriptionLimits['extraUsage'] = null; - const rawExtra = json.extra_usage as Record | null | undefined; - if (rawExtra && typeof rawExtra.is_enabled === 'boolean') { - extraUsage = { - isEnabled: rawExtra.is_enabled, - monthlyLimit: typeof rawExtra.monthly_limit === 'number' ? rawExtra.monthly_limit : null, - usedCredits: typeof rawExtra.used_credits === 'number' ? rawExtra.used_credits : null, - utilization: typeof rawExtra.utilization === 'number' ? rawExtra.utilization : null, - }; - } - const result: ClaudeSubscriptionLimits = { tokenMasked: maskToken(oauthToken), - buckets, - extraUsage, + buckets: parseBuckets(json), + extraUsage: parseExtraUsage(json), }; cacheByToken.set(oauthToken, { data: result, timestamp: Date.now() }); diff --git a/web/src/components/global/claude-code-limits.tsx b/web/src/components/global/claude-code-limits.tsx index c573ad72..d329da67 100644 --- a/web/src/components/global/claude-code-limits.tsx +++ b/web/src/components/global/claude-code-limits.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { trpc } from '@/lib/trpc.js'; import { Separator } from '@/components/ui/separator.js'; +import { trpc } from '@/lib/trpc.js'; function formatResetDate(resetsAt: string): string { if (!resetsAt) return '';