Skip to content
9 changes: 8 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
capacity: 1,
timeToLive: CAPABILITIES_PROBE_TTL,
lookup: () =>
probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe(
Effect.all([
probeClaudeCapabilities(effectiveConfig, processEnv, cwd),
ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath),
]).pipe(
Effect.map(([capabilities, accountId]) =>
capabilities ? { ...capabilities, accountId } : capabilities,
),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {

assert.deepEqual(capabilities, {
email: "dev@example.com",
organization: "Acme",
subscriptionType: "pro",
tokenSource: "oauth",
apiProvider: undefined,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ function nonEmptyProbeString(value: string): string | undefined {

type ClaudeCapabilitiesProbe = {
readonly email: string | undefined;
readonly organization: string | undefined;
/**
* The org UUID from the login's account record. The driver reads it in the
* same cached probe, so it always describes the same login as the rest.
*/
readonly accountId?: string | undefined;
readonly subscriptionType: string | undefined;
readonly tokenSource: string | undefined;
/**
Expand Down Expand Up @@ -382,13 +388,15 @@ const probeClaudeCapabilities = (
const account = init.account as
| {
readonly email?: string;
readonly organization?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
organization: nonEmptyProbeString(account?.organization ?? ""),
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
Expand Down Expand Up @@ -619,6 +627,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
auth: {
status: "authenticated",
...(capabilities.email ? { email: capabilities.email } : {}),
...(capabilities.organization ? { organization: capabilities.organization } : {}),
...(capabilities.accountId ? { accountId: capabilities.accountId } : {}),
...(authMetadata ? authMetadata : {}),
},
...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ function booleanDescriptor(id: string, label: string) {

type TestClaudeCapabilities = {
readonly email: string | undefined;
readonly organization: string | undefined;
readonly accountId?: string | undefined;
readonly subscriptionType: string | undefined;
readonly tokenSource: string | undefined;
readonly apiProvider: string | undefined;
Expand All @@ -164,6 +166,7 @@ function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
return () =>
Effect.succeed({
email: undefined,
organization: undefined,
subscriptionType: undefined,
tokenSource: undefined,
apiProvider: undefined,
Expand Down Expand Up @@ -3059,6 +3062,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
() =>
Effect.succeed({
email: undefined,
organization: undefined,
subscriptionType: undefined,
tokenSource: undefined,
apiProvider: undefined,
Expand Down Expand Up @@ -3131,14 +3135,20 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
),
);

it.effect("returns claude auth email from initialization result", () =>
it.effect("returns claude auth email and organization from initialization result", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities({ email: "claude@example.com" }),
claudeCapabilities({
email: "claude@example.com",
organization: "Acme",
accountId: "org-1",
}),
);
assert.strictEqual(status.auth.status, "authenticated");
assert.strictEqual(status.auth.email, "claude@example.com");
assert.strictEqual(status.auth.organization, "Acme");
assert.strictEqual(status.auth.accountId, "org-1");
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/claudeResetCredits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,19 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => {
}),
);
});

effectIt.layer(NodeServices.layer)("readClaudeOrganizationId", (it) => {
it.effect("reads the login's org id, and nothing from a missing or garbled account", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const { configDir, accountConfigPath } = yield* writeLogin;
const signedIn = yield* ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath);
const missing = yield* ClaudeResetCredits.readClaudeOrganizationId(
`${configDir}/absent.json`,
);
yield* fs.writeFileString(accountConfigPath, "{not json");
const garbled = yield* ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath);
expect([signedIn, missing, garbled]).toEqual(["org-1", undefined, undefined]);
}),
);
});
10 changes: 10 additions & 0 deletions apps/server/src/provider/Layers/claudeResetCredits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ export const claudeAccountConfigPath = (configDir: string | undefined) =>
configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"),
);

/**
* The org the login in `accountConfigPath` draws its quota from, or undefined
* when the account record is missing or unreadable.
*/
export const readClaudeOrganizationId = (accountConfigPath: string) =>
readJson(Config, accountConfigPath).pipe(
Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined),
Effect.orElseSucceed(() => undefined),
);

const CLAIM_OUTCOMES = {
reset: "reset",
not_limited: "nothingToReset",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ lines.on("line", (line) => {
output_style: "default",
available_output_styles: ["default"],
models: [],
account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
account: { email: "dev@example.com", organization: "Acme", subscriptionType: "pro", tokenSource: "oauth" },
});
}
// The probe follows initialize with get_usage on the same process.
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ export const ServerProviderAuth = Schema.Struct({
canLogout: Schema.optional(Schema.Boolean),
subscriptionSharing: Schema.optional(Schema.Boolean),
profileId: Schema.optional(TrimmedNonEmptyString),
/**
* The display name of the organization the login is signed in to, when the
* provider reports one. Names can repeat and change; see `accountId`.
*/
organization: Schema.optional(TrimmedNonEmptyString),
/**
* A stable id for the organization or workspace whose quota the login draws
* on, such as Claude's organization UUID. One email can belong to several,
* each with its own quota.
*/
accountId: Schema.optional(TrimmedNonEmptyString),
});
export type ServerProviderAuth = typeof ServerProviderAuth.Type;

Expand Down
166 changes: 166 additions & 0 deletions packages/shared/src/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,124 @@ describe("pools", () => {
]);
});

it("keeps one email signed in to two orgs as two accounts", () => {
const personal = provider({
driver: claude,
instanceId: ProviderInstanceId.make("claude"),
auth: { status: "authenticated", email: "same@example.com", organization: "Personal" },
usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 36 }] },
});
const work = {
...personal,
instanceId: ProviderInstanceId.make("work"),
auth: { status: "authenticated" as const, email: "same@example.com", organization: "Acme" },
usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 2 }] },
};
const input = new Map([
[EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [personal, work] } }],
]);
expect(
collectLimitAccounts(input).map((account) => [
account.key,
account.limits.windows[0]?.usedPercent,
]),
).toEqual([
["env-a:claude", 36],
["env-a:work", 2],
]);
});

