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
5 changes: 4 additions & 1 deletion apps/server/src/provider/AntigravityProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,10 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => {
windows: [],
unavailable: { reason: "probeFailed" },
});
expect((yield* harness.provider.snapshot.refresh).usageLimits).toEqual(limits);
expect((yield* harness.provider.snapshot.refresh).usageLimits).toEqual({
...limits,
unavailable: { reason: "probeFailed" },
});
yield* harness.provider.onSignedOut;
expect((yield* harness.provider.snapshot.getSnapshot).usageLimits).toBeUndefined();
yield* Ref.set(usage, {
Expand Down
17 changes: 11 additions & 6 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,15 @@ const layerPreview = Layer.empty.pipe(
Layer.provideMerge(layerPortScanner),
);

// Billing readers must be available when provider instances capture their services.
const layerServerBrowser = ServerBrowser.layer.pipe(
Layer.provideMerge(DesktopBrowserChannel.layer),
Layer.provideMerge(PreviewBrowser.layer),
Layer.provideMerge(PreviewAutomationBroker.layer),
Layer.provideMerge(PreviewAgentActivity.layer),
Layer.provide(PreviewManager.layer),
);

const layerDevice = DeviceService.layer.pipe(
Layer.provide(layerServerSettings),
Layer.provide(ProcessRunner.layer),
Expand Down Expand Up @@ -685,6 +694,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)),
Layer.provideMerge(layerRepositoryIdentityResolver),
Layer.provideMerge(CodexManagedTokenBroker.layer),
Layer.provideMerge(layerServerBrowser),
Layer.provideMerge(layerServerEnvironment),
Layer.provideMerge(layerAuth),
Layer.provideMerge(ServerSecretStore.layer),
Expand Down Expand Up @@ -763,12 +773,7 @@ const layerMakeRoutes = Layer.mergeAll(
Layer.provide(PeerLinkRequests.layer),
// Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches.
Layer.provide(layerPullRequestService),
// The same activity instance RunExecutionService reports to, by layer memoization.
Layer.provide(
ServerBrowser.layer.pipe(
Layer.provide(Layer.merge(DesktopBrowserChannel.layer, PreviewAgentActivity.layer)),
),
),
Layer.provide(layerServerBrowser),
Layer.provide(PreviewBrowser.layer),
// Links to other environments, shared by Settings (WebSocket) and agents (/mcp).
Layer.provide(
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/usage/UsageAccounts.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,36 @@ it("cancels a drop when a refresh removes a snapshot account", async () => {
expect(labels(screen.container)).toEqual(["Work", "Research"]);
expect(readUsagePagePreferences().accountOrder).toBeUndefined();
});

it("explains undisclosed credits and unsupported usage on each account", async () => {
const accounts: UsageAccount[] = [
{
...samples[0]!,
label: "Lab",
limits: {
...samples[0]!.limits!,
creditBalance: { hasCredits: true, unlimited: false },
},
},
{ ...samples[1]!, label: "Local agent", limits: undefined },
];
await using screen = await render(<Harness accounts={accounts} />);
await expect
.element(
screen
.getByRole("button", { name: "Credit details for Lab" })
.getByText("Available", { exact: true }),
)
.toBeVisible();
await expect
.element(
screen.getByText(
"Codex confirms credits are available; the account balance is not disclosed.",
{ exact: true },
),
)
.toBeVisible();
await expect
.element(screen.getByText("This connection has not reported account usage.", { exact: true }))
.toBeVisible();
});
22 changes: 16 additions & 6 deletions apps/web/src/components/usage/UsageAccounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function AccountRow({
: credits?.balance !== undefined
? Number(credits.balance).toLocaleString(undefined, { maximumFractionDigits: 2 })
: credits?.hasCredits
? "Unknown"
? "Available"
: "0";
const summaryLimit = credits || isApi ? 2 : 3;
const summaryWindows =
Expand Down Expand Up @@ -261,6 +261,12 @@ function AccountRow({
<span className="block text-lg font-semibold tabular-nums wrap-anywhere">
{creditValue}
</span>
{credits.hasCredits && !credits.unlimited && credits.balance === undefined ? (
<span className="block text-xs text-muted-foreground wrap-anywhere">
{providerLabel} confirms credits are available; the account balance is not
disclosed.
</span>
) : null}
</button>
</dd>
</div>
Expand All @@ -272,7 +278,7 @@ function AccountRow({
{prepaidBalance && limits?.unavailable?.reason === "probeFailed" ? " · stale" : ""}
</dt>
<dd className="flex flex-wrap items-baseline gap-x-2 text-lg font-semibold tabular-nums">
{prepaidBalance ? formatUsd(prepaidBalance.remainingUsd) : "Unknown"}
{prepaidBalance ? formatUsd(prepaidBalance.remainingUsd) : "Unavailable"}
{creditPercent !== null ? (
<span className="text-xs font-normal text-muted-foreground">
{creditPercent.toLocaleString(undefined, { maximumFractionDigits: 1 })}% left
Expand Down Expand Up @@ -307,17 +313,21 @@ function AccountRow({
</div>
) : null}
{!prepaidBalance ? (
<p className="text-xs text-muted-foreground">Balance unavailable</p>
<p className="text-xs text-muted-foreground">
{limits?.unavailable?.message ??
"Connect account billing access to read the balance."}
</p>
) : null}
</div>
) : null}
{!windows.length && !credits && !isApi ? (
<div>
<dt className="sr-only">Usage status</dt>
<dd className="text-xs text-muted-foreground">
{limits?.unavailable?.reason === "probeFailed"
? "Refresh to retry usage"
: "Usage unavailable"}
{limits?.unavailable?.message ??
(limits?.unavailable?.reason === "probeFailed"
? "Usage could not be refreshed. Check this connection’s sign-in."
: "This connection has not reported account usage.")}
</dd>
</div>
) : null}
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ function managedChatGpt(instanceId: string, displayName: string): ServerProvider
};
}

describe("UsageLimitsPooled ChatGPT usage card", () => {
it("names the managed connections it covers", async () => {
describe("UsageLimitsPooled billing links", () => {
it("keeps billing links in account details without duplicate billing cards", async () => {
const presentations = new Map([
[
EnvironmentId.make("desk"),
Expand All @@ -63,11 +63,17 @@ describe("UsageLimitsPooled ChatGPT usage card", () => {
/>,
);

await expect.element(screen.getByRole("article", { name: "ChatGPT Work" })).toBeVisible();
await expect
.element(screen.getByText("ChatGPT Work on Desk, ChatGPT Personal on Desk"))
.toBeVisible();
.not.toBeInTheDocument();
await screen.getByRole("button", { name: "Details for ChatGPT Work" }).click();
await expect
.element(screen.getByText("View usage in ChatGPT with your connected account."))
.element(
screen.getByRole("button", {
name: "Open ChatGPT usage for ChatGPT Work",
}),
)
.toBeVisible();
});
});
45 changes: 2 additions & 43 deletions apps/web/src/components/usage/UsageLimitsPooled.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { UsagePagePreferences } from "./usagePagePreferences";
import {
ANTIGRAVITY_USAGE_SCOPE,
CHATGPT_USAGE_URL,
collectExternalUsageLinks,
collectLimitAccounts,
collectUsageAccounts,
collectLimitNotices,
Expand All @@ -17,11 +15,9 @@ import {
paceRemainingPercent,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { ensureLocalApi } from "../../localApi";
import { OpenAI } from "../Icons";
import type { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display";
import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
import { AlertTriangleIcon, TicketIcon } from "lucide-react";
import {
createContext,
type ReactNode,
Expand Down Expand Up @@ -634,7 +630,6 @@ export function UsageLimitsPooled({
const segmentPopoverActions = useRef<PopoverPrimitive.Root.Actions | null>(null);
const pools = collectLimitPools(collectLimitAccounts(presentations), now);
const notices = collectLimitNotices(presentations);
const externalLinks = collectExternalUsageLinks(presentations);
const accounts = collectUsageAccounts(presentations);
return (
<div className="flex flex-col gap-4">
Expand All @@ -647,10 +642,7 @@ export function UsageLimitsPooled({
onPreferencesChange={onPreferencesChange}
historyEnvironments={historyEnvironments}
/>
{accounts.length === 0 &&
notices.length === 0 &&
!cursorPrompt &&
externalLinks.length === 0 ? (
{accounts.length === 0 && notices.length === 0 && !cursorPrompt ? (
<p className="text-sm text-muted-foreground">
Add a provider account in Settings to see its usage here.
</p>
Expand Down Expand Up @@ -684,39 +676,6 @@ export function UsageLimitsPooled({
) : null
}
</Popover>
{externalLinks.map((link) => (
<section
key={link.url}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{link.url === CHATGPT_USAGE_URL ? (
<OpenAI className="size-5 shrink-0" aria-hidden="true" />
) : null}
<div className="min-w-0 space-y-1">
<h2 className="text-sm font-medium">{link.label}</h2>
<p className="text-xs break-words text-muted-foreground">
{link.accounts.join(", ")}
</p>
{link.url === CHATGPT_USAGE_URL ? (
<p className="text-xs text-muted-foreground">
View usage in ChatGPT with your connected account.
</p>
) : link.message ? (
<p className="max-w-xl text-xs text-muted-foreground">{link.message}</p>
) : null}
</div>
</div>
<Button
variant="ghost-muted"
size="xs"
onClick={() => void ensureLocalApi().shell.openExternal(link.url)}
>
Manage usage
<ExternalLinkIcon className="size-3.5" aria-hidden="true" />
</Button>
</section>
))}
<LimitNotices notices={notices} />
</div>
);
Expand Down
5 changes: 2 additions & 3 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,11 @@ Provider billing links give account-wide charges and prepaid balances; standard
keys provide inference access, while organization billing APIs require administrative access.

Codex cards show provider-reported usage credits separately from quota-reset credits when available. A provider can report that credits exist
without disclosing their balance. Google cards show each Gemini model's reported allowance and
without disclosing their balance; the card shows **Available** with that explanation. Google cards show each Gemini model's reported allowance and
reset independently of the shared Claude/GPT allowance. Expand additional quotas to see every model.

GitHub Copilot connections report account-wide AI credit or premium-request allowances through
the signed-in Copilot CLI, including remaining credits and reported reset times. Chat and
completion entitlements marked unlimited stay labeled **Unlimited**. Refresh Limits after
the signed-in Copilot CLI, including remaining credits and reported reset times. Unlimited chat and completion entitlements are excluded from premium credit percentages. Refresh Limits after
signing in or switching accounts to fetch the current account's allowance. These account quotas
are separate from the recorded token and estimated-cost history under **Cost** and **Tokens**.

Expand Down
52 changes: 48 additions & 4 deletions packages/provider-acp-registry/src/server/copilotUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("Copilot account usage", () => {
expect(limits.creditBalance).toBeUndefined();
});

it("preserves unlimited entitlement flags and the legacy -1 sentinel", () => {
it("keeps unlimited chat and completions separate from premium quota availability", () => {
const limits = copilotUsageLimits(
{
quotaSnapshots: {
Expand All @@ -44,11 +44,55 @@ describe("Copilot account usage", () => {
},
checkedAt,
);
expect(limits.unavailable?.reason).toBe("probeFailed");
expect(limits.windows).toEqual([]);
});

it("preserves unlimited premium credit entitlements", () => {
for (const fields of [
{ entitlementRequests: 0, isUnlimitedEntitlement: true },
{ entitlementRequests: -1 },
]) {
const limits = copilotUsageLimits(
{
quotaSnapshots: {
premium_interactions: quota({ ...fields, tokenBasedBilling: true }),
},
},
checkedAt,
);
expect(limits.unavailable).toBeUndefined();
expect(limits.creditBalance).toEqual({ hasCredits: true, unlimited: true });
}
});

it("reports exhausted AI credits even when Copilot marks hasQuota false", () => {
const limits = copilotUsageLimits(
{
quotaSnapshots: {
premium_interactions: quota({
entitlementRequests: 1500,
usedRequests: 1500,
hasQuota: false,
tokenBasedBilling: true,
resetDate: checkedAt,
}),
chat: quota({ entitlementRequests: 0, usedRequests: 0, isUnlimitedEntitlement: true }),
completions: quota({ entitlementRequests: -1, usedRequests: 0 }),
},
},
checkedAt,
);
expect(limits.unavailable).toBeUndefined();
expect(limits.windows.map((w) => [w.label, w.usedPercent, w.resetsAt])).toEqual([
["Chat · Unlimited", 0, undefined],
["Completions · Unlimited", 0, undefined],
expect(limits.windows).toEqual([
{
id: "copilot_premium_interactions",
kind: "monthly",
label: "AI credits · 1,500 / 1,500",
usedPercent: 100,
},
]);
expect(limits.creditBalance).toEqual({ hasCredits: false, unlimited: false, balance: "0" });
});

it("clamps exhausted credits and leaves a malformed reset unknown", () => {
Expand Down
22 changes: 20 additions & 2 deletions packages/provider-acp-registry/src/server/copilotUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,15 @@ export function copilotUsageLimits(payload: unknown, checkedAt: string): ServerP
["completions", "Completions"],
] as const) {
const parsed = decodeQuota(response.value.quotaSnapshots[id]);
if (Option.isNone(parsed) || parsed.value.hasQuota === false) continue;
if (Option.isNone(parsed)) continue;
const quota = parsed.value;
const unlimited = quota.isUnlimitedEntitlement === true || quota.entitlementRequests === -1;
if (!unlimited && (quota.entitlementRequests <= 0 || quota.usedRequests < 0)) continue;
// Copilot uses hasQuota=false when the remaining allowance is exhausted.
if (quota.hasQuota === false && (unlimited || quota.usedRequests < quota.entitlementRequests))
continue;
// Unlimited chat/completions do not establish remaining premium credits.
if (unlimited && id !== "premium_interactions") continue;
const credits = id === "premium_interactions" && quota.tokenBasedBilling === true;
const reset = quota.resetDate ? DateTime.make(quota.resetDate) : Option.none();
windows.push({
Expand All @@ -83,7 +88,11 @@ export function copilotUsageLimits(payload: unknown, checkedAt: string): ServerP
usedPercent: unlimited
? 0
: clampPercent((quota.usedRequests / quota.entitlementRequests) * 100),
...(Option.isSome(reset) && !unlimited ? { resetsAt: DateTime.formatIso(reset.value) } : {}),
...(Option.isSome(reset) &&
!unlimited &&
DateTime.toEpochMillis(reset.value) > Date.parse(checkedAt)
? { resetsAt: DateTime.formatIso(reset.value) }
: {}),
});
if (credits) {
const remaining = Math.max(0, quota.entitlementRequests - quota.usedRequests);
Expand All @@ -107,6 +116,15 @@ export function copilotUsageLimits(payload: unknown, checkedAt: string): ServerP
return {
...makeUsageLimits({ checkedAt, windows }),
...(creditBalance ? { creditBalance } : {}),
...(!windows.some((window) => window.id === "copilot_premium_interactions")
? {
unavailable: {
reason: "probeFailed" as const,
message:
"Copilot did not report premium usage. Check GitHub Copilot usage for remaining requests or credits.",
},
}
: {}),
externalUsage,
};
}
Expand Down
Loading
Loading