Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3a14e41
feat(usage): show subscription limits
mackinleysmith Aug 26, 2026
d55071e
fix(usage): harden subscription limit reporting
mackinleysmith Aug 26, 2026
55ee358
fix(server): bound subscription usage probes
mackinleysmith Aug 26, 2026
6dc1407
fix(server): finish usage probes in background
mackinleysmith Aug 26, 2026
f5256b9
fix(server): probe configured codex account
mackinleysmith Aug 26, 2026
d8ff47d
refactor(web): share usage meter layout
mackinleysmith Aug 26, 2026
46c1603
perf(server): reduce subscription limit probes
mackinleysmith Aug 27, 2026
8903c18
fix(usage): harden subscription limit reporting
mackinleysmith Aug 27, 2026
3cc3032
fix(usage): reuse fresh subscription limits
mackinleysmith Aug 27, 2026
6c43b31
fix(usage): bound transcript snapshots
mackinleysmith Aug 27, 2026
0bc2c4e
Merge branch 'main' into t3code/usage-page-subscription-limits
mackinleysmith Aug 27, 2026
02949bd
fix(usage): bound Codex transcript reads
mackinleysmith Aug 27, 2026
49babe4
fix(usage): decouple subscription limit refreshes
mackinleysmith Aug 27, 2026
45b0207
fix(usage): recover Codex limits from transcripts
mackinleysmith Aug 27, 2026
b430e27
Merge remote-tracking branch 'origin/main' into t3code/usage-page-sub…
mackinleysmith Aug 27, 2026
419e3c5
fix(usage): address post-merge review findings
mackinleysmith Aug 27, 2026
afe77eb
refactor(usage): remove unused unlimited state
mackinleysmith Aug 27, 2026
67b7533
fix(usage): scope limit cache by account
mackinleysmith Aug 27, 2026
61b1e0e
fix(usage): honor inherited provider homes
mackinleysmith Aug 27, 2026
c902fe1
fix(usage): read Codex rate limits from real rollout shape
mackinleysmith Aug 27, 2026
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
148 changes: 126 additions & 22 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useNavigation } from "@react-navigation/native";
import type { UsageLimitWindow, UsageProviderLimits } from "@t3tools/contracts";
import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge";
import {
enumerateDays,
Expand All @@ -8,10 +9,13 @@ import {
formatHourShort,
formatPercent,
formatTokens,
formatUsageObservationAge,
formatUsageResetCountdown,
formatUsageResetDateTime,
formatUsd,
makeWindow,
} from "@t3tools/shared/usageFormat";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

Expand Down Expand Up @@ -41,10 +45,16 @@ export function UsageRouteScreen() {
window: makeWindow(30),
}));
const [metric, setMetric] = useState<UsageChartMetric>("cost");
const [nowMs, setNowMs] = useState(Date.now());
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
const { merged, environments, isPending, isPartial, refresh } = useUsage(window);

useEffect(() => {
const interval = globalThis.setInterval(() => setNowMs(Date.now()), 60_000);
return () => globalThis.clearInterval(interval);
}, []);

