Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/server/src/usage/cliproxyApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/usage/cliproxyApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ProviderDriverKind,
TrimmedNonEmptyString,
UsageLimitSourceError,
type ProviderConsumeResetCreditResult,
type UsageLimitSourceAccount,
Expand Down Expand Up @@ -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({
Expand All @@ -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));
Expand Down Expand Up @@ -308,9 +313,15 @@ const HUB_PROVIDERS = new Map<string, HubProvider>([
"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"
? [
Expand All @@ -331,6 +342,9 @@ const HUB_PROVIDERS = new Map<string, HubProvider>([
};
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,
Expand Down
Loading