diff --git a/docs/providers.md b/docs/providers.md index cb2b4ec53..4481157d7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -31,7 +31,7 @@ When a provider needs an API key, `omp` resolves it in this order (first match w 1. **Runtime override** — a key supplied for the current process, e.g. CLI `--api-key`. Never persisted. 2. **`models.yml` config key** — an `apiKey` pinned on a custom provider, registered as a config-sourced bearer. This deliberately beats stored OAuth, so a key supplied for a custom `baseUrl`/gateway is honored instead of forwarding an upstream OAuth token the proxy would reject. 3. **Stored API key** — an API-key credential saved in the auth store. -4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. +4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic, each organization counts as its own account: one email holding both a Team seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts. 5. **Provider environment variable** — including values loaded from `.env` files (see [the env-var table](#environment-variables-and-env-files)). 6. **`models.yml` fallback resolver** — keys for custom providers not otherwise registered. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index af7572e8e..24c76e48c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,8 @@ - Healed GLM in-band tool calls whose `` closer is missing or mistyped as ``; the scanner now ends the value at the next-pair signature instead of swallowing the remaining arguments into one field. - Healed the same `arg_key`/`arg_value` spill when it arrives through native tool calling (provider parses the in-band syntax server-side): as a last resort after validation and coercion fail, contaminated string arguments are split at the spill boundary and the swallowed pairs restored. +- Fixed Anthropic logins silently replacing the stored credential when one account email holds multiple organizations (e.g. a Team seat plus a personal Max plan). Credentials are now identified by email + organization: the login flow captures the organization from the token exchange (with a `claude_cli/bootstrap` fallback), both subscriptions store side by side, and the existing multi-account rotation treats them as separate accounts. Legacy email-keyed rows are claimed in place by the first org-scoped login with the same email, and an org-less credential never clobbers org-scoped rows. Usage reports and the per-credential usage cache also partition by organization so the two subscriptions' limit pools no longer merge into one confused row. +- Fixed broker-served usage routing for org-scoped credentials: `RemoteAuthCredentialStore` now matches aggregate reports and keys header-ingest overlays by organization first, so with a Team seat exhausted and a personal Max healthy under one email, each credential receives its own pool instead of whichever report appeared first. The Anthropic usage-cache key version was bumped so pre-org cache entries (including the 24h last-good fallback) cannot be replayed across organizations. ## [16.4.3] - 2026-07-11 diff --git a/packages/ai/src/auth-broker/remote-store.ts b/packages/ai/src/auth-broker/remote-store.ts index 4351fe616..bd314c6b6 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -143,8 +143,13 @@ interface UsageCacheEntry { function usageOverlayKey( provider: Provider, - ids: { accountId?: string; email?: string; projectId?: string }, + ids: { accountId?: string; email?: string; projectId?: string; orgId?: string }, ): string | undefined { + // Org first: one account email can hold several organizations (Anthropic + // Team seat + personal Max), each with its own limit pools. Keying the + // overlay by account/email would merge the two pools' header ingests. + const orgId = ids.orgId?.trim().toLowerCase(); + if (orgId) return `${provider}\0org:${orgId}`; const accountId = ids.accountId?.trim().toLowerCase(); if (accountId) return `${provider}\0account:${accountId}`; const email = ids.email?.trim().toLowerCase(); @@ -982,6 +987,24 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore { function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null { const candidates = reports.filter(report => report.provider === provider); if (candidates.length === 0) return null; + // Org precedence: when the credential is org-scoped and the broker's + // reports are org-attributed, only an org match may win — falling through + // to the shared email/account would hand one subscription the OTHER + // subscription's pool (e.g. mark healthy Max exhausted via Team's report). + const orgId = credential.orgId?.trim().toLowerCase(); + if (orgId) { + let sawReportOrg = false; + for (const report of candidates) { + const metaOrg = readMetadataString((report.metadata ?? {}) as Record, "orgId"); + if (metaOrg) { + sawReportOrg = true; + if (metaOrg.toLowerCase() === orgId) return report; + } + } + // Org-attributed reports exist but none is ours: report "no usage data" + // rather than mis-attributing another org's pool. + if (sawReportOrg) return null; + } if (candidates.length === 1) return candidates[0]; const accountId = credential.accountId?.trim().toLowerCase(); const email = credential.email?.trim().toLowerCase(); @@ -997,8 +1020,22 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): .map((report, index) => ({ report, index })) .filter(candidate => candidate.report.provider === overlay.provider); if (candidates.length === 0) return -1; - if (candidates.length === 1) return candidates[0]!.index; const metadata = (overlay.metadata ?? {}) as Record; + // Org precedence — mirror matchUsageReport: an org-attributed overlay may + // only merge into the report of the SAME org. + const overlayOrg = readMetadataString(metadata, "orgId")?.toLowerCase(); + if (overlayOrg) { + let sawReportOrg = false; + for (const candidate of candidates) { + const candidateOrg = readMetadataString((candidate.report.metadata ?? {}) as Record, "orgId"); + if (candidateOrg) { + sawReportOrg = true; + if (candidateOrg.toLowerCase() === overlayOrg) return candidate.index; + } + } + if (sawReportOrg) return -1; + } + if (candidates.length === 1) return candidates[0]!.index; const accountId = readMetadataString(metadata, "accountId")?.toLowerCase(); const email = readMetadataString(metadata, "email")?.toLowerCase(); const projectId = readMetadataString(metadata, "projectId")?.toLowerCase(); diff --git a/packages/ai/src/auth-broker/wire-schemas.ts b/packages/ai/src/auth-broker/wire-schemas.ts index 35180ebf1..44bb55baa 100644 --- a/packages/ai/src/auth-broker/wire-schemas.ts +++ b/packages/ai/src/auth-broker/wire-schemas.ts @@ -32,6 +32,8 @@ export const oauthCredentialSchema = type({ "projectId?": "string", "email?": "string", "accountId?": "string", + "orgId?": "string", + "orgName?": "string", }); /** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */ @@ -45,6 +47,8 @@ export const remoteOauthCredentialSchema = type({ "projectId?": "string", "email?": "string", "accountId?": "string", + "orgId?": "string", + "orgName?": "string", }); export const apiKeyCredentialSchema = type({ diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 382e053e2..76c5c8612 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -598,6 +598,11 @@ const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000; const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial> = { "google-antigravity": 2, zai: 2, + // v2: cache identity gained an `org:` component so two subscriptions on one + // account email stop sharing a slot. The bump also retires pre-org entries — + // otherwise an org-less credential could replay another org's cached pool + // (incl. the 24h last-good fallback) via the old bare email/account key. + anthropic: 2, }; const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000; /** @@ -700,6 +705,19 @@ export interface OAuthAccess { apiEndpoint?: string; } +/** + * Identity slice of the credential a successful {@link AuthStorage.login} + * stored — lets callers confirm WHICH account (and for Anthropic, which + * organization/subscription) was added, without exposing tokens. + */ +export interface OAuthLoginIdentity { + type: "oauth" | "api_key"; + email?: string; + accountId?: string; + orgId?: string; + orgName?: string; +} + export interface OAuthAccessFailure { credentialId?: number; accountId?: string; @@ -720,6 +738,9 @@ export interface OAuthAccountIdentity { accountId?: string; email?: string; projectId?: string; + /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */ + orgId?: string; + orgName?: string; } export type OAuthAccessResolution = ({ ok: true } & OAuthAccess) | ({ ok: false } & OAuthAccessFailure); @@ -736,6 +757,9 @@ export interface OAuthAccountSummary { email?: string; projectId?: string; enterpriseUrl?: string; + /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */ + orgId?: string; + orgName?: string; } export interface InvalidateCredentialMatchingOptions { signal?: AbortSignal; @@ -2057,6 +2081,8 @@ export class AuthStorage { projectId: refreshed.projectId ?? current.projectId, enterpriseUrl: refreshed.enterpriseUrl ?? current.enterpriseUrl, apiEndpoint: refreshed.apiEndpoint ?? current.apiEndpoint, + orgId: refreshed.orgId ?? current.orgId, + orgName: refreshed.orgName ?? current.orgName, }; if (this.#store.tryUpdateAuthCredentialIfMatches) { if ( @@ -2281,6 +2307,12 @@ export class AuthStorage { if (typeof preferred.projectId === "string" && preferred.projectId.length > 0) { identity.projectId = preferred.projectId; } + if (typeof preferred.orgId === "string" && preferred.orgId.length > 0) { + identity.orgId = preferred.orgId; + } + if (typeof preferred.orgName === "string" && preferred.orgName.length > 0) { + identity.orgName = preferred.orgName; + } if (!identity.accountId && !identity.email && !identity.projectId) return undefined; return identity; } @@ -2302,7 +2334,10 @@ export class AuthStorage { } /** - * Login to an OAuth provider. + * Login to an OAuth provider. Resolves with the stored credential's + * identity slice (or `undefined` when nothing was stored) so callers can + * surface which account — and for Anthropic, which organization — the + * login registered. */ async login( provider: OAuthProviderId, @@ -2312,7 +2347,7 @@ export class AuthStorage { /** onPrompt is required for some providers (github-copilot, openai-codex) */ onPrompt: (prompt: { message: string; placeholder?: string }) => Promise; }, - ): Promise { + ): Promise { // Only paste-code providers (fixed non-loopback redirect, e.g. GitLab Duo // Agent's vscode:// URI) get a default manual-code prompt. For loopback OAuth // providers the `OAuthCallbackFlow` would otherwise race this readline prompt @@ -2340,7 +2375,7 @@ export class AuthStorage { if (typeof result === "string") { // Some flows (e.g. ollama) return "" to signal that no key was entered. if (!result) { - return; + return undefined; } const newCredential: ApiKeyCredential = { type: "api_key", key: result, source: "login" }; const stored = this.#store.upsertAuthCredentialRemote @@ -2351,13 +2386,20 @@ export class AuthStorage { stored.map(entry => ({ id: entry.id, credential: entry.credential })), ); this.#resetProviderAssignments(provider); - return; + return { type: "api_key" }; } const newCredential: OAuthCredential = { type: "oauth", ...result }; // Use #upsertOAuthCredential to upsert the new credential. // Any legacy api_key rows from older versions will be cleaned up so they do not // shadow the new OAuth row, while preserving other active OAuth credentials. await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential); + return { + type: "oauth", + email: newCredential.email, + accountId: newCredential.accountId, + orgId: newCredential.orgId, + orgName: newCredential.orgName, + }; } /** @@ -2381,6 +2423,8 @@ export class AuthStorage { accountId: credential.accountId, projectId: credential.projectId, email: credential.email, + orgId: credential.orgId, + orgName: credential.orgName, enterpriseUrl: credential.enterpriseUrl, apiEndpoint: credential.apiEndpoint, }; @@ -2392,6 +2436,8 @@ export class AuthStorage { if (accountId) parts.push(`account:${accountId}`); const email = credential.email?.trim().toLowerCase(); if (email) parts.push(`email:${email}`); + const orgId = credential.orgId?.trim(); + if (orgId) parts.push(`org:${orgId}`); const projectId = credential.projectId?.trim(); if (projectId) parts.push(`project:${projectId}`); const enterpriseUrl = credential.enterpriseUrl?.trim().toLowerCase(); @@ -2460,6 +2506,8 @@ export class AuthStorage { accountId: credential.accountId, projectId: credential.projectId, email: credential.email, + orgId: credential.orgId, + orgName: credential.orgName, enterpriseUrl: credential.enterpriseUrl, apiEndpoint: credential.apiEndpoint, }; @@ -2500,6 +2548,8 @@ export class AuthStorage { email: refreshed.email ?? credential.email, enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl, apiEndpoint: refreshed.apiEndpoint ?? credential.apiEndpoint, + orgId: refreshed.orgId ?? credential.orgId, + orgName: refreshed.orgName ?? credential.orgName, }; } @@ -2517,7 +2567,8 @@ export class AuthStorage { return ( entry.credential.accountId === previous.accountId && entry.credential.email === previous.email && - entry.credential.projectId === previous.projectId + entry.credential.projectId === previous.projectId && + entry.credential.orgId === previous.orgId ); }); return match?.id; @@ -2532,7 +2583,8 @@ export class AuthStorage { return ( entry.credential.accountId === previous.accountId && entry.credential.email === previous.email && - entry.credential.projectId === previous.projectId + entry.credential.projectId === previous.projectId && + entry.credential.orgId === previous.orgId ); }); if (index === -1) return; @@ -2548,6 +2600,8 @@ export class AuthStorage { email: next.email, enterpriseUrl: next.enterpriseUrl, apiEndpoint: next.apiEndpoint, + orgId: next.orgId ?? existing.orgId, + orgName: next.orgName ?? existing.orgName, }); } @@ -2647,11 +2701,30 @@ export class AuthStorage { if (providerImpl.supports && !providerImpl.supports(params)) return null; try { - return await providerImpl.fetchUsage(params, { + const report = await providerImpl.fetchUsage(params, { fetch: this.#usageFetch, logger: this.#usageLogger, listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [], }); + // Attribute the report to the credential's organization. The orgId and + // orgName fallbacks apply independently: Claude's usage endpoint stamps + // orgId from the `anthropic-organization-id` response header but never + // carries a display name, so the stored name must still be attached. + // Never attach the stored name over a DIFFERENT org's report. + if (report && params.credential.orgId !== undefined) { + const metadata = report.metadata ?? {}; + const sameOrg = metadata.orgId === undefined || metadata.orgId === params.credential.orgId; + const needsOrgId = metadata.orgId === undefined; + const needsOrgName = sameOrg && params.credential.orgName !== undefined && metadata.orgName === undefined; + if (needsOrgId || needsOrgName) { + report.metadata = { + ...metadata, + ...(needsOrgId ? { orgId: params.credential.orgId } : {}), + ...(needsOrgName ? { orgName: params.credential.orgName } : {}), + }; + } + } + return report; } catch (error) { logger.debug("AuthStorage usage fetch failed", { provider: request.provider, @@ -2832,6 +2905,8 @@ export class AuthStorage { if (credential.accountId && metadata.accountId === undefined) metadata.accountId = credential.accountId; if (credential.email && metadata.email === undefined) metadata.email = credential.email; if (credential.projectId && metadata.projectId === undefined) metadata.projectId = credential.projectId; + if (credential.orgId && metadata.orgId === undefined) metadata.orgId = credential.orgId; + if (credential.orgName && metadata.orgName === undefined) metadata.orgName = credential.orgName; const report: UsageReport = { ...parsedReport, metadata }; const storeIngest = this.#store.ingestUsageReport?.bind(this.#store); @@ -2959,6 +3034,19 @@ export class AuthStorage { const email = this.#getUsageReportMetadataValue(report, "email"); if (email) identifiers.push(`email:${email.toLowerCase()}`); if (report.provider === "openai-codex" || report.provider === "anthropic") { + // Anthropic: one account email can hold several organizations + // (Team seat + personal Max). Reports from different orgs must not + // merge — scope every identifier by org when the report carries one. + // Org-less reports (pre-upgrade caches) keep the bare email key and + // only merge among themselves. + if (report.provider === "anthropic") { + const orgId = this.#getUsageReportMetadataValue(report, "orgId"); + if (orgId) { + return identifiers.map( + identifier => `${report.provider}:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`, + ); + } + } return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`); } const projectId = @@ -4171,6 +4259,8 @@ export class AuthStorage { projectId: result.newCredentials.projectId ?? selection.credential.projectId, enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl, apiEndpoint: result.newCredentials.apiEndpoint ?? selection.credential.apiEndpoint, + orgId: result.newCredentials.orgId ?? selection.credential.orgId, + orgName: result.newCredentials.orgName ?? selection.credential.orgName, }; if (credentialId !== undefined) { const idx = this.#replaceCredentialById(provider, credentialId, updated); @@ -4516,6 +4606,8 @@ export class AuthStorage { email: selection.credential.email, projectId: selection.credential.projectId, enterpriseUrl: selection.credential.enterpriseUrl, + orgId: selection.credential.orgId, + orgName: selection.credential.orgName, })); } @@ -5091,6 +5183,8 @@ export class AuthStorage { projectId: refreshed.projectId ?? attempted.projectId, enterpriseUrl: refreshed.enterpriseUrl ?? attempted.enterpriseUrl, apiEndpoint: refreshed.apiEndpoint ?? attempted.apiEndpoint, + orgId: refreshed.orgId ?? attempted.orgId, + orgName: refreshed.orgName ?? attempted.orgName, }; // Persist by id: the array may have been reordered/shrunk while the // refresh was in flight, so the pre-await positional index is unsafe. A @@ -5358,7 +5452,16 @@ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): Store function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null { const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:")); - if ((provider === "openai-codex" || provider === "anthropic") && emailIdentifier) return emailIdentifier; + if (provider === "anthropic" && emailIdentifier) { + // One Anthropic account email can hold several organizations (e.g. a + // Team seat plus a personal Max plan), each with its own org-scoped + // token and limit pools. Scope identity by org so both subscriptions + // can be stored side by side; org-less credentials (rows written + // before org capture existed) keep the bare email key. + const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); + return orgIdentifier ? `${emailIdentifier}|${orgIdentifier}` : emailIdentifier; + } + if (provider === "openai-codex" && emailIdentifier) return emailIdentifier; const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); if (accountIdentifier) return accountIdentifier; if (emailIdentifier) return emailIdentifier; @@ -5390,7 +5493,16 @@ function matchesReplacementCredential( return existing.type === "api_key" && existing.key === incoming.key; } const incomingIdentityKey = resolveCredentialIdentityKey(provider, incoming); - return incomingIdentityKey !== null && incomingIdentityKey === existingIdentityKey; + if (incomingIdentityKey === null) return false; + if (incomingIdentityKey === existingIdentityKey) return true; + // One-time upgrade: a pre-org row keyed by bare email (`email:`) is + // claimed (and re-keyed) by the first org-scoped login (`email:|org:`) + // with the same email — mirroring the pre-org replace behavior. The reverse + // stays a non-match: an org-less credential must never clobber an + // org-scoped row. + if (existingIdentityKey === null || !incomingIdentityKey.startsWith("email:")) return false; + const orgSeparator = incomingIdentityKey.indexOf("|org:"); + return orgSeparator !== -1 && incomingIdentityKey.slice(0, orgSeparator) === existingIdentityKey; } function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] { @@ -5401,6 +5513,8 @@ function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[ if (email) identifiers.add(`email:${email}`); const projectId = normalizeStoredAccountId(credential.projectId); if (projectId) identifiers.add(`project:${projectId}`); + const orgId = normalizeStoredAccountId(credential.orgId); + if (orgId) identifiers.add(`org:${orgId}`); const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? []; for (const identifier of accessIdentifiers) { identifiers.add(identifier); diff --git a/packages/ai/src/registry/oauth/anthropic.ts b/packages/ai/src/registry/oauth/anthropic.ts index 3333570b9..bdb84a5ca 100644 --- a/packages/ai/src/registry/oauth/anthropic.ts +++ b/packages/ai/src/registry/oauth/anthropic.ts @@ -79,15 +79,35 @@ interface AnthropicTokenResponse { refresh_token: string; expires_in: number; account?: { uuid?: string; email_address?: string }; + organization?: { uuid?: string; name?: string }; } interface AnthropicBootstrapResponse { oauth_account?: { account_uuid?: string; account_email?: string; + organization_uuid?: string; + organization_name?: string; }; } +/** + * Account + organization identity slice resolved from the token response + * and/or the `/api/claude_cli/bootstrap` endpoint. The organization is the + * subscription workspace the token draws limits from — one account email can + * hold several (e.g. a Team seat plus a personal Max plan). + */ +interface AnthropicIdentity { + accountId?: string; + email?: string; + orgId?: string; + orgName?: string; +} + +function nonEmpty(value: string | undefined): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + function parseOAuthTokenResponse(responseBody: string, operation: string): AnthropicTokenResponse { try { return JSON.parse(responseBody) as AnthropicTokenResponse; @@ -100,28 +120,23 @@ function parseOAuthTokenResponse(responseBody: string, operation: string): Anthr } /** - * Lift the OAuth response's `account: { uuid, email_address }` block onto - * {@link OAuthCredentials} so downstream identity propagation (e.g. - * `metadata.user_id.account_uuid`, usage tracking) works without a separate - * `/api/oauth/profile` round-trip. Returns `undefined` for either field when - * the response omits it or carries a non-string / empty value. + * Lift the OAuth response's `account: { uuid, email_address }` and + * `organization: { uuid, name }` blocks onto {@link OAuthCredentials} so + * downstream identity propagation (e.g. `metadata.user_id.account_uuid`, + * usage tracking, org-scoped credential identity) works without a separate + * `/api/oauth/profile` round-trip. Returns `undefined` for any field the + * response omits or carries as a non-string / empty value. */ -function extractAccountFromTokenResponse(data: AnthropicTokenResponse): { - accountId?: string; - email?: string; -} { - const accountUuid = data.account?.uuid; - const emailAddress = data.account?.email_address; +function extractAccountFromTokenResponse(data: AnthropicTokenResponse): AnthropicIdentity { return { - accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, - email: typeof emailAddress === "string" && emailAddress.length > 0 ? emailAddress : undefined, + accountId: nonEmpty(data.account?.uuid), + email: nonEmpty(data.account?.email_address), + orgId: nonEmpty(data.organization?.uuid), + orgName: nonEmpty(data.organization?.name), }; } -async function fetchBootstrapIdentity( - accessToken: string, - fetchImpl: FetchImpl, -): Promise<{ accountId?: string; email?: string }> { +async function fetchBootstrapIdentity(accessToken: string, fetchImpl: FetchImpl): Promise { const url = `${BOOTSTRAP_URL}?entrypoint=cli&model=${encodeURIComponent(CLAUDE_CODE_BOOTSTRAP_MODEL)}`; const response = await fetchImpl(url, { method: "GET", @@ -150,25 +165,36 @@ async function fetchBootstrapIdentity( { kind: "validation", provider: "anthropic", cause: error }, ); } - const accountUuid = data.oauth_account?.account_uuid; - const accountEmail = data.oauth_account?.account_email; return { - accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, - email: typeof accountEmail === "string" && accountEmail.length > 0 ? accountEmail : undefined, + accountId: nonEmpty(data.oauth_account?.account_uuid), + email: nonEmpty(data.oauth_account?.account_email), + orgId: nonEmpty(data.oauth_account?.organization_uuid), + orgName: nonEmpty(data.oauth_account?.organization_name), }; } +/** + * Resolve account (and optionally organization) identity for a token + * response. `includeOrg` is login-only: the org an access token is scoped to + * is captured once when the credential is created and deliberately never + * refreshed afterwards — rewriting identity during background token + * refreshes could silently re-key stored credentials. + */ async function resolveAccountIdentity( data: AnthropicTokenResponse, fetchImpl: FetchImpl, -): Promise<{ accountId?: string; email?: string }> { + options?: { includeOrg?: boolean }, +): Promise { const identity = extractAccountFromTokenResponse(data); - if (identity.accountId && identity.email) return identity; + const orgSatisfied = !options?.includeOrg || identity.orgId !== undefined; + if (identity.accountId && identity.email && orgSatisfied) return identity; try { const bootstrap = await fetchBootstrapIdentity(data.access_token, fetchImpl); return { accountId: identity.accountId ?? bootstrap.accountId, email: identity.email ?? bootstrap.email, + orgId: identity.orgId ?? bootstrap.orgId, + orgName: identity.orgName ?? bootstrap.orgName, }; } catch { return identity; @@ -243,7 +269,9 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow { } const tokenData = parseOAuthTokenResponse(responseBody, "token exchange"); - const { accountId, email } = await resolveAccountIdentity(tokenData, this.#fetch); + const { accountId, email, orgId, orgName } = await resolveAccountIdentity(tokenData, this.#fetch, { + includeOrg: true, + }); return { refresh: tokenData.refresh_token, @@ -251,6 +279,8 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow { expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000, accountId, email, + orgId, + orgName, }; } } @@ -299,6 +329,9 @@ export async function refreshAnthropicToken( } const data = parseOAuthTokenResponse(responseBody, "token refresh"); + // Deliberately no `includeOrg` and no org fields on the result: the org a + // credential is scoped to is fixed at login. Callers merge refresh results + // over the stored credential, so omitting org here preserves it verbatim. const { accountId, email } = await resolveAccountIdentity(data, fetchImpl); return { diff --git a/packages/ai/src/registry/oauth/types.ts b/packages/ai/src/registry/oauth/types.ts index a11b4e5fd..5edb34997 100644 --- a/packages/ai/src/registry/oauth/types.ts +++ b/packages/ai/src/registry/oauth/types.ts @@ -10,6 +10,14 @@ export type OAuthCredentials = { email?: string; accountId?: string; apiEndpoint?: string; + /** + * Organization/workspace the token is scoped to (e.g. an Anthropic org + * UUID). Captured once at login; token refreshes never rewrite it. Lets + * one account email hold credentials for multiple subscriptions. + */ + orgId?: string; + /** Human-readable organization name for display (may embed the email). */ + orgName?: string; }; export type OAuthProvider = OAuthProviderUnion; diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 166a616cc..a04979fbf 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -260,6 +260,10 @@ export interface UsageCredential { accountId?: string; projectId?: string; email?: string; + /** Organization/workspace the credential is scoped to (see OAuthCredentials.orgId). */ + orgId?: string; + /** Human-readable organization name for display. */ + orgName?: string; enterpriseUrl?: string; metadata?: Record; apiEndpoint?: string; diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index cecae9371..7ec1fbde1 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -43,6 +43,10 @@ describe("anthropic oauth alignment", () => { uuid: "11111111-2222-3333-4444-555555555555", email_address: "user@example.com", }, + organization: { + uuid: "99999999-8888-7777-6666-555555555555", + name: "Team Workspace", + }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -55,6 +59,9 @@ describe("anthropic oauth alignment", () => { expect(result.access).toBe("access-token"); expect(result.refresh).toBe("refresh-token"); + expect(result.orgId).toBe("99999999-8888-7777-6666-555555555555"); + expect(result.orgName).toBe("Team Workspace"); + // Org came from the token response — no bootstrap fallback call. expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -73,6 +80,7 @@ describe("anthropic oauth alignment", () => { uuid: "11111111-2222-3333-4444-555555555555", email_address: "user@example.com", }, + organization: { uuid: "99999999-8888-7777-6666-555555555555" }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -99,6 +107,7 @@ describe("anthropic oauth alignment", () => { uuid: "11111111-2222-3333-4444-555555555555", email_address: "user@example.com", }, + organization: { uuid: "99999999-8888-7777-6666-555555555555" }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -174,6 +183,7 @@ describe("anthropic oauth alignment", () => { uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", email_address: "refreshed@example.com", }, + organization: { uuid: "99999999-8888-7777-6666-555555555555", name: "Drifted Org" }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); @@ -183,6 +193,10 @@ describe("anthropic oauth alignment", () => { expect(result.accountId).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); expect(result.email).toBe("refreshed@example.com"); + // Refresh must never emit org fields — the org a credential is scoped to + // is captured once at login; merge sites preserve the stored value. + expect(result.orgId).toBeUndefined(); + expect(result.orgName).toBeUndefined(); }); it("fetches bootstrap identity when token response omits account block", async () => { @@ -209,6 +223,8 @@ describe("anthropic oauth alignment", () => { oauth_account: { account_uuid: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", account_email: "bootstrap@example.com", + organization_uuid: "cccccccc-dddd-eeee-ffff-000000000000", + organization_name: "Bootstrap Org", }, }), { status: 200, headers: { "Content-Type": "application/json" } }, @@ -221,6 +237,8 @@ describe("anthropic oauth alignment", () => { expect(result.accountId).toBe("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"); expect(result.email).toBe("bootstrap@example.com"); + expect(result.orgId).toBe("cccccccc-dddd-eeee-ffff-000000000000"); + expect(result.orgName).toBe("Bootstrap Org"); expect(fetchMock).toHaveBeenCalledTimes(2); }); diff --git a/packages/ai/test/auth-storage-org-scoped-identity.test.ts b/packages/ai/test/auth-storage-org-scoped-identity.test.ts new file mode 100644 index 000000000..0c4c338f4 --- /dev/null +++ b/packages/ai/test/auth-storage-org-scoped-identity.test.ts @@ -0,0 +1,268 @@ +/** + * Anthropic org-scoped credential identity. + * + * One Anthropic account email can hold several organizations (a Team seat + * plus a personal Max plan), each with its own org-scoped token and limit + * pools. These tests defend the contracts that make that setup storable: + * + * 1. Credentials with the same email but different `orgId` coexist as + * separate rows; a same-org re-login updates its row in place. + * 2. A legacy row keyed by bare email is claimed (re-keyed) by the first + * org-scoped login with the same email — no duplicate rows. + * 3. An org-less credential never clobbers org-scoped rows. + * 4. Usage reports from two orgs on one email do NOT merge into a single + * report; org-less reports keep merging by email as before. + */ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + type AuthCredential, + type AuthCredentialStore, + AuthStorage, + SqliteAuthCredentialStore, + type StoredAuthCredential, +} from "@oh-my-pi/pi-ai/auth-storage"; +import type { UsageReport } from "@oh-my-pi/pi-ai/usage"; +import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude"; +import { removeWithRetries } from "../../utils/src/temp"; + +const EMAIL = "shared@example.com"; +const TEAM_ORG = "org-team-1111"; +const MAX_ORG = "org-max-2222"; + +function orgCredential(args: { suffix: string; orgId?: string; orgName?: string }): AuthCredential { + return { + type: "oauth", + access: `access-${args.suffix}`, + refresh: `refresh-${args.suffix}`, + expires: Date.now() + 3_600_000, + accountId: "account-shared", + email: EMAIL, + orgId: args.orgId, + orgName: args.orgName, + }; +} + +function readIdentityRows(dbPath: string): Array<{ identity_key: string | null; disabled_cause: string | null }> { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + "SELECT identity_key, disabled_cause FROM auth_credentials WHERE provider = 'anthropic' ORDER BY id ASC", + ) + .all() as Array<{ identity_key: string | null; disabled_cause: string | null }>; + } finally { + db.close(); + } +} + +describe("anthropic org-scoped credential identity", () => { + let tempDir = ""; + let dbPath = ""; + let store: SqliteAuthCredentialStore | null = null; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-org-identity-")); + dbPath = path.join(tempDir, "agent.db"); + store = await SqliteAuthCredentialStore.open(dbPath); + }); + + afterEach(async () => { + store?.close(); + store = null; + if (tempDir) await removeWithRetries(tempDir); + }); + + it("stores two subscriptions of one email side by side and updates same-org logins in place", () => { + if (!store) throw new Error("test setup failed"); + + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "team", orgId: TEAM_ORG })); + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "max", orgId: MAX_ORG })); + + expect(readIdentityRows(dbPath)).toEqual([ + { identity_key: `email:${EMAIL}|org:${TEAM_ORG}`, disabled_cause: null }, + { identity_key: `email:${EMAIL}|org:${MAX_ORG}`, disabled_cause: null }, + ]); + + // Same-org re-login: replaces the matching row instead of adding a third. + const rows = store.upsertAuthCredentialForProvider( + "anthropic", + orgCredential({ suffix: "team-renewed", orgId: TEAM_ORG }), + ); + expect(readIdentityRows(dbPath)).toEqual([ + { identity_key: `email:${EMAIL}|org:${TEAM_ORG}`, disabled_cause: null }, + { identity_key: `email:${EMAIL}|org:${MAX_ORG}`, disabled_cause: null }, + ]); + const teamRow = rows.find(row => row.credential.type === "oauth" && row.credential.orgId === TEAM_ORG); + expect(teamRow?.credential.type).toBe("oauth"); + if (teamRow?.credential.type === "oauth") { + expect(teamRow.credential.access).toBe("access-team-renewed"); + } + }); + + it("upgrades a legacy email-keyed row on the first org-scoped login with the same email", () => { + if (!store) throw new Error("test setup failed"); + + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "legacy" })); + expect(readIdentityRows(dbPath)).toEqual([{ identity_key: `email:${EMAIL}`, disabled_cause: null }]); + + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "max", orgId: MAX_ORG })); + expect(readIdentityRows(dbPath)).toEqual([ + { identity_key: `email:${EMAIL}|org:${MAX_ORG}`, disabled_cause: null }, + ]); + }); + + it("never clobbers org-scoped rows with an org-less credential", () => { + if (!store) throw new Error("test setup failed"); + + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "team", orgId: TEAM_ORG })); + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "max", orgId: MAX_ORG })); + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "orgless" })); + + expect(readIdentityRows(dbPath)).toEqual([ + { identity_key: `email:${EMAIL}|org:${TEAM_ORG}`, disabled_cause: null }, + { identity_key: `email:${EMAIL}|org:${MAX_ORG}`, disabled_cause: null }, + { identity_key: `email:${EMAIL}`, disabled_cause: null }, + ]); + }); +}); + +// ─── Usage report dedupe partitioning ─────────────────────────────────────── + +interface CacheEntry { + value: string; + expiresAtSec: number; +} + +function makeStore(rows: StoredAuthCredential[]): AuthCredentialStore { + const cache = new Map(); + return { + close() {}, + listAuthCredentials() { + return rows; + }, + updateAuthCredential() {}, + deleteAuthCredential() {}, + tryDisableAuthCredentialIfMatches() { + return false; + }, + replaceAuthCredentialsForProvider() { + return rows; + }, + upsertAuthCredentialForProvider() { + return rows; + }, + deleteAuthCredentialsForProvider() {}, + getCache(key) { + const entry = cache.get(key); + if (!entry) return null; + if (entry.expiresAtSec * 1000 <= Date.now()) return null; + return entry.value; + }, + setCache(key, value, expiresAtSec) { + cache.set(key, { value, expiresAtSec }); + }, + cleanExpiredCache() {}, + }; +} + +function oauthRow(id: number, orgId?: string, orgName?: string): StoredAuthCredential { + return { + id, + provider: "anthropic", + credential: { + type: "oauth", + access: `oat-${id}`, + refresh: `refresh-${id}`, + expires: Date.now() + 3_600_000, + accountId: "account-shared", + email: EMAIL, + orgId, + orgName, + }, + disabledCause: null, + }; +} + +/** Report carrying ONLY email identity — org attribution must come from the credential. */ +function emailOnlyReport(): UsageReport { + return { + provider: "anthropic", + fetchedAt: Date.now(), + limits: [ + { + id: "anthropic:5h", + label: "5 Hour", + scope: { provider: "anthropic", windowId: "5h" }, + window: { id: "5h", label: "5 Hour" }, + amount: { used: 42, limit: 100, unit: "percent" }, + status: "ok", + }, + ], + metadata: { email: EMAIL, accountId: "account-shared" }, + }; +} + +describe("anthropic usage report dedupe partitions by org", () => { + let storage: AuthStorage | null = null; + + afterEach(() => { + storage?.close(); + storage = null; + vi.restoreAllMocks(); + }); + + it("keeps reports from two orgs on one email separate and attributes each to its org", async () => { + storage = new AuthStorage( + makeStore([oauthRow(1, TEAM_ORG, "Team Workspace"), oauthRow(2, MAX_ORG, "Personal Max")]), + { + usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined), + }, + ); + await storage.reload(); + + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => emailOnlyReport()); + + const reports = ((await storage.fetchUsageReports()) ?? []).filter(r => r.provider === "anthropic"); + expect(reports).toHaveLength(2); + const orgIds = reports.map(report => report.metadata?.orgId).sort(); + expect(orgIds).toEqual([MAX_ORG, TEAM_ORG].sort()); + const orgNames = reports.map(report => report.metadata?.orgName).sort(); + expect(orgNames).toEqual(["Personal Max", "Team Workspace"].sort()); + }); + + it("attaches the stored org name when the provider response already carries the org id", async () => { + // Regression: the real Claude usage path stamps orgId from the + // `anthropic-organization-id` response header, so the orgName fallback + // must apply independently of the orgId fallback. + storage = new AuthStorage(makeStore([oauthRow(1, TEAM_ORG, "Team Workspace")]), { + usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined), + }); + await storage.reload(); + + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => ({ + ...emailOnlyReport(), + metadata: { email: EMAIL, accountId: "account-shared", orgId: TEAM_ORG }, + })); + + const reports = ((await storage.fetchUsageReports()) ?? []).filter(r => r.provider === "anthropic"); + expect(reports).toHaveLength(1); + expect(reports[0]?.metadata?.orgId).toBe(TEAM_ORG); + expect(reports[0]?.metadata?.orgName).toBe("Team Workspace"); + }); + + it("still merges org-less reports with the same email into one row", async () => { + storage = new AuthStorage(makeStore([oauthRow(1), oauthRow(2)]), { + usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined), + }); + await storage.reload(); + + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => emailOnlyReport()); + + const reports = ((await storage.fetchUsageReports()) ?? []).filter(r => r.provider === "anthropic"); + expect(reports).toHaveLength(1); + }); +}); diff --git a/packages/ai/test/auth-storage-usage-cache.test.ts b/packages/ai/test/auth-storage-usage-cache.test.ts index 5974381df..d9c8b9e17 100644 --- a/packages/ai/test/auth-storage-usage-cache.test.ts +++ b/packages/ai/test/auth-storage-usage-cache.test.ts @@ -585,7 +585,7 @@ describe("AuthStorage usage cache: terminal refresh failure", () => { // is in the past (so `get()` misses) but the entry is still reachable via // `getStale()`. Mirrors what the prior poll would have written. const lastGood = makeReport("a@example.com"); - const cacheKey = "usage_cache:report:anthropic:default:oauth|account:account-1|email:a@example.com"; + const cacheKey = "usage_cache:report:2:anthropic:default:oauth|account:account-1|email:a@example.com"; cache.set(cacheKey, { value: JSON.stringify({ value: lastGood, expiresAt: 1 }), expiresAtSec: Math.floor((Date.now() + 24 * 60 * 60_000) / 1000), @@ -663,7 +663,7 @@ describe("AuthStorage usage cache: terminal refresh failure", () => { }; const lastGood = makeReport("b@example.com"); - const cacheKey = "usage_cache:report:anthropic:default:oauth|account:account-2|email:b@example.com"; + const cacheKey = "usage_cache:report:2:anthropic:default:oauth|account:account-2|email:b@example.com"; cache.set(cacheKey, { value: JSON.stringify({ value: lastGood, expiresAt: 1 }), expiresAtSec: Math.floor((Date.now() + 24 * 60 * 60_000) / 1000), diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index e2c81b38f..b848f1d05 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -426,6 +426,79 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => { ]); }); + test("getUsageReport routes each org-scoped credential to its own org's report", async () => { + // Two subscriptions (orgs) on one account email: the broker aggregate + // carries both pools. Matching by shared email/account would hand the + // healthy Max credential the exhausted Team report (and vice versa). + const brokerClient = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused" }); + const now = Date.now(); + const makeCredential = (id: number, orgId: string) => ({ + type: "oauth" as const, + access: `remote-access-${id}`, + refresh: REMOTE_REFRESH_SENTINEL, + expires: now + 120_000, + accountId: "account-shared", + email: "shared@example.com", + orgId, + }); + const makeOrgReport = (orgId: string, usedFraction: number, status: "ok" | "exhausted"): UsageReport => ({ + provider: "anthropic", + fetchedAt: now, + limits: [ + { + id: "anthropic:5h", + label: "Claude 5 Hour", + scope: { provider: "anthropic", windowId: "5h" }, + window: { id: "5h", label: "5 Hour" }, + amount: { used: usedFraction * 100, limit: 100, usedFraction, unit: "percent" }, + status, + }, + ], + metadata: { email: "shared@example.com", accountId: "account-shared", orgId }, + }); + vi.spyOn(brokerClient, "fetchUsage").mockResolvedValue({ + generatedAt: now, + reports: [makeOrgReport("org-team", 1, "exhausted"), makeOrgReport("org-max", 0.1, "ok")], + }); + const remoteStore = new RemoteAuthCredentialStore({ + client: brokerClient, + streamSnapshots: false, + initialSnapshot: { + generation: 1, + generatedAt: now, + serverNowMs: now, + refresher: { enabled: false, intervalMs: 0, skewMs: 0, nextSweepInMs: Number.MAX_SAFE_INTEGER }, + credentials: [ + { + id: 1, + provider: "anthropic", + credential: makeCredential(1, "org-team"), + identityKey: "email:shared@example.com|org:org-team", + rotatesInMs: null, + }, + { + id: 2, + provider: "anthropic", + credential: makeCredential(2, "org-max"), + identityKey: "email:shared@example.com|org:org-max", + rotatesInMs: null, + }, + ], + }, + }); + try { + const teamReport = await remoteStore.getUsageReport("anthropic", makeCredential(1, "org-team")); + expect(teamReport?.metadata?.orgId).toBe("org-team"); + expect(requireLimit(teamReport!, "anthropic:5h").status).toBe("exhausted"); + + const maxReport = await remoteStore.getUsageReport("anthropic", makeCredential(2, "org-max")); + expect(maxReport?.metadata?.orgId).toBe("org-max"); + expect(requireLimit(maxReport!, "anthropic:5h").status).toBe("ok"); + } finally { + remoteStore.close(); + } + }); + test("RemoteAuthCredentialStore reads snapshot blocks and applies upserts before broker acknowledgement", () => { const futureBlock = Date.now() + 60_000; const laterBlock = futureBlock + 60_000; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5e51c9c75..bc521af8f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,6 +40,11 @@ - Fixed native Windows binary compatibility on older Windows 10 CPUs by building the `omp-windows-x64.exe` release asset with a baseline x64 runtime instead of AVX2. (#5172) - Fixed `GenerateImage` rejecting OpenAI Codex-compatible proxy bearer keys when the token does not expose a `chatgpt-account-id`. (#5174) - Fixed context promotion documentation to accurately reflect the `contextPromotionTarget` runtime behavior and `contextPromotion.enabled` default. (#5163) +### Changed + +- `omp usage` and the in-session `/usage` view now show the Anthropic organization next to the account for org-scoped credentials (with `--redact` masking applied per part in the CLI, falling back to the org id when no display name is available), attribute "no usage data" rows per organization, and match the "in use by this session" marker by organization so only the active subscription is flagged. The OAuth login success message names the account and organization that was stored — a login landing on an unintended subscription is visible immediately. +- `/logout` and `omp token --list` label Anthropic accounts with their organization and mark only the credential of the active organization as active, so two subscriptions sharing one email are distinguishable when selecting which to remove or mint a token for. +- `omp auth-broker migrate --from-local` dedupes Anthropic OAuth identities per organization, so a Team seat already on the broker no longer blocks uploading the personal plan under the same email. ## [16.4.3] - 2026-07-11 diff --git a/packages/coding-agent/src/cli/auth-broker-cli.ts b/packages/coding-agent/src/cli/auth-broker-cli.ts index a14c6d2b6..f277ff11d 100644 --- a/packages/coding-agent/src/cli/auth-broker-cli.ts +++ b/packages/coding-agent/src/cli/auth-broker-cli.ts @@ -660,19 +660,22 @@ interface MigrateSkip { function credentialIdentity(provider: string, credential: AuthCredential): string { if (credential.type === "api_key") return "(api key)"; - return credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`; + const base = credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`; + return credential.orgId ? `${base} (${credential.orgName ?? credential.orgId})` : base; } /** * Build the set of "identities already on the broker" so re-runs are idempotent. - * For OAuth, identity = email|accountId|projectId. For api_key, we collapse + * For OAuth, identity = email|accountId|projectId, each org-qualified when the + * row carries an organization (one Anthropic email can hold a Team seat AND a + * personal Max plan — those must migrate as two rows). For api_key, we collapse * to a single marker per provider (broker has no concept of "multiple api keys * per provider with different identities"; upsert would coalesce them). */ function indexBrokerSnapshot(snapshot: { credentials: Array<{ provider: string; - credential: { type: string; email?: string; accountId?: string; projectId?: string }; + credential: { type: string; email?: string; accountId?: string; projectId?: string; orgId?: string }; }>; }): Map> { const out = new Map>(); @@ -681,9 +684,10 @@ function indexBrokerSnapshot(snapshot: { if (entry.credential.type === "api_key") { ids.add("@api_key"); } else { - if (entry.credential.email) ids.add(`email:${entry.credential.email}`); - if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}`); - if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}`); + const orgSuffix = entry.credential.orgId ? `|org:${entry.credential.orgId}` : ""; + if (entry.credential.email) ids.add(`email:${entry.credential.email}${orgSuffix}`); + if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}${orgSuffix}`); + if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}${orgSuffix}`); } out.set(entry.provider, ids); } @@ -694,9 +698,10 @@ function brokerAlreadyHas(existing: Map>, provider: string, const ids = existing.get(provider); if (!ids) return false; if (credential.type === "api_key") return ids.has("@api_key"); - if (credential.email && ids.has(`email:${credential.email}`)) return true; - if (credential.accountId && ids.has(`accountId:${credential.accountId}`)) return true; - if (credential.projectId && ids.has(`projectId:${credential.projectId}`)) return true; + const orgSuffix = credential.orgId ? `|org:${credential.orgId}` : ""; + if (credential.email && ids.has(`email:${credential.email}${orgSuffix}`)) return true; + if (credential.accountId && ids.has(`accountId:${credential.accountId}${orgSuffix}`)) return true; + if (credential.projectId && ids.has(`projectId:${credential.projectId}${orgSuffix}`)) return true; return false; } diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 8dfe9ca90..856c29864 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -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(); + 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 { 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, 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 { 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 = {}; diff --git a/packages/coding-agent/src/commands/token.ts b/packages/coding-agent/src/commands/token.ts index b2bf5eb8a..620294b67 100644 --- a/packages/coding-agent/src/commands/token.ts +++ b/packages/coding-agent/src/commands/token.ts @@ -64,12 +64,14 @@ export default class Token extends Command { } if (flags.list) { for (const acct of accounts) { - const label = + const base = acct.email ?? acct.accountId ?? acct.projectId ?? acct.enterpriseUrl ?? `credential #${acct.credentialId}`; + const org = acct.orgName ?? acct.orgId; + const label = org && org !== base ? `${base} (${org})` : base; process.stdout.write(`${acct.position + 1}. ${label}\n`); } return; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index ef6363d89..12b9117ac 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1380,14 +1380,22 @@ function formatWindowSuffix(label: string, windowLabel: string, uiTheme: typeof return uiTheme.fg("dim", `(${windowLabel})`); } +/** ` (org)` suffix when the report is org-attributed — two subscriptions can share one email. */ +function orgSuffix(report: UsageReport): string { + const orgName = report.metadata?.orgName; + const orgId = report.metadata?.orgId; + const org = typeof orgName === "string" && orgName ? orgName : typeof orgId === "string" ? orgId : undefined; + return org ? ` (${org})` : ""; +} + function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: number): string { const email = report.metadata?.email; - if (typeof email === "string" && email) return email; + if (typeof email === "string" && email) return `${email}${orgSuffix(report)}`; const accountId = typeof report.metadata?.accountId === "string" && report.metadata.accountId ? report.metadata.accountId : limit.scope.accountId || undefined; - if (accountId) return accountId; + if (accountId) return `${accountId}${orgSuffix(report)}`; const projectId = typeof report.metadata?.projectId === "string" && report.metadata.projectId ? report.metadata.projectId @@ -1398,9 +1406,9 @@ function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: numbe function formatUnlimitedReportLabel(report: UsageReport, index: number): string { const email = report.metadata?.email; - if (typeof email === "string" && email) return email; + if (typeof email === "string" && email) return `${email}${orgSuffix(report)}`; const accountId = report.metadata?.accountId; - if (typeof accountId === "string" && accountId) return accountId; + if (typeof accountId === "string" && accountId) return `${accountId}${orgSuffix(report)}`; const projectId = report.metadata?.projectId; if (typeof projectId === "string" && projectId) return projectId; return `account ${index + 1}`; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index f87c79181..c833888f1 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1200,7 +1200,7 @@ export class SelectorController { this.ctx.ui.setFocus(dialog); this.ctx.ui.requestRender(); try { - await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { + const identity = await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { signal: dialog.signal, onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => { // The dialog renders the full URL (SSH-safe copy target) and @@ -1219,8 +1219,21 @@ export class SelectorController { }); this.ctx.session.modelRegistry.refreshInBackground(); const block = new TranscriptBlock(); + // Name the account (and Anthropic organization) that was stored so a + // login that lands on an unintended account/subscription is visible + // immediately instead of silently replacing an existing registration. + const who = + identity?.type === "oauth" && (identity.email || identity.accountId) + ? ` as ${identity.email ?? identity.accountId}${ + identity.orgName || identity.orgId ? ` (${identity.orgName ?? identity.orgId})` : "" + }` + : ""; block.addChild( - new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0), + new Text( + theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}${who}`), + 1, + 0, + ), ); block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0)); this.ctx.present(block); diff --git a/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts b/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts index b32350c28..f7804cdfb 100644 --- a/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts +++ b/packages/coding-agent/src/slash-commands/helpers/active-oauth-account.ts @@ -9,6 +9,9 @@ function normalizeIdentityValue(value: unknown): string | undefined { * True when a single usage-limit column belongs to the given OAuth identity. * * Single definition of the matching rules for both `/usage` renderers: + * - `orgId` ↔ report metadata `orgId` — checked first and DECISIVE when + * both sides carry it: two subscriptions (orgs) can share one email, and + * the shared email/account would otherwise mark both reports as active * - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId` * - `email` ↔ report metadata `email` * - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId` @@ -21,6 +24,9 @@ export function limitMatchesActiveAccount( ): boolean { if (!identity) return false; const metadata = report.metadata ?? {}; + const activeOrgId = normalizeIdentityValue(identity.orgId); + const reportOrgId = normalizeIdentityValue(metadata.orgId); + if (activeOrgId && reportOrgId) return reportOrgId === activeOrgId; const activeAccountId = normalizeIdentityValue(identity.accountId); if (activeAccountId) { const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id); diff --git a/packages/coding-agent/src/slash-commands/helpers/logout.ts b/packages/coding-agent/src/slash-commands/helpers/logout.ts index 1e5b105f5..6753e62c9 100644 --- a/packages/coding-agent/src/slash-commands/helpers/logout.ts +++ b/packages/coding-agent/src/slash-commands/helpers/logout.ts @@ -22,13 +22,16 @@ function nonEmpty(value: string | undefined): string | undefined { function oauthLabel(row: StoredAuthCredential): string { const credential = row.credential; if (credential.type !== "oauth") return `API key #${row.id}`; - return ( + const base = nonEmpty(credential.email) ?? nonEmpty(credential.accountId) ?? nonEmpty(credential.projectId) ?? nonEmpty(credential.enterpriseUrl) ?? - `OAuth credential #${row.id}` - ); + `OAuth credential #${row.id}`; + // Two subscriptions (orgs) can share one email — the org is the only + // user-visible way to tell which row a logout will remove. + const org = nonEmpty(credential.orgName) ?? nonEmpty(credential.orgId); + return org && org !== base ? `${base} (${org})` : base; } function oauthDetail(row: StoredAuthCredential, label: string): string { @@ -53,6 +56,11 @@ function oauthMatchesActiveIdentity( ): boolean { if (!activeIdentity || row.credential.type !== "oauth") return false; const credential = row.credential; + // Org precedence: when both sides are org-scoped, the org decides — the + // shared email/account would otherwise mark BOTH subscriptions active. + if (activeIdentity.orgId !== undefined && credential.orgId !== undefined) { + return credential.orgId === activeIdentity.orgId; + } return ( (activeIdentity.accountId !== undefined && credential.accountId === activeIdentity.accountId) || (activeIdentity.email !== undefined && credential.email === activeIdentity.email) || diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index 120f57385..435bad9e6 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -25,8 +25,18 @@ function formatUsageAmount(limit: UsageLimit): string { } function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string { + const metaOrgName = report.metadata?.orgName; + const metaOrgId = report.metadata?.orgId; + const org = + typeof metaOrgName === "string" && metaOrgName + ? metaOrgName + : typeof metaOrgId === "string" && metaOrgId + ? metaOrgId + : undefined; + // Two subscriptions (orgs) can share one email — suffix the org so the rows + // are tellable apart. const email = report.metadata?.email; - if (typeof email === "string" && email) return email; + if (typeof email === "string" && email) return org ? `${email} (${org})` : email; // Guard metadata values for truthiness before using, then fall back to scope. // ?? won't help here: empty string is not null/undefined, so it would suppress // a valid scoped fallback (e.g. metadata.accountId="" hides limit.scope.accountId).