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:
chan1103
2026-07-11 22:49:12 +09:00
parent 2081bae6a6
commit 044d722a36
21 changed files with 717 additions and 63 deletions
+41 -5
View File
@@ -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[]> = {};