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
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
61 changes: 60 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,36 @@ 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") {',
' if (process.env.T3_PROBE_HANG_USAGE === "true") return;',
" 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 +156,36 @@ 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",
description: "Review changes",
input: { hint: "[path]" },
},
],
});

const capabilitiesWithHungUsage = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: executablePath }),
{
...process.env,
T3_PROBE_INVOCATION_PATH: invocationPath,
T3_PROBE_HANG_USAGE: "true",
},
workspaceCwd,
25,
);

assert.deepEqual(capabilitiesWithHungUsage, {
email: "dev@example.com",
subscriptionType: "pro",
tokenSource: "oauth",
apiProvider: undefined,
rateLimits: undefined,
slashCommands: [
{
name: "review",
Expand Down
45 changes: 44 additions & 1 deletion apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as NodeTimersPromises from "node:timers/promises";

import {
type ClaudeSettings,
type ModelCapabilities,
Expand Down Expand Up @@ -42,6 +44,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 @@ -583,6 +586,27 @@ function apiProviderAuthMetadata(
// account info. The previous 8s budget expired mid-init, so the probe returned
// `undefined` and left the provider unverified and unselectable in the picker.
const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000;
const USAGE_PROBE_TIMEOUT_MS = 2_000;
const CAPABILITIES_PROBE_RETURN_BUFFER_MS = 100;

async function optionalPromiseWithin<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
if (timeoutMs <= 0) {
void promise.catch(() => undefined);
return null;
}

const timeoutAbort = new AbortController();
try {
return await Promise.race([
promise.catch(() => null),
NodeTimersPromises.setTimeout(timeoutMs, null, { signal: timeoutAbort.signal }).catch(
() => null,
),
]);
} finally {
timeoutAbort.abort();
}
}

/**
* Keep workspace-scoped command discovery intact while isolating the periodic
Expand Down Expand Up @@ -641,6 +665,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 +749,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 All @@ -733,6 +759,7 @@ const probeClaudeCapabilities = (
claudeSettings: ClaudeSettings,
environment?: NodeJS.ProcessEnv,
cwd?: string,
usageTimeoutMs = USAGE_PROBE_TIMEOUT_MS,
) => {
const abort = new AbortController();
return Effect.gen(function* () {
Expand All @@ -742,6 +769,7 @@ const probeClaudeCapabilities = (
claudeEnvironment,
);
return yield* Effect.tryPromise(async () => {
const startedAt = performance.now();
const q = claudeQuery({
// Never yield — we only need initialization data, not a conversation.
// This prevents any prompt from reaching the Anthropic API.
Expand All @@ -765,11 +793,24 @@ const probeClaudeCapabilities = (
readonly apiProvider?: string;
}
| undefined;
const remainingProbeMs =
CAPABILITIES_PROBE_TIMEOUT_MS -
(performance.now() - startedAt) -
CAPABILITIES_PROBE_RETURN_BUFFER_MS;
const boundedUsageTimeoutMs = Math.min(usageTimeoutMs, remainingProbeMs);
const usage =
boundedUsageTimeoutMs > 0
? await optionalPromiseWithin(
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
boundedUsageTimeoutMs,
)
: 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 +994,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