/** * Claude Code subscription usage. Both sources produce windows with the same * ids so a turn-driven `rate_limit_event` lands on the row the SDK's * `get_usage` read established: * * - `get_usage` (on demand, during the capabilities probe) reports every * window at once as 0–100 percentages with ISO reset times. * - `rate_limit_event` (streamed during a turn) names one window at a time * with a 0–1 utilization fraction and an epoch-seconds reset. * * @module provider/Layers/claudeUsageLimits */import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";import type { ProviderUsageLimitsUpdate, ServerProviderUsageLimits, ServerProviderUsageWindow,} from "@t3tools/contracts";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as Option from "effect/Option";import * as Ref from "effect/Ref"; import { clampPercent, makeUnavailableUsageLimits, makeUsageLimits,} from "../providerUsageLimits.ts"; const SESSION_MINS = 5 * 60;const WEEK_MINS = 7 * 24 * 60; /** * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped * weeklies are additive on top of these: the CLI reports them under * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included * model bucket (Fable today) as `seven_day_overage_included`. */const WINDOWS: Readonly< Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">>> = { five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS },}; /** * The streamed event names the overage-included bucket by type * (`seven_day_overage_included`), while `get_usage` names it by the model's * `display_name`. Which model that is changes over time, so the probe records * the name it saw and the event mapper reuses it; the mid-turn update then * lands on the row the probe drew instead of opening a second one. */const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; export interface ClaudeScopedLimitNames { readonly overageIncluded: string | undefined;} export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({ overageIncluded: undefined,}); function scopedWindowId(displayName: string): string { return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;} function scopedWindow( displayName: string, usedPercent: number, resetsAt: string | undefined,): ServerProviderUsageWindow { return { id: scopedWindowId(displayName), kind: "weekly", label: `Weekly · ${displayName}`, windowDurationMins: WEEK_MINS, usedPercent: clampPercent(usedPercent), ...(resetsAt ? { resetsAt } : {}), };} /** * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is * read structurally until the `.d.ts` catches up. */interface ModelScopedWindow { readonly display_name: string; readonly utilization: number | null; readonly resets_at: string | null;} function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> { const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; if (!Array.isArray(raw)) return []; return raw.filter( (entry): entry is ModelScopedWindow => typeof entry === "object" && entry !== null && typeof (entry as ModelScopedWindow).display_name === "string", );} function isoFromEpochSeconds(value: number | undefined): string | undefined { if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; const dt = DateTime.make(value * 1000); return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;} function isoFromString(value: string | null | undefined): string | undefined { if (!value) return undefined; const dt = DateTime.make(value); return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;} function makeWindow( id: keyof typeof WINDOWS & string, usedPercent: number, resetsAt: string | undefined,): ServerProviderUsageWindow { const window = WINDOWS[id]!; return { id, ...window, usedPercent: clampPercent(usedPercent), ...(resetsAt ? { resetsAt } : {}), };} /** * Utilization is a 0–1 fraction on the streamed event. An overage-included * event before any probe has named the bucket is dropped: guessing a name * would draw a row the next probe cannot reconcile. */export function claudeRateLimitEventToUpdate( info: SDKRateLimitInfo, names: ClaudeScopedLimitNames,): ProviderUsageLimitsUpdate | undefined { const type: string | undefined = info.rateLimitType; if (!type || typeof info.utilization !== "number") { return undefined; } const usedPercent = info.utilization * 100; const resetsAt = isoFromEpochSeconds(info.resetsAt); if (type in WINDOWS) { return { windows: [makeWindow(type, usedPercent, resetsAt)] }; } if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; } return undefined;} /** * Percentages on the `get_usage` response are already 0–100. Also yields the * scoped-bucket names the response carried, for the event mapper to reuse. */export function claudeUsageResponseToLimits(input: { readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; readonly checkedAt: string;}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { const { response, checkedAt } = input; if (!response.rate_limits_available || !response.rate_limits) { return { limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), names: { overageIncluded: undefined }, }; } const windows: ServerProviderUsageWindow[] = []; for (const id of Object.keys(WINDOWS)) { const window = response.rate_limits[id as "five_hour" | "seven_day"]; if (!window || typeof window.utilization !== "number") continue; windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); } // The CLI filters `model_scoped` to the overage-included allowlist, which // today holds one model; the first entry is the one the event refers to. let overageIncluded: string | undefined; for (const entry of readModelScoped(response.rate_limits)) { if (typeof entry.utilization !== "number") continue; windows.push( scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), ); // Only a bucket that drew a row may receive events; naming one that was // skipped would let a mid-turn event open a row the probe never showed. overageIncluded ??= entry.display_name; } return { limits: makeUsageLimits({ checkedAt, windows }), names: { overageIncluded }, };} /** Probe-side helper: map the response and remember the scoped names for events. */export const recordClaudeUsageResponse = ( namesRef: Ref.Ref<ClaudeScopedLimitNames>, input: Parameters<typeof claudeUsageResponseToLimits>[0],): Effect.Effect<ServerProviderUsageLimits> => { const { limits, names } = claudeUsageResponseToLimits(input); return Ref.set(namesRef, names).pipe(Effect.as(limits));};
/**
* Claude Code subscription usage. Both sources produce windows with the same
* ids so a turn-driven `rate_limit_event` lands on the row the SDK's
* `get_usage` read established:
*
* - `get_usage` (on demand, during the capabilities probe) reports every
* window at once as 0–100 percentages with ISO reset times.
* - `rate_limit_event` (streamed during a turn) names one window at a time
* with a 0–1 utilization fraction and an epoch-seconds reset.
*
* @module provider/Layers/claudeUsageLimits
*/
import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
import type {
ProviderUsageLimitsUpdate,
ServerProviderUsageLimits,
ServerProviderUsageWindow,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import {
clampPercent,
makeUnavailableUsageLimits,
makeUsageLimits,
} from "../providerUsageLimits.ts";
const SESSION_MINS = 5 * 60;
const WEEK_MINS = 7 * 24 * 60;
/**
* The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped
* weeklies are additive on top of these: the CLI reports them under
* `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included
* model bucket (Fable today) as `seven_day_overage_included`.
*/
const WINDOWS: Readonly<
Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">>
> = {
five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS },
seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS },
};
/**
* The streamed event names the overage-included bucket by type
* (`seven_day_overage_included`), while `get_usage` names it by the model's
* `display_name`. Which model that is changes over time, so the probe records
* the name it saw and the event mapper reuses it; the mid-turn update then
* lands on the row the probe drew instead of opening a second one.
*/
const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included";
export interface ClaudeScopedLimitNames {
readonly overageIncluded: string | undefined;
}
export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({
overageIncluded: undefined,
});
function scopedWindowId(displayName: string): string {
return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
}
function scopedWindow(
displayName: string,
usedPercent: number,
resetsAt: string | undefined,
): ServerProviderUsageWindow {
return {
id: scopedWindowId(displayName),
kind: "weekly",
label: `Weekly · ${displayName}`,
windowDurationMins: WEEK_MINS,
usedPercent: clampPercent(usedPercent),
...(resetsAt ? { resetsAt } : {}),
};
}
/**
* `model_scoped` shipped in the CLI after the SDK typings we pin, so it is
* read structurally until the `.d.ts` catches up.
*/
interface ModelScopedWindow {
readonly display_name: string;
readonly utilization: number | null;
readonly resets_at: string | null;
}
function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> {
const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped;
if (!Array.isArray(raw)) return [];
return raw.filter(
(entry): entry is ModelScopedWindow =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as ModelScopedWindow).display_name === "string",
);
}
function isoFromEpochSeconds(value: number | undefined): string | undefined {
if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined;
const dt = DateTime.make(value * 1000);
return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
}
function isoFromString(value: string | null | undefined): string | undefined {
if (!value) return undefined;
const dt = DateTime.make(value);
return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
}
function makeWindow(
id: keyof typeof WINDOWS & string,
usedPercent: number,
resetsAt: string | undefined,
): ServerProviderUsageWindow {
const window = WINDOWS[id]!;
return {
id,
...window,
usedPercent: clampPercent(usedPercent),
...(resetsAt ? { resetsAt } : {}),
};
}
/**
* Utilization is a 0–1 fraction on the streamed event. An overage-included
* event before any probe has named the bucket is dropped: guessing a name
* would draw a row the next probe cannot reconcile.
*/
export function claudeRateLimitEventToUpdate(
info: SDKRateLimitInfo,
names: ClaudeScopedLimitNames,
): ProviderUsageLimitsUpdate | undefined {
const type: string | undefined = info.rateLimitType;
if (!type || typeof info.utilization !== "number") {
return undefined;
}
const usedPercent = info.utilization * 100;
const resetsAt = isoFromEpochSeconds(info.resetsAt);
if (type in WINDOWS) {
return { windows: [makeWindow(type, usedPercent, resetsAt)] };
}
if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) {
return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] };
}
return undefined;
}
/**
* Percentages on the `get_usage` response are already 0–100. Also yields the
* scoped-bucket names the response carried, for the event mapper to reuse.
*/
export function claudeUsageResponseToLimits(input: {
readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
readonly checkedAt: string;
}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } {
const { response, checkedAt } = input;
if (!response.rate_limits_available || !response.rate_limits) {
return {
limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }),
names: { overageIncluded: undefined },
};
}
const windows: ServerProviderUsageWindow[] = [];
for (const id of Object.keys(WINDOWS)) {
const window = response.rate_limits[id as "five_hour" | "seven_day"];
if (!window || typeof window.utilization !== "number") continue;
windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at)));
}
// The CLI filters `model_scoped` to the overage-included allowlist, which
// today holds one model; the first entry is the one the event refers to.
let overageIncluded: string | undefined;
for (const entry of readModelScoped(response.rate_limits)) {
if (typeof entry.utilization !== "number") continue;
windows.push(
scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)),
);
// Only a bucket that drew a row may receive events; naming one that was
// skipped would let a mid-turn event open a row the probe never showed.
overageIncluded ??= entry.display_name;
}
return {
limits: makeUsageLimits({ checkedAt, windows }),
names: { overageIncluded },
};
}
/** Probe-side helper: map the response and remember the scoped names for events. */
export const recordClaudeUsageResponse = (
namesRef: Ref.Ref<ClaudeScopedLimitNames>,
input: Parameters<typeof claudeUsageResponseToLimits>[0],
): Effect.Effect<ServerProviderUsageLimits> => {
const { limits, names } = claudeUsageResponseToLimits(input);
return Ref.set(namesRef, names).pipe(Effect.as(limits));
};