Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = {
fontSmoothing: true,
glassOpacity: 80,
planModeEnabled: false,
showPlanUsageInSidebar: true,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
Expand Down
34 changes: 33 additions & 1 deletion apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,35 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
"const lines = createInterface({ input: process.stdin });",
'lines.on("line", (line) => {',
" const message = JSON.parse(line);",
' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',
' if (message.type !== "control_request") return;',
' if (message.request?.subtype === "get_usage") {',
" process.stdout.write(JSON.stringify({",
' type: "control_response",',
" response: {",
' subtype: "success",',
" request_id: message.request_id,",
" response: {",
" session: {",
" total_cost_usd: 0,",
" total_api_duration_ms: 0,",
" total_duration_ms: 0,",
" total_lines_added: 0,",
" total_lines_removed: 0,",
" model_usage: {},",
" },",
' subscription_type: "pro",',
" rate_limits_available: true,",
" rate_limits: {",
' seven_day: { utilization: 12, resets_at: "2026-08-28T01:00:00.000Z" },',
' seven_day_fable: { utilization: 18, resets_at: "2026-08-28T01:00:00.000Z" },',
" },",
" behaviors: null,",
" },",
" },",
' }) + "\\n");',
" return;",
" }",
' if (message.request?.subtype !== "initialize") return;',
" process.stdout.write(JSON.stringify({",
' type: "control_response",',
" response: {",
Expand Down Expand Up @@ -127,6 +155,10 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
subscriptionType: "pro",
tokenSource: "oauth",
apiProvider: undefined,
rateLimits: {
seven_day: { utilization: 12, resets_at: "2026-08-28T01:00:00.000Z" },
seven_day_fable: { utilization: 18, resets_at: "2026-08-28T01:00:00.000Z" },
},
slashCommands: [
{
name: "review",
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
import { normalizeClaudePlanUsage } from "../providerPlanUsage.ts";

const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
optionDescriptors: [],
Expand Down Expand Up @@ -641,6 +642,7 @@ type ClaudeCapabilitiesProbe = {
* the subscription/token fields are absent and auth is external AWS creds.
*/
readonly apiProvider: string | undefined;
readonly rateLimits?: unknown;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
};

Expand Down Expand Up @@ -724,7 +726,8 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
* message is ever written to the subprocess stdin. This means the Claude
* Code subprocess completes its local initialization IPC (returning
* account info and slash commands) but never starts an API request to
* Anthropic. We read the init data and then abort the subprocess.
* Anthropic. We read the init data, request account usage over the SDK's
* control channel when available, and then abort the subprocess.
*
* This is used as a fallback when `claude auth status` does not include
* subscription type information.
Expand Down Expand Up @@ -765,11 +768,15 @@ const probeClaudeCapabilities = (
readonly apiProvider?: string;
}
| undefined;
const usage = await q
.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()
.catch(() => null);
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
rateLimits: usage?.rate_limits as unknown,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usage probe can break Claude auth

High Severity

After a successful initializationResult(), the probe awaits the experimental usage API inside the same timed Effect. A hang or slow get_usage response burns the shared 25s budget, so the whole probe returns undefined even though account data was already fetched. That surfaces Claude as unverified (warning / auth: unknown) and is cached for five minutes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9d9d5da. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e774eb9. Account initialization remains authoritative; the experimental usage request now has its own short timeout inside the remaining probe budget. If usage hangs, the probe preserves the account and slash commands and returns no rate-limit data. The SDK-boundary test now covers that case.

slashCommands: parseClaudeInitializationCommands(init.commands),
} satisfies ClaudeCapabilitiesProbe;
});
Expand Down Expand Up @@ -953,13 +960,15 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
subscriptionType: capabilities.subscriptionType,
authMethod: capabilities.tokenSource,
}) ?? apiProviderAuthMetadata(capabilities.apiProvider);
const planUsage = normalizeClaudePlanUsage(capabilities.rateLimits, checkedAt);
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
enabled: claudeSettings.enabled,
checkedAt,
models,
slashCommands: dedupedSlashCommands,
skills,
...(planUsage ? { planUsage } : {}),
probe: {
installed: true,
version: parsedVersion,
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { expandHomePath } from "../../pathExpansion.ts";
import { normalizeCodexPlanUsage } from "../providerPlanUsage.ts";
import packageJson from "../../../package.json" with { type: "json" };
const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);

