fix(usage): share SDK helpers and bound source cache

This commit is contained in:
Mohamed Boudra
2026-09-26 20:52:37 +02:00
parent abf2646e18
commit d0cb4e6be8
10 changed files with 153 additions and 25 deletions
+38
View File
@@ -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();
});
+58
View File
@@ -49,3 +49,61 @@ export interface UsageSourceRegistration {
fetch(input: unknown): Promise<UsageReport>;
discover?(): Promise<JsonValue[]>;
}
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;
}
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M7 12h10M12 7v10" stroke="currentColor" stroke-width="2"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"/></svg>

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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: [],
@@ -17,6 +17,12 @@ import { runGitCommand } from "../../utils/run-git-command.js";
const roots: string[] = [];
type TestPluginRuntime = NonNullable<ConstructorParameters<typeof PluginService>[3]["runtime"]>;
const emptyUsageRuntime = {
getUsageSourceRegistrations: () => [],
fetchUsage: async () => undefined,
discoverUsage: async () => [],
} satisfies Pick<TestPluginRuntime, "getUsageSourceRegistrations" | "fetchUsage" | "discoverUsage">;
async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-"));
roots.push(directory);
@@ -104,6 +110,7 @@ function createPausedRuntime() {
});
const running = new Set<string>();
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<string>();
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<string>();
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<string>();
let starts = 0;
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
+5 -6
View File
@@ -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<void>;
@@ -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);
},
});
@@ -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<string, unknown>;
expect(cache.size).toBe(1);
});
test("turns fetch and discovery failures into error reports", async () => {
const registry = new UsageSourceRegistry();
registry.register({
@@ -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);
@@ -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 });
+4 -6
View File
@@ -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<unknown>;
listUsageReports?(options?: {
forceRefresh?: boolean;
}): Promise<import("@getpaseo/protocol/messages").UsageReportEntry[]>;
listUsageReports(options?: { forceRefresh?: boolean }): Promise<UsageReportEntry[]>;
};
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
mcpBaseUrl?: string | null;
@@ -3011,8 +3009,8 @@ export class Session {
msg: Extract<SessionInboundMessage, { type: "usage.list_reports.request" }>,
): Promise<void> {
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 },