diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 431af7b5971f..e44a75347c31 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -84,6 +84,14 @@ function fixture( ); expect(path).toMatch(/\/v0\/management\/api-call$/); expect(body?.header?.Authorization).toBe("Bearer $TOKEN$"); + if (body?.url?.endsWith("/api/oauth/profile")) + return HttpClientResponse.fromWeb( + request, + Response.json({ + status_code: 200, + body: encodeJson({ organization: { uuid: `org-${body.auth_index}` } }), + }), + ); const upstream = options.upstream?.(body!) ?? { status: 200, body: body?.url?.endsWith("/consume") @@ -373,6 +381,26 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("tells apart Claude logins that share an email by organisation", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [ + { ...accounts[0]!, provider: "claude", email: "same@example.com" }, + { ...accounts[1]!, provider: "claude", email: "same@example.com" }, + ], + upstream: () => ({ + status: 200, + body: { five_hour: { utilization: 10, resets_at: null } }, + }), + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result.map((account) => [account.email, account.workspaceId])).toEqual([ + ["same@example.com", "org-a"], + ["same@example.com", "org-b"], + ]); + }), + ); + it.effect.each([ { name: "spend budget", diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 030983f50e33..709293d8169c 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -1,5 +1,6 @@ import { ProviderDriverKind, + TrimmedNonEmptyString, UsageLimitSourceError, type ProviderConsumeResetCreditResult, type UsageLimitSourceAccount, @@ -105,6 +106,9 @@ const ClaudeUsage = Schema.Struct({ ), ), }); +const ClaudeProfile = Schema.Struct({ + organization: Schema.Struct({ uuid: TrimmedNonEmptyString }), +}); const CreditList = Schema.Struct({ credits: Schema.Array( Schema.Struct({ @@ -121,6 +125,7 @@ const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage)); +const decodeClaudeProfile = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeProfile)); const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); const decodeGrokUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(GrokUsageResponse)); const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); @@ -308,9 +313,15 @@ const HUB_PROVIDERS = new Map([ "User-Agent": "claude-code/2.1.273", }), read: Effect.fnUntraced(function* ({ account, call, checkedAt }) { - const usage = yield* decodeClaudeUsage( - yield* call("https://api.anthropic.com/api/oauth/usage"), + const [usageBody, profileBody] = yield* Effect.all( + [ + call("https://api.anthropic.com/api/oauth/usage"), + call("https://api.anthropic.com/api/oauth/profile"), + ], + { concurrency: "unbounded" }, ); + const usage = yield* decodeClaudeUsage(usageBody); + const profile = yield* decodeClaudeProfile(profileBody); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" ? [ @@ -331,6 +342,9 @@ const HUB_PROVIDERS = new Map([ }; return { ...listedIdentity(account), + // One email can hold several Claude organisations. The local provider keys its + // account by organisation too, so each hub login stays a separate account. + workspaceId: profile.organization.uuid, plan: "Claude Subscription", usageLimits: claudeUsageResponseToLimits({ checkedAt,