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
183 changes: 183 additions & 0 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { useAtomValue } from "@effect/atom-react";
import type {
ServerProvider,
ServerProviderUsageWindow,
UsageLimitSourceAccount,
} from "@t3tools/contracts";
import {
collectLimitSources,
collectLimitsGroups,
elapsedShare,
formatResetsIn,
limitsNotice,
paceOf,
providerLimitsLabel,
} from "@t3tools/shared/usageLimits";
import { useState } from "react";
import { View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { environmentPresentations } from "../../state/presentation";
import { SettingsSection } from "../settings/components/SettingsSection";

const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;

/**
* One window as a bar spanning its whole duration: the fill is quota spent,
* the hairline is how far into the window the clock is.
*/
function WindowBar(props: { readonly window: ServerProviderUsageWindow; readonly now: number }) {
const { window, now } = props;
const used = Math.round(Math.max(0, Math.min(100, window.usedPercent)));
const elapsed = elapsedShare(window, now);
const pace = paceOf(window, now);
const resetsIn = formatResetsIn(window, now);
const detail = [pace ? PACE_LABEL[pace] : null, resetsIn].filter(Boolean).join(" · ");
return (
<View className="gap-1.5">
<View className="flex-row items-baseline justify-between gap-3">
<Text className="text-base text-foreground">{window.label}</Text>
<Text className="text-base tabular-nums text-foreground">{used}% used</Text>
</View>
<View className="h-3 justify-center">
<View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">
<View
className={
used >= 90
? "h-full rounded-full bg-destructive"
: used >= 70
? "h-full rounded-full bg-warning"
: "h-full rounded-full bg-foreground"
}
style={{ flex: used }}
/>
<View style={{ flex: 100 - used }} />
</View>
{elapsed !== null ? (
<View
className="absolute top-0 bottom-0 w-px bg-foreground"
style={{ left: `${elapsed * 100}%`, opacity: 0.6 }}
/>
) : null}
</View>
{detail ? <Text className="text-xs text-foreground-tertiary">{detail}</Text> : null}
</View>
);
}

function AccountLimits(props: {
readonly label: string;
readonly detail: string | undefined;
readonly limits: ServerProvider["usageLimits"];
readonly now: number;
readonly first: boolean;
}) {
const { limits, now } = props;
if (!limits) return null;
const notice = limitsNotice(limits);
return (
<View className={props.first ? "gap-3 p-4" : "gap-3 border-t border-border-subtle p-4"}>
<View className="flex-row items-baseline gap-2">
<Text className="text-lg text-foreground">{props.label}</Text>
{props.detail ? (
<Text className="text-sm text-foreground-muted">{props.detail}</Text>
) : null}
</View>
{notice ? (
<Text className="text-sm text-foreground-muted">{notice}</Text>
) : (
limits.windows.map((window) => <WindowBar key={window.id} window={window} now={now} />)
)}
</View>
);
}

function ProviderLimits(props: {
readonly provider: ServerProvider;
readonly now: number;
readonly first: boolean;
}) {
const { provider } = props;
return (
<AccountLimits
label={providerLimitsLabel(provider, () => undefined)}
detail={provider.auth.label}
limits={provider.usageLimits}
now={props.now}
first={props.first}
/>
);
}

const DRIVER_LABEL: Partial<Record<string, string>> = { codex: "Codex", claudeAgent: "Claude" };

/** Emails stay off the phone screen; the plan and driver identify the row. */
function SourceAccountLimits(props: {
readonly account: UsageLimitSourceAccount;
readonly now: number;
readonly first: boolean;
}) {
const { account } = props;
return (
<AccountLimits
label={DRIVER_LABEL[account.driver] ?? String(account.driver)}
detail={account.plan}
limits={account.usageLimits}
now={props.now}
first={props.first}
/>
);
}

/**
* Subscription quota windows from every connected environment's providers,
* read from the config each environment already streams. Countdowns anchor to
* render time rather than ticking.
*/
export function UsageLimitsSection() {
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const groups = collectLimitsGroups(presentations);
const sources = collectLimitSources(presentations);
// Anchored once per mount on purpose: countdowns must not tick.
const [now] = useState(() => Date.now());
if (groups.length === 0 && sources.length === 0) return null;

return (
<>
{sources.map((source) => (
<SettingsSection key={source.key} title={`${source.label} · CLIProxyAPI`} card>
{source.error ? (
<Text className="p-4 text-sm text-foreground-muted">{source.error}</Text>
) : source.accounts.length === 0 ? (
<Text className="p-4 text-sm text-foreground-muted">No accounts reported.</Text>
) : (
source.accounts.map((account, index) => (
<SourceAccountLimits
key={account.id}
account={account}
now={now}
first={index === 0}
/>
))
)}
</SettingsSection>
))}
{groups.map((group) => (
<SettingsSection
key={group.environmentId}
title={group.environmentLabel ? `Limits · ${group.environmentLabel}` : "Limits"}
card
>
{group.providers.map((provider, index) => (
<ProviderLimits
key={provider.instanceId}
provider={provider}
now={now}
first={index === 0}
/>
))}
</SettingsSection>
))}
</>
);
}
2 changes: 2 additions & 0 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
import { SettingsSection } from "../settings/components/SettingsSection";
import { UsageDailyChart } from "./UsageDailyChart";
import { UsageLimitsSection } from "./UsageLimitsSection";
import type { UsageChartMetric } from "./usageChartData";
import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";

Expand Down Expand Up @@ -139,6 +140,7 @@ export function UsageRouteScreen() {
timeZone={window.timeZone}
/>
<ProviderSection merged={merged} metric={metric} />
<UsageLimitsSection />
<TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
<ModelsSection merged={merged} />
</>
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { environmentSession } from "./session";

export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
initialConfigValueAtom: environmentSession.initialConfigValueAtom,
usageLimitSources: true,
});
export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
catalogValueAtom: environmentCatalog.catalogValueAtom,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export const make = Effect.gen(function* () {
threadAutoSettlement: true,
threadSnooze: true,
environmentThemes: true,
usageLimitSources: true,
threadPinning: true,
threadPinReorder: true,
threadTitleRegeneration: true,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";
import {
checkClaudeProviderStatus,
makePendingClaudeProvider,
Expand Down Expand Up @@ -134,10 +135,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
continuationGroupKey,
});

// One per instance: the status probe writes the model-scoped bucket
// names it saw, the adapter reads them to place turn-driven events.
const scopedLimitNames = yield* makeClaudeScopedLimitNames;
const adapterOptions = {
instanceId,
environment: processEnv,
modelCatalog,
scopedLimitNames,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
};
const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions);
Expand Down Expand Up @@ -171,6 +176,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
processEnv,
cwd,
resolveClaudeModelCatalog(manifest),
scopedLimitNames,
),
),
Effect.map(stampIdentity),
Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Random from "effect/Random";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
Expand All @@ -44,6 +45,7 @@ import {
} from "../ClaudeModelCatalog.testFixtures.ts";
import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";
import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts";
import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
Expand Down Expand Up @@ -163,6 +165,7 @@ function makeHarness(config?: {
readonly baseDir?: string;
readonly claudeConfig?: Partial<ClaudeSettings>;
readonly instanceId?: ProviderInstanceId;
readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];
}) {
const query = new FakeClaudeQuery();
let createInput:
Expand All @@ -174,6 +177,7 @@ function makeHarness(config?: {

const adapterOptions: ClaudeAdapterLiveOptions = {
...(config?.instanceId ? { instanceId: config.instanceId } : {}),
...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),
modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
createQuery: (input) => {
createInput = input;
Expand Down Expand Up @@ -1239,6 +1243,84 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("places overage-included rate-limit events on the bucket the probe named", () => {
const scopedLimitNames = Ref.makeUnsafe<ClaudeScopedLimitNames>({ overageIncluded: undefined });
const harness = makeHarness({ scopedLimitNames });
const rateLimitEvent = (utilization: number): SDKMessage =>
({
type: "rate_limit_event",
rate_limit_info: {
status: "allowed",
rateLimitType: "seven_day_overage_included",
utilization,
},
uuid: `rate-limit-${utilization}`,
session_id: "sdk-session-1",
}) as unknown as SDKMessage;
const resultMessage = (uuid: string): SDKMessage =>
({
type: "result",
subtype: "success",
is_error: false,
errors: [],
num_turns: 1,
session_id: "sdk-session-1",
uuid,
}) as unknown as SDKMessage;
const limitsUpdates = (events: Iterable<ProviderRuntimeEvent>) =>
Array.from(events).flatMap((event) =>
event.type === "account.rate-limits.updated" ? [event.payload.limits] : [],
);
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

// Before any probe names the bucket the event has nowhere to land.
// Collecting through the turn's completion proves the SDK message was
// handled, not merely still queued.
const firstTurnFiber = yield* adapter.streamEvents.pipe(
Stream.takeUntil((event) => event.type === "turn.completed"),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] });
harness.query.emit(rateLimitEvent(0.2));
harness.query.emit(resultMessage("result-1"));
assert.deepStrictEqual(limitsUpdates(yield* Fiber.join(firstTurnFiber)), []);

// The status probe reads `get_usage` and records the model it saw.
yield* Ref.set(scopedLimitNames, { overageIncluded: "Fable" });
const secondTurnFiber = yield* adapter.streamEvents.pipe(
Stream.takeUntil((event) => event.type === "turn.completed"),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.sendTurn({ threadId: session.threadId, input: "again", attachments: [] });
harness.query.emit(rateLimitEvent(0.4));
harness.query.emit(resultMessage("result-2"));
assert.deepStrictEqual(limitsUpdates(yield* Fiber.join(secondTurnFiber)), [
{
windows: [
{
id: "seven_day_fable",
kind: "weekly",
label: "Weekly · Fable",
usedPercent: 40,
windowDurationMins: 10_080,
},
],
},
]);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("does not emit turn.completed for a result with no active turn", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
12 changes: 9 additions & 3 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@anthropic-ai/claude-agent-sdk";
import { parseCliArgs } from "@t3tools/shared/cliArgs";
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts";
import {
ApprovalRequestId,
type CanonicalItemType,
Expand Down Expand Up @@ -342,6 +343,8 @@ export interface ClaudeAdapterLiveOptions {
readonly nativeEventLogPath?: string;
readonly nativeEventLogger?: EventNdjsonLogger;
readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
/** Scoped-bucket names the driver's status probe last saw; see `claudeUsageLimits`. */
readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;
}

function isUuid(value: string): boolean {
Expand Down Expand Up @@ -3595,12 +3598,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}

if (message.type === "rate_limit_event") {
const names = options?.scopedLimitNames
? yield* Ref.get(options.scopedLimitNames)
: { overageIncluded: undefined };
const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names);
if (!limits) return;
yield* offerRuntimeEvent({
...base,
type: "account.rate-limits.updated",
payload: {
rateLimits: message,
},
payload: { limits },
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
});
return;
}
Expand Down
Loading
Loading