diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2b6591279..cac1521e6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ - 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. - Fixed OAuth access results (`getOAuthAccess`, `getOAuthAccesses`, `getOAuthAccessAt`) and `checkCredentials` health results dropping the organization: they now carry `orgId`/`orgName` so consumers that key or label per-account results (e.g. `omp dry-balance --bench`, `omp auth-gateway check`) can tell two same-email subscriptions apart. Active-account matching is org-decisive whenever either side carries an organization — an org-scoped session no longer flags the legacy bare-email row, and a legacy-row session no longer flags org-scoped siblings, via the shared email. `getOAuthAccountIdentity` also preserves org-only identities instead of discarding them. - Fixed usage-path OAuth refreshes on broker-backed credentials persisting the rotated token into the wrong row: the shared `REMOTE_REFRESH_SENTINEL` refresh value is no longer treated as row identity when locating the row to update, so with two same-email organizations the refreshed token lands on the org that was actually refreshed. +- Fixed the org qualifier only riding on email-based Anthropic identities: when the login email cannot be recovered (token response omits it and the bootstrap fallback fails), the account/project fallback keys are now org-qualified too — the account UUID is identical across the orgs of one login account, so a second subscription could otherwise still replace the first on the no-email path. The same one-way legacy upgrade applies to bare account/project keys, usage-report dedupe falls back to the org-qualified account for no-email reports, and broker report routing is org-decisive on either side (an org-less legacy credential no longer receives an org-attributed sibling's pool). ## [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 bd314c6b6..54b99eff4 100644 --- a/packages/ai/src/auth-broker/remote-store.ts +++ b/packages/ai/src/auth-broker/remote-store.ts @@ -985,16 +985,18 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore { * usage data" (ranking proceeds without a usage signal for this credential). */ 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 all = reports.filter(report => report.provider === provider); + if (all.length === 0) return null; + // Org precedence, decisive on EITHER side: an org-scoped credential may + // only take its own org's report, and an org-less (legacy) credential may + // only take org-less reports — the shared email/account would otherwise + // hand one subscription the OTHER subscription's pool (e.g. mark healthy + // Max exhausted via Team's report, or rank a legacy row on a sibling's + // numbers). const orgId = credential.orgId?.trim().toLowerCase(); if (orgId) { let sawReportOrg = false; - for (const report of candidates) { + for (const report of all) { const metaOrg = readMetadataString((report.metadata ?? {}) as Record, "orgId"); if (metaOrg) { sawReportOrg = true; @@ -1002,9 +1004,14 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential } } // Org-attributed reports exist but none is ours: report "no usage data" - // rather than mis-attributing another org's pool. + // rather than mis-attributing another org's pool. When NO report carries + // an org (legacy broker aggregate), fall through with all candidates. if (sawReportOrg) return null; } + const candidates = orgId + ? all + : all.filter(report => !readMetadataString((report.metadata ?? {}) as Record, "orgId")); + if (candidates.length === 0) return null; if (candidates.length === 1) return candidates[0]; const accountId = credential.accountId?.trim().toLowerCase(); const email = credential.email?.trim().toLowerCase(); @@ -1016,17 +1023,18 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential } function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): number { - const candidates = reports + const all = reports .map((report, index) => ({ report, index })) .filter(candidate => candidate.report.provider === overlay.provider); - if (candidates.length === 0) return -1; + if (all.length === 0) return -1; const metadata = (overlay.metadata ?? {}) as Record; // Org precedence — mirror matchUsageReport: an org-attributed overlay may - // only merge into the report of the SAME org. + // only merge into the report of the SAME org, and an org-less overlay may + // only merge into an org-less report. const overlayOrg = readMetadataString(metadata, "orgId")?.toLowerCase(); if (overlayOrg) { let sawReportOrg = false; - for (const candidate of candidates) { + for (const candidate of all) { const candidateOrg = readMetadataString((candidate.report.metadata ?? {}) as Record, "orgId"); if (candidateOrg) { sawReportOrg = true; @@ -1035,6 +1043,12 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): } if (sawReportOrg) return -1; } + const candidates = overlayOrg + ? all + : all.filter( + candidate => !readMetadataString((candidate.report.metadata ?? {}) as Record, "orgId"), + ); + if (candidates.length === 0) return -1; if (candidates.length === 1) return candidates[0]!.index; const accountId = readMetadataString(metadata, "accountId")?.toLowerCase(); const email = readMetadataString(metadata, "email")?.toLowerCase(); diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index bbdeb7948..9d6c53525 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3050,20 +3050,28 @@ export class AuthStorage { const identifiers: string[] = []; const email = this.#getUsageReportMetadataValue(report, "email"); if (email) identifiers.push(`email:${email.toLowerCase()}`); - if (report.provider === "openai-codex" || report.provider === "anthropic") { + if (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()}`, - ); - } + // When the email could not be recovered, fall back to the account + // (identical across orgs, hence the org qualifier is what keeps two + // subscriptions apart) so no-email reports still merge per org. + // Org-less reports (pre-upgrade caches) keep their bare identifiers + // and only merge among themselves. + if (identifiers.length === 0) { + const accountId = + this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report); + if (accountId) identifiers.push(`account:${accountId}`); } + const orgId = this.#getUsageReportMetadataValue(report, "orgId"); + if (orgId) { + if (identifiers.length === 0) return [`anthropic:org:${orgId.toLowerCase()}`]; + return identifiers.map(identifier => `anthropic:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`); + } + return identifiers.map(identifier => `anthropic:${identifier.toLowerCase()}`); + } + if (report.provider === "openai-codex") { return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`); } const projectId = @@ -5479,14 +5487,24 @@ 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 === "anthropic" && emailIdentifier) { + if (provider === "anthropic") { // 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. + // can be stored side by side. The qualifier rides on whichever base + // identity is available — the account UUID is IDENTICAL across the + // orgs of one login account, so an unqualified account/project + // fallback would still collapse two subscriptions whenever the email + // could not be recovered. Org-less credentials (rows written before + // org capture existed) keep their bare key. + const base = + emailIdentifier ?? + identifiers.find(identifier => identifier.startsWith("account:")) ?? + identifiers.find(identifier => identifier.startsWith("project:")); const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:")); - return orgIdentifier ? `${emailIdentifier}|${orgIdentifier}` : emailIdentifier; + if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base; + // No base identity at all: the org alone still distinguishes the row. + return orgIdentifier ?? null; } if (provider === "openai-codex" && emailIdentifier) return emailIdentifier; const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:")); @@ -5522,12 +5540,13 @@ function matchesReplacementCredential( const incomingIdentityKey = resolveCredentialIdentityKey(provider, incoming); 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; + // One-time upgrade: a pre-org row keyed by a bare base identity + // (`email:`, `account:`, or `project:

`) is claimed (and re-keyed) + // by the first org-scoped login with the same base — mirroring the pre-org + // replace behavior. The reverse stays a non-match: an org-less credential + // must never clobber an org-scoped row. Only anthropic identity keys carry + // the `|org:` qualifier, so this cannot affect other providers. + if (existingIdentityKey === null) return false; const orgSeparator = incomingIdentityKey.indexOf("|org:"); return orgSeparator !== -1 && incomingIdentityKey.slice(0, orgSeparator) === existingIdentityKey; } diff --git a/packages/ai/test/auth-storage-org-scoped-identity.test.ts b/packages/ai/test/auth-storage-org-scoped-identity.test.ts index 3c3bb9027..4ee0a1466 100644 --- a/packages/ai/test/auth-storage-org-scoped-identity.test.ts +++ b/packages/ai/test/auth-storage-org-scoped-identity.test.ts @@ -34,14 +34,20 @@ 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 { +function orgCredential(args: { + suffix: string; + orgId?: string; + orgName?: string; + /** Simulate the no-email edge: token response omits it AND bootstrap recovery fails. */ + omitEmail?: boolean; +}): AuthCredential { return { type: "oauth", access: `access-${args.suffix}`, refresh: `refresh-${args.suffix}`, expires: Date.now() + 3_600_000, accountId: "account-shared", - email: EMAIL, + email: args.omitEmail ? undefined : EMAIL, orgId: args.orgId, orgName: args.orgName, }; @@ -129,6 +135,46 @@ describe("anthropic org-scoped credential identity", () => { { identity_key: `email:${EMAIL}`, disabled_cause: null }, ]); }); + + it("scopes account-only identities (no email) by org so the second subscription cannot replace the first", () => { + if (!store) throw new Error("test setup failed"); + + // The account UUID is identical across the orgs of one login account — + // without the org qualifier these two would collapse to one row. + store.upsertAuthCredentialForProvider( + "anthropic", + orgCredential({ suffix: "team", orgId: TEAM_ORG, omitEmail: true }), + ); + store.upsertAuthCredentialForProvider( + "anthropic", + orgCredential({ suffix: "max", orgId: MAX_ORG, omitEmail: true }), + ); + expect(readIdentityRows(dbPath)).toEqual([ + { identity_key: `account:account-shared|org:${TEAM_ORG}`, disabled_cause: null }, + { identity_key: `account:account-shared|org:${MAX_ORG}`, disabled_cause: null }, + ]); + + // Same-org no-email re-login still replaces its own row in place. + store.upsertAuthCredentialForProvider( + "anthropic", + orgCredential({ suffix: "team-renewed", orgId: TEAM_ORG, omitEmail: true }), + ); + expect(readIdentityRows(dbPath)).toHaveLength(2); + + // A legacy bare account-keyed row is claimed by the first org-scoped + // login with the same account, mirroring the email upgrade path. + store.upsertAuthCredentialForProvider("anthropic", orgCredential({ suffix: "legacy", omitEmail: true })); + expect(readIdentityRows(dbPath)).toHaveLength(3); + store.upsertAuthCredentialForProvider( + "anthropic", + orgCredential({ suffix: "claimed", orgId: "org-third-3333", omitEmail: true }), + ); + expect(readIdentityRows(dbPath)).toEqual([ + { identity_key: `account:account-shared|org:${TEAM_ORG}`, disabled_cause: null }, + { identity_key: `account:account-shared|org:${MAX_ORG}`, disabled_cause: null }, + { identity_key: "account:account-shared|org:org-third-3333", disabled_cause: null }, + ]); + }); }); // ─── Usage report dedupe partitioning ─────────────────────────────────────── @@ -179,7 +225,7 @@ function oauthRow( id: number, orgId?: string, orgName?: string, - overrides?: { refresh?: string; expires?: number }, + overrides?: { refresh?: string; expires?: number; omitEmail?: boolean }, ): StoredAuthCredential { return { id, @@ -190,7 +236,7 @@ function oauthRow( refresh: overrides?.refresh ?? `refresh-${id}`, expires: overrides?.expires ?? Date.now() + 3_600_000, accountId: "account-shared", - email: EMAIL, + email: overrides?.omitEmail ? undefined : EMAIL, orgId, orgName, }, @@ -245,6 +291,32 @@ describe("anthropic usage report dedupe partitions by org", () => { expect(orgNames).toEqual(["Personal Max", "Team Workspace"].sort()); }); + it("keeps no-email reports from two orgs separate via the account fallback", async () => { + // The account UUID is shared across orgs; without the org-qualified + // account fallback these two reports would either merge or lose their + // dedupe identity entirely when the email cannot be recovered. + storage = new AuthStorage( + makeStore([ + oauthRow(1, TEAM_ORG, "Team Workspace", { omitEmail: true }), + oauthRow(2, MAX_ORG, "Personal Max", { omitEmail: true }), + ]), + { + usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined), + }, + ); + await storage.reload(); + + vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => ({ + ...emailOnlyReport(), + metadata: { accountId: "account-shared" }, + })); + + 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()); + }); + 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 diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index b848f1d05..255ccd36c 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -432,7 +432,7 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => { // 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) => ({ + const makeCredential = (id: number, orgId?: string) => ({ type: "oauth" as const, access: `remote-access-${id}`, refresh: REMOTE_REFRESH_SENTINEL, @@ -494,6 +494,12 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => { 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"); + + // An org-less (legacy) credential must not receive an org-attributed + // sibling's pool via the shared email/account — "no usage data" is + // the correct answer. + const legacyReport = await remoteStore.getUsageReport("anthropic", makeCredential(3)); + expect(legacyReport).toBeNull(); } finally { remoteStore.close(); } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6d19e665d..6603fa604 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -46,6 +46,7 @@ - `/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. - The status line invalidates its cached usage when the session rotates to a different Anthropic organization (previously the old subscription's quota could linger for the cache TTL), and `omp auth-gateway check` labels each credential with its organization so a failing row says which subscription needs re-login. +- `omp usage` "no usage data" attribution is org-decisive whenever either the stored account or a report carries an organization: an org-less legacy credential whose own fetch failed is no longer hidden by an org-attributed sibling report sharing the same email. ## [16.4.3] - 2026-07-11 diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 856c29864..318287a5a 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -298,17 +298,21 @@ 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); + // Org-decisive attribution when EITHER side carries an org (Anthropic + // multi-subscription): two orgs share every other identifier, so an + // org-scoped account is covered only by its own org's report, and an + // org-less legacy account is never covered by an org-attributed sibling + // report — its own fetch failing must surface as "no usage data". The + // email/account fallback below applies only when both sides are + // org-less. + const accountOrg = 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 (accountOrg || reportedOrgs.size > 0) { + return !(accountOrg !== undefined && reportedOrgs.has(accountOrg)); } const ids = [account.email, account.accountId, account.projectId] .filter((value): value is string => typeof value === "string" && value.length > 0) diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts index c7b9fa4a7..948173f3f 100644 --- a/packages/coding-agent/test/usage-cli.test.ts +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -139,6 +139,30 @@ describe("collectUnreportedAccounts", () => { const unreported = collectUnreportedAccounts(anonymous, accounts); expect(unreported).toEqual([{ provider: "cerebras", type: "api_key" }]); }); + + it("attributes org-decisively when either side carries an org", () => { + const shared = "shared@example.test"; + const orgAccounts: UsageAccountIdentity[] = [ + { provider: "anthropic", type: "oauth", email: shared, orgId: "org-team" }, + { provider: "anthropic", type: "oauth", email: shared, orgId: "org-max" }, + { provider: "anthropic", type: "oauth", email: shared }, + ]; + const teamReport = { + ...makeReport("anthropic", shared, []), + metadata: { email: shared, orgId: "org-team" }, + }; + // Only the Team org reported: Max and the org-less legacy row must both + // surface as unreported despite the shared email. + const unreported = collectUnreportedAccounts([teamReport], orgAccounts); + expect(unreported).toEqual([ + { provider: "anthropic", type: "oauth", email: shared, orgId: "org-max" }, + { provider: "anthropic", type: "oauth", email: shared }, + ]); + // Both sides org-less: the email fallback still covers the account. + const orglessReport = { ...makeReport("anthropic", shared, []), metadata: { email: shared } }; + const orglessAccounts: UsageAccountIdentity[] = [{ provider: "anthropic", type: "oauth", email: shared }]; + expect(collectUnreportedAccounts([orglessReport], orglessAccounts)).toEqual([]); + }); }); describe("formatUsageBreakdown", () => {