From 73d25317a433caa60504dd11fcee05f6c932b6cc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 2 Oct 2026 11:18:13 +0100 Subject: [PATCH] fix(usage): derive window names and unify pin selection (#5903) * fix(usage): derive window names and unify pin selection Quota slots do not identify a period. Derive numeric windows and IDs from reported duration and quota scope; keep unknown lengths neutral. Resolve source defaults and explicit pins through one model, with a persisted empty selection distinct from defaults. * fix(plugins): declare the duration helper host requirement * fix(usage): verify persisted selections at the storage boundary --- docs/providers.md | 4 +- packages/app/e2e/browser/usage-screen.spec.ts | 71 +++++++-- .../e2e/browser/usage-sidebar-item.spec.ts | 29 ++-- .../app/e2e/support/helpers/usage-login.ts | 74 +++++++++- .../src/hooks/use-settings/storage.test.ts | 23 ++- packages/app/src/usage/display.ts | 19 ++- packages/app/src/usage/host-usage-section.tsx | 2 +- packages/app/src/usage/pinned.test.ts | 17 ++- packages/app/src/usage/pinned.ts | 54 +++---- packages/app/src/usage/preferences.test.ts | 109 +++++++++++--- packages/app/src/usage/preferences.ts | 70 +++++++-- packages/plugin/src/server/usage.test.ts | 21 +++ packages/plugin/src/server/usage.ts | 49 +++++++ .../usage-source-directory/index.server.ts | 9 +- .../usage-source/index.server.ts | 9 +- .../server/plugins/usage-sources.e2e.test.ts | 7 + plugins/codex-usage-source/paseo-plugin.json | 2 +- .../codex-usage-source/server/usage.test.ts | 137 ++++++++++++++++-- plugins/codex-usage-source/server/usage.ts | 97 +++++++++---- .../grok-usage-source/server/usage.test.ts | 43 ++++++ plugins/grok-usage-source/server/usage.ts | 26 ++-- .../kimi-usage-source/server/usage.test.ts | 40 ++++- plugins/kimi-usage-source/server/usage.ts | 20 ++- plugins/muse-provider/paseo-plugin.json | 2 +- plugins/muse-provider/server/usage.ts | 8 +- plugins/muse-provider/test/provider.test.ts | 24 ++- public-docs/plugins/reference.md | 21 +++ 27 files changed, 815 insertions(+), 172 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 9aa2fe8bf6..47c39fd1a8 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -188,7 +188,9 @@ promise for completion: equal results, including equal discovery timestamps, emi ## Usage sources See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the -contract, account identity, login fallback, and read-only credential rules. +contract, account and window identity, provider-derived period names, login fallback, and +read-only credential rules. Usage adapters own the interpretation of provider fields; the app +renders their names and resolves pins without provider-specific duration guesses. --- diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 71f3dfd8cc..48625eea4e 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -15,6 +15,8 @@ import { } from "../support/helpers/usage-reports"; import { installLoginUsage, + installCodexWindowUsage, + expectCodexReportedWindows, openUsage, refreshLoginUsage, hoverUsageWindow, @@ -312,29 +314,26 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag ).toHaveCount(0); await expectPinnedUsage(page, ["31% 5h", "54% wk"]); const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); - await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "0"); + await expect(row).toBeChecked(); + await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "0"); await hoverUsageWindow(page, "Weekly"); await expect(page.getByText("Pin", { exact: true })).toBeVisible(); - await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1"); - await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute( - "fill", - "none", - ); - await qaScreenshot(page, "usage-pin-hover"); - await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly"); await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1"); await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute( "fill", "none", ); - await expectPinnedUsage(page, ["54% wk"]); - await qaScreenshot(page, "usage-pin-selected"); + await qaScreenshot(page, "usage-pin-hover"); await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly"); await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1"); await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute( "fill", "none", ); + await expectPinnedUsage(page, ["31% 5h"]); + await qaScreenshot(page, "usage-pin-unselected"); + await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly"); + await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1"); await expectPinnedUsage(page, ["31% 5h", "54% wk"]); } finally { await fixture.cleanup(); @@ -348,11 +347,57 @@ test("compact usage rows always show the pin glyph", async ({ page }) => { await gotoAppShell(page); await openUsage(page); const card = page.getByTestId("usage-report-login-journey:account"); - await expect(card.getByTestId("usage-pin-glyph-unpinned")).toHaveCount(2); - await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(0)).toHaveCSS("opacity", "1"); - await expect(card.getByTestId("usage-pin-glyph-unpinned").nth(1)).toHaveCSS("opacity", "1"); + await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2); + await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1"); + await expect(card.getByTestId("usage-pin-glyph-pinned").nth(1)).toHaveCSS("opacity", "1"); await qaScreenshot(page, "usage-pin-compact"); } finally { await fixture.cleanup(); } }); + +for (const shape of ["seven-day-only", "Spark"] as const) { + test(`Codex ${shape} windows use provider durations on the card and sidebar`, async ({ + page, + }) => { + const reset = Math.floor(Date.now() / 1000) + 5 * 86400; + const source = await installCodexWindowUsage({ + rate_limit: { + primary_window: { used_percent: 11, limit_window_seconds: 604800, reset_at: reset }, + secondary_window: null, + }, + additional_rate_limits: + shape === "Spark" + ? [ + { + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { + used_percent: 0, + limit_window_seconds: 18000, + reset_at: Math.floor(Date.now() / 1000) + 3600, + }, + secondary_window: { + used_percent: 0, + limit_window_seconds: 604800, + reset_at: reset, + }, + }, + }, + ] + : [], + }); + try { + await page.setViewportSize({ width: 1440, height: 900 }); + await gotoAppShell(page); + await expect(usageItem(page)).toBeVisible(); + await usageItem(page).click(); + await qaScreenshot(page, `codex-${shape}-card-and-summary`); + await expectCodexReportedWindows(page, shape); + await qaScreenshot(page, `codex-${shape}-pins`); + } finally { + await source.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 8a1d4bb7f0..902435a475 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -114,6 +114,11 @@ test.describe("Usage item", () => { await gotoAppShell(page); await usageItem(page).click(); await expectOnUsageScreen(page); + await qaScreenshot(page, "default-pins-card"); + await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); + await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Session")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked(); }); await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => { @@ -122,9 +127,13 @@ test.describe("Usage item", () => { await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount( 0, ); - await togglePin(screen, "Claude", "Session"); - await expectPinnedUsage(page, ["31% 5h"]); - await togglePin(screen, "Codex", "Weekly"); + await togglePin(screen, "Claude", "Weekly"); + await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); + await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Session")).toBeChecked(); + await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked(); + await qaScreenshot(page, "default-pins-after-one-toggle"); + await togglePin(screen, "Codex", "Session"); await expectPinnedUsage(page, ["31% 5h", "12% wk"]); await expect(usageItem(page)).not.toHaveText("Usage"); await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); @@ -226,16 +235,18 @@ test.describe("Usage item", () => { await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); }); - await test.step("unpinning both brings back default windows, and that survives a reload", async () => { + await test.step("unpinning everything stays empty after a reload", async () => { await togglePin(screen, "Claude", "Session"); await togglePin(screen, "Codex", "Weekly"); - await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]); + await expectNoUsageItem(page); await page.reload(); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); - await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]); - await gotoAppShell(page); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await expectNoUsageItem(page); + await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked(); + await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked(); + await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked(); + await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked(); + await qaScreenshot(page, "explicit-empty-pins-after-reload"); }); await test.step("Settings > Sidebar lists the Usage item", async () => { diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 5a2bef072d..4e83541b91 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -1,8 +1,9 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { UsageReport } from "@getpaseo/protocol/messages"; -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; +import { expectPinnedUsage, pinRow, usageItem } from "./usage-sidebar-item"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; @@ -61,6 +62,73 @@ export async function refreshLoginUsage(page: Page) { export async function hoverUsageWindow(page: Page, window: string) { await page .getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) }) - .getByTestId("usage-pin-glyph-unpinned") + .getByTestId(/usage-pin-glyph-/) .hover(); } + +/** Exercises the built-in Codex adapter through the real plugin loader and daemon transport. */ +export async function installCodexWindowUsage(payload: unknown) { + const directory = await mkdtemp(path.join(tmpdir(), "usage-codex-windows-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const cleanup = async () => { + try { + await client.removePlugin("codex-window-qa"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }; + try { + const source = path.resolve(__dirname, "../../../../../plugins/codex-usage-source"); + await cp(path.join(source, "server"), path.join(directory, "server"), { recursive: true }); + await cp(path.join(source, "shared"), path.join(directory, "shared"), { recursive: true }); + await writeFile( + path.join(directory, "auth.json"), + JSON.stringify({ tokens: { access_token: "fixture-codex" } }), + ); + await writeFile(path.join(directory, "payload.json"), JSON.stringify(payload)); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "codex-window-qa", requirements: pluginRequirements }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import {readFile} from "node:fs/promises"; +import {fetchUsage, discover} from "./server/usage.js"; +import {inputSchema} from "./shared/input.js"; +export default function contribute(server) { + server.registerUsageSource({id: "codex-window-qa", label: "Codex", input: inputSchema, + discover: () => discover({home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}), + fetch: (input) => fetchUsage(input, async () => Response.json(JSON.parse(await readFile(${JSON.stringify(path.join(directory, "payload.json"))}, "utf8")))), + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: directory }); + return { cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} + +export async function expectCodexReportedWindows(page: Page, shape: "seven-day-only" | "Spark") { + await expectPinnedUsage(page, ["11% wk"]); + await expect(pinRow(page.locator("body"), "Codex", "Weekly")).toBeChecked(); + await expect(page.getByText("Session", { exact: true })).toHaveCount(0); + if (shape === "Spark") { + const fiveHour = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · 5-hour"); + const weekly = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · Weekly"); + await expect(fiveHour).not.toBeChecked(); + await expect(weekly).not.toBeChecked(); + await fiveHour.click(); + await expect(fiveHour).toBeChecked(); + await expect(usageItem(page)).toHaveAccessibleName( + /Codex Weekly 11% used, Codex GPT-5.3-Codex-Spark · 5-hour 0% used/, + ); + } +} diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 9761256278..d396f26d9a 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -263,10 +263,10 @@ describe("loadAppSettingsFromStorage", () => { ]); }); - it("loads usage preferences with pins in order and defaults a fresh device to none", async () => { + it("loads legacy usage pins in order and defaults a fresh device to source defaults", async () => { expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({ displayAs: "used", - pinned: [], + pins: null, serverId: null, }); @@ -287,7 +287,7 @@ describe("loadAppSettingsFromStorage", () => { expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ displayAs: "remaining", - pinned: [ + pins: [ { sourceId: "codex", windowId: "weekly" }, { sourceId: "claude", windowId: "five-hour" }, ], @@ -295,6 +295,21 @@ describe("loadAppSettingsFromStorage", () => { }); }); + it("loads an explicitly empty usage selection without restoring defaults", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + usage: { displayAs: "remaining", pins: [], serverId: "server" }, + }), + }), + }); + expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ + displayAs: "remaining", + pins: [], + serverId: "server", + }); + }); + it("keeps valid usage preferences when one field is malformed", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ @@ -310,7 +325,7 @@ describe("loadAppSettingsFromStorage", () => { expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ displayAs: "used", - pinned: [{ sourceId: "claude", windowId: "weekly" }], + pins: [{ sourceId: "claude", windowId: "weekly" }], serverId: null, }); }); diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts index 09fdb3d8ea..cfacf8d738 100644 --- a/packages/app/src/usage/display.ts +++ b/packages/app/src/usage/display.ts @@ -1,5 +1,7 @@ import { useCallback, useMemo } from "react"; import { useAppSettings } from "@/hooks/use-settings"; +import { useUsageHostId } from "./hosts"; +import { useUsageHostReports } from "./queries"; import { isUsagePinned, setUsageDisplayAs, @@ -18,9 +20,14 @@ export interface UsageDisplay { } /** The device's usage preferences, and the commands that change them. */ -export function useUsagePreferences(): { preferences: UsagePreferences; display: UsageDisplay } { +export function useUsagePreferences(serverId?: string): { + preferences: UsagePreferences; + display: UsageDisplay; +} { const { settings, updateSettings } = useAppSettings(); const preferences = settings.usage; + const defaultServerId = useUsageHostId(); + const reports = useUsageHostReports(serverId ?? defaultServerId); const setDisplayAs = useCallback( (displayAs: UsageDisplayAs) => { void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) })); @@ -29,18 +36,20 @@ export function useUsagePreferences(): { preferences: UsagePreferences; display: ); const togglePin = useCallback( (pin: UsagePin) => { - void updateSettings((current) => ({ usage: toggleUsagePin(current.usage, pin) })); + void updateSettings((current) => ({ + usage: toggleUsagePin({ preferences: current.usage, pin, reports }), + })); }, - [updateSettings], + [updateSettings, reports], ); const display = useMemo( () => ({ displayAs: preferences.displayAs, setDisplayAs, - isPinned: (pin) => isUsagePinned(preferences, pin), + isPinned: (pin) => isUsagePinned(preferences, pin, reports), togglePin, }), - [preferences, setDisplayAs, togglePin], + [preferences, reports, setDisplayAs, togglePin], ); return { preferences, display }; } diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index f3cb0af3b0..927d32e7d8 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -6,7 +6,7 @@ import { UsageSection } from "./usage-section"; /** A host's usage reports, for its settings page. */ export function HostUsageSection({ serverId }: { serverId: string }) { const { view, refresh } = useHostUsage(serverId); - const { display } = useUsagePreferences(); + const { display } = useUsagePreferences(serverId); return ( { @@ -73,7 +73,7 @@ describe("choosePinnedUsageLayout", () => { }); describe("resolvePinnedUsage", () => { - it("defaults to the first window with a percent for each source account", () => { + it("defaults to source-wide pins collected from each account", () => { const work = report({ sourceId: "claude", sourceLabel: "Claude", @@ -89,12 +89,13 @@ describe("resolvePinnedUsage", () => { windows: [{ id: "empty", label: "Empty" }], }); expect( - windows([claude, codex, work, empty], preferences([])).map((item) => [ + windows([claude, codex, work, empty], preferences(null)).map((item) => [ item.key, item.percentText, ]), ).toEqual([ ["claude:default/five-hour", "31%"], + ["claude:default/weekly", "80%"], ["claude:work/weekly", "90%"], ["codex:default/weekly", "12%"], ]); @@ -107,7 +108,7 @@ describe("resolvePinnedUsage", () => { (item) => item.key, ), ).toEqual(["claude:default/weekly"]); - expect(windows(reports, preferences([])).map((item) => item.key)).toEqual([ + expect(windows(reports, preferences(null)).map((item) => item.key)).toEqual([ "claude:default/five-hour", "codex:default/weekly", ]); @@ -176,7 +177,7 @@ describe("resolvePinnedUsage", () => { ], }); - expect(windows([marked, codex], preferences([])).map((item) => item.key)).toEqual([ + expect(windows([marked, codex], preferences(null)).map((item) => item.key)).toEqual([ "claude:default/five-hour", "claude:default/weekly", "codex:default/weekly", @@ -189,7 +190,7 @@ describe("resolvePinnedUsage", () => { sourceLabel: "OpenCode Go", windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }], }); - const items = windows([opencode], preferences([])); + const items = windows([opencode], preferences(null)); expect(items.map((item) => item.shortLabel)).toEqual([""]); }); @@ -268,5 +269,5 @@ it("keeps unavailable and error reports out of the sidebar summary", () => { ...codex, report: { status: "error", error: "Store deleted" }, }; - expect(resolvePinnedUsage([expired, failed], preferences([]))).toEqual([]); + expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]); }); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts index f05f64cdbc..547b2c3e48 100644 --- a/packages/app/src/usage/pinned.ts +++ b/packages/app/src/usage/pinned.ts @@ -1,8 +1,8 @@ import { formatPct } from "./format"; import { displayPercent } from "./model"; -import type { UsagePreferences } from "./preferences"; +import { effectiveUsagePins, type UsagePreferences } from "./preferences"; import { windowTone } from "./tone"; -import type { UsageReportEntry, UsageTone, UsageWindow } from "./types"; +import type { UsageReportEntry, UsageTone } from "./types"; /** One summary window of one account, as the sidebar Usage item shows it. */ export interface PinnedUsageWindow { @@ -61,24 +61,6 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId)); } -/** - * Pins replace defaults. Without pins, an account shows the windows its source marks as summary, - * or its first window with a percent when the source marks none. - */ -function summaryWindows(entry: UsageReportEntry, preferences: UsagePreferences): UsageWindow[] { - if (entry.report.status !== "available") return []; - const withPercent = entry.report.windows.filter( - (window) => displayPercent(window, preferences.displayAs) !== null, - ); - if (preferences.pinned.length === 0) { - const marked = withPercent.filter((window) => window.summary); - return marked.length > 0 ? marked : withPercent.slice(0, 1); - } - return withPercent.filter((window) => - preferences.pinned.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id), - ); -} - /** * The sidebar Usage item's summary: one group per account, grouped by source, each with its * windows in its report's order. Accounts with nothing to show are left out. @@ -87,20 +69,28 @@ export function resolvePinnedUsage( reports: readonly UsageReportEntry[], preferences: UsagePreferences, ): PinnedUsageSource[] { + const pins = effectiveUsagePins(preferences, reports); const meaning = preferences.displayAs === "remaining" ? "left" : "used"; return groupBySource(reports).flatMap((entry) => { - const windows = summaryWindows(entry, preferences).map((window) => { - const percent = displayPercent(window, preferences.displayAs) ?? 0; - const percentText = formatPct(percent); - return { - key: `${entry.id}/${window.id}`, - label: `${describe(entry, window.label)} ${percentText} ${meaning}`, - shortLabel: window.shortLabel ?? window.label, - percent, - percentText, - tone: windowTone(window), - }; - }); + if (entry.report.status !== "available") return []; + const windows = entry.report.windows + .filter( + (window) => + displayPercent(window, preferences.displayAs) !== null && + pins.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id), + ) + .map((window) => { + const percent = displayPercent(window, preferences.displayAs) ?? 0; + const percentText = formatPct(percent); + return { + key: `${entry.id}/${window.id}`, + label: `${describe(entry, window.label)} ${percentText} ${meaning}`, + shortLabel: window.shortLabel ?? window.label, + percent, + percentText, + tone: windowTone(window), + }; + }); if (windows.length === 0) return []; return [{ key: entry.id, icon: entry.icon ?? null, windows }]; }); diff --git a/packages/app/src/usage/preferences.test.ts b/packages/app/src/usage/preferences.test.ts index 391ec05cd3..43dccc466d 100644 --- a/packages/app/src/usage/preferences.test.ts +++ b/packages/app/src/usage/preferences.test.ts @@ -13,45 +13,118 @@ const codexWeekly = { sourceId: "codex", windowId: "weekly" }; describe("usage preferences", () => { it("pins windows in the order the user pins them", () => { - const pinned = toggleUsagePin( - toggleUsagePin(DEFAULT_USAGE_PREFERENCES, codexWeekly), - claudeFiveHour, - ); + const pinned = toggleUsagePin({ + preferences: toggleUsagePin({ + preferences: DEFAULT_USAGE_PREFERENCES, + pin: codexWeekly, + reports: [], + }), + pin: claudeFiveHour, + reports: [], + }); - expect(pinned.pinned).toEqual([codexWeekly, claudeFiveHour]); - expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "weekly" })).toBe(true); - expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "five-hour" })).toBe(false); + expect(pinned.pins).toEqual([codexWeekly, claudeFiveHour]); + expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "weekly" }, [])).toBe(true); + expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "five-hour" }, [])).toBe(false); }); it("unpins a pinned window and keeps the others in order", () => { const three = [codexWeekly, claudeFiveHour, { sourceId: "claude", windowId: "weekly" }]; - const unpinned = toggleUsagePin( - { displayAs: "used", pinned: three, serverId: null }, - claudeFiveHour, - ); + const unpinned = toggleUsagePin({ + preferences: { displayAs: "used", pins: three, serverId: null }, + pin: claudeFiveHour, + reports: [], + }); - expect(unpinned.pinned).toEqual([codexWeekly, { sourceId: "claude", windowId: "weekly" }]); + expect(unpinned.pins).toEqual([codexWeekly, { sourceId: "claude", windowId: "weekly" }]); }); it("switches between used and remaining without touching pins", () => { - const preferences = { displayAs: "used" as const, pinned: [codexWeekly], serverId: null }; + const preferences = { displayAs: "used" as const, pins: [codexWeekly], serverId: null }; expect(setUsageDisplayAs(preferences, "remaining")).toEqual({ displayAs: "remaining", - pinned: [codexWeekly], + pins: [codexWeekly], serverId: null, }); }); it("remembers the picked host without touching pins", () => { - expect(setUsageHost({ ...DEFAULT_USAGE_PREFERENCES, pinned: [codexWeekly] }, "server")).toEqual( - { displayAs: "used", pinned: [codexWeekly], serverId: "server" }, - ); + expect(setUsageHost({ ...DEFAULT_USAGE_PREFERENCES, pins: [codexWeekly] }, "server")).toEqual({ + displayAs: "used", + pins: [codexWeekly], + serverId: "server", + }); }); it("reads preferences saved before hosts could be picked as no pick", () => { expect(UsagePreferencesSchema.parse({ displayAs: "remaining", pinned: [codexWeekly] })).toEqual( - { displayAs: "remaining", pinned: [codexWeekly], serverId: null }, + { displayAs: "remaining", pins: [codexWeekly], serverId: null }, ); }); }); + +it("default sidebar windows are pinned on cards and the first toggle removes only that row", () => { + const reports = [ + { + id: "claude:default", + sourceId: "claude", + sourceLabel: "Claude", + account: {}, + fetchedAt: "2026-10-02T00:00:00Z", + report: { + status: "available" as const, + windows: [ + { id: "five_hour", label: "5-hour", summary: true, usedPct: 31 }, + { id: "weekly", label: "Weekly", summary: true, usedPct: 54 }, + ], + }, + }, + { + id: "codex:default", + sourceId: "codex", + sourceLabel: "Codex", + account: {}, + fetchedAt: "2026-10-02T00:00:00Z", + report: { + status: "available" as const, + windows: [{ id: "five_hour", label: "5-hour", summary: true, usedPct: 7 }], + }, + }, + ]; + const pins = [ + { sourceId: "claude", windowId: "five_hour" }, + { sourceId: "claude", windowId: "weekly" }, + { sourceId: "codex", windowId: "five_hour" }, + ]; + for (const pin of pins) expect(isUsagePinned(DEFAULT_USAGE_PREFERENCES, pin, reports)).toBe(true); + const changed = toggleUsagePin({ + preferences: DEFAULT_USAGE_PREFERENCES, + pin: pins[1]!, + reports, + }); + expect(isUsagePinned(changed, pins[1]!, reports)).toBe(false); + expect(isUsagePinned(changed, pins[0]!, reports)).toBe(true); + expect(isUsagePinned(changed, pins[2]!, reports)).toBe(true); + const empty = toggleUsagePin({ + preferences: toggleUsagePin({ preferences: changed, pin: pins[0]!, reports }), + pin: pins[2]!, + reports, + }); + const restored = UsagePreferencesSchema.parse(JSON.parse(JSON.stringify(empty))); + for (const pin of pins) expect(isUsagePinned(restored, pin, reports)).toBe(false); +}); + +it("reads old selections without losing display or host and preserves an explicit empty selection", () => { + for (const [input, pins] of [ + [{ pinned: [] }, null], + [{ pinned: [codexWeekly] }, [codexWeekly]], + [{ pins: [], pinned: [codexWeekly] }, []], + [{ pins: null, pinned: [codexWeekly] }, null], + [{ pins: "bad", pinned: [codexWeekly] }, null], + ] as const) { + expect( + UsagePreferencesSchema.parse({ displayAs: "remaining", serverId: "host", ...input }), + ).toEqual({ displayAs: "remaining", serverId: "host", pins }); + } +}); diff --git a/packages/app/src/usage/preferences.ts b/packages/app/src/usage/preferences.ts index c25cf6e1c7..e082870af6 100644 --- a/packages/app/src/usage/preferences.ts +++ b/packages/app/src/usage/preferences.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { displayPercent } from "./model"; +import type { UsageReportEntry } from "./types"; /** Whether percentages read as the share used or the share left. */ export type UsageDisplayAs = "used" | "remaining"; @@ -9,42 +11,86 @@ export interface UsagePin { windowId: string; } -/** Device-local. `pinned` is in display order. */ +/** Device-local. `pins` is in selection order. */ export interface UsagePreferences { displayAs: UsageDisplayAs; - pinned: UsagePin[]; + /** null follows source defaults; an array is the user's selection, including none. */ + pins: UsagePin[] | null; /** The host the user picked to show usage for; null until they pick one. */ serverId: string | null; } export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = { displayAs: "used", - pinned: [], + pins: null, serverId: null, }; +const pinsSchema = z.array(z.object({ sourceId: z.string(), windowId: z.string() })); + export const UsagePreferencesSchema = z .object({ displayAs: z.enum(["used", "remaining"]).catch("used"), - pinned: z.array(z.object({ sourceId: z.string(), windowId: z.string() })).catch([]), + pins: pinsSchema.nullable().optional().catch(null), + // COMPAT(usagePinSelection): added in v0.11, remove after 2027-04-02. + // Old [] meant defaults. Read it only when the new selection is absent. + pinned: pinsSchema.optional().catch([]), serverId: z.string().nullable().catch(null), }) + .transform(({ displayAs, pins, pinned, serverId }): UsagePreferences => { + const legacyPins = pinned && pinned.length > 0 ? pinned : null; + return { displayAs, pins: pins === undefined ? legacyPins : pins, serverId }; + }) .catch(DEFAULT_USAGE_PREFERENCES); function samePin(a: UsagePin, b: UsagePin): boolean { return a.sourceId === b.sourceId && a.windowId === b.windowId; } -export function isUsagePinned(preferences: UsagePreferences, pin: UsagePin): boolean { - return preferences.pinned.some((pinned) => samePin(pinned, pin)); +/** The only owner of default and customized pin selection, shared by every usage surface. */ +export function effectiveUsagePins( + preferences: UsagePreferences, + reports: readonly UsageReportEntry[], +): UsagePin[] { + if (preferences.pins !== null) return preferences.pins; + const pins: UsagePin[] = []; + for (const entry of reports) { + if (entry.report.status !== "available") continue; + const withPercent = entry.report.windows.filter( + (window) => displayPercent(window, preferences.displayAs) !== null, + ); + const marked = withPercent.filter((window) => window.summary); + for (const window of marked.length > 0 ? marked : withPercent.slice(0, 1)) { + const pin = { sourceId: entry.sourceId, windowId: window.id }; + if (!pins.some((existing) => samePin(existing, pin))) pins.push(pin); + } + } + return pins; } -/** Unpins a pinned window; pins any other at the end, so the Usage item grows to the right. */ -export function toggleUsagePin(preferences: UsagePreferences, pin: UsagePin): UsagePreferences { - const pinned = isUsagePinned(preferences, pin) - ? preferences.pinned.filter((existing) => !samePin(existing, pin)) - : [...preferences.pinned, { sourceId: pin.sourceId, windowId: pin.windowId }]; - return { ...preferences, pinned }; +export function isUsagePinned( + preferences: UsagePreferences, + pin: UsagePin, + reports: readonly UsageReportEntry[], +): boolean { + return effectiveUsagePins(preferences, reports).some((pinned) => samePin(pinned, pin)); +} + +/** The first edit snapshots the displayed defaults before toggling one window. */ +export function toggleUsagePin({ + preferences, + pin, + reports, +}: { + preferences: UsagePreferences; + pin: UsagePin; + reports: readonly UsageReportEntry[]; +}): UsagePreferences { + const selected = effectiveUsagePins(preferences, reports); + const pins = selected.some((existing) => samePin(existing, pin)) + ? selected.filter((existing) => !samePin(existing, pin)) + : [...selected, { sourceId: pin.sourceId, windowId: pin.windowId }]; + return { ...preferences, pins }; } export function setUsageDisplayAs( diff --git a/packages/plugin/src/server/usage.test.ts b/packages/plugin/src/server/usage.test.ts index 1a8c5de939..b70e8d6d01 100644 --- a/packages/plugin/src/server/usage.test.ts +++ b/packages/plugin/src/server/usage.test.ts @@ -4,6 +4,7 @@ import { toneFromUsedPct, usedPctOf, windowFromUsedPct, + windowFromReportedDuration, } from "./usage.js"; test("shared usage tones preserve the app threshold contract", () => { @@ -84,3 +85,23 @@ describe("balanceToneFromRemaining", () => { expect(balanceToneFromRemaining(null)).toBe("default"); }); }); + +test.each([ + [18000, "five_hour", "5-hour", "5h"], + [604800, "weekly", "Weekly", "wk"], + [7200, "7200s", "2-hour", "2h"], + [86400, "86400s", "1-day", "1d"], + [90, "90s", "90-second", "90s"], + [null, "primary", "Primary limit", ""], +])( + "duration %s owns the window's identity and both names", + (durationSeconds, id, label, shortLabel) => { + expect( + windowFromReportedDuration({ + durationSeconds: durationSeconds as number | null, + unknown: { id: "primary", label: "Primary limit", shortLabel: "" }, + utilizationPct: 11, + }), + ).toMatchObject({ id, label, shortLabel, usedPct: 11 }); + }, +); diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index ebf00c3b2f..1b78f8fe12 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -96,6 +96,55 @@ export function windowFromUsedPct(input: { return window; } +/** + * Numeric provider windows have one identity and vocabulary, independent of response slots. + * Pass null when the provider omits the duration; reset countdowns are not window lengths. + * Named API fields (weekly, monthly, etc.) use windowFromUsedPct instead. + */ +export function windowFromReportedDuration(input: { + durationSeconds: number | null; + /** Stable quota identity and provider name for a model- or feature-scoped limit. */ + scope?: { id: string; label: string }; + /** Neutral identity and names when the provider does not report a positive duration. */ + unknown: { id: string; label: string; shortLabel: string }; + utilizationPct: number | null | undefined; + resetsAt?: string | null; + summary?: boolean; + tone?: UsageWindow["tone"]; +}): UsageWindow { + const duration = input.durationSeconds; + const name = + duration !== null && Number.isFinite(duration) && duration > 0 + ? durationWindowName(duration) + : input.unknown; + const scope = input.scope; + return windowFromUsedPct({ + id: scope ? `${scope.id}:${name.id}` : name.id, + label: scope ? `${scope.label} · ${name.label}` : name.label, + shortLabel: scope + ? `${scope.label}${name.shortLabel ? ` ${name.shortLabel}` : ""}` + : name.shortLabel, + utilizationPct: input.utilizationPct, + resetsAt: input.resetsAt, + summary: input.summary, + tone: input.tone, + }); +} + +function durationWindowName(seconds: number): { id: string; label: string; shortLabel: string } { + if (seconds === 604800) return { id: "weekly", label: "Weekly", shortLabel: "wk" }; + const id = seconds === 18000 ? "five_hour" : `${seconds}s`; + const units = [ + [86400, "day", "d"], + [3600, "hour", "h"], + [60, "minute", "m"], + [1, "second", "s"], + ] as const; + const unit = units.find(([size]) => seconds % size === 0) ?? units[units.length - 1]!; + const amount = seconds / unit[0]; + return { id, label: `${amount}-${unit[1]}`, shortLabel: `${amount}${unit[2]}` }; +} + /** * The tone scale for anything measured against a known limit, windows and balances alike. * diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts index 0663e0070d..9a0f102a29 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts @@ -1,5 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { z } from "zod"; +import { windowFromReportedDuration } from "@getpaseo/plugin/server/usage"; let fetches = 0; export default function contribute(server: PluginServerContext) { @@ -32,7 +33,13 @@ export default function contribute(server: PluginServerContext) { fetches++; return { status: "available", - windows: [{ id: "count", label: "Count", usedPct: fetches }], + windows: [ + windowFromReportedDuration({ + durationSeconds: 604800, + unknown: { id: "unknown", label: "Limit", shortLabel: "" }, + utilizationPct: fetches, + }), + ], }; }, }); diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts index 12d7ced848..e42aa64edc 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts @@ -1,5 +1,6 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { z } from "zod"; +import { windowFromReportedDuration } from "@getpaseo/plugin/server/usage"; let fetches = 0; export default function contribute(server: PluginServerContext) { @@ -32,7 +33,13 @@ export default function contribute(server: PluginServerContext) { fetches++; return { status: "available", - windows: [{ id: "count", label: "Count", usedPct: fetches }], + windows: [ + windowFromReportedDuration({ + durationSeconds: 604800, + unknown: { id: "unknown", label: "Limit", shortLabel: "" }, + utilizationPct: fetches, + }), + ], }; }, }); diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index 0b2b3ca9a2..cff548adc5 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -57,6 +57,13 @@ test("lists built-in and subprocess usage; validates input and isolates fetch er await client.installDirectoryPlugin(subprocessDirectory, "fixture-directory"); const both = await client.listUsageReports({ forceRefresh: true }); expect(both.reports).toHaveLength(8); + for (const id of ["fixture:one", "fixture-directory:one"]) { + expect(both.reports.find((entry) => entry.id === id)?.report.windows[0]).toMatchObject({ + id: "weekly", + label: "Weekly", + shortLabel: "wk", + }); + } await client.patchDaemonConfig({ pluginsEnabled: false }); await expect.poll(async () => (await client.listUsageReports()).reports.length).toBe(4); } finally { diff --git a/plugins/codex-usage-source/paseo-plugin.json b/plugins/codex-usage-source/paseo-plugin.json index 64470099fb..f86baa137a 100644 --- a/plugins/codex-usage-source/paseo-plugin.json +++ b/plugins/codex-usage-source/paseo-plugin.json @@ -1 +1 @@ -{ "id": "codex-usage-source", "requirements": { "paseo": ">=0.9.2" } } +{ "id": "codex-usage-source", "requirements": { "paseo": ">=0.11.0" } } diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index c70066c886..e92c7ea2f2 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -39,7 +39,9 @@ function response(headers: HeadersInit, accountId?: string): Promise { new Response( JSON.stringify({ plan_type: "plus", - rate_limit: { primary_window: { used_percent: 30, reset_at: 1700000000 } }, + rate_limit: { + primary_window: { used_percent: 30, limit_window_seconds: 18000, reset_at: 1700000000 }, + }, }), { status: 200 }, ), @@ -62,7 +64,7 @@ test("explicit route reads Codex auth and preserves the usage request", async () expect(report).toMatchObject({ status: "available", planLabel: "plus", - windows: [{ id: "session", usedPct: 30 }], + windows: [{ id: "five_hour", usedPct: 30 }], }); } finally { await rm(home, { recursive: true, force: true }); @@ -95,8 +97,8 @@ test("coerces credit balance and marks a 96 percent window dangerous", async () new Response( JSON.stringify({ rate_limit: { - primary_window: { used_percent: 12 }, - secondary_window: { used_percent: 96 }, + primary_window: { used_percent: 12, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 96, limit_window_seconds: 604800 }, }, credits: { balance: "0" }, }), @@ -105,7 +107,7 @@ test("coerces credit balance and marks a 96 percent window dangerous", async () ); expect(report.windows).toEqual( expect.arrayContaining([ - expect.objectContaining({ id: "session", tone: "ok" }), + expect.objectContaining({ id: "five_hour", tone: "ok" }), expect.objectContaining({ id: "weekly", tone: "danger" }), ]), ); @@ -121,8 +123,8 @@ test("summarizes the session and weekly windows by default, not code review", as new Response( JSON.stringify({ rate_limit: { - primary_window: { used_percent: 12 }, - secondary_window: { used_percent: 40 }, + primary_window: { used_percent: 12, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 40, limit_window_seconds: 604800 }, }, code_review_rate_limit: { primary_window: { used_percent: 5 } }, }), @@ -130,9 +132,9 @@ test("summarizes the session and weekly windows by default, not code review", as ), ); expect(report.windows.map((window) => [window.id, window.summary ?? false])).toEqual([ - ["session", true], + ["five_hour", true], ["weekly", true], - ["code_review", false], + ["code_review:unknown_primary", false], ]); }); @@ -260,3 +262,120 @@ test.each([401, 403])("reports a Codex login rejected with HTTP %i", async (stat await fetchUsage(authInput(fixtureHome), async () => new Response(null, { status })), ).toEqual({ status: "unavailable", problem: { kind: "rejected", status, refreshedBy: "codex" } }); }); + +// Issue #4165: the primary slot contains the only weekly window. +test("names a seven-day-only primary window from its reported duration", async () => { + const report = await fetchUsage(authInput(fixtureHome), async () => + Response.json({ + rate_limit: { + primary_window: { used_percent: 1, limit_window_seconds: 604800 }, + secondary_window: null, + }, + }), + ); + expect(report).toMatchObject({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 1 }], + }); +}); + +test("reports every named additional limit with its own duration identity", async () => { + const report = await fetchUsage(authInput(fixtureHome), async () => + Response.json({ + additional_rate_limits: [ + { + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { used_percent: 0, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 0, limit_window_seconds: 604800 }, + }, + }, + { + limit_name: "Another limit", + metered_feature: "another", + rate_limit: { + primary_window: { used_percent: 4, limit_window_seconds: 7200 }, + }, + }, + ], + }), + ); + expect(report).toMatchObject({ + status: "available", + windows: [ + { + id: "limit:codex_bengalfox:five_hour", + label: "GPT-5.3-Codex-Spark · 5-hour", + shortLabel: "GPT-5.3-Codex-Spark 5h", + }, + { + id: "limit:codex_bengalfox:weekly", + label: "GPT-5.3-Codex-Spark · Weekly", + shortLabel: "GPT-5.3-Codex-Spark wk", + }, + { id: "limit:another:7200s", label: "Another limit · 2-hour" }, + ], + }); +}); + +test("a weekly pin means weekly after the provider moves it between slots", async () => { + const { resolvePinnedUsage } = await import("../../../packages/app/src/usage/pinned.js"); + const preferences = { + displayAs: "used" as const, + serverId: null, + pins: [{ sourceId: "codex", windowId: "weekly" }], + }; + for (const primaryWeekly of [false, true]) { + const weekly = { used_percent: 11, limit_window_seconds: 604800 }; + const report = await fetchUsage(authInput(fixtureHome), async () => + Response.json({ + rate_limit: { + primary_window: primaryWeekly ? weekly : { used_percent: 4, limit_window_seconds: 18000 }, + secondary_window: primaryWeekly ? null : weekly, + }, + }), + ); + const entry = { + id: "codex:account", + sourceId: "codex", + sourceLabel: "Codex", + account: { key: "account" }, + fetchedAt: "2026-10-02T00:00:00Z", + report, + }; + expect(resolvePinnedUsage([entry], preferences)).toMatchObject([ + { windows: [{ key: "codex:account/weekly", percentText: "11%", shortLabel: "wk" }] }, + ]); + // The old slot ID is ambiguous: do not silently retarget a saved Session pin. + expect( + resolvePinnedUsage([entry], { + ...preferences, + pins: [{ sourceId: "codex", windowId: "session" }], + }), + ).toEqual([]); + } +}); + +test("unknown lengths and code review never get a duration from their slot", async () => { + const report = await fetchUsage(authInput(fixtureHome), async () => + Response.json({ + rate_limit: { + primary_window: { used_percent: 11 }, + secondary_window: { used_percent: 22, limit_window_seconds: null }, + }, + code_review_rate_limit: { + primary_window: { used_percent: 3, limit_window_seconds: 604800 }, + secondary_window: { used_percent: 4, limit_window_seconds: 18000 }, + }, + }), + ); + expect(report).toMatchObject({ + windows: [ + { id: "unknown_primary", label: "Primary limit", shortLabel: "" }, + { id: "unknown_secondary", label: "Secondary limit", shortLabel: "" }, + { id: "code_review:weekly", label: "Code review · Weekly", shortLabel: "Code review wk" }, + { id: "code_review:five_hour", label: "Code review · 5-hour", shortLabel: "Code review 5h" }, + ], + }); +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 106a77336d..7d2d89402f 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -7,7 +7,7 @@ import { unavailable, type UsageAccount, toneFromUsedPct, - windowFromUsedPct, + windowFromReportedDuration, type UsageReport, type UsageWindow, } from "@getpaseo/plugin/server/usage"; @@ -24,14 +24,29 @@ const authSchema = z.object({ .optional(), }); const number = z.coerce.number().finite(); -const windowSchema = z.object({ used_percent: number.optional(), reset_at: number.optional() }); +const windowSchema = z.object({ + used_percent: number.optional(), + reset_at: number.optional(), + limit_window_seconds: number.nullish(), +}); +const rateLimitSchema = z.object({ + primary_window: windowSchema.nullish(), + secondary_window: windowSchema.nullish(), +}); const responseSchema = z.object({ plan_type: z.string().optional(), email: z.string().optional(), - rate_limit: z - .object({ primary_window: windowSchema.nullish(), secondary_window: windowSchema.nullish() }) + rate_limit: rateLimitSchema.nullish(), + additional_rate_limits: z + .array( + z.object({ + limit_name: z.string().optional(), + metered_feature: z.string().optional(), + rate_limit: rateLimitSchema.nullish(), + }), + ) .nullish(), - code_review_rate_limit: z.object({ primary_window: windowSchema.nullish() }).nullish(), + code_review_rate_limit: rateLimitSchema.nullish(), credits: z.object({ balance: number.optional() }).nullish(), }); @@ -90,17 +105,37 @@ export async function readAuth( }; } -function usageWindow( - spec: { id: string; label: string; shortLabel: string; summary?: boolean }, - value: z.infer | null | undefined, -): UsageWindow | null { - if (!value) return null; - const usedPct = value.used_percent ?? 0; - return windowFromUsedPct({ - ...spec, - utilizationPct: usedPct, - resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null, - tone: toneFromUsedPct(usedPct), +function usageWindows({ + rateLimit, + scope, + summary = false, +}: { + rateLimit: z.infer | null | undefined; + scope?: { id: string; label: string }; + summary?: boolean; +}): UsageWindow[] { + if (!rateLimit) return []; + return (["primary_window", "secondary_window"] as const).flatMap((slot) => { + const value = rateLimit[slot]; + if (!value) return []; + const primary = slot === "primary_window"; + // Codex does not always report a length. A slot is then only an unknown limit, + // never evidence of a five-hour or weekly period. + return [ + windowFromReportedDuration({ + durationSeconds: value.limit_window_seconds ?? null, + unknown: { + id: primary ? "unknown_primary" : "unknown_secondary", + label: primary ? "Primary limit" : "Secondary limit", + shortLabel: "", + }, + scope, + summary, + utilizationPct: value.used_percent, + resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null, + tone: toneFromUsedPct(value.used_percent), + }), + ]; }); } @@ -135,19 +170,23 @@ export async function fetchUsage( if (text.trim().startsWith("<")) throw new Error("Codex usage API returned HTML"); const usage = responseSchema.parse(JSON.parse(text)); const windows = [ - usageWindow( - { id: "session", label: "Session", shortLabel: "5h", summary: true }, - usage.rate_limit?.primary_window, - ), - usageWindow( - { id: "weekly", label: "Weekly", shortLabel: "wk", summary: true }, - usage.rate_limit?.secondary_window, - ), - usageWindow( - { id: "code_review", label: "Code review", shortLabel: "review" }, - usage.code_review_rate_limit?.primary_window, - ), - ].filter((window): window is UsageWindow => window !== null); + ...usageWindows({ rateLimit: usage.rate_limit, summary: true }), + ...(usage.additional_rate_limits ?? []).flatMap((limit) => { + const identity = limit.metered_feature || limit.limit_name; + if (!identity) return []; // An unnamed limit has no stable quota identity. + return usageWindows({ + rateLimit: limit.rate_limit, + scope: { + id: `limit:${encodeURIComponent(identity)}`, + label: limit.limit_name || identity, + }, + }); + }), + ...usageWindows({ + rateLimit: usage.code_review_rate_limit, + scope: { id: "code_review", label: "Code review" }, + }), + ]; const balance = usage.credits?.balance; return { status: "available", diff --git a/plugins/grok-usage-source/server/usage.test.ts b/plugins/grok-usage-source/server/usage.test.ts index 0d95b0b229..8086b48176 100644 --- a/plugins/grok-usage-source/server/usage.test.ts +++ b/plugins/grok-usage-source/server/usage.test.ts @@ -326,3 +326,46 @@ it.each([401, 403])("reports an existing login rejected with HTTP %i", async (st else process.env["GROK_TOKEN"] = previous; } }); + +it.each([undefined, "USAGE_PERIOD_TYPE_DAILY", "USAGE_PERIOD_TYPE_UNKNOWN_WEEKLY"])( + "does not invent a monthly period for %s", + async (type) => { + const directory = mkdtempSync(join(tmpdir(), "grok-period-")); + try { + const locator = join(directory, "auth.json"); + writeFileSync(locator, JSON.stringify({ access_token: "fixture" })); + const report = await fetchUsage({ store: "file", locator }, async () => + Response.json({ config: { creditUsagePercent: 11, currentPeriod: { type } } }), + ); + expect(report).toMatchObject({ + status: "available", + windows: [ + { + id: type ? `period:${type}` : "period:unknown", + label: type ?? "Current period", + shortLabel: "", + }, + ], + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); + +it("does not call credits monthly when no monthly limit was reported", async () => { + const directory = mkdtempSync(join(tmpdir(), "grok-credits-")); + try { + const locator = join(directory, "auth.json"); + writeFileSync(locator, JSON.stringify({ access_token: "fixture" })); + const report = await fetchUsage({ store: "file", locator }, async () => + Response.json({ config: { used: { val: 11 } } }), + ); + expect(report).toMatchObject({ + status: "available", + balances: [{ id: "credits", label: "Credits", used: 11 }], + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/plugins/grok-usage-source/server/usage.ts b/plugins/grok-usage-source/server/usage.ts index 5f73aac6b9..5a57ca7ddd 100644 --- a/plugins/grok-usage-source/server/usage.ts +++ b/plugins/grok-usage-source/server/usage.ts @@ -74,15 +74,14 @@ export function extractGrokTokenFromAuth(auth: unknown): string | null { return null; } -function grokMonthlyCreditBalance( - response: z.infer, -): UsageBalance | null { +function grokCreditBalance(response: z.infer): UsageBalance | null { const limit = response.config?.monthlyLimit?.val ?? null; const used = response.config?.used?.val ?? response.usage?.creditUsage ?? null; if (limit === null && used === null) return null; return { - id: "monthly_credits", - label: "Monthly credits", + // Only the named monthly allowance establishes a monthly credit bucket. + id: limit === null ? "credits" : "monthly_credits", + label: limit === null ? "Credits" : "Monthly credits", used, remaining: limit !== null && used !== null ? Math.max(0, limit - used) : null, limit, @@ -95,11 +94,18 @@ function grokUsageWindow(response: z.infer): Usa const percent = response.config?.creditUsagePercent; if (typeof percent !== "number") return null; const period = response.config?.currentPeriod; - const weekly = (period?.type ?? "").toUpperCase().includes("WEEKLY"); + const names = { + USAGE_PERIOD_TYPE_WEEKLY: { id: "weekly", label: "Weekly", shortLabel: "wk" }, + USAGE_PERIOD_TYPE_MONTHLY: { id: "monthly", label: "Monthly", shortLabel: "mo" }, + }; + // An absent or unfamiliar enum does not establish a monthly duration. + const name = names[period?.type as keyof typeof names] ?? { + id: `period:${period?.type || "unknown"}`, + label: period?.type || "Current period", + shortLabel: "", + }; return windowFromUsedPct({ - id: weekly ? "weekly" : "monthly", - label: weekly ? "Weekly" : "Monthly", - shortLabel: weekly ? "wk" : "mo", + ...name, utilizationPct: percent, resetsAt: period?.end ?? null, tone: toneFromUsedPct(percent), @@ -129,7 +135,7 @@ export async function fetchUsage( if (!res.ok) throw new Error(`Grok usage API returned ${res.status}`); const resp = GrokUsageResponseSchema.parse(await res.json()); - const balance = grokMonthlyCreditBalance(resp); + const balance = grokCreditBalance(resp); const window = grokUsageWindow(resp); return { diff --git a/plugins/kimi-usage-source/server/usage.test.ts b/plugins/kimi-usage-source/server/usage.test.ts index c5ba74d004..a67e416272 100644 --- a/plugins/kimi-usage-source/server/usage.test.ts +++ b/plugins/kimi-usage-source/server/usage.test.ts @@ -279,7 +279,7 @@ describe("Kimi usage source usage windows", () => { windows: [ { id: "coding_usage", - label: "Weekly limit", + label: "Usage limit", usedPct: 61, remainingPct: 39, resetsAt: "2026-08-05T00:01:45Z", @@ -428,3 +428,41 @@ it.each([401, 403])("reports an existing login rejected with HTTP %i", async (st else process.env["KIMI_TOKEN"] = previous; } }); + +it("does not invent a weekly duration for unnamed top-level usage", async () => { + const directory = mkdtempSync(join(tmpdir(), "kimi-period-")); + try { + const locator = join(directory, "auth.json"); + writeFileSync(locator, JSON.stringify({ access_token: "fixture" })); + const report = await fetchUsage({ store: "file", locator }, async () => + Response.json({ usage: { limit: "100", remaining: "74" } }), + ); + expect(report).toMatchObject({ + status: "available", + windows: [{ id: "coding_usage", label: "Usage limit" }], + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +it("does not infer a duration from an unfamiliar time-unit name containing HOUR", async () => { + const directory = mkdtempSync(join(tmpdir(), "kimi-unit-")); + try { + const locator = join(directory, "auth.json"); + writeFileSync(locator, JSON.stringify({ access_token: "fixture" })); + const report = await fetchUsage({ store: "file", locator }, async () => + Response.json({ + limits: [ + { + window: { duration: 2, timeUnit: "TIME_UNIT_NOT_HOUR" }, + detail: { limit: "100", used: "11" }, + }, + ], + }), + ); + expect(report).toMatchObject({ status: "available", windows: [{ label: "Limit 1" }] }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/plugins/kimi-usage-source/server/usage.ts b/plugins/kimi-usage-source/server/usage.ts index 095b187226..556008990f 100644 --- a/plugins/kimi-usage-source/server/usage.ts +++ b/plugins/kimi-usage-source/server/usage.ts @@ -90,18 +90,18 @@ function durationFrom(value: unknown): number | null { return Number.isFinite(duration) && duration > 0 ? duration : null; } -function durationLabel(duration: number, timeUnit: string | undefined): string | null { - if (!timeUnit) return null; +function durationLabel(duration: number | null, timeUnit: string | undefined): string | null { + if (duration === null || !timeUnit) return null; const normalizedUnit = timeUnit.replace(/^TIME_UNIT_/i, "").toUpperCase(); - if (normalizedUnit.includes("MINUTE")) { + if (normalizedUnit === "MINUTE") { if (duration % 60 === 0) return `${duration / 60}-hour limit`; return `${duration}-minute limit`; } - if (normalizedUnit.includes("HOUR")) return `${duration}-hour limit`; - if (normalizedUnit.includes("DAY")) return `${duration}-day limit`; - if (normalizedUnit.includes("WEEK")) return `${duration}-week limit`; - if (normalizedUnit.includes("SECOND")) return `${duration}-second limit`; + if (normalizedUnit === "HOUR") return `${duration}-hour limit`; + if (normalizedUnit === "DAY") return `${duration}-day limit`; + if (normalizedUnit === "WEEK") return `${duration}-week limit`; + if (normalizedUnit === "SECOND") return `${duration}-second limit`; return null; } @@ -201,7 +201,11 @@ function kimiUsageWindowsFromPayload(payload: unknown): UsageWindow[] { windows.push( windowFromFields({ id: uniqueWindowId("coding_usage", seenWindowIds), - label: explicitUsageLabel(usage) ?? "Weekly limit", + // Top-level usage reports no period unless it supplies duration metadata. + label: + explicitUsageLabel(usage) ?? + durationLabel(durationFrom(usage.duration), usage.timeUnit) ?? + "Usage limit", fields: usage, }), ); diff --git a/plugins/muse-provider/paseo-plugin.json b/plugins/muse-provider/paseo-plugin.json index a53be3fef5..6b632ab2bc 100644 --- a/plugins/muse-provider/paseo-plugin.json +++ b/plugins/muse-provider/paseo-plugin.json @@ -1 +1 @@ -{ "id": "muse-provider", "requirements": { "paseo": ">=0.10.0" } } +{ "id": "muse-provider", "requirements": { "paseo": ">=0.11.0" } } diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index 85cfb473b6..a8c77428d6 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -7,6 +7,7 @@ import { hashAccountKey, unavailable, windowFromUsedPct, + windowFromReportedDuration, toneFromUsedPct, } from "@getpaseo/plugin/server/usage"; @@ -76,10 +77,9 @@ export class Usage { status: "available", planLabel: usage.tier, windows: [ - windowFromUsedPct({ - id: "five_hour", - label: `${usage.window.windowDurationMins / 60} hours`, - shortLabel: `${usage.window.windowDurationMins / 60}h`, + windowFromReportedDuration({ + durationSeconds: usage.window.windowDurationMins * 60, + unknown: { id: "window", label: "Current window", shortLabel: "" }, summary: true, utilizationPct: usage.window.usedPercent, resetsAt: new Date(usage.window.resetsAtMs).toISOString(), diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 4393cda7ee..ffb7c0468e 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1171,7 +1171,7 @@ for (const usage of [ windows: [ { id: "five_hour", - label: "5 hours", + label: "5-hour", usedPct: 75, remainingPct: 25, tone: "warning", @@ -1318,3 +1318,25 @@ test.each(["no sessions", "unrelated environment"])("Muse discovery is empty wit const { Usage } = await import("../server/usage.js"); expect(await new Usage().registration().discover()).toEqual([]); }); + +test("Muse window identity follows the reported duration instead of assuming five hours", async () => { + const h = await harness("catalog-controls", { + MUSE_TEST_USAGE: JSON.stringify({ + usage: { + observedAtMs: 1700000000000, + tier: "Pro", + window: { usedPercent: 11, resetsAtMs: 1700018000000, windowDurationMins: 120 }, + weekly: { usedPercent: 22, resetsAtMs: 1700604800000 }, + }, + }), + }); + await h.open(); + const [account] = await h.usageSource.discover(); + expect(await h.usageSource.fetch(account!.input)).toMatchObject({ + status: "available", + windows: [ + { id: "7200s", label: "2-hour", shortLabel: "2h" }, + { id: "weekly", label: "Weekly", shortLabel: "wk" }, + ], + }); +}); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index f92a6b06a4..188e88c154 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -363,6 +363,27 @@ inputs tried in order until a report is `available`. An unavailable report, erro fetch falls through to the next input. If none succeeds, the card carries the last report. Discovery failures are logged by the daemon and produce no card. +Window names must come from provider data: an explicit duration, a named API field such as +`five_hour` or `weekly`, or the provider's own period name. Response slots and reset countdowns +do not establish duration. When the provider omits it, use a neutral name such as “Rolling” or +“Primary limit” and explain the missing metadata next to the adapter. + +For numeric durations, use `windowFromReportedDuration()` from +`@getpaseo/plugin/server/usage` (Paseo 0.11+). Pass the reported seconds or `null`, a neutral +`unknown` identity and name, and an optional stable quota `scope`. The helper derives the ID, +label, and short label together; it accepts no duration-label override. For named API periods, +use `windowFromUsedPct()` with names justified by that field. + +A window ID identifies a quota scope and period, never its response position, utilization, or +reset instant. Scope model-specific quotas by the provider's stable feature ID, falling back to +the reported limit name when no ID exists. Preserve IDs across slot moves and display-name +changes. Do not alias an old ambiguous ID to a different period: saved pins match source and +window IDs across all accounts, so users must select the corrected window again. + +`summary: true` selects source defaults until the user customizes pins. The app computes one +effective selection for cards, the sidebar, and toggles. The first edit snapshots those defaults; +an explicit empty selection stays empty. + Re-read the selected store in `fetch()` so the CLI's token rotations take effect. Never redeem refresh tokens or write credential stores: refreshing elsewhere can invalidate the CLI's copy, and rewriting parsed files can discard fields you do not model. If the store disappeared after