Skip to content
99 changes: 98 additions & 1 deletion packages/core/src/billing/usageDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import type { UsageOutput } from "../usage/schemas";
import { formatResetTime, isUsageExceeded } from "./usageDisplay";
import {
codeUsageMeter,
formatResetTime,
formatUsdAmount,
isCodeUsageFreeTier,
isUsageExceeded,
} from "./usageDisplay";

function makeUsage(
overrides: Partial<{
Expand Down Expand Up @@ -53,6 +59,92 @@ describe("isUsageExceeded", () => {
});
});

describe("isCodeUsageFreeTier", () => {
it.each([
[false, true],
[true, false],
// Absent means unknown, never free.
[undefined, false],
] as const)("code_usage_subscribed=%s -> %s", (subscribed, expected) => {
expect(isCodeUsageFreeTier({ code_usage_subscribed: subscribed })).toBe(
expected,
);
});

it("treats missing usage as not confirmed free", () => {
expect(isCodeUsageFreeTier(null)).toBe(false);
expect(isCodeUsageFreeTier(undefined)).toBe(false);
});
});

describe("codeUsageMeter", () => {
it("prefers billing's org dollars when both numbers are present", () => {
const meter = codeUsageMeter({
...makeUsage(),
code_usage_subscribed: true,
ai_credits: { exhausted: false, used_usd: 12.4, limit_usd: 50 },
billing_period_end: "2026-06-01T00:00:00.000Z",
});
expect(meter).toEqual({
kind: "dollars",
usedUsd: 12.4,
limitUsd: 50,
percent: 25,
exceeded: false,
resetAt: "2026-06-01T00:00:00.000Z",
});
});

it("marks the dollars meter exceeded from the org bucket and falls back to the sustained reset", () => {
const meter = codeUsageMeter({
...makeUsage(),
code_usage_subscribed: false,
ai_credits: { exhausted: true, used_usd: 20, limit_usd: 20 },
});
expect(meter).toMatchObject({
kind: "dollars",
percent: 100,
exceeded: true,
resetAt: "2026-05-01T13:00:00.000Z",
});
});

it.each([
["missing numbers", { exhausted: false }],
["null numbers", { exhausted: false, used_usd: null, limit_usd: null }],
["a zero limit", { exhausted: false, used_usd: 0, limit_usd: 0 }],
])("falls back to the free-tier valve bucket with %s", (_name, aiCredits) => {
const usage: UsageOutput = {
...makeUsage(),
code_usage_subscribed: false,
ai_credits: aiCredits,
};
expect(codeUsageMeter(usage)).toEqual({
kind: "bucket",
bucket: usage.sustained,
});
});

it("hides the meter for a subscribed or unknown org without dollars", () => {
expect(
codeUsageMeter({ ...makeUsage(), code_usage_subscribed: true }),
).toEqual({ kind: "hidden" });
expect(codeUsageMeter(makeUsage())).toEqual({ kind: "hidden" });
expect(codeUsageMeter(null)).toEqual({ kind: "hidden" });
});
});

describe("formatUsdAmount", () => {
it.each([
[50, "$50"],
[12.4, "$12.40"],
[0.5, "$0.50"],
[0, "$0"],
])("formats %s as %s", (amount, expected) => {
expect(formatUsdAmount(amount)).toBe(expected);
});
});

