Skip to content
Merged
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
85 changes: 38 additions & 47 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
type ServerProviderUsageWindow,
} from "@t3tools/contracts";
import {
formatDuration,
type ResetCreditChoice,
resetCreditChoices,
formatResetsIn,
limitsNotice,
paceOf,
Expand Down Expand Up @@ -243,21 +244,11 @@ export function ResetCredits(props: {
const [status, setStatus] = useState<string | null>(null);
if (dense && credits.availableCount === 0 && status === null) return null;

const expiresIn = credits.nextExpiresAt
? formatDuration(Date.parse(credits.nextExpiresAt) - now)
: null;
const summary =
credits.availableCount === 0
? "No reset credits banked"
: `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${
expiresIn ? ` · next expires in ${expiresIn}` : ""
}`;

const redeem = async () => {
const redeem = async (choice: ResetCreditChoice) => {
if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
setBusy(true);
setStatus(null);
const result = await consume({ environmentId, input });
const result = await consume({ environmentId, input: choice.input });
setBusy(false);
if (result._tag === "Success") {
setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
Expand All @@ -270,44 +261,44 @@ export function ResetCredits(props: {
);
};

const confirm = () => {
const confirm = (choice: ResetCreditChoice) => {
if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
Dialog.alert(
"Use a reset credit?",
"This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{ text: "Use credit", onPress: () => void redeem() },
],
);
Dialog.alert("Use a reset credit?", choice.confirmDescription, [
{ text: "Cancel", style: "cancel" },
{ text: "Use credit", onPress: () => void redeem(choice) },
]);
};

return (
<View className="flex-row flex-wrap items-center gap-x-3 gap-y-1">
<Text className="text-xs tabular-nums text-foreground-tertiary">{summary}</Text>
{credits.availableCount > 0 ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || !canManageProviders }}
disabled={busy || !canManageProviders}
onPress={confirm}
className={
dense
? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]"
: "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]"
}
>
<Text
className={
dense
? "text-xs font-t3-medium text-foreground"
: "text-sm font-t3-medium text-foreground"
}
>
{busy ? "Using…" : "Use reset"}
</Text>
</Pressable>
) : null}
<View className="gap-1">
{resetCreditChoices(credits, input, now).map((choice) => (
<View key={choice.key} className="flex-row flex-wrap items-center gap-x-3 gap-y-1">
<Text className="text-xs tabular-nums text-foreground-tertiary">{choice.summary}</Text>
{choice.availableCount > 0 ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || !canManageProviders }}
disabled={busy || !canManageProviders}
onPress={() => confirm(choice)}
className={
dense
? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]"
: "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]"
}
>
<Text
className={
dense
? "text-xs font-t3-medium text-foreground"
: "text-sm font-t3-medium text-foreground"
}
>
{busy ? "Using…" : choice.actionLabel}
</Text>
</Pressable>
) : null}
</View>
))}
{!canManageProviders ? (
<Text className="text-xs text-foreground-tertiary">
This connection cannot manage provider accounts.
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,15 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
const consumeResetCredit: NonNullable<ProviderInstance["consumeResetCredit"]> = Effect.fn(
"ClaudeDriver.consumeResetCredit",
)(
function* () {
function* (creditId) {
const current = yield* snapshot.getSnapshot;
const grantId = current.usageLimits?.resetCredits?.nextCreditId;
const credits = current.usageLimits?.resetCredits;
const grantId =
creditId === undefined
? credits?.nextCreditId
: credits?.options?.some((option) => option.creditId === creditId)
? creditId
: undefined;
if (!grantId || !current.version) return "noCredit" as const;
const version = current.version;
return yield* resetCreditCoordinator.redeem(
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/ProviderInstanceRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
const before = yield* instance!.snapshot.refresh;
expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100);
expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a");
const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result);
const outcome = yield* instance!.consumeResetCredit!(undefined).pipe(Effect.result);
return { outcome, after: yield* instance!.snapshot.getSnapshot };
}).pipe(
// macOS logins live in the Keychain, where resets are never read.
Expand Down
44 changes: 43 additions & 1 deletion apps/server/src/provider/claudeResetCredits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,51 @@ describe("claudeResetCreditsToContract", () => {
availableCount: 2,
nextCreditId: "grant_a",
nextExpiresAt: "2026-10-01T00:00:00.000Z",
options: [
expect.objectContaining({
creditId: "grant_a",
label: "Full reset",
availableCount: 2,
expiresAt: "2026-10-01T00:00:00.000Z",
}),
],
});
});

it("offers each kind of reset by its own grant", () => {
const credits = ClaudeResetCredits.claudeResetCreditsToContract(
{
eligible: true,
next_grant_id: "weekly_a",
grants: [
grant({ id: "weekly_a", clears: ["seven_day"], ends_at: "2026-12-10T22:00:00Z" }),
grant({
id: "fable_a",
clears: ["seven_day_overage_included"],
gives_back_weekly: true,
ends_at: "2027-03-09T07:59:00Z",
}),
grant({ id: "fable_b", gives_back_weekly: true, type: "weekly" }),
grant({ id: "session_a", type: "session" }),
grant({ id: "spent", clears: ["seven_day"], resets_left: 0 }),
],
},
NOW,
);
expect(
credits?.options?.map(({ creditId, label, availableCount }) => [
creditId,
label,
availableCount,
]),
).toEqual([
["session_a", "5-hour reset", 1],
["weekly_a", "Weekly reset", 1],
["fable_a", "Fable reset", 2],
]);
expect(credits).toMatchObject({ availableCount: 4, nextCreditId: "weekly_a" });
});

it("offers nothing to redeem without a usable next grant or an eligible account", () => {
expect(
ClaudeResetCredits.claudeResetCreditsToContract(
Expand Down Expand Up @@ -111,7 +153,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => {
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HttpClient.HttpClient, client),
);
expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" });
expect(credits).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" });
}),
);

Expand Down
62 changes: 58 additions & 4 deletions apps/server/src/provider/claudeResetCredits.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ProviderConsumeResetCreditOutcome,
ServerProviderResetCreditOption,
ServerProviderResetCredits,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
Expand Down Expand Up @@ -31,6 +32,9 @@ const Grant = Schema.Struct({
ends_at: Schema.optional(Schema.NullOr(Schema.String)),
paused: Schema.optional(Schema.Boolean),
usable_now: Schema.optional(Schema.Boolean),
clears: Schema.optional(Schema.Array(Schema.Unknown)),
type: Schema.optional(Schema.Unknown),
gives_back_weekly: Schema.optional(Schema.Boolean),
});
const decodeGrant = Schema.decodeUnknownOption(Grant);
const CedarEmber = Schema.Struct({
Expand Down Expand Up @@ -95,6 +99,31 @@ const isFutureTimestamp = (value: string, nowMs: number) => {
);
};

// Claude Code's own grouping: a grant that gives back weekly usage is a Fable reset, else its
// declared type, else what it clears.
const RESET_KINDS = {
session: { label: "5-hour reset", description: "Refills your 5-hour limit." },
weekly: { label: "Weekly reset", description: "Refills your weekly limit." },
fable: {
label: "Fable reset",
description:
"Refills your Fable allowance and gives back what Fable used on your weekly limit. It doesn't reset your whole week.",
},
full: { label: "Full reset", description: "Refills your 5-hour and weekly limits." },
} as const;
type ResetKind = keyof typeof RESET_KINDS;

const grantKind = (grant: typeof Grant.Type): ResetKind => {
if (grant.gives_back_weekly) return "fable";
if (grant.type === "session" || grant.type === "weekly" || grant.type === "full") {
return grant.type;
}
const clears = grant.clears ?? [];
const session = clears.includes("five_hour");
const other = clears.some((limit) => limit !== "five_hour");
return session && !other ? "session" : other && !session ? "weekly" : "full";
};

export function claudeResetCreditsToContract(
block: unknown,
nowMs: number,
Expand All @@ -110,13 +139,38 @@ export function claudeResetCreditsToContract(
(grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)),
);
const next = live.find((grant) => grant.id === parsed.value.next_grant_id);
const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none();
const expiresAt = (grant: typeof Grant.Type | undefined) => {
const at = grant?.ends_at ? DateTime.make(grant.ends_at) : Option.none();
return Option.isSome(at) ? DateTime.formatIso(at.value) : undefined;
};
const byKind = new Map<ResetKind, Array<typeof Grant.Type>>();
for (const grant of live) {
if (grant.resets_left === 0) continue;
const kind = grantKind(grant);
byKind.set(kind, [...(byKind.get(kind) ?? []), grant]);
}
const options = (Object.keys(RESET_KINDS) as Array<ResetKind>).flatMap(
(kind): Array<ServerProviderResetCreditOption> => {
const grants = byKind.get(kind);
const target = grants?.[0];
if (!grants || !target) return [];
const optionExpiresAt = expiresAt(target);
return [
{
creditId: target.id,
...RESET_KINDS[kind],
availableCount: grants.reduce((sum, grant) => sum + grant.resets_left, 0),
...(optionExpiresAt ? { expiresAt: optionExpiresAt } : {}),
},
];
},
);
const nextExpiresAt = expiresAt(next);
return {
availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0,
...(Option.isSome(nextExpiresAt)
? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) }
: {}),
...(nextExpiresAt ? { nextExpiresAt } : {}),
...(next ? { nextCreditId: next.id } : {}),
...(next ? { options } : {}),
};
}

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2649,7 +2649,7 @@ export const layerWsRpc = (
detail: "This provider does not bank reset credits.",
});
}
const outcome = yield* instance.consumeResetCredit().pipe(
const outcome = yield* instance.consumeResetCredit(input.creditId).pipe(
Effect.mapError(
(error) =>
new ProviderSetupError({
Expand Down
Loading
Loading