Skip to content
Merged
6 changes: 6 additions & 0 deletions apps/server/src/provider/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined {

type ClaudeCapabilitiesProbe = {
readonly email: string | undefined;
/**
* The organization UUID from the login's `.claude.json`. The driver reads it
* in the same cached probe, so it describes the same login as the rest.
*/
readonly workspaceId?: string | undefined;
readonly subscriptionType: string | undefined;
readonly tokenSource: string | undefined;
/** Where the CLI found an API key, when it authenticates with one. */
Expand Down Expand Up @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
auth: {
status: "authenticated",
...(capabilities.email ? { email: capabilities.email } : {}),
...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}),
...(authMetadata ? authMetadata : {}),
},
...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
Expand Down
12 changes: 10 additions & 2 deletions apps/server/src/provider/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ type CodexRateLimitsProbe =
| null
| undefined;
readonly resetCredits: CodexResetCreditsSummary | null | undefined;
/** The ChatGPT workspace the usage belongs to, when the backend names it. */
readonly accountId?: string | null | undefined;
}
| { readonly failure: string };

Expand Down Expand Up @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
snapshot: response.rateLimits,
rateLimitsByLimitId: response.rateLimitsByLimitId,
resetCredits: response.rateLimitResetCredits,
accountId: response.accountId,
})),
Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
Effect.map(
Expand Down Expand Up @@ -539,18 +542,23 @@ const makePendingCodexProvider = (
});
});

function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): {
function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): {
readonly status: Exclude<ServerProviderState, "disabled">;
readonly auth: ServerProvider["auth"];
readonly message?: string;
} {
const authLabel = codexAccountAuthLabel(account.account);
const authEmail = codexAccountEmail(account.account);
// One email can hold several workspaces, each with its own quota; the usage
// read names the one this login draws on.
const workspaceId =
rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined;
const auth = {
status: account.account ? ("authenticated" as const) : ("unknown" as const),
...(account.account?.type ? { type: account.account?.type } : {}),
...(authLabel ? { label: authLabel } : {}),
...(authEmail ? { email: authEmail } : {}),
...(workspaceId ? { workspaceId } : {}),
} satisfies ServerProvider["auth"];

if (account.account) {
Expand Down Expand Up @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
const snapshot = probeResult.success.value;
const accountStatus = managedAuth
? { status: "ready" as const, auth: managedAuth, message: undefined }
: accountProbeStatus(snapshot.account);
: accountProbeStatus(snapshot);
const usageLimits =
snapshot.account.account?.type === "apiKey"
? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
Expand Down
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 @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
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, workspaceId]) =>
capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities,
),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
});
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/claudeResetCredits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")(
);

/** The CLI keeps the account record beside its settings, or in the home directory by default. */
/** The organization the login is signed in to; one email can belong to several. */
export const readClaudeOrganizationId = (accountConfigPath: string) =>
readJson(Config, accountConfigPath).pipe(
Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined),
Effect.orElseSucceed(() => undefined),
);

export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* (
configDir: string | undefined,
) {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/usage/cliproxyApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () {
id: account.id,
driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"),
...(account.email ? { email: account.email } : {}),
...(account.id_token?.chatgpt_account_id?.trim()
? { workspaceId: account.id_token.chatgpt_account_id.trim() }
: {}),
};
const read = Effect.gen(function* () {
if (account.provider === "claude") {
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/providerUsageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({
email: Schema.optional(TrimmedNonEmptyString),
/** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */
plan: Schema.optional(TrimmedNonEmptyString),
/** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */
workspaceId: Schema.optional(TrimmedNonEmptyString),
usageLimits: ServerProviderUsageLimits,
});
export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type;
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({
canLogout: Schema.optional(Schema.Boolean),
subscriptionSharing: Schema.optional(Schema.Boolean),
profileId: Schema.optional(TrimmedNonEmptyString),
/**
* The workspace or organization whose quota the login draws on, such as a
* ChatGPT workspace or Claude organization id. One email can belong to
* several, each with its own quota.
*/
workspaceId: Schema.optional(TrimmedNonEmptyString),
});
export type ServerProviderAuth = typeof ServerProviderAuth.Type;

Expand Down
16 changes: 15 additions & 1 deletion packages/provider-core/src/server/managedProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => {
checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe(
Effect.map((count) =>
count === 1
? { ...refreshedSnapshot, usageLimits: probedLimits }
? {
...refreshedSnapshot,
auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" },
usageLimits: probedLimits,
}
: {
...refreshedSnapshotSecond,
...(count === 3
? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } }
: {}),
usageLimits: {
checkedAt: "2026-04-10T00:00:03.000Z",
windows: [],
Expand Down Expand Up @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => {
const refreshed = yield* provider.refresh;
assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message);
assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]);
// ...and the workspace they were read for.
assert.strictEqual(refreshed.auth.workspaceId, "ws-a");

// A failed read for another workspace does not inherit those windows.
const switched = yield* provider.refresh;
assert.strictEqual(switched.auth.workspaceId, "ws-b");
assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed");
}),
).pipe(Effect.provide(layerAlwaysRunTest)),
);
Expand Down
27 changes: 22 additions & 5 deletions packages/provider-core/src/server/managedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
const generation = input.enrichSnapshot
? state.enrichmentGeneration + 1
: state.enrichmentGeneration;
// A failed read keeps the last good limits only for the same account,
// and they keep the workspace they were read for.
const previous = state.snapshot.auth;
const differs = (a: string | undefined, b: string | undefined) =>
a !== undefined && b !== undefined && a !== b;
// Limits read without a workspace cannot be attributed to a new one.
const switchedAccount =
differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) ||
(probedSnapshot.auth.workspaceId !== undefined &&
probedSnapshot.auth.workspaceId !== previous.workspaceId);
const usageLimits = switchedAccount
? probedSnapshot.usageLimits
: resolveUsageLimitsAfterProbe({
published: state.snapshot.usageLimits,
probed: probedSnapshot.usageLimits,
});
const workspaceId =
usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId;
const snapshot = withUsageLimits(
probedSnapshot,
resolveUsageLimitsAfterProbe({
published: state.snapshot.usageLimits,
probed: probedSnapshot.usageLimits,
}),
workspaceId && !probedSnapshot.auth.workspaceId
? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } }
: probedSnapshot,
usageLimits,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
return [
{ snapshot, generation },
Expand Down
Loading
Loading