From 1a3ae174a5ca62865639ce15d39c5d613452ad46 Mon Sep 17 00:00:00 2001 From: Angus Ross Date: Tue, 10 Nov 2026 10:00:25 +1100 Subject: [PATCH 1/2] fix(server): keep Claude hub logins that share an email separate --- apps/server/src/usage/cliproxyApi.test.ts | 28 +++++++++++++++++++++++ apps/server/src/usage/cliproxyApi.ts | 14 +++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b9f357522bb7..c7c3a876faee 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -80,6 +80,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") @@ -369,6 +377,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 87ec1e05b8f7..e4aa6d4e14c1 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -89,6 +89,7 @@ const ClaudeUsage = Schema.Struct({ ), ), }); +const ClaudeProfile = Schema.Struct({ organization: Schema.Struct({ uuid: Schema.String }) }); const CreditList = Schema.Struct({ credits: Schema.Array( Schema.Struct({ @@ -105,6 +106,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 isUsageLimitSourceError = Schema.is(UsageLimitSourceError); @@ -284,8 +286,15 @@ export const makeCliproxyApi = Effect.gen(function* () { }; const read = Effect.gen(function* () { if (account.provider === "claude") { - const body = yield* apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"); + const [body, profileBody] = yield* Effect.all( + [ + apiCall(hub, account, "https://api.anthropic.com/api/oauth/usage"), + apiCall(hub, account, "https://api.anthropic.com/api/oauth/profile"), + ], + { concurrency: "unbounded" }, + ); const usage = yield* decodeClaudeUsage(body); + const profile = yield* decodeClaudeProfile(profileBody); const model_scoped = (usage.limits ?? []).flatMap((limit) => limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" ? [ @@ -306,6 +315,9 @@ export const makeCliproxyApi = Effect.gen(function* () { }; return { ...base, + // 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, From 85b32b0e9a05b01bd70e36cae267877253a8a2e1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 00:14:38 +0000 Subject: [PATCH 2/2] fix(server): reject blank Claude organization ids from the hub --- apps/server/src/usage/cliproxyApi.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index f3a07967acba..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,7 +106,9 @@ const ClaudeUsage = Schema.Struct({ ), ), }); -const ClaudeProfile = Schema.Struct({ organization: Schema.Struct({ uuid: Schema.String }) }); +const ClaudeProfile = Schema.Struct({ + organization: Schema.Struct({ uuid: TrimmedNonEmptyString }), +}); const CreditList = Schema.Struct({ credits: Schema.Array( Schema.Struct({