diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index dec29b4923fd2..cbfc284fde67f 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -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, { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7f8c2ef2e8ba3..72b3c2d2bc148 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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), @@ -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), @@ -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( diff --git a/apps/web/src/components/usage/UsageAccounts.browser.test.tsx b/apps/web/src/components/usage/UsageAccounts.browser.test.tsx index 7b09b3d7f8938..85c6628d1b753 100644 --- a/apps/web/src/components/usage/UsageAccounts.browser.test.tsx +++ b/apps/web/src/components/usage/UsageAccounts.browser.test.tsx @@ -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(); + 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(); +}); diff --git a/apps/web/src/components/usage/UsageAccounts.tsx b/apps/web/src/components/usage/UsageAccounts.tsx index f9265fd8c0655..1e677715c9668 100644 --- a/apps/web/src/components/usage/UsageAccounts.tsx +++ b/apps/web/src/components/usage/UsageAccounts.tsx @@ -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 = @@ -261,6 +261,12 @@ function AccountRow({ {creditValue} + {credits.hasCredits && !credits.unlimited && credits.balance === undefined ? ( + + {providerLabel} confirms credits are available; the account balance is not + disclosed. + + ) : null} @@ -272,7 +278,7 @@ function AccountRow({ {prepaidBalance && limits?.unavailable?.reason === "probeFailed" ? " · stale" : ""}
- {prepaidBalance ? formatUsd(prepaidBalance.remainingUsd) : "Unknown"} + {prepaidBalance ? formatUsd(prepaidBalance.remainingUsd) : "Unavailable"} {creditPercent !== null ? ( {creditPercent.toLocaleString(undefined, { maximumFractionDigits: 1 })}% left @@ -307,7 +313,10 @@ function AccountRow({ ) : null} {!prepaidBalance ? ( -

Balance unavailable

+

+ {limits?.unavailable?.message ?? + "Connect account billing access to read the balance."} +

) : null} ) : null} @@ -315,9 +324,10 @@ function AccountRow({
Usage status
- {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.")}
) : null} diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index dc2f8cd0fe241..ddd7d8cfed3b0 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -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"), @@ -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(); }); }); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index f6eaed60cb63b..50257a52823cb 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -1,8 +1,6 @@ import type { UsagePagePreferences } from "./usagePagePreferences"; import { ANTIGRAVITY_USAGE_SCOPE, - CHATGPT_USAGE_URL, - collectExternalUsageLinks, collectLimitAccounts, collectUsageAccounts, collectLimitNotices, @@ -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, @@ -634,7 +630,6 @@ export function UsageLimitsPooled({ const segmentPopoverActions = useRef(null); const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); - const externalLinks = collectExternalUsageLinks(presentations); const accounts = collectUsageAccounts(presentations); return (
@@ -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 ? (

Add a provider account in Settings to see its usage here.

@@ -684,39 +676,6 @@ export function UsageLimitsPooled({ ) : null } - {externalLinks.map((link) => ( -
-
- {link.url === CHATGPT_USAGE_URL ? ( -
- -
- ))}
); diff --git a/docs/user/usage.md b/docs/user/usage.md index f0ce2bc8aca4c..93e43b429b1e4 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -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**. diff --git a/packages/provider-acp-registry/src/server/copilotUsage.test.ts b/packages/provider-acp-registry/src/server/copilotUsage.test.ts index e9cef1015f642..57685a45aa193 100644 --- a/packages/provider-acp-registry/src/server/copilotUsage.test.ts +++ b/packages/provider-acp-registry/src/server/copilotUsage.test.ts @@ -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: { @@ -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", () => { diff --git a/packages/provider-acp-registry/src/server/copilotUsage.ts b/packages/provider-acp-registry/src/server/copilotUsage.ts index 816e5047123af..1ea8c56048357 100644 --- a/packages/provider-acp-registry/src/server/copilotUsage.ts +++ b/packages/provider-acp-registry/src/server/copilotUsage.ts @@ -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({ @@ -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); @@ -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, }; } diff --git a/packages/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index a8c4caff9f789..f8f1754348964 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -68,7 +68,7 @@ describe("acpRegistrySnapshotReadiness", () => { const result = { quotaSnapshots: { premium_interactions: { entitlementRequests: 1500, usedRequests: 6, remainingPercentage: 99.6, isUnlimitedEntitlement: false, tokenBasedBilling: true, hasQuota: true, - resetDate: '2026-11-01T00:00:00Z' + resetDate: null } } }; const body = JSON.stringify({ jsonrpc: '2.0', id: request.id, result }); process.stdout.write('Content-Length: ' + Buffer.byteLength(body) + '\\r\\n\\r\\n' + body); @@ -109,7 +109,6 @@ describe("acpRegistrySnapshotReadiness", () => { kind: "monthly", label: "AI credits · 6 / 1,500", usedPercent: expect.closeTo(0.4, 5), - resetsAt: "2026-11-01T00:00:00.000Z", }, ]); expect(provider.usageLimits?.creditBalance).toEqual({ diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 600fb48ba1b96..d757ed67567a7 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -154,7 +154,12 @@ describe("resolveUsageLimitsAfterProbe", () => { it("keeps the last good windows through a failed probe but not an unsupported one", () => { const failed = { checkedAt, windows: [], unavailable: { reason: "probeFailed" as const } }; const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; - expect(resolveUsageLimitsAfterProbe({ published, probed: failed })).toBe(published); + const stale = resolveUsageLimitsAfterProbe({ published, probed: failed }); + expect(stale).toEqual({ ...published, unavailable: failed.unavailable }); + expect(resolveUsageLimitsAfterProbe({ published: stale, probed: failed })).toBe(failed); + expect(resolveUsageLimitsAfterProbe({ published: stale, probed: published })).toEqual( + published, + ); expect(resolveUsageLimitsAfterProbe({ published, probed: unsupported })).toBe(unsupported); expect(resolveUsageLimitsAfterProbe({ published: undefined, probed: failed })).toBe(failed); }); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index feb16071c081a..db94c00172fff 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -118,12 +118,13 @@ export function resolveUsageLimitsAfterProbe(input: { readonly probed: ServerProviderUsageLimits | undefined; }): ServerProviderUsageLimits | undefined { const { published, probed } = input; - if (probed?.unavailable?.reason === "probeFailed" && published?.prepaidBalance) { + if ( + probed?.unavailable?.reason === "probeFailed" && + published && + (published.prepaidBalance !== undefined || published.unavailable === undefined) + ) { return { ...published, unavailable: probed.unavailable }; } - if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { - return published; - } if ( published && probed && diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 7a16ae29a45dc..1b5d0f427f58f 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1697,6 +1697,39 @@ describe("usage account coverage", () => { }); describe("external usage settings", () => { + it("keeps a connected Copilot account and its failed quota notice visible with a billing link", () => { + const message = "Copilot did not report premium usage."; + const copilot = provider({ + instanceId: ProviderInstanceId.make("copilot"), + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Copilot account", + auth: { status: "authenticated" }, + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [], + unavailable: { reason: "probeFailed", message }, + externalUsage: { + label: "GitHub Copilot usage", + url: "https://github.com/settings/copilot", + }, + }, + }); + const presentations = new Map([ + [ + EnvironmentId.make("local"), + { + entry: { target: { label: "Local" } }, + serverConfig: { providers: [copilot] }, + }, + ], + ]); + expect(collectUsageAccounts(presentations)).toMatchObject([ + { label: "Copilot account", status: "Connected" }, + ]); + expect(collectLimitNotices(presentations)).toEqual([`Copilot account: ${message}`]); + expect(collectLimitAccounts(presentations)).toEqual([]); + }); + it("deduplicates destinations across accounts and environments without inventing quota pools", () => { const managed = provider({ usageLimits: { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index bde6eda351f57..d47ff7e4fa610 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -596,7 +596,12 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly const environmentLabel = presentation.entry.target.label; for (const provider of presentation.serverConfig?.providers ?? []) { if (!provider.enabled || !provider.installed || !isProviderAvailable(provider)) continue; - if (provider.usageLimits?.externalUsage && provider.auth.status === "authenticated") continue; + if ( + provider.usageLimits?.externalUsage && + provider.auth.status === "authenticated" && + provider.usageLimits.unavailable?.reason !== "probeFailed" + ) + continue; const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : "Subscription limits are unavailable for this provider.";