diff --git a/packages/plugin/src/server/usage.test.ts b/packages/plugin/src/server/usage.test.ts new file mode 100644 index 0000000000..4206bbdfcf --- /dev/null +++ b/packages/plugin/src/server/usage.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "vitest"; +import { + balanceToneFromRemaining, + toneFromUsedPct, + usedPctOf, + windowFromUsedPct, +} from "./usage.js"; + +test("shared usage tones preserve the app threshold contract", () => { + expect([null, 69.9, 70, 90, 90.1].map(toneFromUsedPct)).toEqual([ + "default", + "ok", + "warning", + "warning", + "danger", + ]); + expect([null, 0, 0.01].map(balanceToneFromRemaining)).toEqual(["default", "danger", "ok"]); +}); + +test("shared windows retain percentage, reset time, and optional headline", () => { + expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30, headline: true })).toEqual({ + id: "a", + label: "A", + usedPct: 30, + remainingPct: 70, + resetsAt: null, + headline: true, + }); + expect(windowFromUsedPct({ id: "b", label: "B", utilizationPct: null })).toEqual({ + id: "b", + label: "B", + usedPct: null, + remainingPct: null, + resetsAt: null, + }); + expect(usedPctOf(25, 100)).toBe(25); + expect(usedPctOf(25, 0)).toBeNull(); +}); diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index af6d3bea4d..07bf5c5562 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -49,3 +49,61 @@ export interface UsageSourceRegistration { fetch(input: unknown): Promise; discover?(): Promise; } + +export function windowFromUsedPct(input: { + id: string; + label: string; + utilizationPct: number | null | undefined; + resetsAt?: string | null; + tone?: UsageWindow["tone"]; + headline?: boolean; +}): UsageWindow { + const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; + const window: UsageWindow = { + id: input.id, + label: input.label, + usedPct, + remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), + resetsAt: input.resetsAt ?? null, + }; + if (input.tone) window.tone = input.tone; + if (input.headline) window.headline = true; + return window; +} + +/** + * The tone scale for anything measured against a known limit, windows and balances alike. + * + * Thresholds match `deriveTone` in the app's provider-usage/tone.ts, which is what the + * client falls back to when a window arrives without a tone. Healthy is "ok" rather than + * "default" because that is what every provider setting a tone has always sent, and it is + * what the bars render today below their thresholds. + */ +export function toneFromUsedPct(usedPct: number | null | undefined): UsageWindow["tone"] { + if (typeof usedPct !== "number") return "default"; + if (usedPct > 90) return "danger"; + if (usedPct >= 70) return "warning"; + return "ok"; +} + +/** + * Tone for a balance with no known limit, where a percentage cannot be computed and the + * only signal is whether anything is left. Prefer `toneFromUsedPct` when a limit exists: + * this one stays "ok" until the balance is completely spent. + */ +export function balanceToneFromRemaining( + remaining: number | null | undefined, +): UsageBalance["tone"] { + if (typeof remaining !== "number") return "default"; + if (remaining <= 0) return "danger"; + return "ok"; +} + +/** Percentage of a limit consumed, or null when either side is unknown. */ +export function usedPctOf( + used: number | null | undefined, + limit: number | null | undefined, +): number | null { + if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null; + return (used / limit) * 100; +} diff --git a/packages/server/src/plugins/codex-usage-source/icon.svg b/packages/server/src/plugins/codex-usage-source/icon.svg index d184309848..5d9e2fec39 100644 --- a/packages/server/src/plugins/codex-usage-source/icon.svg +++ b/packages/server/src/plugins/codex-usage-source/icon.svg @@ -1 +1 @@ - + diff --git a/packages/server/src/plugins/codex-usage-source/server/usage.ts b/packages/server/src/plugins/codex-usage-source/server/usage.ts index d444ed5570..c04ab28fef 100644 --- a/packages/server/src/plugins/codex-usage-source/server/usage.ts +++ b/packages/server/src/plugins/codex-usage-source/server/usage.ts @@ -2,7 +2,13 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { UsageReport, UsageWindow } from "@getpaseo/plugin/server/usage"; +import { + balanceToneFromRemaining, + toneFromUsedPct, + windowFromUsedPct, + type UsageReport, + type UsageWindow, +} from "@getpaseo/plugin/server/usage"; import { z } from "zod"; import type { CodexUsageInput } from "../shared/input.js"; @@ -55,18 +61,14 @@ function usageWindow( ): UsageWindow | null { if (!value) return null; const usedPct = value.used_percent ?? 0; - let tone: UsageWindow["tone"] = "ok"; - if (usedPct > 90) tone = "danger"; - else if (usedPct >= 70) tone = "warning"; - return { + return windowFromUsedPct({ id, label, - usedPct, - remainingPct: Math.max(0, 100 - usedPct), + utilizationPct: usedPct, resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null, - tone, - ...(headline ? { headline: true } : {}), - }; + tone: toneFromUsedPct(usedPct), + headline, + }); } export async function fetchUsage( @@ -112,7 +114,7 @@ export async function fetchUsage( label: "Credits", remaining: balance, unit: "usd", - tone: balance <= 0 ? "danger" : "ok", + tone: balanceToneFromRemaining(balance), }, ], details: [], diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 2de5903096..4b76a4af25 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -17,6 +17,12 @@ import { runGitCommand } from "../../utils/run-git-command.js"; const roots: string[] = []; type TestPluginRuntime = NonNullable[3]["runtime"]>; +const emptyUsageRuntime = { + getUsageSourceRegistrations: () => [], + fetchUsage: async () => undefined, + discoverUsage: async () => [], +} satisfies Pick; + async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); roots.push(directory); @@ -104,6 +110,7 @@ function createPausedRuntime() { }); const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -136,6 +143,7 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { const starts: string[] = []; const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -235,6 +243,7 @@ describe("PluginService", () => { ]; const cleared: string[] = []; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [], invoke: async () => undefined, getLogs: () => entries, @@ -620,6 +629,7 @@ export default function contribute(server: PluginServerContext) { const events: string[] = []; const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -703,6 +713,7 @@ export default function contribute(server: PluginServerContext) { const starts: string[] = []; let failNextStart = true; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -766,6 +777,7 @@ export default function contribute(server: PluginServerContext) { const running = new Set(); let starts = 0; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 2d49f45116..54a2accbd1 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -39,9 +39,9 @@ interface PluginRuntimePort { getLogs(pluginId: string): PluginLogEntry[]; clearLogs(pluginId: string): void; getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; - getUsageSourceRegistrations?(pluginId: string): readonly PluginUsageSourceMetadata[]; - fetchUsage?: PluginRuntime["fetchUsage"]; - discoverUsage?: PluginRuntime["discoverUsage"]; + getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[]; + fetchUsage: PluginRuntime["fetchUsage"]; + discoverUsage: PluginRuntime["discoverUsage"]; connectProvider: PluginRuntime["connectProvider"]; getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; validatePlugin?(path: string): Promise; @@ -545,7 +545,7 @@ export class PluginService { } private publishUsageSources(pluginId: string): void { - const metadata = this.runtime.getUsageSourceRegistrations?.(pluginId) ?? []; + const metadata = this.runtime.getUsageSourceRegistrations(pluginId); const registered: string[] = []; try { for (const source of metadata) { @@ -555,13 +555,12 @@ export class PluginService { icon: source.icon, discover: async () => { if (!source.discover) return []; - const result = await this.runtime.discoverUsage?.(pluginId, source.id); + const result = await this.runtime.discoverUsage(pluginId, source.id); if (!Array.isArray(result)) throw new Error(`Invalid usage discovery from ${source.id}`); return result; }, fetch: (input) => { - if (!this.runtime.fetchUsage) throw new Error("Usage source runtime unavailable"); return this.runtime.fetchUsage(pluginId, source.id, input); }, }); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 2aa0e1f511..5ece13c69b 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -44,6 +44,22 @@ test("coalesces concurrent fetches and dedupes accounts", async () => { expect(await registry.listReports()).toHaveLength(1); }); +test("drops expired cached inputs on a later write", async () => { + let now = 0; + const registry = new UsageSourceRegistry(() => now, 100); + registry.register({ + id: "fixture", + label: "Fixture", + discover: async () => [], + fetch: async (input) => report(String(input), 1), + }); + await registry.fetch("fixture", "old"); + now = 101; + await registry.fetch("fixture", "new"); + const cache = Reflect.get(registry, "cache") as Map; + expect(cache.size).toBe(1); +}); + test("turns fetch and discovery failures into error reports", async () => { const registry = new UsageSourceRegistry(); registry.register({ diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index 7571b11da5..1a677a2f4e 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -70,7 +70,11 @@ export class UsageSourceRegistry { } catch (error) { entry = this.errorEntry(source, error, hash); } - this.cache.set(key, { at: this.now(), entry }); + const now = this.now(); + for (const [cachedKey, stored] of this.cache) { + if (now - stored.at >= this.ttlMs) this.cache.delete(cachedKey); + } + this.cache.set(key, { at: now, entry }); return entry; })(); this.pending.set(key, request); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index ab43c023cc..29c3ba3378 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -521,6 +521,7 @@ test("routes plugin requests and releases its owned catalog subscription on clea }, catalog: () => [{ id: "example", clientBundle: "bundle" }], invokePluginRpc: async () => ({ ok: true }), + listUsageReports: async () => [], }; const session = createSessionForTest({ messages, pluginRuntime }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9347827231..62dbdf8af8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { SessionEventSubscription, UsageReportEntry } from "@getpaseo/protocol/messages"; import { relative } from "node:path"; import { isAbsolute } from "node:path"; import { CreationService } from "./creation/index.js"; @@ -506,9 +506,7 @@ export interface SessionOptions { subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void; catalog(): Array<{ id: string; clientBundle: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; - listUsageReports?(options?: { - forceRefresh?: boolean; - }): Promise; + listUsageReports(options?: { forceRefresh?: boolean }): Promise; }; orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills; mcpBaseUrl?: string | null; @@ -3011,8 +3009,8 @@ export class Session { msg: Extract, ): Promise { try { - const reports = - (await this.pluginRuntime?.listUsageReports?.({ forceRefresh: msg.forceRefresh })) ?? []; + if (!this.pluginRuntime) throw new Error("Plugin runtime is unavailable"); + const reports = await this.pluginRuntime.listUsageReports({ forceRefresh: msg.forceRefresh }); this.emit({ type: "usage.list_reports.response", payload: { requestId: msg.requestId, reports },