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
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ComposerUsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function ComposerUsageLimits({
environmentId={environmentId}
input={resetCreditInput}
credits={account.limits.resetCredits}
windows={account.limits.windows}
now={now}
/>
) : undefined
Expand Down
20 changes: 17 additions & 3 deletions apps/mobile/src/features/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
remainingPercent,
type LimitAccount,
type LimitPoolWindow,
resetCreditsForWindow,
} from "@t3tools/shared/usageLimits";
import { Fragment, type ReactNode, useId, useState } from "react";
import { Linking, Pressable, View } from "react-native";
Expand Down Expand Up @@ -233,7 +234,11 @@ function PoolWindowCard({
);
const { account, window } = member;
const timeLeft = paceRemainingPercent(window, now);
const credits = account.limits.resetCredits?.availableCount ?? 0;
const { count: credits, scope } = resetCreditsForWindow(
account.limits.resetCredits,
account.limits.windows,
window.id,
);
const resetsIn = formatResetsIn(window, now)?.replace("resets in ", "");
return (
<Pressable
Expand Down Expand Up @@ -293,6 +298,9 @@ function PoolWindowCard({
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{credits}
</Text>
{scope ? (
<Text className="text-xs text-foreground-muted">{scope}</Text>
) : null}
</>
) : null}
</View>
Expand Down Expand Up @@ -321,13 +329,17 @@ function PoolWindowCard({
{pool.columns.map(({ account, window }, index) => {
if (!window) return null;
const segmentColor = normalizeProviderAccentColor(account.accentColor) ?? color;
const credits = account.limits.resetCredits?.availableCount ?? 0;
const { count: credits, scope } = resetCreditsForWindow(
account.limits.resetCredits,
account.limits.windows,
window.id,
);
const resetsIn = formatResetsIn(window, now);
return (
<Pressable
key={account.key}
accessibilityRole="button"
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} reset credits banked` : ""}`}
accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${resetsIn ? `, ${resetsIn}` : ""}${credits ? `, ${credits} ${scope ? `${scope} ` : ""}reset ${credits === 1 ? "credit" : "credits"} banked` : ""}`}
accessibilityHint="Show account details"
onPress={() => openAccount(account)}
className="min-h-[44px] flex-row items-center gap-2 active:opacity-60"
Expand Down Expand Up @@ -371,6 +383,7 @@ function PoolWindowCard({
<Text className="text-xs font-t3-medium tabular-nums text-foreground">
{credits}
</Text>
{scope ? <Text className="text-xs text-foreground-muted">{scope}</Text> : null}
</>
) : null}
</View>
Expand Down Expand Up @@ -624,6 +637,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) {
environmentId={account.redeem.environmentId}
input={account.redeem.input}
credits={account.limits.resetCredits}
windows={account.limits.windows}
now={now}
/>
</View>
Expand Down
103 changes: 58 additions & 45 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
paceOf,
paceRemainingPercent,
remainingPercent,
resetCreditsForWindow,
withResetCreditId,
} from "@t3tools/shared/usageLimits";
import { type ReactNode, useContext, useRef, useState } from "react";
import { Atom } from "effect/reactivity";
Expand Down Expand Up @@ -221,7 +223,7 @@ export function AccountLimits(props: {
}

const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
reset: "Reset applied. Your windows have cleared.",
reset: "Reset applied.",
nothingToReset: "Nothing to reset right now.",
noCredit: "No reset credit left.",
alreadyRedeemed: "That credit was already redeemed.",
Expand All @@ -231,34 +233,27 @@ export function ResetCredits(props: {
readonly environmentId: EnvironmentId;
readonly input: ProviderConsumeResetCreditInput;
readonly credits: ServerProviderResetCredits;
readonly windows: ReadonlyArray<ServerProviderUsageWindow>;
readonly now: number;
readonly dense?: boolean;
}) {
const { environmentId, input, credits, now, dense = false } = props;
const { environmentId, input, credits, windows, now, dense = false } = props;
const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
reportFailure: false,
});
const [busy, setBusy] = useState(false);
const [busyCreditId, setBusyCreditId] = useState<string | null | undefined>(null);
const [status, setStatus] = useState<string | null>(null);
if (dense && credits.availableCount === 0 && status === null) return null;
const { grants, waiting } = resetCreditsForWindow(credits, windows);
if (dense && grants.length === 0 && !waiting && status === null) return null;
const busy = busyCreditId !== 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 (creditId: string | undefined) => {
if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
setBusy(true);
setBusyCreditId(creditId);
setStatus(null);
const result = await consume({ environmentId, input });
setBusy(false);
const result = await consume({ environmentId, input: withResetCreditId(input, creditId) });
setBusyCreditId(null);
if (result._tag === "Success") {
setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
return;
Expand All @@ -270,44 +265,62 @@ export function ResetCredits(props: {
);
};

const confirm = () => {
const confirm = (creditId: string | undefined, effect: string | undefined) => {
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.",
`${effect ?? "Clears the current rate-limit windows."} This can't be undone.`,
[
{ text: "Cancel", style: "cancel" },
{ text: "Use credit", onPress: () => void redeem() },
{ text: "Use credit", onPress: () => void redeem(creditId) },
],
);
};

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>
<View className="gap-2">
{grants.length === 0 && !waiting ? (
<Text className="text-xs text-foreground-tertiary">No reset credits banked</Text>
) : null}
{grants.map((grant) => {
const expiresIn = grant.expiresAt
? formatDuration(Date.parse(grant.expiresAt) - now)
: null;
const scope = grant.scope ? `${grant.scope} ` : "";
return (
<View key={grant.id ?? "all"} className="flex-row flex-wrap items-center gap-x-3 gap-y-1">
<Text className="min-w-0 shrink text-xs tabular-nums text-foreground-tertiary">
{`${grant.count} ${scope}${grant.count === 1 ? "reset credit" : "reset credits"} banked${
expiresIn
? ` · ${grant.count > 1 ? "next expires" : "expires"} in ${expiresIn}`
: ""
}`}
</Text>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || !canManageProviders }}
disabled={busy || !canManageProviders}
onPress={() => confirm(grant.id, grant.effect)}
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 && busyCreditId === grant.id ? "Using…" : "Use reset"}
</Text>
</Pressable>
</View>
);
})}
{waiting ? <Text className="text-xs text-foreground-tertiary">{waiting}</Text> : null}
{!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/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
/** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */
scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>,
resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>,
resolveResetCredits?: (
version: string,
overageIncluded: string | undefined,
) => Effect.Effect<ServerProviderResetCredits | undefined>,
): Effect.fn.Return<
ServerProviderDraft,
never,
Expand Down Expand Up @@ -727,7 +730,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
capabilities.subscriptionType &&
!usageLimits.unavailable &&
parsedVersion
? yield* resolveResetCredits(parsedVersion)
? yield* resolveResetCredits(
parsedVersion,
scopedLimitNames ? (yield* Ref.get(scopedLimitNames)).overageIncluded : undefined,
)
: undefined;
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
Expand Down
14 changes: 9 additions & 5 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,8 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
stateDir,
catalog,
scopedLimitNames,
(version) =>
ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe(
(version, overageIncluded) =>
ClaudeResetCredits.readClaudeResetCredits(configDir, version, overageIncluded).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Expand Down Expand Up @@ -309,9 +309,13 @@ 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?.grants?.find((grant) => grant.id === creditId && !grant.waiting)?.id;
if (!grantId || !current.version) return "noCredit" as const;
const version = current.version;
return yield* resetCreditCoordinator.redeem(
Expand All @@ -321,7 +325,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
configDir,
accountConfigPath,
version,
grantId,
creditId: grantId,
requestId,
}),
ClaudeResetCredits.isSettledClaudeResetCreditFailure,
Expand Down
25 changes: 20 additions & 5 deletions apps/server/src/provider/ProviderInstanceRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,8 +603,13 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
}).pipe(Effect.provide(layerTest)),
);

