mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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
This commit is contained in:
+3
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UsageDisplay>(
|
||||
() => ({
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
|
||||
@@ -46,10 +46,10 @@ function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUs
|
||||
}
|
||||
|
||||
function preferences(
|
||||
pinned: UsagePreferences["pinned"],
|
||||
pinned: UsagePreferences["pins"],
|
||||
displayAs: UsagePreferences["displayAs"] = "used",
|
||||
): UsagePreferences {
|
||||
return { displayAs, pinned, serverId: null };
|
||||
return { displayAs, pins: pinned, serverId: null };
|
||||
}
|
||||
|
||||
describe("choosePinnedUsageLayout", () => {
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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 }];
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+8
-1
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "codex-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "codex-usage-source", "requirements": { "paseo": ">=0.11.0" } }
|
||||
|
||||
@@ -39,7 +39,9 @@ function response(headers: HeadersInit, accountId?: string): Promise<Response> {
|
||||
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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof windowSchema> | 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<typeof rateLimitSchema> | 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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,15 +74,14 @@ export function extractGrokTokenFromAuth(auth: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function grokMonthlyCreditBalance(
|
||||
response: z.infer<typeof GrokUsageResponseSchema>,
|
||||
): UsageBalance | null {
|
||||
function grokCreditBalance(response: z.infer<typeof GrokUsageResponseSchema>): 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<typeof GrokUsageResponseSchema>): 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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "muse-provider", "requirements": { "paseo": ">=0.10.0" } }
|
||||
{ "id": "muse-provider", "requirements": { "paseo": ">=0.11.0" } }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user