From 06955d656146023100045c0a76c59b7710954cdf Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Wed, 22 Jul 2026 21:49:54 -0500 Subject: [PATCH 1/9] feat: show provider usage limits --- .../src/provider/Drivers/ClaudeDriver.ts | 11 + .../src/provider/Layers/ClaudeProvider.ts | 54 ++ .../src/provider/Layers/CodexProvider.ts | 16 +- .../provider/Layers/ProviderRegistry.test.ts | 89 +++- .../src/provider/providerSnapshot.test.ts | 25 + apps/server/src/provider/providerSnapshot.ts | 3 + .../src/provider/providerUsageLimits.test.ts | 86 ++++ .../src/provider/providerUsageLimits.ts | 145 ++++++ apps/web/src/components/chat/ChatComposer.tsx | 12 + .../components/chat/ContextWindowMeter.tsx | 15 + .../providerUsage/ProviderUsageRows.test.tsx | 52 ++ .../providerUsage/ProviderUsageRows.tsx | 92 ++++ .../settings/ProviderInstanceCard.tsx | 16 + .../components/settings/SettingsPanels.tsx | 483 ++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 5 + packages/contracts/src/server.test.ts | 24 + packages/contracts/src/server.ts | 20 + packages/contracts/src/settings.test.ts | 10 + packages/contracts/src/settings.ts | 4 + 19 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/providerUsageLimits.test.ts create mode 100644 apps/server/src/provider/providerUsageLimits.ts create mode 100644 apps/web/src/components/providerUsage/ProviderUsageRows.test.tsx create mode 100644 apps/web/src/components/providerUsage/ProviderUsageRows.tsx diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e518..7f9818a96bc 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -33,6 +33,7 @@ import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, + probeClaudeUsageLimits, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -162,12 +163,22 @@ export const ClaudeDriver: ProviderDriver = { ), }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const usageProbeCache = yield* Cache.make({ + capacity: 1, + timeToLive: CAPABILITIES_PROBE_TTL, + lookup: () => + probeClaudeUsageLimits(effectiveConfig, processEnv).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + ), + }); const checkProvider = checkClaudeProviderStatus( effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, cwd, + () => Cache.get(usageProbeCache, capabilitiesCacheKey), ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 0e019f003c7..1c9c3a2f3d5 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,6 +4,7 @@ import { type ModelSelection, type ServerProviderModel, type ServerProviderSlashCommand, + type ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -42,6 +43,7 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { parseClaudeUsageLimitsJson } from "../providerUsageLimits.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -571,6 +573,7 @@ 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 = 4_000; /** * Keep workspace-scoped command discovery intact while isolating the periodic @@ -776,6 +779,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, environment?: NodeJS.ProcessEnv, + options?: { readonly closeStdin?: boolean }, ) { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { @@ -784,10 +788,52 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: claudeEnvironment, shell: spawnCommand.shell, + ...(options?.closeStdin ? { stdin: "ignore" as const } : {}), }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); +export const probeClaudeUsageLimits = Effect.fn("probeClaudeUsageLimits")(function* ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderUsageLimits | undefined, + never, + ChildProcessSpawner.ChildProcessSpawner | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return yield* runClaudeCommand( + claudeSettings, + [ + "--print", + "/usage", + "--output-format", + "json", + "--permission-mode", + "plan", + "--strict-mcp-config", + "--mcp-config", + '{"mcpServers":{}}', + ], + { + ...(environment ?? process.env), + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + { closeStdin: true }, + ).pipe( + Effect.timeoutOption(USAGE_PROBE_TIMEOUT_MS), + Effect.map( + Option.flatMap((result) => + result.code === 0 + ? Option.fromUndefinedOr(parseClaudeUsageLimitsJson(result.stdout, checkedAt)) + : Option.none(), + ), + ), + Effect.catchCause(() => Effect.succeed(Option.none())), + Effect.map(Option.getOrUndefined), + ); +}); + export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( claudeSettings: ClaudeSettings, resolveCapabilities?: ( @@ -795,6 +841,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) => Effect.Effect, environment?: NodeJS.ProcessEnv, cwd?: string, + resolveUsage?: () => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -937,6 +984,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + const usageLimits = + authMetadata?.type === "apiKey" || authMetadata?.type === "bedrock" + ? undefined + : yield* resolveUsage + ? resolveUsage().pipe(Effect.catchCause(() => Effect.void)) + : probeClaudeUsageLimits(claudeSettings, resolvedEnvironment); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -953,6 +1006,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(capabilities.email ? { email: capabilities.email } : {}), ...(authMetadata ? authMetadata : {}), }, + ...(usageLimits ? { usageLimits } : {}), ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), }, }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e..0f9357163f1 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -33,6 +33,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; +import { usageLimitsFromCodexRateLimits } from "../providerUsageLimits.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -45,6 +46,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -395,18 +397,26 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + accountResponse.account?.type === "chatgpt" + ? client + .request("account/rateLimits/read", undefined) + .pipe(Effect.option, Effect.map(Option.getOrUndefined)) + : Effect.void.pipe( + Effect.as(undefined as CodexSchema.V2GetAccountRateLimitsResponse | undefined), + ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + ...(rateLimits ? { rateLimits } : {}), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -594,6 +604,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); + const usageLimits = snapshot.rateLimits + ? usageLimitsFromCodexRateLimits(snapshot.rateLimits, checkedAt) + : undefined; return buildServerProvider({ presentation: CODEX_PRESENTATION, @@ -606,6 +619,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu version: snapshot.version ?? null, status: accountStatus.status, auth: accountStatus.auth, + ...(usageLimits ? { usageLimits } : {}), ...(accountStatus.message ? { message: accountStatus.message } : {}), }, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c78ecb3952a..d3b401c57e0 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -386,6 +386,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("includes Codex subscription usage from the app-server snapshot", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + rateLimits: { + rateLimits: { + primary: { usedPercent: 20, windowDurationMins: 300 }, + secondary: { usedPercent: 40, windowDurationMins: 10_080 }, + }, + }, + }), + ), + ); + + assert.deepStrictEqual(status.usageLimits?.windows, [ + { label: "Session", usedPercent: 20, windowDurationMins: 300 }, + { label: "Weekly", usedPercent: 40, windowDurationMins: 10_080 }, + ]); + }), + ); + it.effect("passes configured launch args to the Codex provider probe", () => Effect.gen(function* () { let observedLaunchArgs: string | undefined; @@ -1862,6 +1884,43 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes best-effort Claude subscription usage", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-22T12:00:00.000Z")); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.deepStrictEqual(status.usageLimits?.windows, [ + { + label: "Session", + usedPercent: 30, + windowDurationMins: 300, + resetsAt: "2026-07-23T06:30:00.000Z", + }, + ]); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined.startsWith("--print /usage --output-format json")) { + return { + stdout: JSON.stringify({ + result: + "Current session: 30% used \u00b7 resets Jul 23, 1:30am (America/Chicago)", + }), + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => Effect.gen(function* () { // Bedrock authenticates via external AWS credentials, so the SDK init @@ -2194,7 +2253,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(status.status, "ready"); assert.deepStrictEqual( recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), - [claudeConfigDir], + [claudeConfigDir, claudeConfigDir], ); }).pipe(Effect.provide(recorded.layer)); }); @@ -2309,6 +2368,34 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("skips Claude subscription usage for API key auth", () => + Effect.gen(function* () { + let usageProbeCalls = 0; + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), + undefined, + () => { + usageProbeCalls += 1; + return Effect.void.pipe(Effect.as(undefined as ServerProvider["usageLimits"])); + }, + ); + + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.usageLimits, undefined); + assert.strictEqual(usageProbeCalls, 0); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + if (args.join(" ") === "--version") { + return { stdout: "2.1.218\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${args.join(" ")}`); + }), + ), + ), + ); + it.effect("returns unavailable when claude is missing", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 01157278066..bb07aaf967d 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -10,11 +10,36 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { + buildServerProvider, isCommandMissingCause, providerModelsFromSettings, spawnAndCollect, } from "./providerSnapshot.ts"; +describe("buildServerProvider", () => { + it("includes usage limits from the provider probe", () => { + const snapshot = buildServerProvider({ + presentation: { displayName: "Codex" }, + enabled: true, + checkedAt: "2026-07-22T12:00:00.000Z", + models: [], + probe: { + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + usageLimits: { + source: "codexAppServer", + checkedAt: "2026-07-22T12:00:00.000Z", + windows: [{ label: "Session", usedPercent: 30 }], + }, + }, + }); + + expect(snapshot.usageLimits?.windows[0]?.usedPercent).toBe(30); + }); +}); + const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d..f498fc1025a 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -7,6 +7,7 @@ import type { ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as PlatformError from "effect/PlatformError"; @@ -50,6 +51,7 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + readonly usageLimits?: ServerProviderUsageLimits; } export interface ServerProviderPresentation { @@ -243,6 +245,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts new file mode 100644 index 00000000000..0200b47ae12 --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + parseClaudeUsageLimitsJson, + usageLimitsFromCodexRateLimits, +} from "./providerUsageLimits.ts"; + +describe("usageLimitsFromCodexRateLimits", () => { + it("maps primary and secondary windows", () => { + expect( + usageLimitsFromCodexRateLimits( + { + rateLimits: { + primary: { usedPercent: 25, windowDurationMins: 300, resetsAt: 1_774_000_000 }, + secondary: { usedPercent: 40, windowDurationMins: 10_080, resetsAt: 1_775_000_000 }, + }, + }, + "2026-03-20T00:00:00.000Z", + ), + ).toEqual({ + source: "codexAppServer", + checkedAt: "2026-03-20T00:00:00.000Z", + windows: [ + { + label: "Session", + usedPercent: 25, + windowDurationMins: 300, + resetsAt: "2026-03-20T09:46:40.000Z", + }, + { + label: "Weekly", + usedPercent: 40, + windowDurationMins: 10_080, + resetsAt: "2026-03-31T23:33:20.000Z", + }, + ], + }); + }); +}); + +describe("parseClaudeUsageLimitsJson", () => { + it("parses dynamic usage windows from the JSON result", () => { + const output = JSON.stringify({ + result: [ + "Current session: 30% used \u00b7 resets Jul 23, 1:30am (America/Chicago)", + "Current week (all models): 16% used \u00b7 resets Jul 28, 1am (America/Chicago)", + "Current week (Fable): 26% used \u00b7 resets Jul 28, 1am (America/Chicago)", + ].join("\n"), + }); + + expect(parseClaudeUsageLimitsJson(output, "2026-07-22T12:00:00.000Z")).toEqual({ + source: "claudePrint", + checkedAt: "2026-07-22T12:00:00.000Z", + windows: [ + { + label: "Session", + usedPercent: 30, + windowDurationMins: 300, + resetsAt: "2026-07-23T06:30:00.000Z", + }, + { + label: "Weekly (all models)", + usedPercent: 16, + windowDurationMins: 10_080, + resetsAt: "2026-07-28T06:00:00.000Z", + }, + { + label: "Weekly (Fable)", + usedPercent: 26, + windowDurationMins: 10_080, + resetsAt: "2026-07-28T06:00:00.000Z", + }, + ], + }); + }); + + it("fails closed for malformed or changed output", () => { + expect(parseClaudeUsageLimitsJson("not json", "2026-07-22T12:00:00.000Z")).toBeUndefined(); + expect( + parseClaudeUsageLimitsJson( + JSON.stringify({ result: "Your limits look healthy." }), + "2026-07-22T12:00:00.000Z", + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts new file mode 100644 index 00000000000..fc2e8a00f2e --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -0,0 +1,145 @@ +import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; +import type * as CodexSchema from "effect-codex-app-server/schema"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +const MONTHS = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", +] as const; + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function codexWindowLabel(windowDurationMins: number | null | undefined): string { + return windowDurationMins !== undefined && + windowDurationMins !== null && + windowDurationMins >= 7 * 24 * 60 + ? "Weekly" + : "Session"; +} + +function mapCodexWindow( + window: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null | undefined, +): ServerProviderUsageWindow | undefined { + if (!window) return undefined; + return { + label: codexWindowLabel(window.windowDurationMins), + usedPercent: clampPercent(window.usedPercent), + ...(window.windowDurationMins !== undefined && window.windowDurationMins !== null + ? { windowDurationMins: Math.max(0, window.windowDurationMins) } + : {}), + ...(window.resetsAt !== undefined && window.resetsAt !== null + ? { resetsAt: DateTime.formatIso(DateTime.makeUnsafe(window.resetsAt * 1000)) } + : {}), + }; +} + +export function usageLimitsFromCodexRateLimits( + response: CodexSchema.V2GetAccountRateLimitsResponse, + checkedAt: string, +): ServerProviderUsageLimits | undefined { + const windows = [ + mapCodexWindow(response.rateLimits.primary), + mapCodexWindow(response.rateLimits.secondary), + ].filter((window): window is ServerProviderUsageWindow => window !== undefined); + return windows.length > 0 ? { source: "codexAppServer", checkedAt, windows } : undefined; +} + +function parseClaudeReset(input: { + readonly month: string; + readonly day: string; + readonly hour: string; + readonly minute: string | undefined; + readonly meridiem: string; + readonly timeZone: string; + readonly checkedAt: string; +}): string | undefined { + const month = + MONTHS.indexOf(input.month.toLowerCase().slice(0, 3) as (typeof MONTHS)[number]) + 1; + if (month === 0) return undefined; + const checked = DateTime.make(input.checkedAt); + if (Option.isNone(checked)) return undefined; + const checkedParts = DateTime.toPartsUtc(checked.value); + const day = Number.parseInt(input.day, 10); + let hour = Number.parseInt(input.hour, 10); + if ( + !Number.isFinite(day) || + !Number.isFinite(hour) || + day < 1 || + day > 31 || + hour < 1 || + hour > 12 + ) { + return undefined; + } + if (hour === 12) hour = 0; + if (input.meridiem.toLowerCase() === "pm") hour += 12; + const year = checkedParts.month === 12 && month === 1 ? checkedParts.year + 1 : checkedParts.year; + const localDateTime = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} ${String(hour).padStart(2, "0")}:${input.minute ?? "00"}:00`; + const reset = DateTime.makeZoned(localDateTime, { + timeZone: input.timeZone, + adjustForTimeZone: true, + }); + return Option.isSome(reset) ? DateTime.formatIso(reset.value) : undefined; +} + +export function parseClaudeUsageLimitsJson( + output: string, + checkedAt: string, +): ServerProviderUsageLimits | undefined { + let result: string; + try { + const decoded: unknown = JSON.parse(output); + if ( + typeof decoded !== "object" || + decoded === null || + typeof (decoded as { result?: unknown }).result !== "string" + ) { + return undefined; + } + result = (decoded as { result: string }).result; + } catch { + return undefined; + } + + const windows: ServerProviderUsageWindow[] = []; + const pattern = + /^Current (session|week(?: \([^)]+\))?):\s*(\d{1,3}(?:\.\d+)?)% used\s*[\u00b7-]\s*resets ([A-Za-z]{3,9}) (\d{1,2}), (\d{1,2})(?::(\d{2}))?(am|pm) \(([^)]+)\)$/gim; + for (const match of result.matchAll(pattern)) { + const [, rawLabel, percent, month, day, hour, minute, meridiem, timeZone] = match; + if (!rawLabel || !percent || !month || !day || !hour || !meridiem || !timeZone) continue; + const usedPercent = Number.parseFloat(percent); + if (!Number.isFinite(usedPercent)) continue; + const isSession = rawLabel.toLowerCase() === "session"; + const suffix = rawLabel.match(/\(([^)]+)\)/)?.[1]; + const resetsAt = parseClaudeReset({ + month, + day, + hour, + minute, + meridiem, + timeZone, + checkedAt, + }); + windows.push({ + label: isSession ? "Session" : suffix ? `Weekly (${suffix})` : "Weekly", + usedPercent: clampPercent(usedPercent), + windowDurationMins: isSession ? 5 * 60 : 7 * 24 * 60, + ...(resetsAt ? { resetsAt } : {}), + }); + } + + return windows.length > 0 ? { source: "claudePrint", checkedAt, windows } : undefined; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b8bacf4b6be..04fa4b634fc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -390,6 +390,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compact: boolean; activeContextWindow: ReturnType; activeThreadProviderDisplayName: string | null; + activeProviderUsageLimits: ServerProvider["usageLimits"] | undefined; + timestampFormat: UnifiedSettings["timestampFormat"]; isPreparingWorktree: boolean; pendingAction: { questionIndex: number; @@ -417,6 +419,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} {props.isPreparingWorktree ? ( @@ -928,6 +932,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return formatProviderDisplayName(activeThreadModelSelection.instanceId); }, [providerStatuses, activeThreadModelSelection]); + const activeThreadProviderInstanceId = + activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; + const activeProviderUsageLimits = settings.showProviderUsageInContextPopover + ? providerStatuses.find((provider) => provider.instanceId === activeThreadProviderInstanceId) + ?.usageLimits + : undefined; // ------------------------------------------------------------------ // Composer-local state @@ -3186,6 +3196,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compact={isComposerPrimaryActionsCompact} activeContextWindow={activeContextWindow} activeThreadProviderDisplayName={activeThreadProviderDisplayName} + activeProviderUsageLimits={activeProviderUsageLimits} + timestampFormat={settings.timestampFormat} pendingAction={pendingPrimaryAction} isRunning={phase === "running"} showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 752ec401376..d0239544af2 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,6 +1,9 @@ import { cn } from "~/lib/utils"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { ProviderUsageRows } from "../providerUsage/ProviderUsageRows"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -15,6 +18,8 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; providerDisplayName?: string | null; + providerUsageLimits?: ServerProviderUsageLimits | undefined; + timestampFormat: TimestampFormat; }) { const { usage, providerDisplayName } = props; const usedPercentage = formatPercentage(usage.usedPercentage); @@ -133,6 +138,16 @@ export function ContextWindowMeter(props: { {providerDisplayName ?? "It"} automatically compacts its context when needed. ) : null} + {props.providerUsageLimits ? ( +
+
Provider limits
+ +
+ ) : null} diff --git a/apps/web/src/components/providerUsage/ProviderUsageRows.test.tsx b/apps/web/src/components/providerUsage/ProviderUsageRows.test.tsx new file mode 100644 index 00000000000..0aca75a12ff --- /dev/null +++ b/apps/web/src/components/providerUsage/ProviderUsageRows.test.tsx @@ -0,0 +1,52 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderUsageRows, ProviderUsageSummary } from "./ProviderUsageRows"; + +describe("ProviderUsageRows", () => { + it("renders dynamic windows with explicit remaining percentages", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Session"); + expect(markup).toContain("70% remaining"); + expect(markup).toContain("Weekly (Fable)"); + expect(markup).toContain("74% remaining"); + }); + + it("renders multiple windows as one compact remaining summary", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Session"); + expect(markup).toContain("16%"); + expect(markup).toContain("Weekly (all models)"); + expect(markup).toContain("80%"); + expect(markup).toContain("Weekly (Fable)"); + expect(markup).toContain("68%"); + expect(markup).toContain("remaining"); + expect(markup).toContain("·"); + }); +}); diff --git a/apps/web/src/components/providerUsage/ProviderUsageRows.tsx b/apps/web/src/components/providerUsage/ProviderUsageRows.tsx new file mode 100644 index 00000000000..2ad28a8d24f --- /dev/null +++ b/apps/web/src/components/providerUsage/ProviderUsageRows.tsx @@ -0,0 +1,92 @@ +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; +import { getTimestampFormatOptions, parseTimestampDate } from "~/timestampFormat"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; + +function usageColor(usedPercent: number): string { + if (usedPercent >= 90) return "bg-red-500"; + if (usedPercent >= 70) return "bg-amber-500"; + return "bg-blue-500"; +} + +function formatResetTimestamp(resetsAt: string, timestampFormat: TimestampFormat): string { + const date = parseTimestampDate(resetsAt); + if (!date) return ""; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + ...getTimestampFormatOptions(timestampFormat, false), + }).format(date); +} + +export function ProviderUsageRows(props: { + readonly usageLimits: ServerProviderUsageLimits; + readonly timestampFormat: TimestampFormat; + readonly compact?: boolean; +}) { + return ( +
+ {props.usageLimits.windows.map((window) => { + const usedPercent = Math.max(0, Math.min(100, window.usedPercent)); + const remainingPercent = Math.max(0, Math.round(100 - usedPercent)); + const resetLabel = window.resetsAt + ? formatResetTimestamp(window.resetsAt, props.timestampFormat) + : ""; + return ( +
+
+ {window.label} + + {remainingPercent}% remaining + +
+
+
+
+ {resetLabel ? ( +
Resets at {resetLabel}
+ ) : null} +
+ ); + })} +
+ ); +} + +export function ProviderUsageSummary(props: { readonly usageLimits: ServerProviderUsageLimits }) { + const summaryItems = props.usageLimits.windows.map((window) => ({ + key: `${window.label}:${window.windowDurationMins ?? "unknown"}:${window.resetsAt ?? "unknown"}`, + label: window.label, + remainingPercent: Math.max(0, Math.round(100 - window.usedPercent)), + })); + return ( +

+ {summaryItems.map((item, index) => ( + + {index > 0 ? · : null} + {item.label}{" "} + {item.remainingPercent}% + + ))} + remaining +

+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 17b1ebdf33d..54ec27e4e10 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -22,6 +22,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; import { cn } from "../../lib/utils"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; @@ -43,6 +44,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { ProviderUsageRows, ProviderUsageSummary } from "../providerUsage/ProviderUsageRows"; import { getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, @@ -349,6 +351,7 @@ interface ProviderInstanceCardProps { readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; readonly isUpdating?: boolean | undefined; + readonly timestampFormat: TimestampFormat; } /** @@ -393,6 +396,7 @@ export function ProviderInstanceCard({ onModelOrderChange, onRunUpdate, isUpdating = false, + timestampFormat, }: ProviderInstanceCardProps) { const enabled = instance.enabled ?? true; // The server-reported status wins when present; otherwise fall back to @@ -705,6 +709,9 @@ export function ProviderInstanceCard({ {titleTailNode}
{authRowNode} + {enabled && liveProvider?.usageLimits ? ( + + ) : null}
+ } + /> + Add provider instance + + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + +
+ } + > + {rows.map((row) => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + const headerAction = + row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null; + return ( + + setOpenInstanceDetails((existing) => ({ + ...existing, + [row.instanceId]: open, + })) + } + onUpdate={(next) => { + const wasEnabled = row.instance.enabled ?? true; + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + if (shouldClearTextGen) { + updateProviderInstance(row, next, { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + } else { + updateProviderInstance(row, next); + } + }} + onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + headerAction={headerAction} + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) + } + onFavoriteModelsChange={(favoriteModels) => + updateProviderFavoriteModels(row.instanceId, favoriteModels) + } + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + })} + + + {isAddInstanceDialogOpen ? ( + + ) : null} + + ); +} + +>>>>>>> c998c5016 (feat: show provider usage limits) export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 2dcdd66f136..d913a8f44cd 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -120,6 +120,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Provider update checks", to: "/settings/general", }, + { + id: "provider-usage-in-chat", + title: "Provider usage in chat", + to: "/settings/general", + }, { id: "new-threads", title: "New threads", diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c..86cd6a1866c 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -115,6 +115,30 @@ describe("ServerProvider", () => { expect(parsed.models[0]?.isLegacy).toBe(true); }); + + it("decodes dynamic provider usage windows", () => { + const parsed = decodeServerProvider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: true, + installed: true, + version: "2.1.218", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-22T12:00:00.000Z", + models: [], + usageLimits: { + source: "claudePrint", + checkedAt: "2026-07-22T12:00:00.000Z", + windows: [ + { label: "Session", usedPercent: 30, windowDurationMins: 300 }, + { label: "Weekly (Fable)", usedPercent: 26, windowDurationMins: 10_080 }, + ], + }, + }); + + expect(parsed.usageLimits?.windows).toHaveLength(2); + }); }); describe("server config forward compatibility", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d7bc4c5c189..80e5e6ce168 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -96,6 +96,25 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +const ServerProviderUsagePercent = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)).check( + Schema.isLessThanOrEqualTo(100), +); + +export const ServerProviderUsageWindow = Schema.Struct({ + label: TrimmedNonEmptyString, + usedPercent: ServerProviderUsagePercent, + resetsAt: Schema.optional(IsoDateTime), + windowDurationMins: Schema.optional(NonNegativeInt), +}); +export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; + +export const ServerProviderUsageLimits = Schema.Struct({ + source: Schema.Literals(["codexAppServer", "claudePrint"]), + checkedAt: IsoDateTime, + windows: Schema.Array(ServerProviderUsageWindow), +}); +export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; + /** * Availability of a configured provider instance from the runtime's POV. * @@ -192,6 +211,7 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + usageLimits: Schema.optional(ServerProviderUsageLimits), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5bd22e95f20..43024ef3fa9 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -111,6 +111,16 @@ describe("ClientSettings sidebar v2", () => { }); }); +describe("ClientSettings provider usage", () => { + it("keeps context-popover usage opt-in", () => { + expect(decodeClientSettings({}).showProviderUsageInContextPopover).toBe(false); + expect( + decodeClientSettings({ showProviderUsageInContextPopover: true }) + .showProviderUsageInContextPopover, + ).toBe(true); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4b477227f26..a88b034a590 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -200,6 +200,9 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + showProviderUsageInContextPopover: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -797,6 +800,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), sidebarV2ConfiguredByUser: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), + showProviderUsageInContextPopover: Schema.optionalKey(Schema.Boolean), wordWrap: Schema.optionalKey(Schema.Boolean), }); export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; From e0582b8d0bba89ef1f6fe181956b5d5a08caf681 Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Wed, 22 Jul 2026 22:00:18 -0500 Subject: [PATCH 2/9] fix: resolve Claude reset year in local zone --- apps/server/src/provider/providerUsageLimits.test.ts | 10 ++++++++++ apps/server/src/provider/providerUsageLimits.ts | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts index 0200b47ae12..bfe42fdeccc 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -83,4 +83,14 @@ describe("parseClaudeUsageLimitsJson", () => { ), ).toBeUndefined(); }); + + it("uses the reset zone's local year around the UTC new-year boundary", () => { + const output = JSON.stringify({ + result: "Current session: 30% used \u00b7 resets Dec 31, 11pm (America/Los_Angeles)", + }); + + expect( + parseClaudeUsageLimitsJson(output, "2027-01-01T00:30:00.000Z")?.windows[0]?.resetsAt, + ).toBe("2027-01-01T07:00:00.000Z"); + }); }); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index fc2e8a00f2e..c622cdcdfef 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -71,7 +71,9 @@ function parseClaudeReset(input: { if (month === 0) return undefined; const checked = DateTime.make(input.checkedAt); if (Option.isNone(checked)) return undefined; - const checkedParts = DateTime.toPartsUtc(checked.value); + const checkedInResetZone = DateTime.setZoneNamed(checked.value, input.timeZone); + if (Option.isNone(checkedInResetZone)) return undefined; + const checkedParts = DateTime.toParts(checkedInResetZone.value); const day = Number.parseInt(input.day, 10); let hour = Number.parseInt(input.hour, 10); if ( From 3025723895939f62a4a924622c15faf55e9a5eab Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Wed, 22 Jul 2026 22:05:42 -0500 Subject: [PATCH 3/9] fix: harden Claude usage refresh --- .../src/provider/Drivers/ClaudeDriver.ts | 19 +++++++- .../src/provider/Layers/ClaudeProvider.ts | 6 ++- .../provider/Layers/ProviderRegistry.test.ts | 46 ++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 7f9818a96bc..b8e78a08802 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -61,6 +62,13 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); +export function preserveLastAvailableClaudeUsage( + previous: ServerProvider["usageLimits"] | undefined, + next: ServerProvider["usageLimits"] | undefined, +): ServerProvider["usageLimits"] | undefined { + return next ?? previous; +} + function isClaudeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -163,13 +171,22 @@ export const ClaudeDriver: ProviderDriver = { ), }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const lastAvailableUsageRef = yield* Ref.make( + undefined, + ); const usageProbeCache = yield* Cache.make({ capacity: 1, timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => - probeClaudeUsageLimits(effectiveConfig, processEnv).pipe( + probeClaudeUsageLimits(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), + Effect.flatMap((usageLimits) => + Ref.modify(lastAvailableUsageRef, (previous) => { + const next = preserveLastAvailableClaudeUsage(previous, usageLimits); + return [next, next] as const; + }), + ), ), }); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 1c9c3a2f3d5..a5b0f897bf5 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -779,7 +779,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, environment?: NodeJS.ProcessEnv, - options?: { readonly closeStdin?: boolean }, + options?: { readonly closeStdin?: boolean; readonly cwd?: string }, ) { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { @@ -788,6 +788,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: claudeEnvironment, shell: spawnCommand.shell, + ...(options?.cwd ? { cwd: options.cwd } : {}), ...(options?.closeStdin ? { stdin: "ignore" as const } : {}), }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); @@ -796,6 +797,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( export const probeClaudeUsageLimits = Effect.fn("probeClaudeUsageLimits")(function* ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ): Effect.fn.Return< ServerProviderUsageLimits | undefined, never, @@ -819,7 +821,7 @@ export const probeClaudeUsageLimits = Effect.fn("probeClaudeUsageLimits")(functi ...(environment ?? process.env), ENABLE_CLAUDEAI_MCP_SERVERS: "false", }, - { closeStdin: true }, + { closeStdin: true, ...(cwd ? { cwd } : {}) }, ).pipe( Effect.timeoutOption(USAGE_PROBE_TIMEOUT_MS), Effect.map( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index d3b401c57e0..eab3b8b7461 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -32,7 +32,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; +import { checkClaudeProviderStatus, probeClaudeUsageLimits } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; @@ -47,6 +47,7 @@ import { import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; +import { preserveLastAvailableClaudeUsage } from "../Drivers/ClaudeDriver.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -194,6 +195,7 @@ function recordingMockSpawnerLayer( const commands: Array<{ readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv | undefined; + readonly cwd: string | undefined; }> = []; const layer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -202,9 +204,10 @@ function recordingMockSpawnerLayer( args: ReadonlyArray; options?: { readonly env?: NodeJS.ProcessEnv; + readonly cwd?: string; }; }; - commands.push({ args: cmd.args, env: cmd.options?.env }); + commands.push({ args: cmd.args, env: cmd.options?.env, cwd: cmd.options?.cwd }); return Effect.succeed(mockHandle(handler(cmd.args))); }), ); @@ -1858,6 +1861,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // ── checkClaudeProviderStatus tests ────────────────────────── describe("checkClaudeProviderStatus", () => { + it("preserves the last available Claude usage across a transient miss", () => { + const available: ServerProvider["usageLimits"] = { + source: "claudePrint", + checkedAt: "2026-07-22T12:00:00.000Z", + windows: [{ label: "Session", usedPercent: 30 }], + }; + + assert.strictEqual(preserveLastAvailableClaudeUsage(available, undefined), available); + assert.strictEqual(preserveLastAvailableClaudeUsage(undefined, available), available); + }); + it.effect("returns ready when claude is installed and authenticated", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( @@ -1921,6 +1935,34 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("runs the Claude usage probe from the configured workspace cwd", () => { + const recorded = recordingMockSpawnerLayer((args) => { + if (args.join(" ").startsWith("--print /usage --output-format json")) { + return { + stdout: JSON.stringify({ + result: "Current session: 30% used \u00b7 resets Jul 23, 1:30am (America/Chicago)", + }), + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${args.join(" ")}`); + }); + + return Effect.gen(function* () { + const usageLimits = yield* probeClaudeUsageLimits( + defaultClaudeSettings, + undefined, + "/tmp/provider-usage-workspace", + ); + assert.strictEqual(usageLimits?.windows[0]?.usedPercent, 30); + assert.deepStrictEqual( + recorded.commands.map((command) => command.cwd), + ["/tmp/provider-usage-workspace"], + ); + }).pipe(Effect.provide(recorded.layer)); + }); + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => Effect.gen(function* () { // Bedrock authenticates via external AWS credentials, so the SDK init From c1a2c9ae62851be061240a3bb8ff3a27efa6c9b1 Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Wed, 22 Jul 2026 22:12:15 -0500 Subject: [PATCH 4/9] fix: scope retained Claude usage to account --- .../src/provider/Drivers/ClaudeDriver.ts | 50 +++++++++++++------ .../src/provider/Layers/ClaudeProvider.ts | 10 ++-- .../provider/Layers/ProviderRegistry.test.ts | 20 ++++++-- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b8e78a08802..67d399fc33d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -62,11 +62,28 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); -export function preserveLastAvailableClaudeUsage( - previous: ServerProvider["usageLimits"] | undefined, +interface RetainedClaudeUsage { + readonly accountIdentity: string; + readonly usageLimits: NonNullable; +} + +export function resolveRetainedClaudeUsage( + previous: RetainedClaudeUsage | undefined, + accountIdentity: string | undefined, next: ServerProvider["usageLimits"] | undefined, -): ServerProvider["usageLimits"] | undefined { - return next ?? previous; +): { + readonly retained: RetainedClaudeUsage | undefined; + readonly usageLimits: ServerProvider["usageLimits"] | undefined; +} { + if (!accountIdentity) { + return { retained: undefined, usageLimits: next }; + } + if (next) { + return { retained: { accountIdentity, usageLimits: next }, usageLimits: next }; + } + return previous?.accountIdentity === accountIdentity + ? { retained: previous, usageLimits: previous.usageLimits } + : { retained: undefined, usageLimits: undefined }; } function isClaudeNativeCommandPath(commandPath: string): boolean { @@ -171,22 +188,14 @@ export const ClaudeDriver: ProviderDriver = { ), }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - const lastAvailableUsageRef = yield* Ref.make( - undefined, - ); + const lastAvailableUsageRef = yield* Ref.make(undefined); const usageProbeCache = yield* Cache.make({ - capacity: 1, + capacity: 2, timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => probeClaudeUsageLimits(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), - Effect.flatMap((usageLimits) => - Ref.modify(lastAvailableUsageRef, (previous) => { - const next = preserveLastAvailableClaudeUsage(previous, usageLimits); - return [next, next] as const; - }), - ), ), }); @@ -195,7 +204,18 @@ export const ClaudeDriver: ProviderDriver = { () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, cwd, - () => Cache.get(usageProbeCache, capabilitiesCacheKey), + (accountIdentity) => + Cache.get( + usageProbeCache, + `${capabilitiesCacheKey}:${accountIdentity ?? "unknown"}`, + ).pipe( + Effect.flatMap((usageLimits) => + Ref.modify(lastAvailableUsageRef, (previous) => { + const resolved = resolveRetainedClaudeUsage(previous, accountIdentity, usageLimits); + return [resolved.usageLimits, resolved.retained] as const; + }), + ), + ), ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index a5b0f897bf5..c4493a32eb6 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -843,7 +843,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) => Effect.Effect, environment?: NodeJS.ProcessEnv, cwd?: string, - resolveUsage?: () => Effect.Effect, + resolveUsage?: ( + accountIdentity: string | undefined, + ) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -990,8 +992,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( authMetadata?.type === "apiKey" || authMetadata?.type === "bedrock" ? undefined : yield* resolveUsage - ? resolveUsage().pipe(Effect.catchCause(() => Effect.void)) - : probeClaudeUsageLimits(claudeSettings, resolvedEnvironment); + ? resolveUsage(capabilities.email?.trim() || undefined).pipe( + Effect.catchCause(() => Effect.void), + ) + : probeClaudeUsageLimits(claudeSettings, resolvedEnvironment, cwd); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index eab3b8b7461..fb8afccc8cb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -47,7 +47,7 @@ import { import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; -import { preserveLastAvailableClaudeUsage } from "../Drivers/ClaudeDriver.ts"; +import { resolveRetainedClaudeUsage } from "../Drivers/ClaudeDriver.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -1861,15 +1861,27 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // ── checkClaudeProviderStatus tests ────────────────────────── describe("checkClaudeProviderStatus", () => { - it("preserves the last available Claude usage across a transient miss", () => { + it("preserves Claude usage only for the same known account", () => { const available: ServerProvider["usageLimits"] = { source: "claudePrint", checkedAt: "2026-07-22T12:00:00.000Z", windows: [{ label: "Session", usedPercent: 30 }], }; - assert.strictEqual(preserveLastAvailableClaudeUsage(available, undefined), available); - assert.strictEqual(preserveLastAvailableClaudeUsage(undefined, available), available); + const initial = resolveRetainedClaudeUsage(undefined, "first@example.com", available); + assert.strictEqual(initial.usageLimits, available); + assert.strictEqual( + resolveRetainedClaudeUsage(initial.retained, "first@example.com", undefined).usageLimits, + available, + ); + assert.strictEqual( + resolveRetainedClaudeUsage(initial.retained, "second@example.com", undefined).usageLimits, + undefined, + ); + assert.strictEqual( + resolveRetainedClaudeUsage(initial.retained, undefined, undefined).usageLimits, + undefined, + ); }); it.effect("returns ready when claude is installed and authenticated", () => From d00d0d705ff42b71b1ac608291d32dc2b01889aa Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Thu, 23 Jul 2026 21:11:20 -0500 Subject: [PATCH 5/9] test: align Claude provider probe arguments --- apps/server/src/provider/Layers/ProviderRegistry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb8afccc8cb..bcfa90efff8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2429,6 +2429,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te defaultClaudeSettings, claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), undefined, + undefined, () => { usageProbeCalls += 1; return Effect.void.pipe(Effect.as(undefined as ServerProvider["usageLimits"])); From 5eff2f0e7ffc055cf27bf524b8f565688bda6f18 Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Fri, 24 Jul 2026 20:10:09 -0500 Subject: [PATCH 6/9] fix: harden provider usage refreshes --- apps/server/src/provider/Drivers/ClaudeDriver.ts | 16 +++++++++++----- apps/server/src/provider/Layers/CodexProvider.ts | 8 +++++++- .../src/provider/providerUsageLimits.test.ts | 11 +++++++++++ apps/server/src/provider/providerUsageLimits.ts | 2 +- .../src/components/chat/ContextWindowMeter.tsx | 2 +- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 67d399fc33d..105ebb76b2b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -17,6 +17,7 @@ import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; @@ -189,15 +190,20 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); const lastAvailableUsageRef = yield* Ref.make(undefined); - const usageProbeCache = yield* Cache.make({ - capacity: 2, - timeToLive: CAPABILITIES_PROBE_TTL, - lookup: () => + const usageProbeCache = yield* Cache.makeWith( + () => probeClaudeUsageLimits(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ), - }); + { + capacity: 2, + timeToLive: Exit.match({ + onSuccess: (usageLimits) => (usageLimits ? CAPABILITIES_PROBE_TTL : Duration.zero), + onFailure: () => Duration.zero, + }), + }, + ); const checkProvider = checkClaudeProviderStatus( effectiveConfig, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 0f9357163f1..3245d54d253 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -38,6 +38,7 @@ import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; +const CODEX_RATE_LIMITS_PROBE_TIMEOUT = "2 seconds" as const; const CODEX_PRESENTATION = { displayName: "Codex", @@ -406,7 +407,12 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun accountResponse.account?.type === "chatgpt" ? client .request("account/rateLimits/read", undefined) - .pipe(Effect.option, Effect.map(Option.getOrUndefined)) + .pipe( + Effect.option, + Effect.timeoutOption(CODEX_RATE_LIMITS_PROBE_TIMEOUT), + Effect.map(Option.flatten), + Effect.map(Option.getOrUndefined), + ) : Effect.void.pipe( Effect.as(undefined as CodexSchema.V2GetAccountRateLimitsResponse | undefined), ), diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts index bfe42fdeccc..097e6e66495 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -93,4 +93,15 @@ describe("parseClaudeUsageLimitsJson", () => { parseClaudeUsageLimitsJson(output, "2027-01-01T00:30:00.000Z")?.windows[0]?.resetsAt, ).toBe("2027-01-01T07:00:00.000Z"); }); + + it("parses Claude usage with CRLF line endings", () => { + const output = JSON.stringify({ + result: [ + "Current session: 30% used \u00b7 resets Jul 23, 1:30am (America/Chicago)", + "Current week (all models): 16% used \u00b7 resets Jul 28, 1am (America/Chicago)", + ].join("\r\n"), + }); + + expect(parseClaudeUsageLimitsJson(output, "2026-07-22T12:00:00.000Z")?.windows).toHaveLength(2); + }); }); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index c622cdcdfef..102a934bc41 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -111,7 +111,7 @@ export function parseClaudeUsageLimitsJson( ) { return undefined; } - result = (decoded as { result: string }).result; + result = (decoded as { result: string }).result.replaceAll("\r\n", "\n"); } catch { return undefined; } diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index d0239544af2..fb325c67699 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -89,7 +89,7 @@ export function ContextWindowMeter(props: { tooltipStyle side="top" align="end" - viewportClassName="p-0" + viewportClassName="overflow-y-auto p-0" className="w-64 max-w-none text-left whitespace-normal" >
From 996fbfbdd0f62e3962a2aecf8dc49654bceec5a8 Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Fri, 24 Jul 2026 21:16:54 -0500 Subject: [PATCH 7/9] fix: bind Claude usage to live account --- .../src/provider/Drivers/ClaudeDriver.ts | 40 ++------- .../src/provider/Layers/ClaudeProvider.ts | 82 +++++++++++++------ .../provider/Layers/ProviderRegistry.test.ts | 47 +++++------ 3 files changed, 86 insertions(+), 83 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 105ebb76b2b..92aab8b030d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -20,7 +20,6 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -63,30 +62,6 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); -interface RetainedClaudeUsage { - readonly accountIdentity: string; - readonly usageLimits: NonNullable; -} - -export function resolveRetainedClaudeUsage( - previous: RetainedClaudeUsage | undefined, - accountIdentity: string | undefined, - next: ServerProvider["usageLimits"] | undefined, -): { - readonly retained: RetainedClaudeUsage | undefined; - readonly usageLimits: ServerProvider["usageLimits"] | undefined; -} { - if (!accountIdentity) { - return { retained: undefined, usageLimits: next }; - } - if (next) { - return { retained: { accountIdentity, usageLimits: next }, usageLimits: next }; - } - return previous?.accountIdentity === accountIdentity - ? { retained: previous, usageLimits: previous.usageLimits } - : { retained: undefined, usageLimits: undefined }; -} - function isClaudeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -189,7 +164,6 @@ export const ClaudeDriver: ProviderDriver = { ), }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - const lastAvailableUsageRef = yield* Ref.make(undefined); const usageProbeCache = yield* Cache.makeWith( () => probeClaudeUsageLimits(effectiveConfig, processEnv, cwd).pipe( @@ -199,7 +173,10 @@ export const ClaudeDriver: ProviderDriver = { { capacity: 2, timeToLive: Exit.match({ - onSuccess: (usageLimits) => (usageLimits ? CAPABILITIES_PROBE_TTL : Duration.zero), + onSuccess: (result) => + result?.accountIdentity && result?.usageLimits + ? CAPABILITIES_PROBE_TTL + : Duration.zero, onFailure: () => Duration.zero, }), }, @@ -215,11 +192,10 @@ export const ClaudeDriver: ProviderDriver = { usageProbeCache, `${capabilitiesCacheKey}:${accountIdentity ?? "unknown"}`, ).pipe( - Effect.flatMap((usageLimits) => - Ref.modify(lastAvailableUsageRef, (previous) => { - const resolved = resolveRetainedClaudeUsage(previous, accountIdentity, usageLimits); - return [resolved.usageLimits, resolved.retained] as const; - }), + Effect.map((result) => + result?.accountIdentity && result.accountIdentity === accountIdentity + ? result.usageLimits + : undefined, ), ), ).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index c4493a32eb6..6d637413b7d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -799,37 +799,67 @@ export const probeClaudeUsageLimits = Effect.fn("probeClaudeUsageLimits")(functi environment?: NodeJS.ProcessEnv, cwd?: string, ): Effect.fn.Return< - ServerProviderUsageLimits | undefined, + | { + readonly accountIdentity: string | undefined; + readonly usageLimits: ServerProviderUsageLimits | undefined; + } + | undefined, never, ChildProcessSpawner.ChildProcessSpawner | Path.Path > { const checkedAt = DateTime.formatIso(yield* DateTime.now); - return yield* runClaudeCommand( - claudeSettings, + return yield* Effect.all( [ - "--print", - "/usage", - "--output-format", - "json", - "--permission-mode", - "plan", - "--strict-mcp-config", - "--mcp-config", - '{"mcpServers":{}}', + runClaudeCommand( + claudeSettings, + [ + "--print", + "/usage", + "--output-format", + "json", + "--permission-mode", + "plan", + "--strict-mcp-config", + "--mcp-config", + '{"mcpServers":{}}', + ], + { + ...(environment ?? process.env), + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + { closeStdin: true, ...(cwd ? { cwd } : {}) }, + ), + runClaudeCommand(claudeSettings, ["auth", "status", "--json"], environment, { + closeStdin: true, + ...(cwd ? { cwd } : {}), + }), ], - { - ...(environment ?? process.env), - ENABLE_CLAUDEAI_MCP_SERVERS: "false", - }, - { closeStdin: true, ...(cwd ? { cwd } : {}) }, + { concurrency: "unbounded" }, ).pipe( Effect.timeoutOption(USAGE_PROBE_TIMEOUT_MS), Effect.map( - Option.flatMap((result) => - result.code === 0 - ? Option.fromUndefinedOr(parseClaudeUsageLimitsJson(result.stdout, checkedAt)) - : Option.none(), - ), + Option.map(([usageResult, authResult]) => { + let accountIdentity: string | undefined; + if (authResult.code === 0) { + try { + const decoded: unknown = JSON.parse(authResult.stdout); + const email = + typeof decoded === "object" && decoded !== null + ? (decoded as { email?: unknown }).email + : undefined; + accountIdentity = typeof email === "string" ? email.trim() || undefined : undefined; + } catch { + accountIdentity = undefined; + } + } + return { + accountIdentity, + usageLimits: + usageResult.code === 0 + ? parseClaudeUsageLimitsJson(usageResult.stdout, checkedAt) + : undefined, + }; + }), ), Effect.catchCause(() => Effect.succeed(Option.none())), Effect.map(Option.getOrUndefined), @@ -995,7 +1025,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? resolveUsage(capabilities.email?.trim() || undefined).pipe( Effect.catchCause(() => Effect.void), ) - : probeClaudeUsageLimits(claudeSettings, resolvedEnvironment, cwd); + : probeClaudeUsageLimits(claudeSettings, resolvedEnvironment, cwd).pipe( + Effect.map((result) => + result?.accountIdentity && result.accountIdentity === capabilities.email?.trim() + ? result.usageLimits + : undefined, + ), + ); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index bcfa90efff8..f370b923ae3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -47,7 +47,6 @@ import { import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; -import { resolveRetainedClaudeUsage } from "../Drivers/ClaudeDriver.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -1861,34 +1860,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // ── checkClaudeProviderStatus tests ────────────────────────── describe("checkClaudeProviderStatus", () => { - it("preserves Claude usage only for the same known account", () => { - const available: ServerProvider["usageLimits"] = { - source: "claudePrint", - checkedAt: "2026-07-22T12:00:00.000Z", - windows: [{ label: "Session", usedPercent: 30 }], - }; - - const initial = resolveRetainedClaudeUsage(undefined, "first@example.com", available); - assert.strictEqual(initial.usageLimits, available); - assert.strictEqual( - resolveRetainedClaudeUsage(initial.retained, "first@example.com", undefined).usageLimits, - available, - ); - assert.strictEqual( - resolveRetainedClaudeUsage(initial.retained, "second@example.com", undefined).usageLimits, - undefined, - ); - assert.strictEqual( - resolveRetainedClaudeUsage(initial.retained, undefined, undefined).usageLimits, - undefined, - ); - }); - it.effect("returns ready when claude is installed and authenticated", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, - claudeCapabilities(), + claudeCapabilities({ email: "claude@example.com" }), ); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); @@ -1915,7 +1891,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* TestClock.setTime(Date.parse("2026-07-22T12:00:00.000Z")); const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, - claudeCapabilities(), + claudeCapabilities({ email: "claude@example.com" }), ); assert.strictEqual(status.status, "ready"); assert.deepStrictEqual(status.usageLimits?.windows, [ @@ -1941,6 +1917,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te code: 0, }; } + if (joined === "auth status --json") { + return { + stdout: JSON.stringify({ email: "claude@example.com" }), + stderr: "", + code: 0, + }; + } throw new Error(`Unexpected args: ${joined}`); }), ), @@ -1958,6 +1941,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te code: 0, }; } + if (args.join(" ") === "auth status --json") { + return { + stdout: JSON.stringify({ email: "claude@example.com" }), + stderr: "", + code: 0, + }; + } throw new Error(`Unexpected args: ${args.join(" ")}`); }); @@ -1967,10 +1957,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te undefined, "/tmp/provider-usage-workspace", ); - assert.strictEqual(usageLimits?.windows[0]?.usedPercent, 30); + assert.strictEqual(usageLimits?.usageLimits?.windows[0]?.usedPercent, 30); + assert.strictEqual(usageLimits?.accountIdentity, "claude@example.com"); assert.deepStrictEqual( recorded.commands.map((command) => command.cwd), - ["/tmp/provider-usage-workspace"], + ["/tmp/provider-usage-workspace", "/tmp/provider-usage-workspace"], ); }).pipe(Effect.provide(recorded.layer)); }); From 500ea93588f870caf616d782274d214a36065d82 Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Sat, 25 Jul 2026 13:37:13 -0500 Subject: [PATCH 8/9] fix: keep Claude identity check best effort --- .../src/provider/Drivers/ClaudeDriver.ts | 13 ++-- .../src/provider/Layers/ClaudeProvider.ts | 14 +++-- .../provider/Layers/ProviderRegistry.test.ts | 60 +++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 92aab8b030d..c6d105a82f6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -192,11 +192,14 @@ export const ClaudeDriver: ProviderDriver = { usageProbeCache, `${capabilitiesCacheKey}:${accountIdentity ?? "unknown"}`, ).pipe( - Effect.map((result) => - result?.accountIdentity && result.accountIdentity === accountIdentity - ? result.usageLimits - : undefined, - ), + Effect.map((result) => { + if (!result) return undefined; + return result.accountIdentity && + accountIdentity && + result.accountIdentity !== accountIdentity + ? undefined + : result.usageLimits; + }), ), ).pipe( Effect.map(stampIdentity), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 6d637413b7d..a1d3305a934 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1026,11 +1026,15 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( Effect.catchCause(() => Effect.void), ) : probeClaudeUsageLimits(claudeSettings, resolvedEnvironment, cwd).pipe( - Effect.map((result) => - result?.accountIdentity && result.accountIdentity === capabilities.email?.trim() - ? result.usageLimits - : undefined, - ), + Effect.map((result) => { + if (!result) return undefined; + const expectedIdentity = capabilities.email?.trim(); + return result.accountIdentity && + expectedIdentity && + result.accountIdentity !== expectedIdentity + ? undefined + : result.usageLimits; + }), ); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f370b923ae3..2942b27deb3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1966,6 +1966,66 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }).pipe(Effect.provide(recorded.layer)); }); + it.effect("keeps successful Claude usage when live identity is unavailable", () => { + const spawner = mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined.startsWith("--print /usage --output-format json")) { + return { + stdout: JSON.stringify({ + result: "Current session: 30% used \u00b7 resets Jul 23, 1:30am (America/Chicago)", + }), + stderr: "", + code: 0, + }; + } + if (joined === "auth status --json") { + return { stdout: "", stderr: "temporary auth status failure", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); + }); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ email: "claude@example.com" }), + ); + assert.strictEqual(status.usageLimits?.windows[0]?.usedPercent, 30); + }).pipe(Effect.provide(spawner)); + }); + + it.effect("omits Claude usage when live identity mismatches capabilities", () => { + const spawner = mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined.startsWith("--print /usage --output-format json")) { + return { + stdout: JSON.stringify({ + result: "Current session: 30% used \u00b7 resets Jul 23, 1:30am (America/Chicago)", + }), + stderr: "", + code: 0, + }; + } + if (joined === "auth status --json") { + return { + stdout: JSON.stringify({ email: "second@example.com" }), + stderr: "", + code: 0, + }; + } + throw new Error(`Unexpected args: ${joined}`); + }); + + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ email: "first@example.com" }), + ); + assert.strictEqual(status.usageLimits, undefined); + }).pipe(Effect.provide(spawner)); + }); + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => Effect.gen(function* () { // Bedrock authenticates via external AWS credentials, so the SDK init From 752b4dfb4f27af854926c7d4afd1a9032d58def3 Mon Sep 17 00:00:00 2001 From: Nicolas Layne Date: Wed, 29 Jul 2026 18:10:21 -0500 Subject: [PATCH 9/9] fix: keep provider usage resets and popover naming consistent --- .../src/provider/providerUsageLimits.test.ts | 20 + .../src/provider/providerUsageLimits.ts | 27 +- apps/web/src/components/chat/ChatComposer.tsx | 16 +- .../settings/ProviderSettingsPanel.tsx | 1 + .../components/settings/SettingsPanels.tsx | 449 ------------------ 5 files changed, 49 insertions(+), 464 deletions(-) diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts index 097e6e66495..9b7fdcd21df 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -94,6 +94,26 @@ describe("parseClaudeUsageLimitsJson", () => { ).toBe("2027-01-01T07:00:00.000Z"); }); + it("rolls the reset year forward when the reported date already passed this year", () => { + const output = JSON.stringify({ + result: "Current session: 30% used \u00b7 resets Jan 2, 1am (America/Chicago)", + }); + + expect( + parseClaudeUsageLimitsJson(output, "2026-12-31T12:00:00.000Z")?.windows[0]?.resetsAt, + ).toBe("2027-01-02T07:00:00.000Z"); + }); + + it("never reports a reset in the past", () => { + const output = JSON.stringify({ + result: "Current week (all models): 30% used \u00b7 resets Jan 5, 1am (America/Chicago)", + }); + + expect( + parseClaudeUsageLimitsJson(output, "2026-02-10T12:00:00.000Z")?.windows[0]?.resetsAt, + ).toBe("2027-01-05T07:00:00.000Z"); + }); + it("parses Claude usage with CRLF line endings", () => { const output = JSON.stringify({ result: [ diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index 102a934bc41..961a0bffda8 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -88,13 +88,26 @@ function parseClaudeReset(input: { } if (hour === 12) hour = 0; if (input.meridiem.toLowerCase() === "pm") hour += 12; - const year = checkedParts.month === 12 && month === 1 ? checkedParts.year + 1 : checkedParts.year; - const localDateTime = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} ${String(hour).padStart(2, "0")}:${input.minute ?? "00"}:00`; - const reset = DateTime.makeZoned(localDateTime, { - timeZone: input.timeZone, - adjustForTimeZone: true, - }); - return Option.isSome(reset) ? DateTime.formatIso(reset.value) : undefined; + // Claude reports resets without a year, and a reset is always upcoming. Pick the earliest + // candidate year that still lands at or after the probe time, which handles both directions + // of a year boundary (a January reset probed in December, and a December reset probed just + // after New Year in UTC). A minute of slack absorbs rounding in the reported time. + const checkedMillis = DateTime.toEpochMillis(checkedInResetZone.value) - 60_000; + let best: { readonly iso: string; readonly millis: number } | undefined; + for (const year of [checkedParts.year - 1, checkedParts.year, checkedParts.year + 1]) { + const localDateTime = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} ${String(hour).padStart(2, "0")}:${input.minute ?? "00"}:00`; + const reset = DateTime.makeZoned(localDateTime, { + timeZone: input.timeZone, + adjustForTimeZone: true, + }); + if (Option.isNone(reset)) continue; + const millis = DateTime.toEpochMillis(reset.value); + if (millis < checkedMillis) continue; + if (!best || millis < best.millis) { + best = { iso: DateTime.formatIso(reset.value), millis }; + } + } + return best?.iso; } export function parseClaudeUsageLimitsJson( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 04fa4b634fc..451d58a3a96 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -922,18 +922,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), [activeThreadActivities], ); + // The running session's instance is the source of truth for both the popover's provider + // name and its usage limits, so the two can never describe different providers. + const activeThreadProviderInstanceId = + activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; const activeThreadProviderDisplayName = useMemo(() => { - if (!activeThreadModelSelection) return null; - const entry = providerStatuses.find( - (p) => p.instanceId === activeThreadModelSelection.instanceId, - ); + if (!activeThreadProviderInstanceId) return null; + const entry = providerStatuses.find((p) => p.instanceId === activeThreadProviderInstanceId); if (entry) { return getProviderDisplayName(providerStatuses, entry.driver); } - return formatProviderDisplayName(activeThreadModelSelection.instanceId); - }, [providerStatuses, activeThreadModelSelection]); - const activeThreadProviderInstanceId = - activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; + return formatProviderDisplayName(activeThreadProviderInstanceId); + }, [providerStatuses, activeThreadProviderInstanceId]); const activeProviderUsageLimits = settings.showProviderUsageInContextPopover ? providerStatuses.find((provider) => provider.instanceId === activeThreadProviderInstanceId) ?.usageLimits diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index c06826d450d..d6d53e61c90 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -832,6 +832,7 @@ export function EnvironmentProviderSettings({ instance={row.instance} driverOption={driverOption} liveProvider={liveProvider} + timestampFormat={settings.timestampFormat} isExpanded={openInstanceDetails[row.instanceId] ?? false} onExpandedChange={(open) => setOpenInstanceDetails((existing) => ({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 849d63485d8..89460f446db 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2088,455 +2088,6 @@ export function GeneralSettingsPanel() { ); } -<<<<<<< HEAD -======= -export function ProviderSettingsPanel() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); - const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { - reportFailure: false, - }); - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); - const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< - ReadonlySet - >(() => new Set()); - const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); - const refreshingRef = useRef(false); - - const providerUpdateCandidates = useMemo( - () => collectProviderUpdateCandidates(serverProviders), - [serverProviders], - ); - const providerUpdateCandidateByInstanceId = useMemo( - () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), - [providerUpdateCandidates], - ); - const visibleProviderSettings = PROVIDER_SETTINGS.filter( - (providerSettings) => - providerSettings.provider !== "cursor" || - serverProviders.some( - (provider) => - provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), - ); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); - const textGenInstanceId = textGenerationModelSelection.instanceId; - const lastCheckedAt = - serverProviders.length > 0 - ? serverProviders.reduce( - (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), - serverProviders[0]!.checkedAt, - ) - : null; - - const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; - refreshingRef.current = true; - setIsRefreshingProviders(true); - if (!primaryEnvironment) { - refreshingRef.current = false; - setIsRefreshingProviders(false); - return; - } - void (async () => { - const result = await refreshServerProviders({ - environmentId: primaryEnvironment.environmentId, - input: {}, - }); - refreshingRef.current = false; - setIsRefreshingProviders(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to refresh providers", { - operation: "refresh-providers", - environmentId: primaryEnvironment.environmentId, - ...safeErrorLogAttributes(squashAtomCommandFailure(result)), - }); - } - })(); - }, [primaryEnvironment, refreshServerProviders]); - - const runProviderUpdate = useCallback( - async (candidate: ProviderUpdateCandidate) => { - if (!primaryEnvironment) return; - let started = false; - setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { - return previous; - } - started = true; - const next = new Set(previous); - next.add(candidate.driver); - return next; - }); - if (!started) { - return; - } - - const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, - input: { - provider: candidate.driver, - instanceId: candidate.instanceId, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, - description: - error instanceof Error - ? error.message - : "The provider update command could not be started.", - }), - ); - } - setUpdatingProviderDrivers((previous) => { - if (!previous.has(candidate.driver)) { - return previous; - } - const next = new Set(previous); - next.delete(candidate.driver); - return next; - }); - }, - [primaryEnvironment, updateProvider], - ); - - interface InstanceRow { - readonly instanceId: ProviderInstanceId; - readonly instance: ProviderInstanceConfig; - readonly driver: ProviderDriverKind; - readonly isDefault: boolean; - readonly isDirty?: boolean; - } - - const instancesByDriver = new Map< - ProviderDriverKind, - Array<[ProviderInstanceId, ProviderInstanceConfig]> - >(); - for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { - const driver = instance.driver; - const list = instancesByDriver.get(driver) ?? []; - list.push([rawId as ProviderInstanceId, instance]); - instancesByDriver.set(driver, list); - } - - const defaultSlotIdsBySource = new Set( - visibleProviderSettings.map((providerSettings) => - String(defaultInstanceIdForDriver(providerSettings.provider)), - ), - ); - - const rows: InstanceRow[] = []; - const visibleDriverKinds = new Set( - visibleProviderSettings.map((providerSettings) => providerSettings.provider), - ); - - for (const providerSettings of visibleProviderSettings) { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const legacyProviders = settings.providers as Record; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings - >; - const driver = providerSettings.provider; - const defaultInstanceId = defaultInstanceIdForDriver(driver); - const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - const legacyConfig = legacyProviders[providerSettings.provider]!; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; - const effectiveInstance: ProviderInstanceConfig = - explicitInstance ?? - ({ - driver, - enabled: legacyConfig.enabled, - config: legacyConfig, - } satisfies ProviderInstanceConfig); - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); - for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { - if (id === defaultInstanceId) continue; - rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); - } - } - for (const [driver, list] of instancesByDriver) { - if (visibleDriverKinds.has(driver)) continue; - for (const [id, instance] of list) { - rows.push({ - instanceId: id, - instance, - driver: instance.driver, - isDefault: defaultSlotIdsBySource.has(String(id)), - }); - } - } - - const updateProviderInstance = ( - row: InstanceRow, - next: ProviderInstanceConfig, - options?: { - readonly textGenerationModelSelection?: Parameters< - typeof buildProviderInstanceUpdatePatch - >[0]["textGenerationModelSelection"]; - }, - ) => { - updateSettings( - buildProviderInstanceUpdatePatch({ - settings, - instanceId: row.instanceId, - instance: next, - driver: row.driver, - isDefault: row.isDefault, - textGenerationModelSelection: options?.textGenerationModelSelection, - }), - ); - }; - - const deleteProviderInstance = (id: ProviderInstanceId) => { - updateSettings({ - providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), - providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), - }); - }; - - const updateProviderModelPreferences = ( - instanceId: ProviderInstanceId, - next: { - readonly hiddenModels: ReadonlyArray; - readonly modelOrder: ReadonlyArray; - }, - ) => { - const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; - const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; - const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateSettings({ - providerModelPreferences: - hiddenModels.length === 0 && modelOrder.length === 0 - ? rest - : { - ...rest, - [instanceId]: { - hiddenModels, - modelOrder, - }, - }, - }); - }; - - const updateProviderFavoriteModels = ( - instanceId: ProviderInstanceId, - nextFavoriteModels: ReadonlyArray, - ) => { - const favoriteModels = [ - ...new Set( - Arr.filterMap(nextFavoriteModels, (slug) => { - const trimmedSlug = slug.trim(); - return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; - }), - ), - ]; - updateSettings({ - favorites: [ - ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), - ...favoriteModels.map((model) => ({ provider: instanceId, model })), - ], - }); - }; - - const resetDefaultInstance = (driverKind: ProviderDriverKind) => { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - const defaultLegacyProvider = defaultLegacyProviders[driverKind]; - if (defaultLegacyProvider === undefined) return; - updateSettings({ - providers: { - ...settings.providers, - [driverKind]: defaultLegacyProvider, - } as typeof settings.providers, - providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), - providerModelPreferences: withoutProviderInstanceKey( - settings.providerModelPreferences, - defaultInstanceId, - ), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), - }); - }; - - return ( - - - - - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider instance" - > - - - } - /> - Add provider instance - - - void refreshProviders()} - aria-label="Refresh provider status" - > - {isRefreshingProviders ? ( - - ) : ( - - )} - - } - /> - Refresh provider status - -
- } - > - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = row.instance.enabled ?? true; - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); - } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; - } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} - - - {isAddInstanceDialogOpen ? ( - - ) : null} - - ); -} - ->>>>>>> c998c5016 (feat: show provider usage limits) export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();