mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(usage): share SDK helpers and bound source cache
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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: () => [],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user