it("tells orgs apart by their id, not their display name", () => {
const first = provider({
driver: claude,
instanceId: ProviderInstanceId.make("claude"),
auth: {
status: "authenticated",
email: "same@example.com",
organization: "Acme",
accountId: "org-1",
},
usageLimits: { checkedAt, windows: [window] },
});
const withOrg = (instanceId: string, organization: string, accountId: string) => ({
...first,
instanceId: ProviderInstanceId.make(instanceId),
auth: { ...first.auth, organization, accountId },
});
const keysFor = (providers: ServerProvider[]) =>
collectLimitAccounts(
new Map([[EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers } }]]),
).map((account) => account.key);
expect(keysFor([first, withOrg("namesake", "Acme", "org-2")])).toEqual([
"env-a:claude",
"env-a:namesake",
]);
expect(keysFor([first, withOrg("renamed", "Acme Inc", "org-1")])).toEqual(["env-a:claude"]);
});

it("joins a hub account to the native org only when one org uses the email", () => {
const native = provider({
driver: claude,
instanceId: ProviderInstanceId.make("claude"),
auth: { status: "authenticated", email: "same@example.com", organization: "Acme" },
usageLimits: { checkedAt, windows: [window] },
});
const hub = {
...source,
accounts: [
{
id: "claude-same@example.com.json",
driver: claude,
email: "same@example.com",
usageLimits: { checkedAt, windows: [window] },
},
],
};
const accountsFor = (providers: ServerProvider[]) =>
collectLimitAccounts(
new Map([
[
EnvironmentId.make("env-a"),
{ ...laptop, serverConfig: { providers, usageLimitSources: [hub] } },
],
]),
);
expect(accountsFor([native])).toHaveLength(1);
// With two orgs the hub cannot say which it read, so it stays its own row.
const otherOrg = {
...native,
instanceId: ProviderInstanceId.make("work"),
auth: { ...native.auth, organization: "Personal" },
};
expect(accountsFor([native, otherOrg])).toHaveLength(3);
// A failed read still signs in a second org, so the hub stays ambiguous.
const failedOrg = {
...otherOrg,
usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" as const } },
};
expect(accountsFor([native, failedOrg]).map((account) => account.key)).toEqual([
"env-a:claude",
"hub:claude-same@example.com.json",
]);
const unreadOrg = provider({
driver: claude,
instanceId: otherOrg.instanceId,
auth: otherOrg.auth,
});
expect(accountsFor([native, unreadOrg]).map((account) => account.key)).toEqual([
"env-a:claude",
"hub:claude-same@example.com.json",
]);
// A login that names no org keys on the bare email, which the hub must not take.
const { organization: _, ...noOrgAuth } = otherOrg.auth;
const noOrg = { ...otherOrg, auth: noOrgAuth };
expect(accountsFor([native, noOrg]).map((account) => account.key)).toEqual([
"env-a:claude",
"env-a:work",
"hub:claude-same@example.com.json",
]);
});

it("keys a hub account without an email by hub, so two environments on one hub share it", () => {
const seat = {
id: "claude-team-seat.json",
Expand Down Expand Up @@ -950,6 +1068,54 @@ describe("/usage-limits", () => {
expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(3);
});

it("keeps a hub credit off both orgs when one email is signed in to two", () => {
const personal = provider({
usageLimits: limits,
auth: { status: "authenticated", email: "same@example.com", organization: "Personal" },
});
const work = {
...personal,
instanceId: ProviderInstanceId.make("work"),
auth: { ...personal.auth, organization: "Acme" },
};
const hub = [
{
...sources[0]!,
accounts: [
{
id: "duplicate",
driver: personal.driver,
email: "same@example.com",
usageLimits: {
...limits,
resetCredits: { availableCount: 1, nextCreditId: "hub-credit" },
},
},
],
},
];
const report = collectProviderUsageLimits(personal.instanceId, [personal, work], hub, now);
// The hub cannot say which org it read, so redeeming its credit from
// either native row could spend the other org's reset.
expect(report?.accounts.map((account) => [account.id, account.resetCreditInput])).toEqual([
[personal.instanceId, { instanceId: personal.instanceId }],
["work", { instanceId: "work" }],
["hub:duplicate", { sourceId: "hub", accountId: "duplicate", creditId: "hub-credit" }],
]);
// An org whose limits were never read is still signed in with that email.
const unread = provider({ instanceId: work.instanceId, driver: work.driver, auth: work.auth });
const withUnread = collectProviderUsageLimits(
personal.instanceId,
[personal, unread],
hub,
now,
);
expect(withUnread?.accounts.map((account) => [account.id, account.resetCreditInput])).toEqual([
[personal.instanceId, { instanceId: personal.instanceId }],
["hub:duplicate", { sourceId: "hub", accountId: "duplicate", creditId: "hub-credit" }],
]);
});

it("keeps accounts and custom instances separate, filtering by driver", () => {
const report = collectProviderUsageLimits(
selected.instanceId,
Expand Down
Loading
Loading