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
45 changes: 33 additions & 12 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ import {
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
import {
type ClaudeScopedLimitNames,
claudeUsageResponseToLimits,
claudeProbeUsageLimits,
recordClaudeUsageResponse,
} from "./claudeUsageLimits.ts";
import {
Expand Down Expand Up @@ -237,8 +236,9 @@ type ClaudeCapabilitiesProbe = {
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
/**
* Subscription windows from the SDK's `get_usage` control request, or
* `undefined` when the request itself failed. Absent windows on an
* otherwise successful response mean the account has none (API key).
* `undefined` when the request itself failed. A present body with
* `rate_limits_available: false` is an account that cannot report, except
* a subscription login, which is classified as a failed read instead.
*/
readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
};
Expand Down Expand Up @@ -372,6 +372,17 @@ const probeClaudeCapabilities = (
rate_limits: usageResult.success.rate_limits,
}
: undefined;
// The raw body is not useful later; the flag and whether windows
// were present are enough to tell a locked instance from a failed read.
yield* Effect.logInfo(
"Claude get_usage probe finished.",
usage
? {
rateLimitsAvailable: usage.rate_limits_available,
rateLimitsPresent: usage.rate_limits != null,
}
: { failed: true },
);
const account = init.account as
| {
readonly email?: string;
Expand Down Expand Up @@ -416,6 +427,12 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
return yield* spawnAndCollect(claudeSettings.binaryPath, command);
});

/**
* Build the Claude provider snapshot from the CLI version probe and the
* capabilities probe. Usage limits come from `claudeProbeUsageLimits`, so a
* subscription instance with an empty `get_usage` body is a failed read
* rather than an account with no quota.
*/
export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
claudeSettings: ClaudeSettings,
resolveCapabilities?: (
Expand Down Expand Up @@ -560,14 +577,18 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
subscriptionType: capabilities.subscriptionType,
authMethod: capabilities.tokenSource,
}) ?? apiProviderAuthMetadata(capabilities.apiProvider);
const usageLimits = !capabilities.usage
? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" })
: scopedLimitNames
? yield* recordClaudeUsageResponse(scopedLimitNames, {
response: capabilities.usage,
checkedAt,
})
: claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits;
const usageProbe = {
usage: capabilities.usage,
account: {
subscriptionType: capabilities.subscriptionType,
tokenSource: capabilities.tokenSource,
apiProvider: capabilities.apiProvider,
},
checkedAt,
};
const usageLimits = scopedLimitNames
? yield* recordClaudeUsageResponse(scopedLimitNames, usageProbe)
: claudeProbeUsageLimits(usageProbe).limits;
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
enabled: claudeSettings.enabled,
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ type TestClaudeCapabilities = {
readonly tokenSource: string | undefined;
readonly apiProvider: string | undefined;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
readonly usage?: {
readonly rate_limits_available: boolean;
readonly rate_limits: null;
};
};

function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
Expand Down Expand Up @@ -2921,6 +2925,34 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
),
);

it.effect(
"does not mark a second subscription Claude instance unsupported when usage is empty",
() =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities({
email: "other@example.com",
subscriptionType: "max",
tokenSource: "oauth",
usage: { rate_limits_available: false, rate_limits: null },
}),
);
assert.strictEqual(status.auth.status, "authenticated");
assert.strictEqual(status.auth.email, "other@example.com");
assert.strictEqual(status.usageLimits?.unavailable?.reason, "probeFailed");
assert.deepStrictEqual(status.usageLimits?.windows, []);
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("returns an api key label for claude api key auth", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
Expand Down
107 changes: 106 additions & 1 deletion apps/server/src/provider/Layers/claudeUsageLimits.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vite-plus/test";

import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts";
import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "../providerUsageLimits.ts";
import {
claudeAccountReportsSubscriptionUsage,
claudeProbeUsageLimits,
claudeRateLimitEventToUpdate,
claudeUsageResponseToLimits,
} from "./claudeUsageLimits.ts";

const checkedAt = "2026-07-18T10:00:00.000Z";
const noNames = { overageIncluded: undefined } as const;
Expand Down Expand Up @@ -95,6 +101,15 @@ describe("claudeUsageResponseToLimits", () => {
).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } });
});

