fix(ai): scope Anthropic credential identity by organization
One Anthropic account email can hold multiple organizations (a Team seat plus a personal Max plan), each with its own org-scoped OAuth token and independent 5h/7d limit pools. Credentials were deduped by bare email, so logging in with the second subscription silently replaced the first, and usage reports from the two pools merged into one row with mixed numbers. - capture organization uuid/name at login (token exchange response, with a claude_cli/bootstrap fallback); token refreshes never rewrite it - key anthropic credential identity as email + org; a legacy email-keyed row is claimed in place by the first org-scoped login with the same email, and org-less credentials never clobber org-scoped rows - partition usage-report dedupe and the per-credential usage cache by org so the two subscriptions' limit pools stay distinct for rotation - show the organization in omp usage (redaction-safe) and name the stored account/org in the login success message
This commit is contained in:
@@ -40,6 +40,9 @@ export interface UsageAccountIdentity {
|
||||
accountId?: string;
|
||||
projectId?: string;
|
||||
enterpriseUrl?: string;
|
||||
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +136,7 @@ function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountId
|
||||
add(meta.accountId);
|
||||
add(meta.projectId);
|
||||
add(meta.orgId);
|
||||
add(meta.orgName);
|
||||
for (const limit of report.limits) {
|
||||
add(limit.scope.accountId);
|
||||
add(limit.scope.projectId);
|
||||
@@ -143,6 +147,8 @@ function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountId
|
||||
add(account.email);
|
||||
add(account.accountId);
|
||||
add(account.projectId);
|
||||
add(account.orgId);
|
||||
add(account.orgName);
|
||||
add(account.enterpriseUrl);
|
||||
}
|
||||
return values;
|
||||
@@ -292,6 +298,18 @@ export function collectUnreportedAccounts(
|
||||
const providerReports = byProvider.get(account.provider) ?? [];
|
||||
if (providerReports.length === 0) return true;
|
||||
if (account.type === "api_key") return false;
|
||||
// Org-scoped account (Anthropic multi-subscription): when reports carry
|
||||
// org identity, attribution must match on the org — the shared email
|
||||
// would otherwise mark BOTH subscriptions as covered by one report.
|
||||
if (account.orgId) {
|
||||
const orgId = account.orgId.toLowerCase();
|
||||
const reportedOrgs = new Set<string>();
|
||||
for (const report of providerReports) {
|
||||
const metaOrg = report.metadata?.orgId;
|
||||
if (typeof metaOrg === "string" && metaOrg) reportedOrgs.add(metaOrg.toLowerCase());
|
||||
}
|
||||
if (reportedOrgs.size > 0) return !reportedOrgs.has(orgId);
|
||||
}
|
||||
const ids = [account.email, account.accountId, account.projectId]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.map(value => value.toLowerCase());
|
||||
@@ -308,9 +326,17 @@ export function collectUnreportedAccounts(
|
||||
});
|
||||
}
|
||||
|
||||
function accountIdentityLabel(account: UsageAccountIdentity): string {
|
||||
/** Compose the account label from parts, masking each part individually so `--redact` cannot be bypassed by the composite string. */
|
||||
function accountIdentityLabel(account: UsageAccountIdentity, redaction?: Map<string, string>): string {
|
||||
if (account.type === "api_key") return "API key";
|
||||
return account.email ?? account.accountId ?? account.projectId ?? account.enterpriseUrl ?? "OAuth account";
|
||||
const base = account.email ?? account.accountId ?? account.projectId ?? account.enterpriseUrl ?? "OAuth account";
|
||||
const masked = redaction?.get(base) ?? base;
|
||||
// orgId fallback: the uuid is the actual scoped identity; a token response
|
||||
// can carry it without a display name, and two same-email rows must still
|
||||
// be tellable apart.
|
||||
const org = account.orgName ?? account.orgId;
|
||||
if (!org || org === base) return masked;
|
||||
return `${masked} · ${redaction?.get(org) ?? org}`;
|
||||
}
|
||||
|
||||
function formatAccountHeader(
|
||||
@@ -323,6 +349,12 @@ function formatAccountHeader(
|
||||
const icon = STATUS_COLOR[status]("●");
|
||||
const label = reportAccountLabel(report, index);
|
||||
let header = `${icon} ${chalk.bold(redaction?.get(label) ?? label)}`;
|
||||
const metaOrgName = report.metadata?.orgName;
|
||||
const metaOrgId = report.metadata?.orgId;
|
||||
const org = typeof metaOrgName === "string" && metaOrgName ? metaOrgName : metaOrgId;
|
||||
if (typeof org === "string" && org && org !== label) {
|
||||
header += chalk.dim(` · ${redaction?.get(org) ?? org}`);
|
||||
}
|
||||
const planType = report.metadata?.planType;
|
||||
if (typeof planType === "string" && planType) header += chalk.dim(` · plan: ${planType}`);
|
||||
const savedResets = report.resetCredits?.availableCount ?? 0;
|
||||
@@ -519,8 +551,8 @@ export function formatUsageBreakdown(
|
||||
});
|
||||
|
||||
for (const account of providerUnreported) {
|
||||
const label = accountIdentityLabel(account);
|
||||
lines.push(` ${chalk.dim("○")} ${chalk.dim(`${redaction?.get(label) ?? label} — no usage data`)}`);
|
||||
const label = accountIdentityLabel(account, redaction);
|
||||
lines.push(` ${chalk.dim("○")} ${chalk.dim(`${label} — no usage data`)}`);
|
||||
}
|
||||
|
||||
const stats = computeProviderWindowStats(providerReports);
|
||||
@@ -689,6 +721,8 @@ function collectStoredAccounts(authStorage: AuthStorage): UsageAccountIdentity[]
|
||||
accountId: credential.accountId,
|
||||
projectId: credential.projectId,
|
||||
enterpriseUrl: credential.enterpriseUrl,
|
||||
orgId: credential.orgId,
|
||||
orgName: credential.orgName,
|
||||
});
|
||||
} else {
|
||||
accounts.push({ provider, type: "api_key" });
|
||||
@@ -725,7 +759,7 @@ function maskIdentity(redaction: Map<string, string>, value: string | undefined)
|
||||
return value === undefined ? undefined : (redaction.get(value) ?? value);
|
||||
}
|
||||
|
||||
const IDENTITY_METADATA_KEYS = ["email", "accountId", "projectId", "orgId"] as const;
|
||||
const IDENTITY_METADATA_KEYS = ["email", "accountId", "projectId", "orgId", "orgName"] as const;
|
||||
|
||||
/** Mask identity fields in a raw-stripped report for `--redact --json`. */
|
||||
function redactReportForJson(
|
||||
@@ -819,6 +853,8 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
|
||||
accountId: maskIdentity(redaction, account.accountId),
|
||||
projectId: maskIdentity(redaction, account.projectId),
|
||||
enterpriseUrl: maskIdentity(redaction, account.enterpriseUrl),
|
||||
orgId: maskIdentity(redaction, account.orgId),
|
||||
orgName: maskIdentity(redaction, account.orgName),
|
||||
}));
|
||||
}
|
||||
const capacity: Record<string, ProviderWindowStat[]> = {};
|
||||
|
||||
Reference in New Issue
Block a user