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
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

134 changes: 134 additions & 0 deletions src/anthropic/client.ts
Original file line number Diff line number Diff line change
@@ -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<string, CacheEntry>();

/**
* 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<string, string> = {
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<string, unknown>): 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<string, unknown>): ClaudeSubscriptionLimits['extraUsage'] {
const rawExtra = json.extra_usage as Record<string, unknown> | 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<ClaudeSubscriptionLimits | null> {
// 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<string, unknown>;
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();
}
2 changes: 2 additions & 0 deletions src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -29,6 +30,7 @@ export const appRouter = router({
prs: prsRouter,
workItems: workItemsRouter,
users: usersRouter,
claudeCodeLimits: claudeCodeLimitsRouter,
});

export type AppRouter = typeof appRouter;
40 changes: 40 additions & 0 deletions src/api/routers/claudeCodeLimits.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>();
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);
}),
});
31 changes: 31 additions & 0 deletions src/db/repositories/credentialsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
Loading
Loading