it("treats a null rate-limit body as a failed probe", () => {
expect(
claudeUsageResponseToLimits({
checkedAt,
response: { rate_limits_available: true, rate_limits: null },
}).limits,
).toEqual({ checkedAt, windows: [], unavailable: { reason: "probeFailed" } });
});

it("skips a window the endpoint reports without a utilization", () => {
expect(
claudeUsageResponseToLimits({
Expand All @@ -119,6 +134,96 @@ describe("claudeUsageResponseToLimits", () => {
});
});

const noAccount = {
subscriptionType: undefined,
tokenSource: undefined,
apiProvider: undefined,
} as const;

describe("claudeProbeUsageLimits", () => {
it("keeps API key and Bedrock logins unsupported", () => {
const unavailable = { rate_limits_available: false, rate_limits: null } as const;
expect(
claudeProbeUsageLimits({
checkedAt,
usage: unavailable,
account: { ...noAccount, tokenSource: "ANTHROPIC_AUTH_TOKEN" },
}).limits.unavailable?.reason,
).toBe("unsupported");
expect(
claudeProbeUsageLimits({
checkedAt,
usage: unavailable,
account: { ...noAccount, apiProvider: "bedrock" },
}).limits.unavailable?.reason,
).toBe("unsupported");
expect(claudeAccountReportsSubscriptionUsage({ ...noAccount, apiProvider: "vertex" })).toBe(
false,
);
});

it("does not lock a subscription instance that returned no windows", () => {
const account = { subscriptionType: "max", tokenSource: "oauth", apiProvider: undefined };
for (const usage of [
{ rate_limits_available: false, rate_limits: null },
{ rate_limits_available: true, rate_limits: null },
] as const) {
expect(claudeProbeUsageLimits({ checkedAt, usage, account }).limits.unavailable?.reason).toBe(
"probeFailed",
);
}
expect(
claudeProbeUsageLimits({
checkedAt,
usage: undefined,
account,
}).limits.unavailable?.reason,
).toBe("probeFailed");
});

it("publishes a second instance's turn windows and keeps them across the next bad probe", () => {
const account = { subscriptionType: "max", tokenSource: "oauth", apiProvider: undefined };
const probed = claudeProbeUsageLimits({
checkedAt,
account,
usage: { rate_limits_available: false, rate_limits: null },
});
const update = claudeRateLimitEventToUpdate(
{
status: "rejected",
rateLimitType: "five_hour",
utilization: 1.09,
resetsAt: 1_789_938_600,
},
probed.names,
);
const recovered = applyUsageLimitsUpdate({
previous: probed.limits,
checkedAt: "2026-09-20T18:58:27.751Z",
update: update!,
});
expect(recovered?.windows).toEqual([
{
id: "five_hour",
kind: "session",
label: "Session",
usedPercent: 100,
windowDurationMins: 300,
resetsAt: "2026-09-20T21:10:00.000Z",
},
]);
expect(recovered?.unavailable?.reason).toBe("probeFailed");
const again = claudeProbeUsageLimits({
checkedAt: "2026-09-20T19:00:00.000Z",
account,
usage: { rate_limits_available: true, rate_limits: null },
});
expect(resolveUsageLimitsAfterProbe({ published: recovered, probed: again.limits })).toBe(
recovered,
);
});
});

describe("claudeRateLimitEventToUpdate", () => {
it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => {
expect(
Expand Down
94 changes: 89 additions & 5 deletions apps/server/src/provider/Layers/claudeUsageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,21 +150,60 @@ export function claudeRateLimitEventToUpdate(
return undefined;
}

const NON_SUBSCRIPTION_API_PROVIDERS = new Set(["bedrock", "vertex"]);

/**
* Whether this Claude login can have subscription windows.
*
* API-key, Bedrock, and Vertex accounts cannot. A named subscription, or any
* other token source, can — a `get_usage` flag that says otherwise is a
* failed read, not proof the account has no quota.
*/
export function claudeAccountReportsSubscriptionUsage(account: {
readonly subscriptionType: string | undefined;
readonly tokenSource: string | undefined;
readonly apiProvider: string | undefined;
}): boolean {
const apiProvider = account.apiProvider?.trim().toLowerCase();
if (apiProvider !== undefined && NON_SUBSCRIPTION_API_PROVIDERS.has(apiProvider)) return false;
const tokenSource = account.tokenSource?.toLowerCase().replace(/[\s_-]+/g, "");
if (
tokenSource === "apikey" ||
tokenSource === "anthropicapikey" ||
tokenSource === "anthropicauthtoken"
) {
return false;
}
if (account.subscriptionType?.trim()) return true;
return Boolean(account.tokenSource?.trim());
}

/**
* Percentages on the `get_usage` response are already 0–100. Also yields the
* scoped-bucket names the response carried, for the event mapper to reuse.
*
* `rate_limits_available: false` is an account the SDK says cannot report
* subscription windows. `rate_limits_available: true` with no `rate_limits`
* is a fetch that failed this time — Claude keeps the flag and nulls the
* body — so that is `probeFailed`, not a permanent unsupported lock.
*/
export function claudeUsageResponseToLimits(input: {
readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
readonly checkedAt: string;
}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } {
const { response, checkedAt } = input;
if (!response.rate_limits_available || !response.rate_limits) {
if (!response.rate_limits_available) {
return {
limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }),
names: { overageIncluded: undefined },
};
}
if (!response.rate_limits) {
return {
limits: makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }),
names: { overageIncluded: undefined },
};
}
const windows: ServerProviderUsageWindow[] = [];
for (const id of Object.keys(WINDOWS)) {
const window = response.rate_limits[id as "five_hour" | "seven_day"];
Expand All @@ -189,11 +228,56 @@ export function claudeUsageResponseToLimits(input: {
};
}

/** Probe-side helper: map the response and remember the scoped names for events. */
/**
* Limits to publish from one Claude capabilities probe.
*
* No usage payload means the request failed. `unsupported` stands only when
* the account cannot have a subscription. A subscription login that comes
* back `unsupported`, including a second instance whose `get_usage` omits
* windows, is `probeFailed` so a later turn can fill the bars in.
*/
export function claudeProbeUsageLimits(input: {
readonly usage:
| Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">
| undefined;
readonly account: {
readonly subscriptionType: string | undefined;
readonly tokenSource: string | undefined;
readonly apiProvider: string | undefined;
};
readonly checkedAt: string;
}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } {
if (!input.usage) {
return {
limits: makeUnavailableUsageLimits({ checkedAt: input.checkedAt, reason: "probeFailed" }),
names: { overageIncluded: undefined },
};
}
const mapped = claudeUsageResponseToLimits({
response: input.usage,
checkedAt: input.checkedAt,
});
if (
mapped.limits.unavailable?.reason === "unsupported" &&
claudeAccountReportsSubscriptionUsage(input.account)
) {
return {
limits: makeUnavailableUsageLimits({ checkedAt: input.checkedAt, reason: "probeFailed" }),
names: mapped.names,
};
}
return mapped;
}

/**
* Probe-side helper: map the response and remember the scoped names for events.
* A failed request leaves the names from the last successful body in place.
*/
export const recordClaudeUsageResponse = (
namesRef: Ref.Ref<ClaudeScopedLimitNames>,
input: Parameters<typeof claudeUsageResponseToLimits>[0],
input: Parameters<typeof claudeProbeUsageLimits>[0],
): Effect.Effect<ServerProviderUsageLimits> => {
const { limits, names } = claudeUsageResponseToLimits(input);
return Ref.set(namesRef, names).pipe(Effect.as(limits));
const probed = claudeProbeUsageLimits(input);
if (!input.usage) return Effect.succeed(probed.limits);
return Ref.set(namesRef, probed.names).pipe(Effect.as(probed.limits));
};
Loading
Loading