Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/claude-seat-tier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(claude):** a Claude Team connection now shows the seat's own tier (`user_rate_limit_tier`, e.g. `default_claude_max_5x`) instead of the organization's base tier (`organization_rate_limit_tier`, e.g. `default_raven`) in the usage plan label, the quota plan and the dashboard, and stores the seat (`seat_tier`) from the bootstrap, the token exchange and the auth-file import. The import reads the nested `oauth_account` bootstrap and keeps one account's connections in two organizations apart. A member with no seat assigned, whom every OAuth call refuses with `oauth_not_allowed_for_organization`, gets a usage message that names it instead of a bare HTTP 403.
9 changes: 9 additions & 0 deletions open-sse/executors/claudeIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ export type ClaudeBootstrap = {
organization_name: string | null;
organization_type: string | null;
organization_rate_limit_tier: string | null;
/** The account's own tier inside its org (a Team seat's tier), e.g. `default_claude_max_5x`. */
user_rate_limit_tier: string | null;
/**
* Team seat, e.g. `team_standard` or `team_*_premium`. A member with no seat never
* sees it here: the bootstrap itself answers 403 oauth_not_allowed_for_organization.
*/
seat_tier: string | null;
};

/** GET /api/claude_cli/bootstrap with the same headers real CLI uses. */
Expand Down Expand Up @@ -184,6 +191,8 @@ export async function fetchClaudeBootstrap(accessToken: string): Promise<ClaudeB
organization_name: stringOrNull(account.organization_name),
organization_type: stringOrNull(account.organization_type),
organization_rate_limit_tier: stringOrNull(account.organization_rate_limit_tier),
user_rate_limit_tier: stringOrNull(account.user_rate_limit_tier),
seat_tier: stringOrNull(account.seat_tier),
};
} catch {
return null;
Expand Down
6 changes: 5 additions & 1 deletion open-sse/services/genericQuotaFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,11 @@ export function convertUsageToQuotaInfo(
const normalized = normalizeQuotaWindows(providerScopedWindows, context);
const { percentUsed, resetAt } = aggregateGroupedQuotaValues(providerScopedWindows);
const bootstrap = usageRecord.bootstrap as Record<string, unknown> | null | undefined;
const plan = [bootstrap?.organization_rate_limit_tier, usageRecord.plan]
const plan = [
bootstrap?.user_rate_limit_tier,
bootstrap?.organization_rate_limit_tier,
usageRecord.plan,
]
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.find((value) => value.length > 0);
Expand Down
16 changes: 16 additions & 0 deletions open-sse/services/usage/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ async function fetchClaudeUsage(
typeof data.tier === "string" ? data.tier : null,
typeof data.plan === "string" ? data.plan : null,
typeof data.subscription_type === "string" ? data.subscription_type : null,
// A Team seat's own tier (e.g. max_5x) before the org's base tier (raven).
bootstrap?.user_rate_limit_tier,
bootstrap?.organization_rate_limit_tier
) ?? undefined;

Expand Down Expand Up @@ -272,6 +274,20 @@ async function fetchClaudeUsage(
return { ...legacy, bootstrap: await bootstrapPromise };
}

if (oauthResponse.status === 403) {
const body = toRecord(await oauthResponse.json().catch(() => null));
const errorCode = toRecord(toRecord(body.error).details).error_code;
// The org refuses OAuth for this account on every call, inference included.
// Measured cause on a Team: the member has no seat assigned.
if (errorCode === "oauth_not_allowed_for_organization") {
return {
message:
"Claude organization does not allow OAuth for this account (oauth_not_allowed_for_organization); on a Team this usually means the member has no seat assigned.",
bootstrap: await bootstrapPromise,
};
}
}

return {
message: `Claude connected. Usage endpoint returned HTTP ${oauthResponse.status}.`,
bootstrap: await bootstrapPromise,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,9 @@ export function resolvePlanValue(plan, providerSpecificData, providerId) {
psd.tier,
psd.accountTier,
// Claude OAuth bootstrap: rate_limit_tier has the Max 5x/20x multiplier.
psd.organizationRateLimitTier,
// The account's own (seat) tier first: a Team org's base tier is not the seat's.
psd.rateLimitTier,
psd.organizationRateLimitTier,
psd.organizationType,
// Codex OAuth bootstrap: chatgpt_plan_type is captured at import time
// (src/lib/oauth/services/codexImport.ts) and is the only source of the
Expand Down
4 changes: 4 additions & 0 deletions src/lib/oauth/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ async function fetchClaudeBootstrap(accessToken) {
organization_name: acct.organization_name || null,
organization_type: acct.organization_type || null,
organization_rate_limit_tier: acct.organization_rate_limit_tier || null,
user_rate_limit_tier: acct.user_rate_limit_tier || null,
seat_tier: acct.seat_tier || null,
};
} catch {
return null;
Expand Down Expand Up @@ -191,6 +193,8 @@ export const claude = {
if (bs.organization_type) providerSpecificData.organizationType = bs.organization_type;
if (bs.organization_rate_limit_tier)
providerSpecificData.organizationRateLimitTier = bs.organization_rate_limit_tier;
if (bs.user_rate_limit_tier) providerSpecificData.rateLimitTier = bs.user_rate_limit_tier;
if (bs.seat_tier) providerSpecificData.seatTier = bs.seat_tier;
if (plan) providerSpecificData.plan = plan;

const result: any = {
Expand Down
113 changes: 73 additions & 40 deletions src/lib/oauth/utils/claudeAuthImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface EnrichedClaudeAuth extends ParsedClaudeAuth {
organizationName: string | null;
organizationType: string | null;
rateLimitTier: string | null;
seatTier: string | null;
}

export interface CreateConnectionOptions {
Expand Down Expand Up @@ -97,6 +98,23 @@ export function parseAndValidateClaudeAuth(raw: unknown): ParsedClaudeAuth {

// ──── Bootstrap enrichment ────────────────────────────────────────────────────

// The live bootstrap nests the account under `oauth_account` (the shape
// fetchClaudeBootstrap reads); a flat body is still accepted.
function bootstrapAccount(raw: unknown): JsonRecord {
const doc = toRecord(raw);
return doc.oauth_account ? toRecord(doc.oauth_account) : doc;
}

// The account's own (seat) tier first: the org's base tier and the CLI file's
// copy both differ from it once a Team admin moves seats.
function bootstrapRateLimitTier(body: JsonRecord, fileTier: string | null): string | null {
return (
toNonEmptyString(body.user_rate_limit_tier) ||
toNonEmptyString(body.rate_limit_tier) ||
fileTier
);
}

export async function enrichWithBootstrap(
parsed: ParsedClaudeAuth,
// proxyConfig reserved for future authenticated-proxy support
Expand All @@ -109,6 +127,7 @@ export async function enrichWithBootstrap(
organizationName: null,
organizationType: null,
rateLimitTier: parsed.rateLimitTier,
seatTier: null,
};

const controller = new AbortController();
Expand All @@ -131,23 +150,17 @@ export async function enrichWithBootstrap(
return base;
}

const body = toRecord(await res.json());

const accountUUID = toNonEmptyString(body.account_uuid);
const organizationUUID = toNonEmptyString(body.organization_uuid);
const organizationName = toNonEmptyString(body.organization_name);
const organizationType = toNonEmptyString(body.organization_type);
const rateLimitTier = toNonEmptyString(body.rate_limit_tier) || parsed.rateLimitTier;
const bootstrapEmail = toNonEmptyString(body.account_email);
const body = bootstrapAccount(await res.json());

return {
...base,
accountUUID,
organizationUUID,
organizationName,
organizationType,
rateLimitTier,
email: parsed.email || bootstrapEmail,
accountUUID: toNonEmptyString(body.account_uuid),
organizationUUID: toNonEmptyString(body.organization_uuid),
organizationName: toNonEmptyString(body.organization_name),
organizationType: toNonEmptyString(body.organization_type),
rateLimitTier: bootstrapRateLimitTier(body, parsed.rateLimitTier),
seatTier: toNonEmptyString(body.seat_tier),
email: parsed.email || toNonEmptyString(body.account_email),
};
} catch {
// Network error, timeout, or parse failure — best-effort; callers handle null fields
Expand All @@ -160,28 +173,64 @@ export async function enrichWithBootstrap(
// ──── Lookup ──────────────────────────────────────────────────────────────────

export async function findExistingClaudeConnection(
accountUUID: string
accountUUID: string,
organizationUUID: string | null = null
): Promise<JsonRecord | null> {
const connections = await getProviderConnections({ provider: "claude" });
const lower = accountUUID.toLowerCase();
return (
(connections.find((c) => {
const psd = toRecord((c as JsonRecord).providerSpecificData);
const stored = toNonEmptyString(psd.accountUUID);
return stored !== null && stored.toLowerCase() === lower;
}) as JsonRecord | undefined) ?? null
(connections.find((c) =>
isSameClaudeIdentity(
toRecord((c as JsonRecord).providerSpecificData),
accountUUID,
organizationUUID
)
) as JsonRecord | undefined) ?? null
);
}

// One accountUUID reaches its personal workspace and every Team org it belongs
// to; the org separates them. Like isMatchingOauthIdentity, the org decides only
// when both sides carry it.
function isSameClaudeIdentity(
psd: JsonRecord,
accountUUID: string,
organizationUUID: string | null
): boolean {
const stored = toNonEmptyString(psd.accountUUID);
if (stored === null || stored.toLowerCase() !== accountUUID.toLowerCase()) return false;
const storedOrg = toNonEmptyString(psd.organizationUUID)?.toLowerCase();
const org = organizationUUID?.toLowerCase();
return !org || !storedOrg || org === storedOrg;
}

// ──── Create / update connection ──────────────────────────────────────────────

// The identity and plan fields an import writes, on a new connection and over an existing one.
function importedClaudeData(enriched: EnrichedClaudeAuth): JsonRecord {
return {
accountUUID: enriched.accountUUID,
organizationUUID: enriched.organizationUUID,
organizationName: enriched.organizationName,
organizationType: enriched.organizationType,
rateLimitTier: enriched.rateLimitTier,
...(enriched.seatTier ? { seatTier: enriched.seatTier } : {}),
scopes: enriched.scopes,
subscriptionType: enriched.subscriptionType,
bootstrapEmail: enriched.email,
importedAt: new Date().toISOString(),
};
}

export async function createConnectionFromAuthFile(
enriched: EnrichedClaudeAuth,
options: CreateConnectionOptions
): Promise<{ connection: JsonRecord; created: boolean }> {
// Duplicate detection by accountUUID (skipped when bootstrap failed)
if (enriched.accountUUID) {
const existing = await findExistingClaudeConnection(enriched.accountUUID);
const existing = await findExistingClaudeConnection(
enriched.accountUUID,
enriched.organizationUUID
);

if (existing) {
if (!options.overwriteExisting) {
Expand All @@ -207,15 +256,7 @@ export async function createConnectionFromAuthFile(
testStatus: "active",
providerSpecificData: {
...toRecord(existing.providerSpecificData),
accountUUID: enriched.accountUUID,
organizationUUID: enriched.organizationUUID,
organizationName: enriched.organizationName,
organizationType: enriched.organizationType,
rateLimitTier: enriched.rateLimitTier,
scopes: enriched.scopes,
subscriptionType: enriched.subscriptionType,
bootstrapEmail: enriched.email,
importedAt: new Date().toISOString(),
...importedClaudeData(enriched),
// #10143: preserve an already-persisted device identity across
// re-imports so the connection doesn't present as a new device to
// Anthropic on every process restart; only mint one if absent.
Expand Down Expand Up @@ -253,15 +294,7 @@ export async function createConnectionFromAuthFile(
isActive: true,
testStatus: "active",
providerSpecificData: {
accountUUID: enriched.accountUUID,
organizationUUID: enriched.organizationUUID,
organizationName: enriched.organizationName,
organizationType: enriched.organizationType,
rateLimitTier: enriched.rateLimitTier,
scopes: enriched.scopes,
subscriptionType: enriched.subscriptionType,
bootstrapEmail: enriched.email,
importedAt: new Date().toISOString(),
...importedClaudeData(enriched),
// #10143: mint a persistent device identity so this imported
// connection doesn't fall back to a lazy-random device id that
// regenerates on every process restart (see resolveCliUserID).
Expand Down
4 changes: 4 additions & 0 deletions src/lib/usage/providerLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,10 @@ async function syncClaudeBootstrapIfNeeded(
["organization_name", "organizationName"],
["organization_type", "organizationType"],
["organization_rate_limit_tier", "organizationRateLimitTier"],
// The account's own tier: `rateLimitTier` is otherwise frozen at import (copied from the
// CLI credentials file) and goes stale when a Team admin moves seats.
["user_rate_limit_tier", "rateLimitTier"],
["seat_tier", "seatTier"],
];

const nextPsd: JsonRecord = { ...psd };
Expand Down
Loading
Loading