Skip to content
Source
Sign inSign up

claudeUsageLimits.ts

225 lines7.6 KB

1import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
2import type {
3 ProviderUsageLimitsUpdate,
4 ServerProviderUsageLimits,
5 ServerProviderUsageWindow,
6} from "@t3tools/contracts";
7import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as Ref from "effect/Ref";
11import * as Schema from "effect/Schema";
12​
13import {
14 clampPercent,
15 makeUnavailableUsageLimits,
16 makeUsageLimits,
17} from "@t3tools/provider-core/server/usageLimits";
18​
19const SESSION_MINS = 5 * 60;
20const WEEK_MINS = 7 * 24 * 60;
21​
22const WINDOWS: Readonly<
23 Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">>
24> = {
25 five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS },
26 seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS },
27};
28​
29/** Reuse probed display names for streamed bucket types so mid-turn updates target the same row. */
30const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included";
31​
32// The SDK does not yet type the spend budget returned by `get_usage`.
33const SpendMoney = Schema.Struct({
34 amount_minor: Schema.Natural,
35 currency: Schema.NonEmptyString,
36 exponent: Schema.Natural,
37});
38const decodeSpendBudget = Schema.decodeUnknownOption(
39 Schema.Struct({ enabled: Schema.Boolean, used: SpendMoney, limit: SpendMoney }),
40);
41​
42export interface ClaudeScopedLimitNames {
43 readonly overageIncluded: string | undefined;
45​
46export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({
47 overageIncluded: undefined,
48});
49​
50function scopedWindowId(displayName: string): string {
51 return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
53​
54function scopedWindow(
55 displayName: string,
56 usedPercent: number,
57 resetsAt: string | undefined,
58): ServerProviderUsageWindow {
59 return {
60 id: scopedWindowId(displayName),
61 kind: "weekly",
62 label: `Weekly · ${displayName}`,
63 windowDurationMins: WEEK_MINS,
64 usedPercent: clampPercent(usedPercent),
65 ...(resetsAt ? { resetsAt } : {}),
66 };
68​
69function percentFromAmounts(used: number | null | undefined, limit: number | null | undefined) {
70 if (
71 typeof used !== "number" ||
72 typeof limit !== "number" ||
73 !Number.isFinite(used) ||
74 !Number.isFinite(limit) ||
75 used < 0 ||
76 limit <= 0
77 ) {
78 return undefined;
79 }
80 const percent = (used / limit) * 100;
81 return Number.isFinite(percent) ? clampPercent(percent) : undefined;
83​
84function readSpendPercent(rateLimits: NonNullable<SDKControlGetUsageResponse["rate_limits"]>) {
85 const spend = Option.getOrUndefined(
86 decodeSpendBudget((rateLimits as { readonly spend?: unknown }).spend),
87 );
88 // Minor-unit ratios require matching currency and scale; spend.percent is rounded.
89 if (
90 spend?.enabled &&
91 spend.used.currency.trim().length > 0 &&
92 spend.used.currency.trim() === spend.limit.currency.trim() &&
93 spend.used.exponent === spend.limit.exponent
94 ) {
95 const percent = percentFromAmounts(spend.used.amount_minor, spend.limit.amount_minor);
96 if (percent !== undefined) return percent;
97 }
98​
99 const extra = rateLimits.extra_usage;
100 if (extra?.is_enabled !== true) return undefined;
101 const percent = percentFromAmounts(extra.used_credits, extra.monthly_limit) ?? extra.utilization;
102 return typeof percent === "number" && Number.isFinite(percent) && percent >= 0
103 ? clampPercent(percent)
104 : undefined;
106​
107/**
108 * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is
109 * read structurally until the `.d.ts` catches up.
110 */
111interface ModelScopedWindow {
112 readonly display_name: string;
113 readonly utilization: number | null;
114 readonly resets_at: string | null;
116​
117function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> {
118 const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped;
119 if (!Array.isArray(raw)) return [];
120 return raw.filter(
121 (entry): entry is ModelScopedWindow =>
122 typeof entry === "object" &&
123 entry !== null &&
124 typeof (entry as ModelScopedWindow).display_name === "string",
125 );
127​
128function isoFromEpochSeconds(value: number | undefined): string | undefined {
129 if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined;
130 const dt = DateTime.make(value * 1000);
131 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
133​
134function isoFromString(value: string | null | undefined): string | undefined {
135 if (!value) return undefined;
136 const dt = DateTime.make(value);
137 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
139​
140function makeWindow(
141 id: keyof typeof WINDOWS & string,
142 usedPercent: number,
143 resetsAt: string | undefined,
144): ServerProviderUsageWindow {
145 const window = WINDOWS[id]!;
146 return {
147 id,
148 ...window,
149 usedPercent: clampPercent(usedPercent),
150 ...(resetsAt ? { resetsAt } : {}),
151 };
153​
154/** Stream utilization is 0-1. Drop unnamed overage buckets until probed to avoid unreconcilable rows. */
155export function claudeRateLimitEventToUpdate(
156 info: SDKRateLimitInfo,
157 names: ClaudeScopedLimitNames,
158): ProviderUsageLimitsUpdate | undefined {
159 const type: string | undefined = info.rateLimitType;
160 if (!type || typeof info.utilization !== "number") {
161 return undefined;
162 }
163 const usedPercent = info.utilization * 100;
164 const resetsAt = isoFromEpochSeconds(info.resetsAt);
165 if (type in WINDOWS) {
166 return { windows: [makeWindow(type, usedPercent, resetsAt)] };
167 }
168 if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) {
169 return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] };
170 }
171 return undefined;
173​
174/** get_usage percentages are already 0-100. */
175export function claudeUsageResponseToLimits(input: {
176 readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
177 readonly checkedAt: string;
178}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } {
179 const { response, checkedAt } = input;
180 if (!response.rate_limits_available || !response.rate_limits) {
181 return {
182 limits: makeUnavailableUsageLimits({
183 checkedAt,
184 reason: response.rate_limits_available ? "probeFailed" : "unsupported",
185 }),
186 names: { overageIncluded: undefined },
187 };
188 }
189 const windows: ServerProviderUsageWindow[] = [];
190 for (const id of Object.keys(WINDOWS)) {
191 const window = response.rate_limits[id as "five_hour" | "seven_day"];
192 if (!window || typeof window.utilization !== "number") continue;
193 windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at)));
194 }
195 // The CLI's overage allowlist currently has one model; events refer to its first entry.
196 let overageIncluded: string | undefined;
197 for (const entry of readModelScoped(response.rate_limits)) {
198 if (typeof entry.utilization !== "number") continue;
199 windows.push(
200 scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)),
201 );
202 overageIncluded ??= entry.display_name;
203 }
204 const spendPercent = readSpendPercent(response.rate_limits);
205 if (spendPercent !== undefined) {
206 windows.push({
207 id: "monthly_spend",
208 kind: "monthly",
209 label: "Monthly spend",
210 usedPercent: spendPercent,
211 });
212 }
213 return {
214 limits: makeUsageLimits({ checkedAt, windows }),
215 names: { overageIncluded },
216 };
218​
219export const recordClaudeUsageResponse = (
220 namesRef: Ref.Ref<ClaudeScopedLimitNames>,
221 input: Parameters<typeof claudeUsageResponseToLimits>[0],
222): Effect.Effect<ServerProviderUsageLimits> => {
223 const { limits, names } = claudeUsageResponseToLimits(input);
224 return Ref.set(namesRef, names).pipe(Effect.as(limits));
225};