const days = useMemo(
() => enumerateDays(window.sinceDay, window.untilDay),
[window.sinceDay, window.untilDay],
Expand Down Expand Up @@ -138,7 +148,12 @@ export function UsageRouteScreen() {
isPast24Hours={isPast24Hours}
timeZone={window.timeZone}
/>
<ProviderSection merged={merged} metric={metric} />
<ProviderSection
merged={merged}
metric={metric}
nowMs={nowMs}
timeZone={window.timeZone}
/>
<TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
<ModelsSection merged={merged} />
</>
Expand Down Expand Up @@ -295,59 +310,148 @@ function MetricToggle(props: {
function ProviderSection(props: {
readonly merged: MergedUsage;
readonly metric: UsageChartMetric;
readonly nowMs: number;
readonly timeZone: string;
}) {
const { merged, metric } = props;
const colors = useProviderColors();
if (merged.providers.length === 0) return null;
if (merged.providers.length === 0 && merged.subscriptionLimits.length === 0) return null;

// Ranked by whatever the toggle is showing, so the rows always descend.
// .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method.
const ordered = [...merged.providers].sort((a, b) =>
metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
);
const orderedProviders = [
...ordered.map((provider) => provider.provider),
...merged.subscriptionLimits
.map((limits) => limits.provider)
.filter((provider) => !ordered.some((entry) => entry.provider === provider)),
];

return (
<SettingsSection title="Providers" card>
{ordered.map((provider, index) => {
const share = metric === "cost" ? provider.costShare : provider.tokenShare;
{orderedProviders.map((providerKind, index) => {
const provider = merged.providers.find((entry) => entry.provider === providerKind);
const limits = merged.subscriptionLimits.find((entry) => entry.provider === providerKind);
const share = metric === "cost" ? (provider?.costShare ?? 0) : (provider?.tokenShare ?? 0);
return (
<View
key={provider.provider}
key={providerKind}
className={index === 0 ? "gap-2 p-4" : "gap-2 border-t border-border-subtle p-4"}
>
<View className="flex-row items-baseline justify-between gap-3">
<View className="flex-row items-center gap-2">
<View
className="size-2.5 rounded-full"
style={{ backgroundColor: colors[provider.provider] }}
/>
<Text className="text-lg text-foreground">{PROVIDER_LABEL[provider.provider]}</Text>
<View className="gap-0.5">
<View className="flex-row items-baseline justify-between gap-3">
<Text className="text-lg text-foreground">{PROVIDER_LABEL[providerKind]}</Text>
<Text className="text-lg tabular-nums text-foreground">
{metric === "cost"
? formatUsd(provider?.costUsd ?? 0)
: formatTokens(provider?.totalTokens ?? 0)}
</Text>
</View>
<Text className="text-lg tabular-nums text-foreground">
<Text className="text-sm text-foreground-muted">
{metric === "cost"
? formatUsd(provider.costUsd)
: formatTokens(provider.totalTokens)}
? `${formatPercent(share)} of cost · ${formatTokens(provider?.totalTokens ?? 0)} tokens`
: `${formatPercent(share)} of tokens · ${formatUsd(provider?.costUsd ?? 0)}`}
</Text>
</View>
<View className="h-1 flex-row overflow-hidden rounded-full bg-subtle">
<View
className="h-full rounded-full"
style={{ flex: share, backgroundColor: colors[provider.provider] }}
style={{ flex: share, backgroundColor: colors[providerKind] }}
/>
<View style={{ flex: 1 - share }} />
</View>
<Text className="text-sm text-foreground-muted">
{metric === "cost"
? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`
: `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
</Text>
{limits ? (
<UsageLimitMeters
limits={limits}
color={colors[providerKind]}
nowMs={props.nowMs}
timeZone={props.timeZone}
/>
) : null}
</View>
);
})}
</SettingsSection>
);
}

function usageLimitWindowLabel(window: UsageLimitWindow): string {
if (window.label) return window.label;
if (window.kind === "fiveHour") return "5h";
if (window.kind === "weekly") return "Week";
return window.kind;
}

function UsageLimitMeters(props: {
readonly limits: UsageProviderLimits;
readonly color: string;
readonly nowMs: number;
readonly timeZone: string;
}) {
const observationAge =
props.limits.stale === true && props.limits.observedAt
? formatUsageObservationAge(props.limits.observedAt, props.nowMs)
: null;
return (
<View className="gap-2.5 py-1">
{observationAge ? (
<Text className="text-[11px] tabular-nums text-foreground-tertiary">
Limits last updated {observationAge} ago
</Text>
) : null}
{props.limits.windows.map((window) => {
const percent = Math.min(100, Math.max(0, window.usedPercent));
const reset = window.resetsAt
? formatUsageResetDateTime(window.resetsAt, props.timeZone)
: null;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const countdown = window.resetsAt
? formatUsageResetCountdown(window.resetsAt, props.nowMs)
: null;
const label = usageLimitWindowLabel(window);
return (
<View key={`${window.kind}:${label}`} className="gap-1">
<View className="flex-row items-center gap-2">
<Text numberOfLines={1} className="w-9 text-xs text-foreground-muted">
{label}
</Text>
<View
accessibilityRole="progressbar"
accessibilityLabel={`${PROVIDER_LABEL[props.limits.provider]} ${label} limit`}
accessibilityHint={reset ? `Resets ${reset}` : undefined}
accessibilityValue={{ min: 0, max: 100, now: Math.round(percent) }}
className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-subtle"
>
<View
className="h-full rounded-full"
style={{
width: `${percent}%`,
backgroundColor: props.color,
}}
/>
</View>
<Text className="w-9 text-right text-xs tabular-nums text-foreground-muted">
{Math.round(percent)}%
</Text>
</View>
<Text
numberOfLines={1}
className="mx-11 text-[11px] tabular-nums text-foreground-tertiary"
>
{countdown === "now"
? "Reset due"
: countdown
? `Resets in ${countdown}`
: "Reset time unavailable"}
</Text>
</View>
);
})}
</View>
);
}

function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) {
const { merged } = props;
const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter(
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { compareSemverVersions } from "@t3tools/shared/semver";
import {
query as claudeQuery,
type Options as ClaudeQueryOptions,
type SDKControlGetUsageResponse,
type SlashCommand as ClaudeSlashCommand,
type SDKUserMessage,
type SettingSource,
Expand Down Expand Up @@ -782,6 +783,58 @@ const probeClaudeCapabilities = (
);
};

/**
* Reads the structured data behind Claude Code's `/usage` screen without
* sending a prompt or starting an Anthropic API request.
*/
export const probeClaudeUsage = (
claudeSettings: ClaudeSettings,
environment?: NodeJS.ProcessEnv,
cwd?: string,
): Effect.Effect<
SDKControlGetUsageResponse | undefined,
never,
FileSystem.FileSystem | Path.Path
> => {
const abort = new AbortController();
return Effect.gen(function* () {
const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
const executablePath = yield* resolveClaudeSdkExecutablePath(
claudeSettings.binaryPath,
claudeEnvironment,
);
return yield* Effect.tryPromise(async () => {
const q = claudeQuery({
// Never yield. Initialization is enough to use the local control API.
// oxlint-disable-next-line require-yield
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
await waitForAbortSignal(abort.signal);
})(),
options: buildClaudeCapabilitiesProbeQueryOptions({
executablePath,
abortController: abort,
environment: claudeEnvironment,
cwd,
}),
});
await q.initializationResult();
return q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET();
});
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (!abort.signal.aborted) abort.abort();
}),
),
Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
Effect.result,
Effect.map((result) => {
if (Result.isFailure(result)) return undefined;
return Option.isSome(result.success) ? result.success.value : undefined;
}),
);
};

const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
claudeSettings: ClaudeSettings,
args: ReadonlyArray<string>,
Expand Down
56 changes: 48 additions & 8 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export interface CodexAppServerProviderSnapshot {
readonly skills: ReadonlyArray<ServerProviderSkill>;
}

interface CodexAppServerProbeInput {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv;
}

const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
none: "None",
minimal: "Minimal",
Expand Down Expand Up @@ -313,14 +321,9 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
};
}

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly environment?: NodeJS.ProcessEnv;
}) {
const startCodexAppServerProbe = Effect.fn("startCodexAppServerProbe")(function* (
input: CodexAppServerProbeInput,
) {
// `~` is not shell-expanded when env vars are set via `child_process.spawn`,
// so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
// "CODEX_HOME points to '~/.codex_work', but that path does not exist".
Expand Down Expand Up @@ -379,6 +382,16 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
const version = versionMatch ? versionMatch[1] : undefined;

return { client, version } as const;
});

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (
input: CodexAppServerProbeInput & {
readonly customModels?: ReadonlyArray<string>;
},
) {
const { client, version } = yield* startCodexAppServerProbe(input);

const accountResponse = yield* client.request("account/read", {});
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
return {
Expand Down Expand Up @@ -409,6 +422,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
} satisfies CodexAppServerProviderSnapshot;
});

/** Reads Codex's current ChatGPT subscription quota windows. */
export const probeCodexRateLimits = Effect.fn("probeCodexRateLimits")(function* (
codexSettings: CodexSettings,
environment: NodeJS.ProcessEnv = process.env,
cwd: string = process.cwd(),
) {
if (!codexSettings.enabled) return undefined;

const response = yield* Effect.gen(function* () {
const { client } = yield* startCodexAppServerProbe({
binaryPath: codexSettings.binaryPath,
homePath: codexSettings.homePath,
launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, environment),
cwd,
environment,
});
return yield* client.request("account/rateLimits/read", undefined);
}).pipe(
Effect.scoped,
Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
Effect.result,
);

if (Result.isFailure(response) || Option.isNone(response.success)) return undefined;
return response.success.value;
});

const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {
const models = new Set<string>();
for (const model of codexSettings.customModels) {
Expand Down
Loading
Loading