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 */
13import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
14import type {
15 ProviderUsageLimitsUpdate,
16 ServerProviderUsageLimits,
17 ServerProviderUsageWindow,
18} from "@t3tools/contracts";
19import * as DateTime from "effect/DateTime";
20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as Ref from "effect/Ref";
23
24import {
25 clampPercent,
26 makeUnavailableUsageLimits,
27 makeUsageLimits,
28} from "@t3tools/provider-core/server/usageLimits";
29
30const SESSION_MINS = 5 * 60;
31const 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 */
39const 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 */
53const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included";
54
55export interface ClaudeScopedLimitNames {
56 readonly overageIncluded: string | undefined;
57}
58
59export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({
60 overageIncluded: undefined,
61});
62
63function scopedWindowId(displayName: string): string {
64 return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
65}
66
67function 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 */
86interface ModelScopedWindow {
87 readonly display_name: string;
88 readonly utilization: number | null;
89 readonly resets_at: string | null;
90}
91
92function 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
103function 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
109function 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
115function 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 */
134export 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 */
157export 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. */
193export 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};