Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +11,7 @@ import {
displayLimitWindows,
formatResetsIn,
remainingPercent,
usageCreditSummary,
type LimitAccount,
type LimitPoolWindow,
} from "@t3tools/shared/usageLimits";
Expand All @@ -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<Record<string, string>> = {
Expand Down Expand Up @@ -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 (
<View className="gap-3 rounded-[24px] border-continuous bg-grouped-card p-4">
<View className="gap-1">
<Text className="text-sm font-t3-medium text-foreground">Usage credits</Text>
<View className="flex-row items-baseline gap-1.5">
<Text className="text-3xl font-t3-bold tabular-nums text-foreground">
{summary.amount}
</Text>
{summary.remainingPercent !== null ? (
<Text className="text-sm text-foreground-muted">left</Text>
) : null}
</View>
</View>
{showAccountName ? (
account.email && !account.displayName ? (
<AccountInstanceLabel key={account.email} value={account.email} />
) : (
<Text className="text-xs font-t3-medium text-foreground">{accountName(account)}</Text>
)
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{summary.remainingPercent !== null ? (
<View
accessibilityRole="progressbar"
accessibilityLabel="Usage credits remaining under monthly limit"
accessibilityValue={{
min: 0,
max: 100,
now: summary.remainingPercent,
text: summary.amount,
}}
className="h-7 overflow-hidden rounded-md bg-subtle"
>
<AccountSegment remaining={summary.remainingPercent} color={color} pending={false} />
</View>
) : null}
<Text className="text-xs tabular-nums text-foreground-muted">{summary.description}</Text>
<Pressable
accessibilityRole="link"
className="min-h-11 justify-center"
onPress={() => void Linking.openURL(CLAUDE_USAGE_URL).catch(() => undefined)}
>
<Text className="text-sm font-t3-medium text-primary">Manage usage credits</Text>
</Pressable>
</View>
);
}

export function UsageLimitsSection({
now,
failedLabels,
Expand Down Expand Up @@ -284,6 +345,17 @@ export function UsageLimitsSection({
/>
);
})}
{pool.accounts.map((account) =>
account.limits.extraUsage ? (
<UsageCreditsCard
key={`credits:${account.key}`}
account={account}
credits={account.limits.extraUsage}
color={colors.claude}
showAccountName={pool.accounts.length > 1}
/>
) : null,
)}
</View>
</Fragment>
);
Expand Down
49 changes: 48 additions & 1 deletion apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -165,6 +168,7 @@ export function AccountLimits(props: {
))}
</View>
)}
{limits.extraUsage ? <ExtraUsageDetails extraUsage={limits.extraUsage} /> : null}
{externalUsage ? (
<Pressable
accessibilityRole="link"
Expand All @@ -179,6 +183,49 @@ export function AccountLimits(props: {
);
}

function ExtraUsageDetails({ extraUsage }: { readonly extraUsage: ServerProviderExtraUsage }) {
const spent =
typeof extraUsage.usedCredits === "number"
? formatUsageCreditAmount(extraUsage.usedCredits, extraUsage.currency)
: undefined;
const monthlyLimit =
typeof extraUsage.monthlyLimit === "number"
? formatUsageCreditAmount(extraUsage.monthlyLimit, extraUsage.currency)
: extraUsage.monthlyLimit === null
? "No monthly cap"
: undefined;

return (
<View className="gap-1 border-t border-border-subtle pt-3">
<Text className="text-sm font-t3-medium text-foreground">Usage credits</Text>
<Text className="text-sm text-foreground-muted">{extraUsage.isEnabled ? "On" : "Off"}</Text>
{extraUsage.isEnabled && spent ? (
<Text className="text-sm text-foreground-muted">
Spent {spent}
{monthlyLimit && monthlyLimit !== "No monthly cap" ? ` of ${monthlyLimit}` : ""} this
month
</Text>
) : null}
{extraUsage.isEnabled && monthlyLimit === "No monthly cap" ? (
<Text className="text-sm text-foreground-muted">No monthly spend cap</Text>
) : null}
{extraUsage.isEnabled && !spent && monthlyLimit && monthlyLimit !== "No monthly cap" ? (
<Text className="text-sm text-foreground-muted">Monthly cap: {monthlyLimit}</Text>
) : null}
{extraUsage.isEnabled && !spent && !monthlyLimit ? (
<Text className="text-sm text-foreground-muted">Spend data unavailable</Text>
) : null}
<Pressable
accessibilityRole="link"
className="min-h-11 justify-center"
onPress={() => void Linking.openURL(CLAUDE_USAGE_URL).catch(() => undefined)}
>
<Text className="text-sm font-t3-medium text-primary">Manage in Claude</Text>
</Pressable>
</View>
);
}

const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
reset: "Reset applied. Your windows have cleared.",
nothingToReset: "Nothing to reset right now.",
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/provider/claudeUsageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down
66 changes: 64 additions & 2 deletions apps/server/src/provider/claudeUsageLimits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk";
import type {
ProviderUsageLimitsUpdate,
ServerProviderExtraUsage,
ServerProviderUsageLimits,
ServerProviderUsageWindow,
} from "@t3tools/contracts";
Expand All @@ -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<NonNullable<SDKControlGetUsageResponse["rate_limits"]>, "extra_usage"> & {
readonly extra_usage?: unknown;
})
| null;
};

const WINDOWS: Readonly<
Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">>
> = {
Expand Down Expand Up @@ -82,6 +92,54 @@ function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> {
);
}

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<string, unknown>;
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);
Expand Down Expand Up @@ -136,7 +194,7 @@ export function claudeRateLimitEventToUpdate(
* Percentages on the `get_usage` response are already 0–100.
*/
export function claudeUsageResponseToLimits(input: {
readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
readonly response: ClaudeUsageResponse;
readonly checkedAt: string;
}): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } {
const { response, checkedAt } = input;
Expand All @@ -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;
Expand All @@ -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 },
};
}
Expand Down
Loading
Loading