Expand All @@ -45,6 +46,7 @@ const CODEX_PRESENTATION = {

export interface CodexAppServerProviderSnapshot {
readonly account: CodexSchema.V2GetAccountResponse;
readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse | undefined;
readonly version: string | undefined;
readonly models: ReadonlyArray<ServerProviderModel>;
readonly skills: ReadonlyArray<ServerProviderSkill>;
Expand Down Expand Up @@ -395,24 +397,27 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
return {
account: accountResponse,
rateLimits: undefined,
version,
models: appendCustomCodexModels([], input.customModels ?? []),
skills: [],
} satisfies CodexAppServerProviderSnapshot;
}

const [skillsResponse, models] = yield* Effect.all(
const [skillsResponse, models, rateLimits] = yield* Effect.all(
[
client.request("skills/list", {
cwds: [input.cwd],
}),
requestAllCodexModels(client),
client.request("account/rateLimits/read", undefined).pipe(Effect.option),
],
{ concurrency: "unbounded" },
);

return {
account: accountResponse,
rateLimits: Option.getOrUndefined(rateLimits),
version,
models: applyPreferredCodexDefaultModel(
appendCustomCodexModels(models, input.customModels ?? []),
Expand Down Expand Up @@ -600,13 +605,15 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu

const snapshot = probeResult.success.value;
const accountStatus = accountProbeStatus(snapshot.account);
const planUsage = normalizeCodexPlanUsage(snapshot.rateLimits, checkedAt);

return buildServerProvider({
presentation: CODEX_PRESENTATION,
enabled: codexSettings.enabled,
checkedAt,
models: snapshot.models,
skills: snapshot.skills,
...(planUsage ? { planUsage } : {}),
slashCommands: [
{
name: "feedback",
Expand Down
106 changes: 106 additions & 0 deletions apps/server/src/provider/providerPlanUsage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vite-plus/test";

import { normalizeClaudePlanUsage, normalizeCodexPlanUsage } from "./providerPlanUsage.ts";

const checkedAt = "2026-08-24T18:00:00.000Z";

describe("provider plan usage", () => {
it("normalizes Codex primary and weekly windows", () => {
expect(
normalizeCodexPlanUsage(
{
rateLimits: {
limitId: "codex",
primary: { usedPercent: 31, windowDurationMins: 300, resetsAt: 1_777_000_000 },
secondary: { usedPercent: 72, windowDurationMins: 10_080, resetsAt: 1_777_500_000 },
},
},
checkedAt,
),
).toEqual({
checkedAt,
windows: [
{
id: "codex:primary",
label: "5-hour",
usedPercent: 31,
resetsAt: "2026-04-24T03:06:40.000Z",
windowDurationMinutes: 300,
},
{
id: "codex:secondary",
label: "Weekly",
usedPercent: 72,
resetsAt: "2026-04-29T22:00:00.000Z",
windowDurationMinutes: 10_080,
},
],
});
});

it("keeps named Codex buckets distinct and clamps percentages", () => {
const usage = normalizeCodexPlanUsage(
{
rateLimitsByLimitId: {
codex: { limitName: "Codex", primary: { usedPercent: -1 } },
codex_fast: { limitName: "Fast", primary: { usedPercent: 120 } },
},
},
checkedAt,
);

expect(usage?.windows).toEqual([
{
id: "codex:primary",
label: "Primary",
usedPercent: 0,
resetsAt: null,
},
{
id: "codex_fast:primary",
label: "Fast · Primary",
usedPercent: 100,
resetsAt: null,
},
]);
});

it("normalizes Claude all-models and forward-compatible Fable windows", () => {
expect(
normalizeClaudePlanUsage(
{
seven_day: { utilization: 10, resets_at: "2026-08-29T01:00:00Z" },
seven_day_fable: { utilization: 18, resets_at: "2026-08-29T01:00:00Z" },
extra_usage: { utilization: 70 },
},
checkedAt,
),
).toEqual({
checkedAt,
windows: [
{
id: "seven_day",
label: "All models",
usedPercent: 10,
resetsAt: "2026-08-29T01:00:00.000Z",
windowDurationMinutes: 10_080,
},
{
id: "seven_day_fable",
label: "Fable",
usedPercent: 18,
resetsAt: "2026-08-29T01:00:00.000Z",
windowDurationMinutes: 10_080,
},
],
});
});

it("omits unavailable and malformed usage", () => {
expect(normalizeCodexPlanUsage({}, checkedAt)).toBeUndefined();
expect(normalizeClaudePlanUsage(null, checkedAt)).toBeUndefined();
expect(
normalizeClaudePlanUsage({ seven_day: { utilization: "10" } }, checkedAt),
).toBeUndefined();
});
});
Loading
Loading