diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2fc11311aac..76e7f8e90883 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -12,14 +12,21 @@ * * @module provider/Drivers/ClaudeDriver */ -import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { + ClaudeSettings, + ProviderDriverKind, + type ServerProvider, + type ServerProviderRateLimits, +} from "@t3tools/contracts"; 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 FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -35,6 +42,7 @@ import { } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { normalizeClaudeRateLimitPayload, streamRateLimitUpdates } from "../providerRateLimits.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -174,6 +182,10 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); + // Claude reports one rate-limit window per turn event, backfilled by a + // full `/usage` read at turn end, so windows accumulate here and survive + // snapshot refreshes. + const rateLimitsStore = yield* Ref.make(undefined); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, @@ -183,12 +195,21 @@ export const ClaudeDriver: ProviderDriver = { initialSnapshot: (settings) => makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichSnapshot: ({ settings, snapshot, getSnapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { enableProviderUpdateChecks: settings.enableProviderUpdateChecks, }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.flatMap(() => + streamRateLimitUpdates({ + events: adapter.rateLimitEvents ?? Stream.empty, + store: rateLimitsStore, + getSnapshot, + publishSnapshot, + normalize: normalizeClaudeRateLimitPayload, + }), + ), ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77dc..cde6a7f545ef 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -179,6 +179,9 @@ export const CodexDriver: ProviderDriver = { initialSnapshot: (settings) => makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, + // Codex rate limits come from the `account/rateLimits/read` request in + // `checkCodexProviderStatus` — both windows in one snapshot, refreshed + // on every probe — so there is no event stream to subscribe to here. enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { enableProviderUpdateChecks: settings.enableProviderUpdateChecks, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 285d9dac6088..8956f3515acd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -15,6 +15,7 @@ import { type PermissionUpdate, type SDKMessage, type SDKControlGetContextUsageResponse, + type SDKControlGetUsageResponse, type SDKResultMessage, type SettingSource, type SDKUserMessage, @@ -62,6 +63,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -210,6 +212,11 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; readonly getContextUsage?: () => Promise; + // The structured data behind `/usage`: all plan rate-limit windows at once. + // Optional and feature-detected — the SDK documents that this name changes + // when the API stabilizes, so a bump degrades to the `rate_limit_event` + // path rather than breaking. + readonly usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET?: () => Promise; readonly close: () => void; } @@ -1368,6 +1375,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); + // Broadcast side channel: `runtimeEventQueue` has a single destructive + // consumer (`ProviderService`), so snapshot enrichment reads rate limits + // from here instead of competing for turn events. + const rateLimitEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( @@ -1793,6 +1804,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens); }); + /** + * Reads every plan rate-limit window at once and publishes it to the rate + * limit side channel. `rate_limit_event` only ever carries a single window, + * so without this the 5h and weekly meters fill in one at a time as each + * window happens to be reported. + */ + const publishAccountRateLimits = Effect.fn("publishAccountRateLimits")(function* ( + context: ClaudeSessionContext, + ) { + const readUsage = context.query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET; + if (!readUsage) { + return; + } + + const usage = yield* Effect.promise(async () => { + try { + return await readUsage.call(context.query); + } catch { + return undefined; + } + }); + if (!usage) { + return; + } + + // ponytail: verification logging for the percent scale — see plan Phase 0. + yield* Effect.logInfo("Claude account usage read.", { rawUsage: usage.rate_limits }); + yield* PubSub.publish(rateLimitEventPubSub, usage); + }); + const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( context: ClaudeSessionContext, input: { @@ -1897,6 +1938,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context, accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, ); + yield* publishAccountRateLimits(context); const resultUsageRecord = result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage) ? (result.usage as Record) @@ -2911,6 +2953,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rateLimits: message, }, }); + // ponytail: verification logging for the percent scale — see plan Phase 0. + yield* Effect.logInfo("Claude rate limit event.", { + rawRateLimitInfo: message.rate_limit_info, + }); + yield* PubSub.publish(rateLimitEventPubSub, message); return; } }); @@ -3921,7 +3968,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( Effect.catch((cause) => Effect.logError("Failed to emit Claude session shutdown event.", { cause }), ), - Effect.tap(() => Queue.shutdown(runtimeEventQueue)), + Effect.tap(() => + Effect.all([Queue.shutdown(runtimeEventQueue), PubSub.shutdown(rateLimitEventPubSub)]), + ), ), ); @@ -3944,5 +3993,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( get streamEvents() { return Stream.fromQueue(runtimeEventQueue); }, + get rateLimitEvents() { + return Stream.fromPubSub(rateLimitEventPubSub); + }, } satisfies ClaudeAdapterShape; }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1ed9c750c186..747ba50d799c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -17,6 +17,7 @@ import type { CodexSettings, ServerProvider, ServerProviderState, + ServerProviderRateLimits, ModelCapabilities, ProviderOptionDescriptor, ServerProviderModel, @@ -33,6 +34,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; +import { normalizeCodexRateLimitSnapshot, stampUpdatedAt } from "../providerRateLimits.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -48,6 +50,7 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; + readonly rateLimits?: ServerProviderRateLimits; } const REASONING_EFFORT_LABELS: Readonly> = { @@ -389,15 +392,29 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimitsResponse] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + // Best-effort: older Codex CLI builds or non-ChatGPT auth modes may not + // support this request, so a failure here shouldn't fail the whole probe. + client.request("account/rateLimits/read", undefined).pipe(Effect.option), ], { concurrency: "unbounded" }, ); + // ponytail: verification logging for the percent scale — see plan Phase 0. + yield* Option.match(rateLimitsResponse, { + onNone: () => Effect.void, + onSome: (response) => + Effect.logInfo("Codex account rate limits read.", { rawRateLimits: response.rateLimits }), + }); + const normalizedRateLimits = Option.match(rateLimitsResponse, { + onNone: () => undefined, + onSome: (response) => normalizeCodexRateLimitSnapshot(response.rateLimits), + }); + const rateLimits = normalizedRateLimits ? yield* stampUpdatedAt(normalizedRateLimits) : undefined; return { account: accountResponse, @@ -406,6 +423,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun appendCustomCodexModels(models, input.customModels ?? []), ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + ...(rateLimits ? { rateLimits } : {}), } satisfies CodexAppServerProviderSnapshot; }); @@ -601,6 +619,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: accountStatus.status, auth: accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : {}), + ...(snapshot.rateLimits ? { rateLimits: snapshot.rateLimits } : {}), }, }); }); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd7..390e2959a683 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -123,4 +123,15 @@ export interface ProviderAdapterShape { * Canonical runtime event stream emitted by this adapter. */ readonly streamEvents: Stream.Stream; + + /** + * Account rate-limit payloads, as a PubSub-backed side channel. + * + * `streamEvents` is backed by a Queue for some adapters, so taking from it + * is destructive and it supports exactly one consumer (`ProviderService`). + * Rate limits are account-scoped rather than thread-scoped and are consumed + * by the driver's snapshot enrichment, so they get their own broadcast + * channel instead. Payloads are provider-native and normalized per driver. + */ + readonly rateLimitEvents?: Stream.Stream; } diff --git a/apps/server/src/provider/providerRateLimits.test.ts b/apps/server/src/provider/providerRateLimits.test.ts new file mode 100644 index 000000000000..fa8545a5499f --- /dev/null +++ b/apps/server/src/provider/providerRateLimits.test.ts @@ -0,0 +1,209 @@ +import { describe, it, assert } from "@effect/vitest"; + +import { + mergeRateLimits, + normalizeClaudeRateLimitInfo, + normalizeClaudeRateLimitPayload, + normalizeClaudeUsageResponse, + normalizeCodexRateLimitSnapshot, +} from "./providerRateLimits.ts"; + +describe("providerRateLimits", () => { + it("keeps the 5h window when a weekly-only Claude event arrives", () => { + const fiveHour = normalizeClaudeRateLimitInfo({ + rate_limit_info: { rateLimitType: "five_hour", utilization: 42 }, + }); + const weekly = normalizeClaudeRateLimitInfo({ + rate_limit_info: { rateLimitType: "seven_day", utilization: 10 }, + }); + assert.isDefined(fiveHour); + assert.isDefined(weekly); + + const merged = mergeRateLimits(fiveHour, weekly); + + assert.deepStrictEqual( + merged.windows.map((window) => [window.label, window.usedPercent]), + [ + ["5h", 42], + ["Weekly", 10], + ], + ); + }); + + it("replaces a window when the same one updates, and sorts 5h before weekly", () => { + const initial = mergeRateLimits( + normalizeClaudeRateLimitInfo({ + rate_limit_info: { rateLimitType: "seven_day", utilization: 10 }, + }), + normalizeClaudeRateLimitInfo({ + rate_limit_info: { rateLimitType: "five_hour", utilization: 42 }, + }), + ); + + const updated = mergeRateLimits( + initial, + normalizeClaudeRateLimitInfo({ + rate_limit_info: { rateLimitType: "five_hour", utilization: 99 }, + }), + ); + + assert.deepStrictEqual( + updated.windows.map((window) => [window.label, window.usedPercent]), + [ + ["5h", 99], + ["Weekly", 10], + ], + ); + }); + + it("reads every window at once from the Claude usage response", () => { + const normalized = normalizeClaudeUsageResponse({ + subscription_type: "max", + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 42, resets_at: "2026-07-27T18:00:00.000Z" }, + seven_day: { utilization: 10, resets_at: "2026-08-01T00:00:00.000Z" }, + seven_day_opus: { utilization: 3, resets_at: null }, + }, + }); + + assert.isDefined(normalized); + assert.strictEqual(normalized.planType, "max"); + assert.deepStrictEqual( + normalized.windows.map((window) => [window.label, window.usedPercent]), + [ + ["5h", 42], + ["Weekly", 10], + ["Weekly (Opus)", 3], + ], + ); + // `resets_at` is already ISO and must survive without epoch conversion. + assert.strictEqual(normalized.windows[0]?.resetsAt, "2026-07-27T18:00:00.000Z"); + assert.isUndefined(normalized.windows[2]?.resetsAt); + }); + + it("reports empty windows when plan limits do not apply", () => { + const normalized = normalizeClaudeUsageResponse({ + subscription_type: null, + rate_limits_available: false, + rate_limits: null, + }); + + assert.isDefined(normalized); + assert.deepStrictEqual(normalized.windows, []); + }); + + it("lets an empty window list clear windows accumulated earlier", () => { + const accumulated = normalizeClaudeRateLimitInfo({ + rate_limit_info: { rateLimitType: "five_hour", utilization: 42 }, + }); + + const cleared = mergeRateLimits(accumulated, { windows: [] }); + + assert.deepStrictEqual(cleared.windows, []); + }); + + it("shows overage only when enabled and actually in use", () => { + const idle = normalizeClaudeUsageResponse({ + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 42 }, + extra_usage: { is_enabled: true, utilization: 0 }, + }, + }); + const active = normalizeClaudeUsageResponse({ + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 100 }, + extra_usage: { is_enabled: true, utilization: 12 }, + }, + }); + + assert.deepStrictEqual( + idle?.windows.map((window) => window.label), + ["5h"], + ); + assert.deepStrictEqual( + active?.windows.map((window) => [window.label, window.usedPercent]), + [ + ["5h", 100], + ["Overage", 12], + ], + ); + }); + + it("routes both Claude payload shapes through one normalizer", () => { + const fromEvent = normalizeClaudeRateLimitPayload({ + rate_limit_info: { rateLimitType: "five_hour", utilization: 42 }, + }); + const fromRead = normalizeClaudeRateLimitPayload({ + rate_limits_available: true, + rate_limits: { seven_day: { utilization: 10 } }, + }); + + assert.deepStrictEqual( + fromEvent?.windows.map((window) => window.label), + ["5h"], + ); + assert.deepStrictEqual( + fromRead?.windows.map((window) => window.label), + ["Weekly"], + ); + }); + + it("keeps the fuller picture when an event lands after a control read", () => { + const fromRead = normalizeClaudeRateLimitPayload({ + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 42 }, + seven_day: { utilization: 10 }, + }, + }); + const laterEvent = normalizeClaudeRateLimitPayload({ + rate_limit_info: { rateLimitType: "five_hour", utilization: 55 }, + }); + + const merged = mergeRateLimits(fromRead, laterEvent); + + assert.deepStrictEqual( + merged.windows.map((window) => [window.label, window.usedPercent]), + [ + ["5h", 55], + ["Weekly", 10], + ], + ); + }); + + it("labels Codex windows from their duration and reads percent as-is", () => { + const normalized = normalizeCodexRateLimitSnapshot({ + planType: "plus", + primary: { usedPercent: 20, windowDurationMins: 300 }, + secondary: { usedPercent: 65, windowDurationMins: 10_080 }, + }); + + assert.isDefined(normalized); + assert.strictEqual(normalized.planType, "plus"); + assert.deepStrictEqual( + normalized.windows.map((window) => [window.label, window.usedPercent]), + [ + ["5h", 20], + ["Weekly", 65], + ], + ); + }); + + it("keeps a Codex reading of 1 percent at 1 percent", () => { + const normalized = normalizeCodexRateLimitSnapshot({ + primary: { usedPercent: 1, windowDurationMins: 300 }, + }); + + assert.strictEqual(normalized?.windows[0]?.usedPercent, 1); + }); + + it("returns undefined when a payload carries no usable window", () => { + assert.isUndefined(normalizeClaudeRateLimitInfo({ rate_limit_info: {} })); + assert.isUndefined(normalizeCodexRateLimitSnapshot({ planType: "plus" })); + assert.isUndefined(normalizeCodexRateLimitSnapshot(undefined)); + assert.isUndefined(normalizeClaudeUsageResponse(undefined)); + }); +}); diff --git a/apps/server/src/provider/providerRateLimits.ts b/apps/server/src/provider/providerRateLimits.ts new file mode 100644 index 000000000000..c5d79d3b9964 --- /dev/null +++ b/apps/server/src/provider/providerRateLimits.ts @@ -0,0 +1,348 @@ +/** + * providerRateLimits - Normalizes provider-native account rate-limit payloads + * into `ServerProvider.rateLimits` so plan quota reaches the client over the + * existing config-push pipeline. + * + * Providers report quota per rolling window (Claude: 5h + weekly; Codex: + * primary + secondary). Claude has two sources with different shapes — the + * per-turn `rate_limit_event` (one window each) and the `/usage` control read + * (every window at once) — so windows are merged by label across payloads + * rather than replaced. + * + * @module provider/providerRateLimits + */ +import type { ServerProvider, ServerProviderRateLimits } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +type RateLimitWindow = ServerProviderRateLimits["windows"][number]; + +/** Epoch value of unknown unit (seconds or ms) -> ISO string. */ +export function epochToIso(epoch: number | null | undefined): string | undefined { + if (typeof epoch !== "number" || !Number.isFinite(epoch) || epoch <= 0) { + return undefined; + } + const ms = epoch > 1e12 ? epoch : epoch * 1000; + return DateTime.formatIso(DateTime.makeUnsafe(ms)); +} + +/** Already-ISO timestamp (Claude's control read) -> validated ISO string. */ +export function isoToIso(value: string | null | undefined): string | undefined { + if (typeof value !== "string" || value.length === 0) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : DateTime.formatIso(DateTime.makeUnsafe(parsed)); +} + +/** + * Percent used, clamped to 0-100. + * + * Every source documents this as a 0-100 percentage: Codex's `usedPercent` is + * an int32 0-100, and Claude's control-read `utilization` is documented + * "Percentage of the window used, 0-100". The undocumented one is the + * `rate_limit_event` `utilization` — if it turns out to be a 0-1 fraction, + * this is the single place to scale it. + */ +export function toUsedPercent(value: number | null | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return Math.max(0, Math.min(100, value)); +} + +// Shortest window first, so the meter reads "what stops me now" -> "what +// stops me this week". Labels not listed sort last, alphabetically. +const WINDOW_ORDER: ReadonlyArray = [ + "5h", + "Weekly", + "Weekly (Opus)", + "Weekly (Sonnet)", + "Weekly (Apps)", + "Overage", +]; + +function windowRank(label: string | undefined): number { + const index = WINDOW_ORDER.indexOf(label ?? ""); + return index === -1 ? WINDOW_ORDER.length : index; +} + +function sortWindows(windows: ReadonlyArray): Array { + return [...windows].sort( + (left, right) => + windowRank(left.label) - windowRank(right.label) || + (left.label ?? "").localeCompare(right.label ?? ""), + ); +} + +/** + * Merges two rate-limit views, keyed by window label, with `override` winning + * for labels present in both. Windows only present in `base` survive — that is + * what keeps the 5h window alive when a weekly-only event arrives. + * + * An `override` with zero windows is authoritative rather than a no-op: it is + * how a provider says "plan limits do not apply to this account", so it clears + * anything accumulated earlier. + */ +export function mergeRateLimits( + base: ServerProviderRateLimits | undefined, + override: ServerProviderRateLimits | undefined, +): ServerProviderRateLimits { + const planType = override?.planType ?? base?.planType; + const updatedAt = override?.updatedAt ?? base?.updatedAt; + const carry = (windows: Array): ServerProviderRateLimits => ({ + ...(planType ? { planType } : {}), + ...(updatedAt ? { updatedAt } : {}), + windows, + }); + + if (override && override.windows.length === 0) { + return carry([]); + } + + const byLabel = new Map(); + for (const window of base?.windows ?? []) { + byLabel.set(window.label ?? "", window); + } + for (const window of override?.windows ?? []) { + byLabel.set(window.label ?? "", window); + } + return carry(sortWindows([...byLabel.values()])); +} + +const CLAUDE_WINDOW_LABELS: Readonly> = { + five_hour: "5h", + seven_day: "Weekly", + seven_day_opus: "Weekly (Opus)", + seven_day_sonnet: "Weekly (Sonnet)", + seven_day_oauth_apps: "Weekly (Apps)", + overage: "Overage", +}; + +interface ClaudeRateLimitInfo { + readonly resetsAt?: number; + readonly rateLimitType?: string; + readonly utilization?: number; +} + +/** + * Normalizes a Claude Agent SDK `rate_limit_event`. Each event carries exactly + * one window (`rateLimitType`), so callers must merge rather than replace. + */ +export function normalizeClaudeRateLimitInfo( + payload: unknown, +): ServerProviderRateLimits | undefined { + const info = (payload as { rate_limit_info?: ClaudeRateLimitInfo } | undefined)?.rate_limit_info; + const usedPercent = toUsedPercent(info?.utilization); + if (usedPercent === undefined) { + return undefined; + } + const rawType = info?.rateLimitType; + const label = rawType ? (CLAUDE_WINDOW_LABELS[rawType] ?? rawType) : undefined; + const resetsAt = epochToIso(info?.resetsAt); + return { + windows: [ + { + ...(label ? { label } : {}), + usedPercent, + ...(resetsAt ? { resetsAt } : {}), + }, + ], + }; +} + +interface ClaudeUsageWindow { + readonly utilization?: number | null; + readonly resets_at?: string | null; +} + +interface ClaudeExtraUsage { + readonly is_enabled?: boolean; + readonly utilization?: number | null; +} + +interface ClaudeUsageResponse { + readonly subscription_type?: string | null; + readonly rate_limits_available?: boolean; + readonly rate_limits?: + | (Readonly> & { + readonly extra_usage?: ClaudeExtraUsage | null; + }) + | null; +} + +/** + * Normalizes the Claude `/usage` control read, which reports every plan window + * at once with ISO reset timestamps — unlike the event path's single window and + * epoch timestamps. + * + * Returns an empty window list (rather than `undefined`) when the account has + * no plan limits at all, so the client can tell "does not apply here" apart + * from "nothing reported yet". + */ +export function normalizeClaudeUsageResponse( + payload: unknown, +): ServerProviderRateLimits | undefined { + const response = payload as ClaudeUsageResponse | undefined; + if (!response || !("rate_limits" in response)) { + return undefined; + } + + const planType = response.subscription_type ?? undefined; + const base = planType ? { planType } : {}; + if (response.rate_limits_available === false || !response.rate_limits) { + return { ...base, windows: [] }; + } + + const windows: Array = []; + for (const [key, label] of Object.entries(CLAUDE_WINDOW_LABELS)) { + const window = response.rate_limits[key]; + const usedPercent = toUsedPercent(window?.utilization); + if (usedPercent === undefined) { + continue; + } + const resetsAt = isoToIso(window?.resets_at); + windows.push({ label, usedPercent, ...(resetsAt ? { resetsAt } : {}) }); + } + + // Overage only exists once the plan is exhausted and the user opted in, so + // it earns a row only while it is actually the thing gating them. + const extraUsage = response.rate_limits.extra_usage; + const overagePercent = toUsedPercent(extraUsage?.utilization); + if (extraUsage?.is_enabled === true && overagePercent !== undefined && overagePercent > 0) { + windows.push({ label: "Overage", usedPercent: overagePercent }); + } + + return { ...base, windows: sortWindows(windows) }; +} + +/** + * Dispatches whichever Claude rate-limit payload arrived on the side channel. + * Both shapes share one PubSub, and they are told apart by key: the control + * read has `rate_limits`, the turn event has `rate_limit_info`. + */ +export function normalizeClaudeRateLimitPayload( + payload: unknown, +): ServerProviderRateLimits | undefined { + if (payload && typeof payload === "object" && "rate_limits" in payload) { + return normalizeClaudeUsageResponse(payload); + } + return normalizeClaudeRateLimitInfo(payload); +} + +const MINUTES_PER_WEEK = 10_080; +const MINUTES_PER_DAY = 1_440; + +function codexWindowLabel(minutes: number | null | undefined, fallback: string): string { + if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) { + return fallback; + } + if (minutes % MINUTES_PER_WEEK === 0) { + const weeks = minutes / MINUTES_PER_WEEK; + return weeks === 1 ? "Weekly" : `${weeks}w`; + } + if (minutes % MINUTES_PER_DAY === 0) { + const days = minutes / MINUTES_PER_DAY; + return days === 1 ? "Daily" : `${days}d`; + } + if (minutes % 60 === 0) { + return `${minutes / 60}h`; + } + return `${minutes}m`; +} + +interface CodexRateLimitWindow { + readonly usedPercent?: number; + readonly resetsAt?: number | null; + readonly windowDurationMins?: number | null; +} + +interface CodexRateLimitSnapshot { + readonly planType?: string | null; + readonly primary?: CodexRateLimitWindow | null; + readonly secondary?: CodexRateLimitWindow | null; +} + +/** + * Normalizes a Codex app-server rate-limit snapshot — the `rateLimits` field + * shared by the `account/rateLimits/updated` notification and the + * `account/rateLimits/read` response. Both windows arrive together, so a + * single snapshot already carries the full picture. + */ +export function normalizeCodexRateLimitSnapshot( + payload: unknown, +): ServerProviderRateLimits | undefined { + const snapshot = payload as CodexRateLimitSnapshot | undefined; + const windows: Array = []; + for (const [fallback, window] of [ + ["Primary", snapshot?.primary], + ["Secondary", snapshot?.secondary], + ] as const) { + const usedPercent = toUsedPercent(window?.usedPercent); + if (usedPercent === undefined) { + continue; + } + const resetsAt = epochToIso(window?.resetsAt); + windows.push({ + label: codexWindowLabel(window?.windowDurationMins, fallback), + usedPercent, + ...(resetsAt ? { resetsAt } : {}), + }); + } + if (windows.length === 0) { + return undefined; + } + return { + ...(snapshot?.planType ? { planType: snapshot.planType } : {}), + windows: sortWindows(windows), + }; +} + +/** Stamps a normalized view with the time it was observed. */ +export const stampUpdatedAt = ( + rateLimits: ServerProviderRateLimits, +): Effect.Effect => + Effect.map(DateTime.now, (now) => ({ ...rateLimits, updatedAt: DateTime.formatIso(now) })); + +/** + * Tracks account rate limits from a provider's `rateLimitEvents` channel and + * republishes the snapshot as they change. + * + * `store` outlives the enrichment fiber (which `makeManagedServerProvider` + * restarts on every refresh), so accumulated windows survive a refresh that + * rebuilt the snapshot without them. + */ +export const streamRateLimitUpdates = Effect.fn("streamRateLimitUpdates")(function* (input: { + readonly events: Stream.Stream; + readonly store: Ref.Ref; + readonly getSnapshot: Effect.Effect; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly normalize: (payload: unknown) => ServerProviderRateLimits | undefined; +}) { + const restored = yield* Ref.get(input.store); + if (restored) { + const snapshot = yield* input.getSnapshot; + yield* input.publishSnapshot({ + ...snapshot, + rateLimits: mergeRateLimits(restored, snapshot.rateLimits), + }); + } + + yield* Stream.runForEach(input.events, (payload) => + Effect.gen(function* () { + const next = input.normalize(payload); + if (!next) { + return; + } + const stamped = yield* stampUpdatedAt(next); + const merged = yield* Ref.modify(input.store, (current) => { + const updated = mergeRateLimits(current, stamped); + return [updated, updated] as const; + }); + const snapshot = yield* input.getSnapshot; + yield* input.publishSnapshot({ ...snapshot, rateLimits: merged }); + }), + ); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d0..1d1fcbe63c46 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -3,6 +3,7 @@ import type { ModelCapabilities, ServerProvider, ServerProviderAuth, + ServerProviderRateLimits, ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, @@ -50,6 +51,7 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + readonly rateLimits?: ServerProviderRateLimits; } export interface ServerProviderPresentation { @@ -240,6 +242,7 @@ export function buildServerProvider(input: { auth: input.probe.auth, checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), + ...(input.probe.rateLimits ? { rateLimits: input.probe.rateLimits } : {}), models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8c35278073e6..dd6e8eecee67 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -19,6 +19,7 @@ import { } from "../ui/sidebar"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdatePill } from "./SidebarUpdatePill"; +import { SidebarUsageMeter } from "./SidebarUsageMeter"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, @@ -111,6 +112,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + = ["5h", "Weekly"]; + +interface UsageRow { + readonly key: string; + readonly provider: string; + readonly window: string | undefined; + readonly usedPercent: number | undefined; + readonly resetsAt: string | undefined; + readonly planType: string | undefined; + readonly updatedAt: string | undefined; + /** Every window the provider reported, for the tooltip. */ + readonly allWindows: ReadonlyArray; +} + +function toUsageRows(providers: ReadonlyArray): ReadonlyArray { + return providers.flatMap((provider): ReadonlyArray => { + const name = provider.displayName ?? provider.driver; + const rateLimits = provider.rateLimits; + const windows = rateLimits?.windows ?? []; + const common = { + provider: name, + planType: rateLimits?.planType, + updatedAt: rateLimits?.updatedAt, + allWindows: windows, + }; + + // `rateLimits` present but empty is the server saying plan limits do not + // apply to this account (API key, Bedrock, Vertex) — not "still waiting". + if (rateLimits && windows.length === 0) { + return []; + } + + if (!rateLimits) { + return provider.auth.status === "authenticated" + ? ROW_LABELS.map((label) => ({ + ...common, + key: `${provider.instanceId}:${label}`, + window: label, + usedPercent: undefined, + resetsAt: undefined, + })) + : []; + } + + return windows + .filter((window) => window.label !== undefined && ROW_LABELS.includes(window.label)) + .map((window, index) => ({ + ...common, + key: `${provider.instanceId}:${window.label ?? index}`, + window: window.label, + usedPercent: Math.min(100, Math.max(0, window.usedPercent)), + resetsAt: window.resetsAt, + })) + .concat( + // Overage is off-plan spend: it only earns a row once it is live. + windows + .filter((window) => window.label === "Overage" && window.usedPercent > 0) + .map((window) => ({ + ...common, + key: `${provider.instanceId}:Overage`, + window: window.label, + usedPercent: Math.min(100, Math.max(0, window.usedPercent)), + resetsAt: window.resetsAt, + })), + ); + }); +} + +function formatResetsAt(resetsAt: string | undefined): string | undefined { + if (!resetsAt) { + return undefined; + } + const parsed = new Date(resetsAt); + if (Number.isNaN(parsed.getTime())) { + return undefined; + } + return parsed.toLocaleString(undefined, { + weekday: "short", + hour: "numeric", + minute: "2-digit", + }); +} + +// "resets in 2h" answers the question the meter raises better than a wall +// clock time does; the absolute time stays in the accessible label. +function formatResetsIn(resetsAt: string | undefined): string | undefined { + if (!resetsAt) { + return undefined; + } + const parsed = new Date(resetsAt); + if (Number.isNaN(parsed.getTime())) { + return undefined; + } + const minutes = Math.round((parsed.getTime() - Date.now()) / 60_000); + if (minutes <= 0) { + return "resets now"; + } + if (minutes < 60) { + return `resets in ${minutes}m`; + } + const hours = Math.round(minutes / 60); + return hours < 24 ? `resets in ${hours}h` : `resets in ${Math.round(hours / 24)}d`; +} + +// Claude only refreshes when a turn ends, so a stale row would otherwise look +// identical to a live one. +function formatUpdatedAgo(updatedAt: string | undefined): string | undefined { + if (!updatedAt) { + return undefined; + } + const parsed = new Date(updatedAt); + if (Number.isNaN(parsed.getTime())) { + return undefined; + } + const minutes = Math.floor((Date.now() - parsed.getTime()) / 60_000); + if (minutes < 1) { + return "Updated just now"; + } + if (minutes < 60) { + return `Updated ${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + return hours < 24 ? `Updated ${hours}h ago` : `Updated ${Math.floor(hours / 24)}d ago`; +} + +// Quota meters earn attention as they fill: quiet until it matters. +function barToneClass(usedPercent: number): string { + if (usedPercent >= 90) { + return "bg-destructive/70"; + } + if (usedPercent >= 75) { + return "bg-warning/70"; + } + return "bg-primary/60"; +} + +// One window inside the tooltip: label and reset on top, bar and percent +// below, so every window lines up on the same two axes. +function TooltipWindowRow({ + window, + isCurrent, +}: { + window: ServerProviderRateLimitWindow; + isCurrent: boolean; +}) { + const percent = Math.round(Math.min(100, Math.max(0, window.usedPercent))); + const resetsIn = formatResetsIn(window.resetsAt); + + return ( +
+
+ + {window.label} + + {resetsIn ? ( + {resetsIn} + ) : null} +
+
+ + + + + {percent}% + +
+
+ ); +} + +export function SidebarUsageMeter() { + const navigate = useNavigate(); + const providers = useAtomValue(primaryServerProvidersAtom); + const openProviderSettings = useCallback(() => { + void navigate({ to: "/settings/providers" }); + }, [navigate]); + + const rows = toUsageRows(providers.filter((p) => ["claudeAgent", "codex"].includes(p.driver))); + if (rows.length === 0) { + return null; + } + + return ( +
+ {rows.map((row) => { + const label = row.window ? `${row.provider} ${row.window}` : row.provider; + const isPending = row.usedPercent === undefined; + const rounded = isPending ? undefined : Math.round(row.usedPercent ?? 0); + const detailWindows = row.allWindows.filter((window) => window.label !== undefined); + const updatedAgo = formatUpdatedAgo(row.updatedAt); + // Screen readers get the whole card as one sentence. + const ariaLabel = [ + isPending ? `${label} — waiting for first report` : `${label} — ${rounded}% used`, + row.planType ? `Plan: ${row.planType}` : undefined, + formatResetsAt(row.resetsAt) ? `Resets ${formatResetsAt(row.resetsAt)}` : undefined, + updatedAgo, + ...detailWindows + .filter((window) => window.label !== row.window) + .map((window) => `${window.label}: ${Math.round(window.usedPercent)}%`), + "Open provider settings", + ] + .filter(Boolean) + .join(" · "); + + return ( + + + {label} + + ); + })} +
+ ); +} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a8394..61efd5a03d23 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -58,6 +58,21 @@ export const ServerProviderAuth = Schema.Struct({ }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; +export const ServerProviderRateLimitWindow = Schema.Struct({ + label: Schema.optional(TrimmedNonEmptyString), + usedPercent: Schema.Number, + resetsAt: Schema.optional(IsoDateTime), +}); +export type ServerProviderRateLimitWindow = typeof ServerProviderRateLimitWindow.Type; + +export const ServerProviderRateLimits = Schema.Struct({ + planType: Schema.optional(TrimmedNonEmptyString), + /** Stamped whenever a window changes, so the client can show staleness. */ + updatedAt: Schema.optional(IsoDateTime), + windows: Schema.Array(ServerProviderRateLimitWindow), +}); +export type ServerProviderRateLimits = typeof ServerProviderRateLimits.Type; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -190,6 +205,7 @@ export const ServerProvider = Schema.Struct({ skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), + rateLimits: Schema.optionalKey(ServerProviderRateLimits), }); export type ServerProvider = typeof ServerProvider.Type;