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" diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts new file mode 100644 index 00000000..21277da4 --- /dev/null +++ b/src/anthropic/client.ts @@ -0,0 +1,134 @@ +/** + * OAuth usage endpoint — returns per-bucket utilization percentages and reset times + * for the authenticated subscription. + */ +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 + +/** 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 { + data: ClaudeSubscriptionLimits; + timestamp: number; +} + +/** + * Per-token cache. 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)}`; +} + +/** 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', +}; + +/** 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.). + * 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_USAGE_URL, { + headers: { + Authorization: `Bearer ${oauthToken}`, + 'anthropic-beta': 'oauth-2025-04-20', + 'Content-Type': 'application/json', + 'User-Agent': 'claude-code/2.1.87', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + return null; + } + + const json = (await response.json()) as Record; + const result: ClaudeSubscriptionLimits = { + tokenMasked: maskToken(oauthToken), + buckets: parseBuckets(json), + extraUsage: parseExtraUsage(json), + }; + + 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..d83fd3ed --- /dev/null +++ b/tests/unit/anthropic/client.test.ts @@ -0,0 +1,253 @@ +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), + }); + } + + // Reflects the actual /api/oauth/usage response shape + const sampleResponse = { + 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 usage buckets on success', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-oauth-token'); + + expect(result).not.toBeNull(); + 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 () => { + 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('calls the oauth/usage endpoint', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-oauth-token'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.any(Object), + ); + }); + + 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 empty buckets when response has no recognized fields', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({ something_unexpected: true }) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).not.toBeNull(); + expect(result?.buckets).toEqual([]); + expect(result?.extraUsage).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..4abe58a5 --- /dev/null +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockContext, createMockSuperAdmin } 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 = { + 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', () => { + 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..d329da67 --- /dev/null +++ b/web/src/components/global/claude-code-limits.tsx @@ -0,0 +1,92 @@ +import { useQuery } from '@tanstack/react-query'; +import { Separator } from '@/components/ui/separator.js'; +import { trpc } from '@/lib/trpc.js'; + +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', + }); + } 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 usage 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 ( +
+ +
+ 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} +
+ {limits.buckets.length === 0 && ( +
No usage data
+ )} + {limits.buckets.map((bucket) => ( +
+
+ {bucket.label} + {bucket.utilization}% +
+
+
+
+
+ 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)} + + )} +
+ )} +
+ ))} +
+
+ ); +} 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) => ( ))} + )}