diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 9807ea4158db..ed6b56cecfce 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -1,7 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type ServerProviderExtraUsage } from "@t3tools/contracts"; import { + CLAUDE_USAGE_URL, collectLimitAccounts, collectExternalUsageLinks, collectLimitNotices, @@ -10,6 +11,7 @@ import { displayLimitWindows, formatResetsIn, remainingPercent, + usageCreditSummary, type LimitAccount, type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; @@ -23,7 +25,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { environmentPresentations } from "../../state/presentation"; -import { ResetCredits } from "./UsageLimitsSection"; +import { AccountInstanceLabel, ResetCredits } from "./UsageLimitsSection"; import { useProviderColors } from "./usageProviders"; const DRIVER_LABEL: Partial> = { @@ -211,6 +213,65 @@ function PoolWindowCard({ ); } +function UsageCreditsCard({ + credits, + color, + account, + showAccountName, +}: { + readonly credits: ServerProviderExtraUsage; + readonly color: string; + readonly account: LimitAccount; + readonly showAccountName: boolean; +}) { + const summary = usageCreditSummary(credits); + return ( + + + Usage credits + + + {summary.amount} + + {summary.remainingPercent !== null ? ( + left + ) : null} + + + {showAccountName ? ( + account.email && !account.displayName ? ( + + ) : ( + {accountName(account)} + ) + ) : null} + {summary.remainingPercent !== null ? ( + + + + ) : null} + {summary.description} + void Linking.openURL(CLAUDE_USAGE_URL).catch(() => undefined)} + > + Manage usage credits + + + ); +} + export function UsageLimitsSection({ now, failedLabels, @@ -284,6 +345,17 @@ export function UsageLimitsSection({ /> ); })} + {pool.accounts.map((account) => + account.limits.extraUsage ? ( + 1} + /> + ) : null, + )} ); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index b72770e7b172..bbe89a31d8d9 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -6,12 +6,15 @@ import { type ProviderConsumeResetCreditOutcome, type ProviderConsumeResetCreditInput, type ServerProvider, + type ServerProviderExtraUsage, type ServerProviderResetCredits, type ServerProviderUsageWindow, type UsageProviderKind, } from "@t3tools/contracts"; import { elapsedShare, + CLAUDE_USAGE_URL, + formatUsageCreditAmount, formatDuration, formatResetsIn, limitsNotice, @@ -94,7 +97,7 @@ function WindowRow(props: { ); } -function AccountInstanceLabel({ value }: { readonly value: string }) { +export function AccountInstanceLabel({ value }: { readonly value: string }) { const [revealed, setRevealed] = useState(false); if (!value.includes("@")) { return ( @@ -165,6 +168,7 @@ export function AccountLimits(props: { ))} )} + {limits.extraUsage ? : null} {externalUsage ? ( + Usage credits + {extraUsage.isEnabled ? "On" : "Off"} + {extraUsage.isEnabled && spent ? ( + + Spent {spent} + {monthlyLimit && monthlyLimit !== "No monthly cap" ? ` of ${monthlyLimit}` : ""} this + month + + ) : null} + {extraUsage.isEnabled && monthlyLimit === "No monthly cap" ? ( + No monthly spend cap + ) : null} + {extraUsage.isEnabled && !spent && monthlyLimit && monthlyLimit !== "No monthly cap" ? ( + Monthly cap: {monthlyLimit} + ) : null} + {extraUsage.isEnabled && !spent && !monthlyLimit ? ( + Spend data unavailable + ) : null} + void Linking.openURL(CLAUDE_USAGE_URL).catch(() => undefined)} + > + Manage in Claude + + + ); +} + const OUTCOME_TEXT: Record = { reset: "Reset applied. Your windows have cleared.", nothingToReset: "Nothing to reset right now.", diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index a2da39b00d72..d065a4cade8e 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -6,6 +6,57 @@ const checkedAt = "2026-07-18T10:00:00.000Z"; const noNames = { overageIncluded: undefined } as const; describe("claudeUsageResponseToLimits", () => { + it("maps Claude paid-plan usage-credit spend and monthly cap", () => { + const extraUsage = { + is_enabled: true, + monthly_limit: 20000, + used_credits: 9967, + utilization: 49.835, + currency: "USD", + decimal_places: 2, + }; + expect( + claudeUsageResponseToLimits({ + checkedAt, + response: { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 12, resets_at: null }, + extra_usage: extraUsage, + }, + }, + }).limits.extraUsage, + ).toEqual({ + checkedAt, + isEnabled: true, + monthlyLimit: 200, + usedCredits: 99.67, + utilization: 49.835, + currency: "USD", + }); + }); + + it.each([ + { decimal_places: undefined, expected: 10 }, + { decimal_places: 0, expected: 1000 }, + { decimal_places: 3, expected: 1 }, + { decimal_places: -1, expected: 10 }, + ])("normalizes money with decimal_places=$decimal_places", ({ decimal_places, expected }) => { + const extraUsage = { + is_enabled: true, + monthly_limit: null, + used_credits: 1000, + utilization: null, + decimal_places, + }; + const { limits } = claudeUsageResponseToLimits({ + checkedAt, + response: { rate_limits_available: true, rate_limits: { extra_usage: extraUsage } }, + }); + expect(limits.extraUsage?.usedCredits).toBe(expected); + expect(limits.extraUsage?.monthlyLimit).toBeNull(); + }); + it("maps the session, weekly, and model-scoped weekly windows", () => { expect( claudeUsageResponseToLimits({ @@ -37,6 +88,13 @@ describe("claudeUsageResponseToLimits", () => { names: { overageIncluded: "Fable" }, limits: { checkedAt, + extraUsage: { + checkedAt, + isEnabled: false, + monthlyLimit: null, + usedCredits: null, + utilization: null, + }, windows: [ { id: "five_hour", diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index a4dd6d0152be..a2ab33013da8 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -1,6 +1,7 @@ import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; import type { ProviderUsageLimitsUpdate, + ServerProviderExtraUsage, ServerProviderUsageLimits, ServerProviderUsageWindow, } from "@t3tools/contracts"; @@ -18,6 +19,15 @@ import { const SESSION_MINS = 5 * 60; const WEEK_MINS = 7 * 24 * 60; +type ClaudeUsageResponse = { + readonly rate_limits_available: SDKControlGetUsageResponse["rate_limits_available"]; + readonly rate_limits: + | (Omit, "extra_usage"> & { + readonly extra_usage?: unknown; + }) + | null; +}; + const WINDOWS: Readonly< Record> > = { @@ -82,6 +92,54 @@ function readModelScoped(rateLimits: object): ReadonlyArray { ); } +function readExtraUsage( + rateLimits: object, + checkedAt: string, +): ServerProviderExtraUsage | undefined { + const raw = (rateLimits as { readonly extra_usage?: unknown }).extra_usage; + if (typeof raw !== "object" || raw === null) return undefined; + const record = raw as Record; + if (typeof record["is_enabled"] !== "boolean") return undefined; + + const decimalPlaces = record["decimal_places"]; + const divisor = + typeof decimalPlaces === "number" && + Number.isInteger(decimalPlaces) && + decimalPlaces >= 0 && + decimalPlaces <= 6 + ? 10 ** decimalPlaces + : 100; + const toAmount = (value: unknown) => { + const amount = readNullableNumber(value); + return typeof amount === "number" ? amount / divisor : amount; + }; + const usedCredits = toAmount(record["used_credits"]); + const monthlyLimit = toAmount(record["monthly_limit"]); + const utilization = readNullableNumber(record["utilization"]); + const currency = readNullableString(record["currency"]); + const disabledReason = readNullableString(record["disabled_reason"]); + + return { + checkedAt, + isEnabled: record["is_enabled"], + ...(usedCredits !== undefined ? { usedCredits } : {}), + ...(monthlyLimit !== undefined ? { monthlyLimit } : {}), + ...(utilization !== undefined ? { utilization } : {}), + ...(currency !== undefined ? { currency } : {}), + ...(disabledReason !== undefined ? { disabledReason } : {}), + }; +} + +function readNullableNumber(value: unknown): number | null | undefined { + if (value === null) return null; + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function readNullableString(value: unknown): string | null | undefined { + if (value === null) return null; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + function isoFromEpochSeconds(value: number | undefined): string | undefined { if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; const dt = DateTime.make(value * 1000); @@ -136,7 +194,7 @@ export function claudeRateLimitEventToUpdate( * Percentages on the `get_usage` response are already 0–100. */ export function claudeUsageResponseToLimits(input: { - readonly response: Pick; + readonly response: ClaudeUsageResponse; readonly checkedAt: string; }): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { const { response, checkedAt } = input; @@ -147,6 +205,7 @@ export function claudeUsageResponseToLimits(input: { }; } const windows: ServerProviderUsageWindow[] = []; + const extraUsage = readExtraUsage(response.rate_limits, checkedAt); 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; @@ -163,7 +222,10 @@ export function claudeUsageResponseToLimits(input: { overageIncluded ??= entry.display_name; } return { - limits: makeUsageLimits({ checkedAt, windows }), + limits: { + ...makeUsageLimits({ checkedAt, windows }), + ...(extraUsage ? { extraUsage } : {}), + }, names: { overageIncluded }, }; } diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 459363e5fe81..663cc71260c3 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -213,6 +213,114 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect("reads Claude credit amounts and their read time without quota windows", () => + Effect.gen(function* () { + const checkedAt = "2026-10-15T12:00:00.000Z"; + yield* TestClock.setTime(Date.parse(checkedAt)); + const test = fixture({ + accounts: accounts.map((account) => ({ ...account, provider: "claude" })), + upstream: (request) => ({ + status: 200, + body: { + extra_usage: + request.auth_index === "a" + ? { + is_enabled: true, + monthly_limit: 20000, + used_credits: 9967, + utilization: 49.835, + currency: "USD", + } + : { + is_enabled: true, + monthly_limit: null, + used_credits: 750, + utilization: null, + currency: "KWD", + decimal_places: 3, + }, + }, + }), + }); + const api = yield* test.api; + const result = yield* api.readAccounts(config); + expect(result.map((account) => account.usageLimits)).toEqual([ + { + checkedAt, + windows: [], + extraUsage: { + checkedAt, + isEnabled: true, + monthlyLimit: 200, + usedCredits: 99.67, + utilization: 49.835, + currency: "USD", + }, + }, + { + checkedAt, + windows: [], + extraUsage: { + checkedAt, + isEnabled: true, + monthlyLimit: null, + usedCredits: 0.75, + utilization: null, + currency: "KWD", + }, + }, + ]); + }), + ); + + it.effect.each([ + { name: "all omitted", extraUsage: { is_enabled: true }, expected: {} }, + { + name: "monthly limit omitted", + extraUsage: { is_enabled: true, used_credits: 1250, utilization: 25 }, + expected: { usedCredits: 12.5, utilization: 25 }, + }, + { + name: "spend omitted", + extraUsage: { is_enabled: true, monthly_limit: 5000, utilization: 25 }, + expected: { monthlyLimit: 50, utilization: 25 }, + }, + { + name: "utilization omitted", + extraUsage: { is_enabled: true, monthly_limit: 5000, used_credits: 1250 }, + expected: { monthlyLimit: 50, usedCredits: 12.5 }, + }, + { + name: "explicit nulls", + extraUsage: { is_enabled: true, monthly_limit: null, used_credits: null, utilization: null }, + expected: { monthlyLimit: null, usedCredits: null, utilization: null }, + }, + ])("keeps Claude quota windows with credit numbers $name", ({ extraUsage, expected }) => + Effect.gen(function* () { + const checkedAt = "2026-10-15T12:00:00.000Z"; + yield* TestClock.setTime(Date.parse(checkedAt)); + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: () => ({ + status: 200, + body: { + five_hour: { utilization: 10, resets_at: null }, + seven_day: { utilization: 50, resets_at: null }, + extra_usage: extraUsage, + }, + }), + }); + const api = yield* test.api; + const [account] = yield* api.readAccounts(config); + expect(account?.usageLimits.unavailable).toBeUndefined(); + expect(account?.usageLimits.windows.map(({ id, usedPercent }) => [id, usedPercent])).toEqual([ + ["five_hour", 10], + ["seven_day", 50], + ]); + expect(account?.usageLimits.extraUsage).toEqual({ checkedAt, isEnabled: true, ...expected }); + }), + ); + it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => Effect.gen(function* () { const test = fixture(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index b6404fce42ae..3daad8a639bf 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -53,6 +53,19 @@ const ClaudeWindow = Schema.Struct({ const ClaudeUsage = Schema.Struct({ five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)), seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)), + extra_usage: Schema.optional( + Schema.NullOr( + Schema.Struct({ + is_enabled: Schema.Boolean, + monthly_limit: Schema.optional(Schema.NullOr(Schema.Number)), + used_credits: Schema.optional(Schema.NullOr(Schema.Number)), + utilization: Schema.optional(Schema.NullOr(Schema.Number)), + currency: Schema.optionalKey(Schema.NullOr(Schema.String)), + decimal_places: Schema.optional(Schema.Number), + disabled_reason: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), limits: Schema.optional( Schema.Array( Schema.Struct({ @@ -243,6 +256,7 @@ export const makeCliproxyApi = Effect.gen(function* () { rate_limits: { five_hour: usage.five_hour ?? null, seven_day: usage.seven_day ?? null, + ...(usage.extra_usage !== undefined ? { extra_usage: usage.extra_usage } : {}), model_scoped, }, }, diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 0b1bc700495d..a3de062e08c3 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; -import { limitsNotice } from "@t3tools/shared/usageLimits"; +import { limitsNotice, usageCreditSummary } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; @@ -85,6 +85,9 @@ function UsageLimitsBannerBody({ (account.instanceId ? { instanceId: account.instanceId } : undefined); const notice = limitsNotice(account.limits); const externalUsage = account.limits.externalUsage; + const credits = account.limits.extraUsage + ? usageCreditSummary(account.limits.extraUsage) + : null; return (
{report.accounts.length > 1 ? ( @@ -102,6 +105,12 @@ function UsageLimitsBannerBody({ now={now} /> )} + {credits ? ( + + Usage credits: {credits.amount} + {credits.remainingPercent !== null ? " left" : ""} · {credits.description} + + ) : null} {externalUsage ? (
+ ); +} + function SegmentPopover({ account, window, @@ -181,6 +228,9 @@ function SegmentPopover({ +{reset.restoresPercent}% of pool ) : null} + {account.limits.extraUsage ? ( + + ) : null} {credits && redeem ? (
@@ -490,6 +540,79 @@ function PoolWindowCard({ ); } +function UsageCreditsCard({ + account, + credits, + color, + showAccountName, +}: { + readonly account: LimitAccount; + readonly credits: ServerProviderExtraUsage; + readonly color: string; + readonly showAccountName: boolean; +}) { + const summary = usageCreditSummary(credits); + return ( +
+
+ Usage credits + + + {summary.amount} + + {summary.remainingPercent !== null ? ( + left + ) : null} + +
+
+ {showAccountName ? ( + account.email && !account.displayName ? ( + + ) : ( + + ) + ) : null} + {summary.remainingPercent !== null ? ( +
+
+ + {Math.round(summary.remainingPercent)}% + +
+ ) : null} + {summary.description} + + Manage usage credits + +
+
+ ); +} + function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { const color = barColor(pool.driver); const label = providerClients.get(pool.driver)?.label ?? String(pool.driver); @@ -519,6 +642,17 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu /> ); })} + {pool.accounts.map((account) => + account.limits.extraUsage ? ( + 1} + /> + ) : null, + )} ); } diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 894a33a05e5f..d2a818b7fd55 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -31,6 +31,18 @@ export const ServerProviderResetCredits = Schema.Struct({ }); export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; +/** Optional paid-plan usage-credit details reported by Claude. */ +export const ServerProviderExtraUsage = Schema.Struct({ + checkedAt: Schema.optional(IsoDateTime), + isEnabled: Schema.Boolean, + usedCredits: Schema.optional(Schema.Union([Schema.Number, Schema.Null])), + monthlyLimit: Schema.optional(Schema.Union([Schema.Number, Schema.Null])), + utilization: Schema.optional(Schema.Union([Schema.Number, Schema.Null])), + currency: Schema.optional(Schema.Union([TrimmedNonEmptyString, Schema.Null])), + disabledReason: Schema.optional(Schema.Union([TrimmedNonEmptyString, Schema.Null])), +}); +export type ServerProviderExtraUsage = typeof ServerProviderExtraUsage.Type; + /** * `unavailable` distinguishes an account that can never report windows (API * key, Bedrock) from a probe that failed this time, so clients can keep the @@ -41,6 +53,7 @@ export const ServerProviderUsageLimits = Schema.Struct({ windows: ForwardCompatibleArray(ServerProviderUsageWindow), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), + extraUsage: Schema.optional(ServerProviderExtraUsage), externalUsage: Schema.optional( Schema.Struct({ label: TrimmedNonEmptyString, diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 9b305a74b98e..be6c3794b5a4 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -76,6 +76,43 @@ describe("applyUsageLimitsUpdate", () => { resetCredits, }); }); + + it("preserves Claude usage-credit details when a streamed window changes", () => { + const extraUsage = { + checkedAt, + isEnabled: true, + usedCredits: 3.42, + monthlyLimit: 25, + currency: "USD", + }; + const next = applyUsageLimitsUpdate({ + previous: { ...published, extraUsage }, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { windows: [{ ...session, usedPercent: 55 }] }, + }); + + expect(next).toEqual({ + checkedAt: "2026-09-03T12:00:05.000Z", + windows: [{ ...session, usedPercent: 55 }, weekly], + extraUsage, + }); + }); + + it("pins legacy credit data to its original read through repeated stream updates", () => { + const extraUsage = { isEnabled: true, usedCredits: 20, monthlyLimit: 200 }; + const first = applyUsageLimitsUpdate({ + previous: { ...published, extraUsage }, + checkedAt: "2026-09-03T12:00:05.000Z", + update: { windows: [{ ...session, usedPercent: 55 }] }, + }); + const second = applyUsageLimitsUpdate({ + previous: first, + checkedAt: "2026-09-03T12:00:10.000Z", + update: { windows: [{ ...session, usedPercent: 60 }] }, + }); + expect(second?.checkedAt).toBe("2026-09-03T12:00:10.000Z"); + expect(second?.extraUsage).toEqual({ ...extraUsage, checkedAt }); + }); }); describe("resolveUsageLimitsAfterProbe", () => { diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index bcb234b9152b..416310560eda 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -84,6 +84,14 @@ export function applyUsageLimitsUpdate(input: { return { ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), + ...(previous?.extraUsage !== undefined + ? { + extraUsage: { + ...previous.extraUsage, + checkedAt: previous.extraUsage.checkedAt ?? previous.checkedAt, + }, + } + : {}), }; } diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index e161d62530a8..1b4225c30500 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -20,6 +20,8 @@ import { displayLimitWindows, elapsedShare, formatResetsIn, + formatUsageCreditAmount, + usageCreditSummary, limitsNotice, paceOf, providersWithLimits, @@ -112,6 +114,53 @@ describe("limitsNotice", () => { }); }); +describe("usageCreditSummary", () => { + it("shows the amount left under the monthly cap and labels the spend", () => { + const summary = usageCreditSummary({ + isEnabled: true, + usedCredits: 99.67, + monthlyLimit: 200, + currency: "USD", + }); + expect(summary.amount).toBe("$100.33"); + expect(summary.remainingPercent).toBeCloseTo(50.165); + expect(summary.description).toBe("$99.67 of $200.00 spent this month"); + }); + + it("does not invent a balance for unlimited, missing, or disabled spend data", () => { + expect(usageCreditSummary({ isEnabled: true, monthlyLimit: null })).toEqual({ + amount: "No cap", + remainingPercent: null, + description: "Monthly spend data unavailable", + }); + expect(usageCreditSummary({ isEnabled: true, monthlyLimit: 200, currency: "USD" })).toEqual({ + amount: "On", + remainingPercent: null, + description: "$200.00 monthly limit · spend unavailable", + }); + expect(usageCreditSummary({ isEnabled: false, monthlyLimit: 200 })).toEqual({ + amount: "Off", + remainingPercent: null, + description: "Usage credits are disabled.", + }); + }); + + it("clamps exhausted and zero caps to zero remaining", () => { + for (const monthlyLimit of [0, 100]) { + const summary = usageCreditSummary({ isEnabled: true, monthlyLimit, usedCredits: 150 }); + expect(summary.amount).toBe("0"); + expect(summary.remainingPercent).toBe(0); + } + }); +}); + +describe("formatUsageCreditAmount", () => { + it("uses the reported currency and keeps an unknown code visible", () => { + expect(formatUsageCreditAmount(3.42, "USD")).toBe("$3.42"); + expect(formatUsageCreditAmount(3.42, "not-a-currency")).toBe("3.42 not-a-currency"); + }); +}); + describe("providersWithLimits", () => { it("keeps only usable providers whose driver reports limits at all", () => { const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; @@ -284,7 +333,75 @@ describe("pools", () => { expect(collectLimitAccounts(input)).toHaveLength(1); }); - it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { + it.each([false, true])( + "uses the money read time rather than stream time (reverse=%s)", + (reverse) => { + const makeProvider = (checkedAt: string, creditCheckedAt: string, usedCredits: number) => + provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [window], + extraUsage: { + checkedAt: creditCheckedAt, + isEnabled: true, + monthlyLimit: 200, + usedCredits, + currency: "USD", + }, + }, + }); + const streamed = makeProvider("2026-09-03T12:00:00.000Z", "2026-09-03T10:00:00.000Z", 0); + const probed = makeProvider("2026-09-03T11:00:00.000Z", "2026-09-03T11:00:00.000Z", 100); + const reports = [streamed, probed]; + if (reverse) reports.reverse(); + const accounts = collectLimitAccounts( + new Map( + reports.map((report, index) => [ + EnvironmentId.make(`env-${index}`), + { ...laptop, serverConfig: { providers: [report] } }, + ]), + ), + ); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.limits.checkedAt).toBe("2026-09-03T12:00:00.000Z"); + expect(accounts[0]?.limits.extraUsage).toEqual(probed.usageLimits?.extraUsage); + expect(usageCreditSummary(accounts[0]!.limits.extraUsage!).amount).toBe("$100.00"); + }, + ); + + it.each(["native", "source"])("keeps credit-only accounts from a %s report", (kind) => { + const extraUsage = { isEnabled: true, usedCredits: 99.67, monthlyLimit: 200, currency: "USD" }; + const usageLimits = { checkedAt, windows: [], extraUsage }; + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: kind === "native" ? [provider({ driver: claude, usageLimits })] : [], + usageLimitSources: + kind === "source" + ? [{ ...source, accounts: [{ id: "claude.json", driver: claude, usageLimits }] }] + : [], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(limitsNotice(usageLimits)).toBeNull(); + expect(collectLimitNotices(input)).toEqual([]); + expect(accounts).toHaveLength(1); + const pools = collectLimitPools(accounts, now); + expect(pools).toHaveLength(1); + expect(pools[0]?.windows).toEqual([]); + expect(pools[0]?.accounts[0]?.limits.extraUsage).toEqual(extraUsage); + }); + + it("takes fresh windows from a hub but keeps native account details when the hub omits them", () => { + const extraUsage = { isEnabled: true, usedCredits: 3.42, monthlyLimit: 25, currency: "USD" }; const native = provider({ driver: claude, instanceId: ProviderInstanceId.make("claude"), @@ -293,6 +410,7 @@ describe("pools", () => { checkedAt, windows: [{ ...window, usedPercent: 40 }], resetCredits: { availableCount: 2 }, + extraUsage, }, }); const input = new Map([ @@ -325,6 +443,7 @@ describe("pools", () => { const [account] = collectLimitAccounts(input); expect(account?.limits.windows[0]?.usedPercent).toBe(55); expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.limits.extraUsage).toEqual(extraUsage); expect(account?.redeem).toEqual({ environmentId: "env-a", input: { instanceId: "claude" } }); expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index a4445bcfe41b..bf35d4aa0442 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -7,6 +7,7 @@ import { isProviderAvailable, type ServerProvider, type ServerProviderUsageLimits, + type ServerProviderExtraUsage, type ServerProviderUsageWindow, type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; @@ -18,6 +19,45 @@ const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; +export const CLAUDE_USAGE_URL = "https://claude.ai/settings/usage"; + +export function formatUsageCreditAmount( + amount: number, + currency: string | null | undefined, +): string { + if (!currency) return new Intl.NumberFormat().format(amount); + try { + return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount); + } catch { + return `${new Intl.NumberFormat().format(amount)} ${currency}`; + } +} + +export function usageCreditSummary(credits: ServerProviderExtraUsage) { + const format = (amount: number) => formatUsageCreditAmount(amount, credits.currency); + const spent = credits.usedCredits; + const cap = credits.monthlyLimit; + if (!credits.isEnabled) { + return { amount: "Off", remainingPercent: null, description: "Usage credits are disabled." }; + } + if (typeof cap === "number" && typeof spent === "number") { + return { + amount: format(Math.max(0, cap - spent)), + remainingPercent: cap > 0 ? Math.max(0, Math.min(100, (1 - spent / cap) * 100)) : 0, + description: `${format(spent)} of ${format(cap)} spent this month`, + }; + } + return { + amount: cap === null ? "No cap" : "On", + remainingPercent: null, + description: + typeof spent === "number" + ? `${format(spent)} spent this month` + : typeof cap === "number" + ? `${format(cap)} monthly limit · spend unavailable` + : "Monthly spend data unavailable", + }; +} export function usesChatGptSharing(provider: ServerProvider | null | undefined): boolean { return provider?.auth.status === "authenticated" && provider.auth.subscriptionSharing === true; @@ -169,6 +209,7 @@ export interface LimitAccount { export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] { const accounts = new Map(); const creditSources = new Map(); + const extraUsageSources = new Map(); const hubRedeems = new Map(); const merge = (key: string, next: LimitAccount) => { // Redeeming through a hub also clears the routing cooldown that hub holds @@ -192,6 +233,17 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ) { creditSources.set(key, next); } + const previousExtraUsage = extraUsageSources.get(key); + if ( + next.limits.extraUsage && + (!previousExtraUsage || + Date.parse(next.limits.extraUsage.checkedAt ?? next.limits.checkedAt) > + Date.parse( + previousExtraUsage.limits.extraUsage?.checkedAt ?? previousExtraUsage.limits.checkedAt, + )) + ) { + extraUsageSources.set(key, next); + } const previous = accounts.get(key); if (!previous) { accounts.set(key, next); @@ -207,6 +259,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ]; const winner = fresher ? next : previous; const creditSource = creditSources.get(key); + const extraUsageSource = extraUsageSources.get(key); accounts.set(key, { ...previous, displayName: previous.displayName ?? next.displayName, @@ -222,6 +275,9 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ...(creditSource?.limits.resetCredits ? { resetCredits: creditSource.limits.resetCredits } : { resetCredits: undefined }), + ...(extraUsageSource?.limits.extraUsage + ? { extraUsage: extraUsageSource.limits.extraUsage } + : { extraUsage: undefined }), }, }); }; @@ -476,7 +532,7 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { if (limits.unavailable?.reason === "probeFailed") { return limits.unavailable.message ?? "Could not read limits."; } - return limits.windows.length === 0 ? "No limits reported." : null; + return limits.windows.length === 0 && !limits.extraUsage ? "No limits reported." : null; } export function remainingPercent(window: ServerProviderUsageWindow): number {