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
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 { pmDiscoveryRouter } from './routers/pm-discovery.js';
Expand Down Expand Up @@ -35,6 +36,7 @@ export const appRouter = router({
workItems: workItemsRouter,
users: usersRouter,
workflowStatuses: workflowStatusesRouter,
claudeCodeLimits: claudeCodeLimitsRouter,
});

export type AppRouter = typeof appRouter;
8 changes: 8 additions & 0 deletions src/api/routers/_shared/maskCredential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Mask a credential value for list previews: short values collapse to '****',
* longer ones keep the last 4 characters ('****abcd'). Shared by the project
* and organization credential list endpoints so the masking rule cannot drift.
*/
export function maskCredentialValue(value: string): string {
return value.length <= 12 ? '****' : `****${value.slice(-4)}`;
}
29 changes: 29 additions & 0 deletions src/api/routers/_shared/orgRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { TRPCError } from '@trpc/server';
import { resolveActorRoleInOrg } from '../../context.js';

type Role = 'member' | 'admin' | 'superadmin';

/**
* Resolve the caller's role *in the effective org*. The `adminProcedure`
* middleware is a coarse global-role gate; this refines it with the per-org
* membership role (users.ts pattern) so an admin who has switched into an org
* where they are only a member cannot perform admin actions there.
*/
export function resolveActorRole(ctx: {
user: { id: string; role: Role; orgId: string };
effectiveOrgId: string;
}): Promise<Role> {
return resolveActorRoleInOrg({
userId: ctx.user.id,
globalRole: ctx.user.role,
homeOrgId: ctx.user.orgId,
orgId: ctx.effectiveOrgId,
});
}

/** Require the caller to be an admin (or superadmin) in the effective org. */
export function assertOrgAdmin(actorRole: Role): void {
if (actorRole !== 'admin' && actorRole !== 'superadmin') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' });
}
}
121 changes: 121 additions & 0 deletions src/api/routers/claudeCodeLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { z } from 'zod';
import { fetchClaudeSubscriptionLimits } from '../../anthropic/client.js';
import {
getProjectOwnCredential,
listAllClaudeCodeCredentials,
} from '../../db/repositories/credentialsRepository.js';
import { resolveOrgCredential } from '../../db/repositories/orgCredentialsRepository.js';
import { logger } from '../../utils/logging.js';
import { adminProcedure, protectedProcedure, router } from '../trpc.js';
import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js';
import { verifyProjectOrgAccess } from './_shared/projectAccess.js';

const CLAUDE_CODE_TOKEN_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';

// The server env token (process.env.CLAUDE_CODE_OAUTH_TOKEN) is deliberately
// NOT surfaced here: (a) this tRPC handler runs in the dashboard service while
// workers receive the ROUTER service's env — the dashboard's view of it can be
// wrong in both directions; (b) it is a host-level operator secret, and its
// usage/billing data must not be exposed to tenant org members. Operators who
// want its usage visible should store the token as an org credential instead —
// which is exactly what this feature is for.

export type ClaudeCodeLimitsScope = 'org' | 'project';

export interface LimitsSource {
scope: ClaudeCodeLimitsScope;
projectId?: string;
projectName?: string;
token: string;
}

/** Resolve the org token, treating decrypt failures as absent (never 500). */
async function safeResolveOrgToken(orgId: string): Promise<string | null> {
try {
return await resolveOrgCredential(orgId, CLAUDE_CODE_TOKEN_KEY);
} catch (err) {
logger.warn('Failed to resolve org CLAUDE_CODE_OAUTH_TOKEN', {
orgId,
error: String(err),
});
return null;
}
}

/**
* Fetch usage limits for each source, preserving source attribution.
* Sources whose token yields no limits data (API error, revoked token) come
* back with `limits: null` so the UI can distinguish "no data" from "not
* configured". The Anthropic client caches per token for 5 minutes, so
* duplicate tokens across sources cost one HTTP call. Raw tokens never leave
* the server — only the masked preview inside `limits`.
*/
async function fetchLimitsForSources<T extends LimitsSource>(sources: T[]) {
return Promise.all(
sources.map(async ({ token, ...source }) => ({
...source,
limits: await fetchClaudeSubscriptionLimits(token),
})),
);
}

export const claudeCodeLimitsRouter = router({
/**
* Claude Code subscription usage for every credential source in the
* effective org: the org-level shared token and each project-level
* override. Org-admin gated — same audience as the organization
* credentials settings page.
*/
forOrg: adminProcedure.query(async ({ ctx }) => {
assertOrgAdmin(await resolveActorRole(ctx));

const sources: LimitsSource[] = [];

const orgToken = await safeResolveOrgToken(ctx.effectiveOrgId);
if (orgToken) sources.push({ scope: 'org', token: orgToken });

const projectCredentials = await listAllClaudeCodeCredentials(ctx.effectiveOrgId);
for (const cred of projectCredentials) {
sources.push({
scope: 'project',
projectId: cred.projectId,
projectName: cred.projectName,
token: cred.value,
});
}

return fetchLimitsForSources(sources);
}),

/**
* Claude Code subscription usage for the credential candidates visible to
* one project: its own override (if set) and the inherited org token (if
* set). `active` marks the credential-system winner (project override
* beats org). Used by the project settings engine tab as a picker preview.
*/
forProject: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId);

// Project tier only, deliberately NOT the inheriting resolver: the
// point is contrasting the override with the org value.
const projectToken = await getProjectOwnCredential(input.projectId, CLAUDE_CODE_TOKEN_KEY);
const orgToken = await safeResolveOrgToken(ctx.effectiveOrgId);

const sources: (LimitsSource & { active: boolean })[] = [];
if (projectToken) {
sources.push({
scope: 'project',
projectId: input.projectId,
token: projectToken,
active: true,
});
}
if (orgToken) {
sources.push({ scope: 'org', token: orgToken, active: !projectToken });
}

return fetchLimitsForSources(sources);
}),
});
Loading
Loading