| 1 | /** |
| 2 | * Claude Code subscription usage. Both sources produce windows with the same |
| 3 | * ids so a turn-driven `rate_limit_event` lands on the row the SDK's |
| 4 | * `get_usage` read established: |
| 5 | * |
| 6 | * - `get_usage` (on demand, during the capabilities probe) reports every |
| 7 | * window at once as 0–100 percentages with ISO reset times. |
| 8 | * - `rate_limit_event` (streamed during a turn) names one window at a time |
| 9 | * with a 0–1 utilization fraction and an epoch-seconds reset. |
| 10 | * |
| 11 | * @module provider/claudeUsageLimits |
| 12 | */ |
| 13 | import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; |
| 14 | import type { |
| 15 | ProviderUsageLimitsUpdate, |
| 16 | ServerProviderUsageLimits, |
| 17 | ServerProviderUsageWindow, |
| 18 | } from "@t3tools/contracts"; |
| 19 | import * as DateTime from "effect/DateTime"; |
| 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as Option from "effect/Option"; |
| 22 | import * as Ref from "effect/Ref"; |
| 23 | |
| 24 | import { |
| 25 | clampPercent, |
| 26 | makeUnavailableUsageLimits, |
| 27 | makeUsageLimits, |
| 28 | } from "@t3tools/provider-core/server/usageLimits"; |
| 29 | |
| 30 | const SESSION_MINS = 5 * 60; |
| 31 | const WEEK_MINS = 7 * 24 * 60; |
| 32 | |
| 33 | /** |
| 34 | * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped |
| 35 | * weeklies are additive on top of these: the CLI reports them under |
| 36 | * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included |
| 37 | * model bucket (Fable today) as `seven_day_overage_included`. |
| 38 | */ |
| 39 | const WINDOWS: Readonly< |
| 40 | Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">> |
| 41 | > = { |
| 42 | five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, |
| 43 | seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, |
| 44 | }; |
| 45 | |
| 46 | /** |
| 47 | * The streamed event names the overage-included bucket by type |
| 48 | * (`seven_day_overage_included`), while `get_usage` names it by the model's |
| 49 | * `display_name`. Which model that is changes over time, so the probe records |
| 50 | * the name it saw and the event mapper reuses it; the mid-turn update then |
| 51 | * lands on the row the probe drew instead of opening a second one. |
| 52 | */ |
| 53 | const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; |
| 54 | |
| 55 | export interface ClaudeScopedLimitNames { |
| 56 | readonly overageIncluded: string | undefined; |
| 57 | } |
| 58 | |
| 59 | export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({ |
| 60 | overageIncluded: undefined, |
| 61 | }); |
| 62 | |
| 63 | function scopedWindowId(displayName: string): string { |
| 64 | return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; |
| 65 | } |
| 66 | |
| 67 | function scopedWindow( |
| 68 | displayName: string, |
| 69 | usedPercent: number, |
| 70 | resetsAt: string | undefined, |
| 71 | ): ServerProviderUsageWindow { |
| 72 | return { |
| 73 | id: scopedWindowId(displayName), |
| 74 | kind: "weekly", |
| 75 | label: `Weekly · ${displayName}`, |
| 76 | windowDurationMins: WEEK_MINS, |
| 77 | usedPercent: clampPercent(usedPercent), |
| 78 | ...(resetsAt ? { resetsAt } : {}), |
| 79 | }; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is |
| 84 | * read structurally until the `.d.ts` catches up. |
| 85 | */ |
| 86 | interface ModelScopedWindow { |
| 87 | readonly display_name: string; |
| 88 | readonly utilization: number | null; |
| 89 | readonly resets_at: string | null; |
| 90 | } |
| 91 | |
| 92 | function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> { |
| 93 | const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; |
| 94 | if (!Array.isArray(raw)) return []; |
| 95 | return raw.filter( |
| 96 | (entry): entry is ModelScopedWindow => |
| 97 | typeof entry === "object" && |
| 98 | entry !== null && |
| 99 | typeof (entry as ModelScopedWindow).display_name === "string", |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | function isoFromEpochSeconds(value: number | undefined): string | undefined { |
| 104 | if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; |
| 105 | const dt = DateTime.make(value * 1000); |
| 106 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; |
| 107 | } |
| 108 | |
| 109 | function isoFromString(value: string | null | undefined): string | undefined { |
| 110 | if (!value) return undefined; |
| 111 | const dt = DateTime.make(value); |
| 112 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; |
| 113 | } |
| 114 | |
| 115 | function makeWindow( |
| 116 | id: keyof typeof WINDOWS & string, |
| 117 | usedPercent: number, |
| 118 | resetsAt: string | undefined, |
| 119 | ): ServerProviderUsageWindow { |
| 120 | const window = WINDOWS[id]!; |
| 121 | return { |
| 122 | id, |
| 123 | ...window, |
| 124 | usedPercent: clampPercent(usedPercent), |
| 125 | ...(resetsAt ? { resetsAt } : {}), |
| 126 | }; |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Utilization is a 0–1 fraction on the streamed event. An overage-included |
| 131 | * event before any probe has named the bucket is dropped: guessing a name |
| 132 | * would draw a row the next probe cannot reconcile. |
| 133 | */ |
| 134 | export function claudeRateLimitEventToUpdate( |
| 135 | info: SDKRateLimitInfo, |
| 136 | names: ClaudeScopedLimitNames, |
| 137 | ): ProviderUsageLimitsUpdate | undefined { |
| 138 | const type: string | undefined = info.rateLimitType; |
| 139 | if (!type || typeof info.utilization !== "number") { |
| 140 | return undefined; |
| 141 | } |
| 142 | const usedPercent = info.utilization * 100; |
| 143 | const resetsAt = isoFromEpochSeconds(info.resetsAt); |
| 144 | if (type in WINDOWS) { |
| 145 | return { windows: [makeWindow(type, usedPercent, resetsAt)] }; |
| 146 | } |
| 147 | if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { |
| 148 | return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; |
| 149 | } |
| 150 | return undefined; |
| 151 | } |
| 152 | |
| 153 | /** |
| 154 | * Percentages on the `get_usage` response are already 0–100. Also yields the |
| 155 | * scoped-bucket names the response carried, for the event mapper to reuse. |
| 156 | */ |
| 157 | export function claudeUsageResponseToLimits(input: { |
| 158 | readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; |
| 159 | readonly checkedAt: string; |
| 160 | }): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { |
| 161 | const { response, checkedAt } = input; |
| 162 | if (!response.rate_limits_available || !response.rate_limits) { |
| 163 | return { |
| 164 | limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), |
| 165 | names: { overageIncluded: undefined }, |
| 166 | }; |
| 167 | } |
| 168 | const windows: ServerProviderUsageWindow[] = []; |
| 169 | for (const id of Object.keys(WINDOWS)) { |
| 170 | const window = response.rate_limits[id as "five_hour" | "seven_day"]; |
| 171 | if (!window || typeof window.utilization !== "number") continue; |
| 172 | windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); |
| 173 | } |
| 174 | // The CLI filters `model_scoped` to the overage-included allowlist, which |
| 175 | // today holds one model; the first entry is the one the event refers to. |
| 176 | let overageIncluded: string | undefined; |
| 177 | for (const entry of readModelScoped(response.rate_limits)) { |
| 178 | if (typeof entry.utilization !== "number") continue; |
| 179 | windows.push( |
| 180 | scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), |
| 181 | ); |
| 182 | // Only a bucket that drew a row may receive events; naming one that was |
| 183 | // skipped would let a mid-turn event open a row the probe never showed. |
| 184 | overageIncluded ??= entry.display_name; |
| 185 | } |
| 186 | return { |
| 187 | limits: makeUsageLimits({ checkedAt, windows }), |
| 188 | names: { overageIncluded }, |
| 189 | }; |
| 190 | } |
| 191 | |
| 192 | /** Probe-side helper: map the response and remember the scoped names for events. */ |
| 193 | export const recordClaudeUsageResponse = ( |
| 194 | namesRef: Ref.Ref<ClaudeScopedLimitNames>, |
| 195 | input: Parameters<typeof claudeUsageResponseToLimits>[0], |
| 196 | ): Effect.Effect<ServerProviderUsageLimits> => { |
| 197 | const { limits, names } = claudeUsageResponseToLimits(input); |
| 198 | return Ref.set(namesRef, names).pipe(Effect.as(limits)); |
| 199 | }; |