describe("formatResetTime", () => {
const NOW = Date.parse("2026-05-01T12:00:00.000Z");
const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString();
Expand All @@ -73,6 +165,11 @@ describe("formatResetTime", () => {
resetAt: isoAt(4 * 3600 * 1000),
expected: "Resets in 4h",
},
{
name: "rolls the hour instead of showing 60 minutes",
resetAt: isoAt((23 * 3600 + 59 * 60 + 40) * 1000),
expected: "Resets in 24h",
},
{
name: "returns localized date when over 24h away",
resetAt: isoAt(30 * 86400 * 1000),
Expand Down
62 changes: 57 additions & 5 deletions packages/core/src/billing/usageDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,55 @@
import type { UsageOutput } from "../usage/schemas";
import type { UsageBucket, UsageOutput } from "../usage/schemas";

/** How much more usage the Pro plan offers relative to the Free plan. */
export const PRO_USAGE_MULTIPLIER = 40;
/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */
export function isCodeUsageFreeTier(
usage: Pick<UsageOutput, "code_usage_subscribed"> | null | undefined,
): boolean {
return usage?.code_usage_subscribed === false;
}

export type CodeUsageMeter =
| {
kind: "dollars";
usedUsd: number;
limitUsd: number;
percent: number;
exceeded: boolean;
resetAt: string;
}
| { kind: "bucket"; bucket: UsageBucket }
| { kind: "hidden" };

/**
* What the usage meter should show. Billing's org-level dollars win when
* present; a free-tier org without them falls back to its per-user valve
* bucket; anything else shows nothing — per-user valve percentages are
* meaningless for a subscribed org, and unknown must not render as free.
*/
export function codeUsageMeter(
usage: UsageOutput | null | undefined,
): CodeUsageMeter {
if (!usage) return { kind: "hidden" };
const usedUsd = usage.ai_credits?.used_usd;
const limitUsd = usage.ai_credits?.limit_usd;
if (usedUsd != null && limitUsd != null && limitUsd > 0) {
return {
kind: "dollars",
usedUsd,
limitUsd,
percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)),
exceeded: usage.ai_credits?.exhausted === true,
resetAt: usage.billing_period_end ?? usage.sustained.reset_at,
};
}
if (isCodeUsageFreeTier(usage)) {
return { kind: "bucket", bucket: usage.sustained };
}
return { kind: "hidden" };
}

export function formatUsdAmount(amount: number): string {
return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`;
}

export function isUsageExceeded(usage: UsageOutput): boolean {
return (
Expand All @@ -22,8 +70,12 @@ export function formatResetTime(

const totalHours = ms / 3_600_000;
if (totalHours < 24) {
const hours = Math.floor(totalHours);
const minutes = Math.round((totalHours - hours) * 60);
let hours = Math.floor(totalHours);
let minutes = Math.round((totalHours - hours) * 60);
if (minutes === 60) {
hours += 1;
minutes = 0;
}
return minutes === 0
? `Resets in ${hours}h`
: `Resets in ${hours}h ${minutes}m`;
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/llm-gateway/llm-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ describe("LlmGatewayService.fetchUsage", () => {
const fetchMock = vi.fn().mockResolvedValue(
createJsonResponse({
...USAGE_BODY,
ai_credits: { exhausted: true },
ai_credits: { exhausted: true, used_usd: 12.4, limit_usd: 50 },
code_usage_subscribed: true,
}),
);
Expand All @@ -377,9 +377,27 @@ describe("LlmGatewayService.fetchUsage", () => {
const usage = await service.fetchUsage();

expect(usage.ai_credits?.exhausted).toBe(true);
expect(usage.ai_credits?.used_usd).toBe(12.4);
expect(usage.ai_credits?.limit_usd).toBe(50);
expect(usage.code_usage_subscribed).toBe(true);
});

it("parses ai_credits with null spend numbers from an unsynced org", async () => {
const fetchMock = vi.fn().mockResolvedValue(
createJsonResponse({
...USAGE_BODY,
ai_credits: { exhausted: false, used_usd: null, limit_usd: null },
}),
);
const { service } = createService(fetchMock);

const usage = await service.fetchUsage();

expect(usage.ai_credits?.exhausted).toBe(false);
expect(usage.ai_credits?.used_usd).toBeNull();
expect(usage.ai_credits?.limit_usd).toBeNull();
});

it("feeds code_usage_subscribed into helper model routing", async () => {
const fetchMock = vi
.fn()
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/usage/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ export const usageOutput = z.object({
user_id: z.number(),
sustained: usageBucketSchema,
burst: usageBucketSchema,
ai_credits: z.object({ exhausted: z.boolean() }).optional(),
ai_credits: z
.object({
exhausted: z.boolean(),
used_usd: z.number().nullish(),
limit_usd: z.number().nullish(),
})
.optional(),
is_rate_limited: z.boolean(),
is_pro: z.boolean(),
code_usage_subscribed: z.boolean().optional(),
Expand Down
Loading
Loading