const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) =>
const redeemClaudeReset = (claim: {
result: string;
usageFailsAfterClaim: boolean;
creditId?: string;
}) =>
Effect.gen(function* () {
const claimedGrantIds: Array<unknown> = [];
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const fixtures = yield* makeTildeProviderFixtures();
Expand All @@ -626,12 +631,20 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
cedar_ember: {
eligible: true,
next_grant_id: "grant_a",
grants: [{ id: "grant_a", resets_left: 1, usable_now: true }],
grants: [
{ id: "grant_a", resets_left: 1, usable_now: true },
{ id: "grant_b", resets_left: 1, usable_now: true },
],
},
}),
);
}
if (request.url.endsWith("/reset_rate_limits")) {
if (request.body._tag === "Uint8Array") {
claimedGrantIds.push(
JSON.parse(new TextDecoder().decode(request.body.body)).grant_id,
);
}
yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie);
return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result }));
}
Expand Down Expand Up @@ -664,8 +677,8 @@ 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);
return { outcome, after: yield* instance!.snapshot.getSnapshot };
const outcome = yield* instance!.consumeResetCredit!(claim.creditId).pipe(Effect.result);
return { outcome, claimedGrantIds, after: yield* instance!.snapshot.getSnapshot };
}).pipe(
// macOS logins live in the Keychain, where resets are never read.
Effect.provideService(HostProcess.Platform, "linux"),
Expand All @@ -674,11 +687,13 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {

it.live.skipIf(windowsHost)("refreshes Claude usage after redeeming a reset", () =>
Effect.gen(function* () {
const { outcome, after } = yield* redeemClaudeReset({
const { outcome, claimedGrantIds, after } = yield* redeemClaudeReset({
result: "reset",
usageFailsAfterClaim: false,
creditId: "grant_b",
});
expect(outcome).toMatchObject({ _tag: "Success", success: "reset" });
expect(claimedGrantIds).toEqual(["grant_b"]);
expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0);
}),
);
Expand Down
Loading
Loading