Skip to content
Draft
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
4 changes: 4 additions & 0 deletions apps/code/src/main/services/llm-gateway/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export interface AnthropicErrorResponse {
export const usageBucketSchema = z.object({
used_percent: z.number(),
resets_in_seconds: z.number(),
// Absolute UTC reset timestamp from gateway A1; preferred over the
// rolling resets_in_seconds, which drifts between polls.
reset_at: z.string().datetime().optional(),
exceeded: z.boolean(),
});

Expand All @@ -69,6 +72,7 @@ export const usageOutput = z.object({
sustained: usageBucketSchema,
burst: usageBucketSchema,
is_rate_limited: z.boolean(),
billing_period_end: z.string().datetime().nullable().optional(),
});

export type UsageBucket = z.infer<typeof usageBucketSchema>;
Expand Down
11 changes: 10 additions & 1 deletion apps/code/src/main/services/llm-gateway/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,18 @@ export class LlmGatewayService {

log.debug("Fetching usage from gateway", { url: usageUrl });

const response = await this.authService.authenticatedFetch(fetch, usageUrl);
let response: Response;
try {
response = await this.authService.authenticatedFetch(fetch, usageUrl);
} catch (err) {
log.warn("Usage fetch network error", {
error: err instanceof Error ? err.message : String(err),
});
throw err;
}

if (!response.ok) {
log.warn("Usage fetch failed", { status: response.status });
throw new LlmGatewayError(
`Failed to fetch usage: HTTP ${response.status}`,
"usage_error",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
import { useFreeUsage } from "@features/billing/hooks/useFreeUsage";
import { isUsageExceeded } from "@features/billing/utils";
import { formatResetTime, isUsageExceeded } from "@features/billing/utils";
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
import { useFeatureFlag } from "@hooks/useFeatureFlag";
import { Circle } from "@phosphor-icons/react";
import { BILLING_FLAG } from "@shared/constants";

export function SidebarUsageBar() {
const billingEnabled = useFeatureFlag(BILLING_FLAG);
const usage = useFreeUsage(billingEnabled);
const { usage, isLoading } = useFreeUsage(billingEnabled);

if (!usage) return null;

const usagePercent = Math.max(
usage.sustained.used_percent,
usage.burst.used_percent,
);
const exceeded = isUsageExceeded(usage);
if (!billingEnabled) return null;

const handleUpgrade = () => {
useSettingsDialogStore.getState().open("plan-usage");
};

if (!usage) {
if (!isLoading) return null;
return (
<div className="shrink-0 border-gray-6 border-t px-3 py-3">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-11 text-xs">Free plan</span>
</div>
<div className="mt-2 h-2.5 w-full animate-pulse overflow-hidden rounded-full bg-gray-4" />
</div>
);
}

const exceeded = isUsageExceeded(usage);
const dominant =
usage.sustained.used_percent >= usage.burst.used_percent
? usage.sustained
: usage.burst;
const usagePercent = Math.min(Math.round(dominant.used_percent), 100);
const resetLabel = formatResetTime(
dominant.reset_at,
dominant.resets_in_seconds,
);

return (
<div className="shrink-0 border-gray-6 border-t px-3 py-3">
<div className="flex items-center justify-between">
Expand All @@ -32,9 +49,7 @@ export function SidebarUsageBar() {
className="mx-1.5 inline text-gray-8"
/>
<span className="font-normal text-gray-10">
{exceeded
? "Limit reached"
: `${Math.min(Math.round(usagePercent), 100)}% used`}
{exceeded ? "Limit reached" : `${usagePercent}% used`}
</span>
</span>
<button
Expand All @@ -48,9 +63,12 @@ export function SidebarUsageBar() {
<div className="mt-2 h-2.5 w-full overflow-hidden rounded-full bg-gray-4">
<div
className={`h-full rounded-full transition-all ${exceeded ? "bg-red-9" : "bg-accent-9"}`}
style={{ width: `${Math.min(Math.round(usagePercent), 100)}%` }}
style={{ width: `${usagePercent}%` }}
/>
</div>
<div className="mt-1.5 font-normal text-[11px] text-gray-9">
{resetLabel}
</div>
</div>
);
}
18 changes: 12 additions & 6 deletions apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import { useSeat } from "@hooks/useSeat";
import type { UsageOutput } from "@main/services/llm-gateway/schemas";
import { useUsage } from "./useUsage";

export function useFreeUsage(billingEnabled: boolean): UsageOutput | null {
export interface FreeUsageResult {
usage: UsageOutput | null;
// True when the user is eligible to see the Free sidebar bar but data
// hasn't arrived yet. Distinguishes "show skeleton" from "render nothing".
isLoading: boolean;
}

export function useFreeUsage(billingEnabled: boolean): FreeUsageResult {
const { seat, isPro } = useSeat();
const seatLoaded = seat !== null;
const { usage } = useUsage({
enabled: billingEnabled && seatLoaded && !isPro,
});
const eligible = billingEnabled && seatLoaded && !isPro;
const { usage, isLoading } = useUsage({ enabled: eligible });

if (!billingEnabled || !seatLoaded || isPro || !usage) return null;
return usage;
if (!eligible) return { usage: null, isLoading: false };
return { usage: usage ?? null, isLoading };
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useEffect, useRef } from "react";
import { useFreeUsage } from "./useFreeUsage";

export function useUsageLimitDetection(billingEnabled: boolean) {
const usage = useFreeUsage(billingEnabled);
const { usage } = useFreeUsage(billingEnabled);
const hasAlertedRef = useRef(false);

useEffect(() => {
Expand Down
35 changes: 34 additions & 1 deletion apps/code/src/renderer/features/billing/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { UsageOutput } from "@main/services/llm-gateway/schemas";
import { describe, expect, it } from "vitest";
import { isUsageExceeded } from "./utils";
import { formatResetTime, isUsageExceeded } from "./utils";

function makeUsage(
overrides: Partial<{
Expand Down Expand Up @@ -51,3 +51,36 @@ describe("isUsageExceeded", () => {
).toBe(true);
});
});

describe("formatResetTime", () => {
const NOW = Date.parse("2026-05-01T12:00:00.000Z");

it("returns minutes-only under 1h", () => {
expect(formatResetTime(undefined, 30 * 60, NOW)).toBe("Resets in 30m");
});

it("returns hours + minutes under 24h", () => {
expect(formatResetTime(undefined, 4 * 3600 + 30 * 60, NOW)).toBe(
"Resets in 4h 30m",
);
});

it("returns hours only when minutes round to 0", () => {
expect(formatResetTime(undefined, 4 * 3600, NOW)).toBe("Resets in 4h");
});

it("returns localized date when over 24h away", () => {
const result = formatResetTime(undefined, 30 * 86400, NOW);
expect(result).toMatch(/^Resets [A-Za-z]+ \d+ at /);
});

it("prefers reset_at over the fallback seconds", () => {
const iso = new Date(NOW + 4 * 3600 * 1000).toISOString();
expect(formatResetTime(iso, 99999, NOW)).toBe("Resets in 4h");
});

it("treats an already-past reset_at as shortly", () => {
const iso = new Date(NOW - 60_000).toISOString();
expect(formatResetTime(iso, 0, NOW)).toBe("Resets shortly");
});
});
35 changes: 35 additions & 0 deletions apps/code/src/renderer/features/billing/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,38 @@ export function isUsageExceeded(usage: UsageOutput): boolean {
usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded
);
}

export function formatResetTime(
resetAtIso: string | undefined,
fallbackSeconds: number,
now: number = Date.now(),
): string {
const ms = resetAtIso
? Math.max(0, Date.parse(resetAtIso) - now)
: Math.max(0, fallbackSeconds * 1000);

const totalMinutes = Math.ceil(ms / 60_000);
if (totalMinutes <= 0) return "Resets shortly";
if (totalMinutes < 60) return `Resets in ${totalMinutes}m`;

const totalHours = ms / 3_600_000;
if (totalHours < 24) {
const hours = Math.floor(totalHours);
const minutes = Math.round((totalHours - hours) * 60);
return minutes === 0
? `Resets in ${hours}h`
: `Resets in ${hours}h ${minutes}m`;
}

const target = new Date(now + ms);
const date = target.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
const time = target.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `Resets ${date} at ${time}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries";
import { TokenSpendAnalysisBanner } from "@features/billing/components/TokenSpendAnalysisBanner";
import { useUsage } from "@features/billing/hooks/useUsage";
import { useSeatStore } from "@features/billing/stores/seatStore";
import { formatResetTime } from "@features/billing/utils";
import { useFeatureFlag } from "@hooks/useFeatureFlag";
import { useSeat } from "@hooks/useSeat";
import type { UsageBucket } from "@main/services/llm-gateway/schemas";
Expand Down Expand Up @@ -47,17 +48,6 @@ async function openBillingPage(orgId: string | null): Promise<void> {
if (url) window.open(url, "_blank");
}

function formatResetTime(seconds: number): string {
if (seconds < 3600) return "less than 1 hour";
if (seconds < 86400) {
const hours = Math.ceil(seconds / 3600);
return hours === 1 ? "1 hour" : `${hours} hours`;
}
const days = Math.ceil(seconds / 86400);
if (days === 1) return "1 day";
return `${days} days`;
}

export function PlanUsageSettings() {
const {
seat,
Expand Down Expand Up @@ -452,7 +442,7 @@ function UsageMeter({ label, bucket, color }: UsageMeterProps) {
<Text className="text-(--gray-9) text-[13px]">
{bucket.exceeded
? "Limit exceeded"
: `Resets in ${formatResetTime(bucket.resets_in_seconds)}`}
: formatResetTime(bucket.reset_at, bucket.resets_in_seconds)}
</Text>
</Flex>
);
Expand Down
Loading