mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96a47b42e0 | ||
|
|
bde2cfadf1 | ||
|
|
7a428d5774 | ||
|
|
83582a0537 | ||
|
|
abec60eb72 | ||
|
|
e8fc388867 | ||
|
|
88b7e096a6 | ||
|
|
6d41463582 | ||
|
|
85e195931b |
@@ -680,6 +680,16 @@ export interface AgentUsageSession {
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
/** An enabled provider that sets its own env, as its sessions report it for usage. */
|
||||
export interface ProviderUsageEnvironment {
|
||||
/** The provider a custom provider extends, or its own id. */
|
||||
provider: string;
|
||||
/** The provider's label, used to name the logins found through it. */
|
||||
label: string;
|
||||
/** The environment the provider launches with. */
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
usageSession?(): AgentUsageSession | null;
|
||||
readonly provider: AgentProvider;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type AgentProvider,
|
||||
type FetchCatalogOptions,
|
||||
type ProviderSnapshotEntry,
|
||||
type ProviderUsageEnvironment,
|
||||
} from "./agent-sdk-types.js";
|
||||
import {
|
||||
raceProviderRefreshAbort,
|
||||
@@ -28,9 +29,10 @@ import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
|
||||
import type { OpenCodeBridge } from "./providers/opencode/bridge.js";
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
import {
|
||||
createProviderEnv,
|
||||
type AgentProviderRuntimeSettingsMap,
|
||||
type ProviderOverride,
|
||||
} from "./provider-launch-config.js";
|
||||
import {
|
||||
buildProviderRegistry,
|
||||
@@ -360,6 +362,21 @@ export class ProviderSnapshotManager {
|
||||
return this.createAgentManagerState(this.generation.definitions, this.providerClients);
|
||||
}
|
||||
|
||||
/** Enabled providers that set their own env, as their sessions report them for usage. */
|
||||
listProviderEnvironments(): ProviderUsageEnvironment[] {
|
||||
return Object.entries(this.generation.definitions).flatMap(([provider, definition]) => {
|
||||
const runtimeSettings = definition.configuration?.runtimeSettings;
|
||||
if (!definition.enabled || !runtimeSettings?.env) return [];
|
||||
return [
|
||||
{
|
||||
provider: definition.derivedFromProviderId ?? provider,
|
||||
label: definition.label,
|
||||
env: createProviderEnv({ baseEnv: process.env, runtimeSettings }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private createAgentManagerState(
|
||||
definitions: Record<AgentProvider, ProviderDefinition>,
|
||||
providerClients: Record<AgentProvider, AgentClient>,
|
||||
|
||||
@@ -675,6 +675,7 @@ export async function createPaseoDaemon(
|
||||
usageAgents: {
|
||||
hasAgent: (id) => agentManager.getAgent(id) !== null,
|
||||
usageSession: (id) => agentManager.usageSession(id),
|
||||
providerEnvironments: () => providerSnapshotManager.listProviderEnvironments(),
|
||||
},
|
||||
managedSources: new ManagedPluginSources(config.paseoHome, {
|
||||
registries: config.pluginRegistries,
|
||||
|
||||
@@ -6,7 +6,8 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { expect, test } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { ProviderOverride } from "../agent/provider-launch-config.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "./builtin/index.js";
|
||||
@@ -16,6 +17,55 @@ const subprocessDirectory = fileURLToPath(
|
||||
new URL("./test-fixtures/usage-source-directory/", import.meta.url),
|
||||
);
|
||||
|
||||
async function json(file: string, value: unknown) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** The real Claude and Codex readers, compiled as built-ins, with home, env and HTTP injected. */
|
||||
interface UsageLookup {
|
||||
home: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
interface InjectedUsageSourcesOptions {
|
||||
root: string;
|
||||
lookup: UsageLookup;
|
||||
/** Source of an `async function fetchApi(url, init)` the readers call instead of the network. */
|
||||
fetchApi: string;
|
||||
}
|
||||
|
||||
async function injectedUsageSources({
|
||||
root,
|
||||
lookup,
|
||||
fetchApi,
|
||||
}: InjectedUsageSourcesOptions): Promise<BuiltinPluginLoader> {
|
||||
const plugins = path.join(root, "plugins");
|
||||
for (const source of ["codex", "claude"]) {
|
||||
const directory = path.join(plugins, `${source}-usage-source`);
|
||||
await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { inputSchema } from './shared/input.js';
|
||||
import { discover, fetchUsage } from './server/usage.js';
|
||||
const lookup = { home: ${JSON.stringify(lookup.home)}, env: ${JSON.stringify(lookup.env)}, platform: 'linux' };
|
||||
${fetchApi}
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema,
|
||||
discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}),
|
||||
fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
return new BuiltinPluginLoader(plugins, ["codex-usage-source", "claude-usage-source"]);
|
||||
}
|
||||
|
||||
test("lists built-in and subprocess usage; validates input and isolates fetch errors", async () => {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.9.2",
|
||||
@@ -157,13 +207,8 @@ export default function contribute(server) {
|
||||
test("built-in sources deduplicate cross-harness accounts and fall back without session wiring", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "usage-cross-harness-"));
|
||||
const home = path.join(root, "home");
|
||||
const plugins = path.join(root, "plugins");
|
||||
const codexHome = path.join(home, ".codex");
|
||||
const env = { CODEX_HOME: codexHome };
|
||||
async function json(file: string, value: unknown) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, JSON.stringify(value));
|
||||
}
|
||||
async function codexToken(token: string) {
|
||||
await json(path.join(codexHome, "auth.json"), {
|
||||
tokens: { access_token: token, account_id: "chatgpt-one" },
|
||||
@@ -205,19 +250,10 @@ test("built-in sources deduplicate cross-harness accounts and fall back without
|
||||
JSON.stringify({ access: "fixture-omp-claude", expires: Date.now() + 60_000 }),
|
||||
);
|
||||
db.close();
|
||||
for (const source of ["codex", "claude"]) {
|
||||
const directory = path.join(plugins, `${source}-usage-source`);
|
||||
await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
// Inject home/env/HTTP at the source boundary; compile and register the real readers.
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { inputSchema } from './shared/input.js';
|
||||
import { discover, fetchUsage } from './server/usage.js';
|
||||
const lookup = { home: ${JSON.stringify(home)}, env: ${JSON.stringify(env)}, platform: 'linux' };
|
||||
async function fetchApi(url, init) {
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root: root,
|
||||
lookup: { home, env },
|
||||
fetchApi: `async function fetchApi(url, init) {
|
||||
const token = new Headers(init.headers).get('Authorization');
|
||||
if (String(url).endsWith('/profile')) {
|
||||
const second = token === 'Bearer fixture-pi-claude';
|
||||
@@ -226,21 +262,9 @@ async function fetchApi(url, init) {
|
||||
if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 });
|
||||
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
|
||||
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } }));
|
||||
}
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema,
|
||||
discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}),
|
||||
fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(plugins, ["codex-usage-source", "claude-usage-source"]),
|
||||
}`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({ pluginsEnabled: false, builtinPlugins });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -291,6 +315,167 @@ export default function contribute(server) {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// The daemon launches every provider with its own environment, and a source lists nothing for a
|
||||
// provider that sends its agents to another API host. A developer's shell, or the repo's .env,
|
||||
// can already set these.
|
||||
const API_HOST_ENV = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"OPENAI_BASE_URL",
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("#3589: host usage lists each enabled provider's own login without an agent", async () => {
|
||||
for (const name of API_HOST_ENV) vi.stubEnv(name, undefined);
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "usage-provider-env-"));
|
||||
const home = path.join(root, "home");
|
||||
const dir = (name: string) => path.join(root, name);
|
||||
await json(path.join(home, ".claude", ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: "fixture-default" },
|
||||
});
|
||||
await json(path.join(home, ".codex", "auth.json"), {
|
||||
tokens: { access_token: "fixture-codex", account_id: "chatgpt-default" },
|
||||
});
|
||||
// claude-same holds the default account's login in another directory.
|
||||
for (const name of ["work", "same", "off"]) {
|
||||
await json(path.join(dir(`claude-${name}`), ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: `fixture-${name}` },
|
||||
});
|
||||
}
|
||||
// claude-stale's token expired. Nothing says whose account it is, so it is a card of its own.
|
||||
await json(path.join(dir("claude-stale"), ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: "fixture-stale", expiresAt: 1 },
|
||||
});
|
||||
await json(path.join(dir("codex-work"), "auth.json"), {
|
||||
tokens: { access_token: "fixture-codex-work", account_id: "chatgpt-work" },
|
||||
});
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root: root,
|
||||
lookup: { home, env: {} },
|
||||
fetchApi: `async function fetchApi(url, init) {
|
||||
const token = new Headers(init.headers).get('Authorization');
|
||||
if (String(url).endsWith('/profile')) {
|
||||
const uuid = { 'Bearer fixture-work': 'work', 'Bearer fixture-off': 'off' }[token] || 'default';
|
||||
return new Response(JSON.stringify({ account: { uuid }, organization: { uuid: 'org' } }));
|
||||
}
|
||||
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: 1 } }, five_hour: { utilization: 1 } }));
|
||||
}`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins,
|
||||
providerOverrides: {
|
||||
"claude-work": {
|
||||
extends: "claude",
|
||||
label: "Claude (work)",
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-work") },
|
||||
},
|
||||
"claude-same": {
|
||||
extends: "claude",
|
||||
label: "Claude (same)",
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-same") },
|
||||
},
|
||||
"claude-stale": {
|
||||
extends: "claude",
|
||||
label: "Claude (stale)",
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-stale") },
|
||||
},
|
||||
"claude-off": {
|
||||
extends: "claude",
|
||||
label: "Claude (off)",
|
||||
enabled: false,
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-off") },
|
||||
},
|
||||
"codex-work": {
|
||||
extends: "codex",
|
||||
label: "Codex (work)",
|
||||
env: { CODEX_HOME: dir("codex-work") },
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const listed = async (status: "available" | "unavailable") =>
|
||||
(await client.listUsageReports()).reports.filter((entry) => entry.report.status === status);
|
||||
const availableIds = async () => (await listed("available")).map((entry) => entry.id).sort();
|
||||
try {
|
||||
await client.connect();
|
||||
expect(await availableIds()).toEqual([
|
||||
"claude:default.org",
|
||||
"claude:work.org",
|
||||
"codex:chatgpt-default",
|
||||
"codex:chatgpt-work",
|
||||
]);
|
||||
// Several cards can read the same failure, so the card says which provider's login it is.
|
||||
expect((await listed("unavailable")).map((entry) => entry.loginErrors)).toEqual([
|
||||
[
|
||||
{
|
||||
harness: "Claude (stale)",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: "1970-01-01T00:00:00.001Z",
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
await client.patchDaemonConfig({ providers: { "claude-work": { enabled: false } } });
|
||||
expect(await availableIds()).toEqual([
|
||||
"claude:default.org",
|
||||
"codex:chatgpt-default",
|
||||
"codex:chatgpt-work",
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("#3589: a provider whose agents talk to another API host has no login in the host list", async () => {
|
||||
vi.stubEnv("ANTHROPIC_BASE_URL", "https://llm-proxy.example");
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "usage-provider-host-"));
|
||||
await json(path.join(root, "claude-work", ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: "fixture-work" },
|
||||
});
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root: root,
|
||||
lookup: { home: path.join(root, "home"), env: {} },
|
||||
fetchApi: `async function fetchApi(url) {
|
||||
if (String(url).endsWith('/profile')) {
|
||||
return new Response(JSON.stringify({ account: { uuid: 'work' }, organization: { uuid: 'org' } }));
|
||||
}
|
||||
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: 1 } }, five_hour: { utilization: 1 } }));
|
||||
}`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins,
|
||||
providerOverrides: {
|
||||
"claude-work": {
|
||||
extends: "claude",
|
||||
label: "Claude (work)",
|
||||
env: { CLAUDE_CONFIG_DIR: path.join(root, "claude-work") },
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
expect((await client.listUsageReports()).reports).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a killed discovery subprocess logs kimi once and produces no provider card", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { default: pino } = await import("pino");
|
||||
@@ -410,3 +595,49 @@ dns.lookup = function(host, ...args) {
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("#3589: an empty default store and disabled built-in provider do not hide configured accounts", async () => {
|
||||
for (const name of API_HOST_ENV) vi.stubEnv(name, undefined);
|
||||
await mkdir(path.join(process.cwd(), ".artifacts"), { recursive: true });
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".artifacts", "reported-usage-"));
|
||||
const providerOverrides: Record<string, ProviderOverride> = { claude: { enabled: false } };
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const configDir = path.join(root, `account-${i}`);
|
||||
await json(path.join(configDir, ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: `fixture-independent-${i}` },
|
||||
});
|
||||
providerOverrides[`claude-${i}`] = {
|
||||
extends: "claude",
|
||||
label: `Account ${i}`,
|
||||
env: { CLAUDE_CONFIG_DIR: configDir },
|
||||
};
|
||||
}
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root,
|
||||
lookup: { home: path.join(root, "empty-home"), env: {} },
|
||||
fetchApi: `async function fetchApi(url, init) {
|
||||
const token = new Headers(init.headers).get('Authorization');
|
||||
const uuid = token.slice('Bearer fixture-independent-'.length);
|
||||
if (String(url).endsWith('/profile')) return new Response(JSON.stringify({account:{uuid},organization:{uuid:'independent'}}));
|
||||
return new Response(JSON.stringify({five_hour:{utilization:7},seven_day:{utilization:13}}));
|
||||
}`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins,
|
||||
providerOverrides,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
const reports = (await client.listUsageReports()).reports;
|
||||
expect(reports.map((x) => x.id).sort()).toEqual(
|
||||
[0, 1, 2, 3, 4].map((i) => `claude:${i}.independent`),
|
||||
);
|
||||
expect(reports.every((x) => x.report.status === "available")).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
@@ -175,7 +175,7 @@ test("concurrent requests for the same ID share one vendor fetch", async () => {
|
||||
const second = registry.listReports();
|
||||
finish({ status: "available", windows: [] });
|
||||
const [one, two] = await Promise.all([first, second]);
|
||||
expect(one[0]).toBe(two[0]);
|
||||
expect(one[0]).toEqual(two[0]);
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
@@ -624,6 +624,76 @@ test("a hung login records its own timeout and still tries the next login", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("#3589: host listings discover each configured provider's environment after the host's own logins", async () => {
|
||||
let providers = [
|
||||
{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/work" } },
|
||||
{ provider: "claude", label: "Claude (copy)", env: { CLAUDE_CONFIG_DIR: "/default-copy" } },
|
||||
];
|
||||
const calls: unknown[] = [];
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => providers,
|
||||
});
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) => {
|
||||
if (scope.kind === "global") return [{ key: "default", input: "/default" }];
|
||||
const dir = scope.env.CLAUDE_CONFIG_DIR;
|
||||
return [{ key: dir === "/work" ? "work" : "default", input: dir }];
|
||||
},
|
||||
fetch: async (input) => {
|
||||
calls.push(input);
|
||||
return input === "/default"
|
||||
? { status: "unavailable", problem: { kind: "rejected", status: 401 } }
|
||||
: { status: "available", windows: [] };
|
||||
},
|
||||
});
|
||||
const reports = await registry.listReports();
|
||||
expect(reports.map((entry) => [entry.id, entry.report.status])).toEqual([
|
||||
["claude:default", "available"],
|
||||
["claude:work", "available"],
|
||||
]);
|
||||
expect(calls).toEqual(["/default", "/default-copy", "/work"]);
|
||||
providers = [];
|
||||
expect((await registry.listReports()).map((entry) => entry.id)).toEqual(["claude:default"]);
|
||||
});
|
||||
|
||||
test("#3589: a failed provider login is named after its provider; one the host already has keeps the host's name", async () => {
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => [
|
||||
{ provider: "claude", label: "Claude (home)", env: { CLAUDE_CONFIG_DIR: "/default" } },
|
||||
{ provider: "claude", label: "Claude (a)", env: { CLAUDE_CONFIG_DIR: "/a" } },
|
||||
{ provider: "claude", label: "Claude (b)", env: { CLAUDE_CONFIG_DIR: "/b" } },
|
||||
{ provider: "claude", label: "Claude (b again)", env: { CLAUDE_CONFIG_DIR: "/b" } },
|
||||
],
|
||||
});
|
||||
// An expired login has no account to read, so each directory is its own account.
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) => {
|
||||
const dir = scope.kind === "global" ? "/default" : scope.env.CLAUDE_CONFIG_DIR;
|
||||
return [{ key: `dir${dir?.replace("/", "-")}`, harness: "Claude", input: dir }];
|
||||
},
|
||||
fetch: async () => ({
|
||||
status: "unavailable",
|
||||
problem: { kind: "expired", expiresAt: "1970-01-01T00:00:01.000Z", refreshedBy: "claude" },
|
||||
}),
|
||||
});
|
||||
const reports = await registry.listReports();
|
||||
expect(
|
||||
reports.map((entry) => [entry.id, entry.loginErrors?.map((login) => login.harness)]),
|
||||
).toEqual([
|
||||
["claude:dir-default", ["Claude"]],
|
||||
["claude:dir-a", ["Claude (a)"]],
|
||||
["claude:dir-b", ["Claude (b)"]],
|
||||
]);
|
||||
});
|
||||
|
||||
test("retains the last successful poll across failures, cache expiry, and recovery", async () => {
|
||||
let now = 0;
|
||||
let report: unknown = {
|
||||
@@ -828,3 +898,107 @@ test("failed discovery preserves history until discovery confirms account remova
|
||||
present = true;
|
||||
expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined();
|
||||
});
|
||||
|
||||
test("#3589: a provider's failed discovery keeps only that provider's last logins", async () => {
|
||||
let providers = [
|
||||
{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/work" } },
|
||||
];
|
||||
const failing = new Set<string>();
|
||||
const registry = new UsageSourceRegistry(
|
||||
Date.now,
|
||||
300_000,
|
||||
{ warn: () => {} },
|
||||
{
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => providers,
|
||||
},
|
||||
);
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) => {
|
||||
const dir = scope.kind === "global" ? "/default" : scope.env.CLAUDE_CONFIG_DIR;
|
||||
if (failing.has(dir)) throw new Error("Login store temporarily unavailable");
|
||||
return [{ key: dir.slice(1), input: dir }];
|
||||
},
|
||||
fetch: async () => ({ status: "available", windows: [] }),
|
||||
});
|
||||
const listIds = async () => (await registry.listReports()).map((entry) => entry.id);
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
failing.add("/work");
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
// Renaming a provider keeps its launch environment.
|
||||
providers = [{ provider: "claude", label: "Claude (Work)", env: { CLAUDE_CONFIG_DIR: "/work" } }];
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
// A changed launch environment has no previous result to keep.
|
||||
providers = [
|
||||
{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/other" } },
|
||||
];
|
||||
failing.add("/other");
|
||||
expect(await listIds()).toEqual(["claude:default"]);
|
||||
failing.clear();
|
||||
providers = [{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/work" } }];
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
// The host's own failed discovery keeps the host's logins, not a disabled provider's.
|
||||
providers = [];
|
||||
failing.add("/default");
|
||||
expect(await listIds()).toEqual(["claude:default"]);
|
||||
});
|
||||
|
||||
test("unregistering a source drops the accounts each host scope kept for it", async () => {
|
||||
let fails = false;
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, { warn: () => {} });
|
||||
const claude = source({
|
||||
id: "claude",
|
||||
discover: async () => {
|
||||
if (fails) throw new Error("Login store temporarily unavailable");
|
||||
return [{ key: "default", input: {} }];
|
||||
},
|
||||
});
|
||||
registry.register(claude);
|
||||
expect((await registry.listReports()).map((entry) => entry.id)).toEqual(["claude:default"]);
|
||||
registry.unregister("claude");
|
||||
registry.register(claude);
|
||||
fails = true;
|
||||
expect(await registry.listReports()).toEqual([]);
|
||||
});
|
||||
|
||||
test("#3589: renaming a provider preserves cached usage and recovery history while updating error labels", async () => {
|
||||
let label = "Before";
|
||||
let available = true;
|
||||
let polls = 0;
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => [
|
||||
{ provider: "claude", label, env: { CLAUDE_CONFIG_DIR: "/work" } },
|
||||
],
|
||||
});
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) =>
|
||||
scope.kind === "global" ? [] : [{ key: "work", input: { path: "/work" } }],
|
||||
fetch: async () => {
|
||||
polls++;
|
||||
return available
|
||||
? { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }
|
||||
: { status: "error", error: "offline" };
|
||||
},
|
||||
});
|
||||
const [success] = await registry.listReports();
|
||||
available = false;
|
||||
label = "After";
|
||||
const [cached] = await registry.listReports();
|
||||
expect(cached?.report).toEqual(success?.report);
|
||||
expect(polls).toBe(1);
|
||||
const [failed] = await registry.listReports({ forceRefresh: true });
|
||||
expect(failed?.lastSuccessful?.report).toEqual(success?.report);
|
||||
expect(failed?.loginErrors?.map((login) => login.harness)).toEqual(["After"]);
|
||||
label = "Renamed again";
|
||||
const [relabeled] = await registry.listReports();
|
||||
expect(polls).toBe(2);
|
||||
expect(relabeled?.lastSuccessful).toEqual(failed?.lastSuccessful);
|
||||
expect(relabeled?.loginErrors?.map((login) => login.harness)).toEqual(["Renamed again"]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AgentUsageSession } from "../../agent/agent-sdk-types.js";
|
||||
import type { AgentUsageSession, ProviderUsageEnvironment } from "../../agent/agent-sdk-types.js";
|
||||
import type { UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
@@ -33,6 +33,26 @@ interface KnownReport {
|
||||
export interface AgentUsageLookup {
|
||||
hasAgent(id: string): boolean;
|
||||
usageSession(id: string): AgentUsageSession | null;
|
||||
/** Enabled providers that set their own env. Host listings discover these like a session. */
|
||||
providerEnvironments?(): ProviderUsageEnvironment[];
|
||||
}
|
||||
|
||||
interface DiscoverOptions {
|
||||
scope: UsageScope;
|
||||
/** Names the logins found in this scope; defaults to the account's harness or the source label. */
|
||||
harness?: string;
|
||||
/** This scope's last result; a source whose discovery fails keeps its accounts from it. */
|
||||
previous?: ReadonlyMap<string, KnownReport>;
|
||||
}
|
||||
|
||||
interface PresentReportOptions {
|
||||
entry: UsageReportEntry;
|
||||
known: KnownReport;
|
||||
}
|
||||
|
||||
interface MergeReportsOptions {
|
||||
into: Map<string, KnownReport>;
|
||||
reports: Iterable<[string, KnownReport]>;
|
||||
}
|
||||
|
||||
export interface ListUsageReportsOptions {
|
||||
@@ -54,6 +74,7 @@ export class UsageSourceRegistry {
|
||||
private readonly sources = new Map<string, UsageSource>();
|
||||
private readonly known = new Map<string, KnownReport>();
|
||||
private defaults = new Map<string, KnownReport>();
|
||||
private defaultsByScope = new Map<string, Map<string, KnownReport>>();
|
||||
private readonly byAgent = new Map<string, AgentReports>();
|
||||
private readonly cache = new Map<string, { at: number; entry: UsageReportEntry }>();
|
||||
private readonly successful = new Map<string, NonNullable<UsageReportEntry["lastSuccessful"]>>();
|
||||
@@ -75,6 +96,8 @@ export class UsageSourceRegistry {
|
||||
unregister(id: string): void {
|
||||
this.sources.delete(id);
|
||||
for (const key of this.defaults.keys()) if (key.startsWith(`${id}:`)) this.defaults.delete(key);
|
||||
for (const reports of this.defaultsByScope.values())
|
||||
for (const key of reports.keys()) if (key.startsWith(`${id}:`)) reports.delete(key);
|
||||
// Re-discover on the next session query when a source is replaced.
|
||||
for (const [agentId, mapping] of this.byAgent) {
|
||||
if ([...mapping.reports.values()].some((report) => report.source.id === id))
|
||||
@@ -99,7 +122,37 @@ export class UsageSourceRegistry {
|
||||
} else if (options.reportIds !== undefined) {
|
||||
ids = options.reportIds;
|
||||
} else {
|
||||
this.defaults = await this.discover({ kind: "global" }, this.defaults);
|
||||
// A provider's own env can name a login the daemon's env doesn't, so list it before any of
|
||||
// its agents runs. The host's own logins come first in each account's fallback order.
|
||||
// A login is named after the provider it was found through: a login that can't be read
|
||||
// (an expired token has no account) is otherwise one more card that looks like the rest.
|
||||
const scopes: DiscoverOptions[] = [
|
||||
{ scope: { kind: "global" } },
|
||||
...(this.agents.providerEnvironments?.() ?? []).map(({ provider, label, env }) => ({
|
||||
scope: { kind: "session" as const, provider, env },
|
||||
harness: label,
|
||||
})),
|
||||
];
|
||||
// Each launch environment is discovered once, under the first provider that uses it, and
|
||||
// keeps its own last result.
|
||||
const unique = new Map<string, DiscoverOptions>();
|
||||
for (const entry of scopes) {
|
||||
const key = canonicalJson(entry.scope);
|
||||
if (!unique.has(key)) unique.set(key, entry);
|
||||
}
|
||||
const previous = this.defaultsByScope;
|
||||
this.defaultsByScope = new Map(
|
||||
await Promise.all(
|
||||
[...unique].map(
|
||||
async ([key, entry]) =>
|
||||
[key, await this.discover({ ...entry, previous: previous.get(key) })] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
const defaults = new Map<string, KnownReport>();
|
||||
for (const scoped of this.defaultsByScope.values())
|
||||
mergeReports({ into: defaults, reports: scoped });
|
||||
this.defaults = defaults;
|
||||
this.mergeKnown();
|
||||
ids = [...this.known.keys()];
|
||||
}
|
||||
@@ -109,8 +162,9 @@ export class UsageSourceRegistry {
|
||||
if (!known) return [];
|
||||
return [
|
||||
this.fetchId(id, known, options.forceRefresh).then((entry) => {
|
||||
options.onReport?.(entry);
|
||||
return entry;
|
||||
const report = presentReport({ entry, known });
|
||||
options.onReport?.(report);
|
||||
return report;
|
||||
}),
|
||||
];
|
||||
}),
|
||||
@@ -139,10 +193,12 @@ export class UsageSourceRegistry {
|
||||
previous.model === session.model;
|
||||
if (sameScope) return [...previous.reports.keys()];
|
||||
const reports = await this.discover({
|
||||
kind: "session",
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
env: session.env,
|
||||
scope: {
|
||||
kind: "session",
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
env: session.env,
|
||||
},
|
||||
});
|
||||
// A query that finishes after a resume must not publish the previous launch's mapping.
|
||||
if (this.agents.usageSession(agentId)?.sessionKey !== session.sessionKey) return [];
|
||||
@@ -162,18 +218,12 @@ export class UsageSourceRegistry {
|
||||
this.defaults,
|
||||
...[...this.byAgent.values()].map((agent) => agent.reports),
|
||||
]) {
|
||||
for (const [id, report] of reports) {
|
||||
if (this.sources.get(report.source.id) !== report.source) continue;
|
||||
const known = this.known.get(id);
|
||||
if (!known) {
|
||||
this.known.set(id, { ...report, logins: [...report.logins] });
|
||||
continue;
|
||||
}
|
||||
for (const login of report.logins) {
|
||||
if (!known.logins.some((existing) => loginKey(existing) === loginKey(login)))
|
||||
known.logins.push(login);
|
||||
}
|
||||
}
|
||||
mergeReports({
|
||||
into: this.known,
|
||||
reports: [...reports].filter(
|
||||
([, report]) => this.sources.get(report.source.id) === report.source,
|
||||
),
|
||||
});
|
||||
}
|
||||
// Fetch freshness expires independently of successful snapshots. Retain only live identities.
|
||||
const active = this.activeCacheKeys();
|
||||
@@ -191,10 +241,11 @@ export class UsageSourceRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
private async discover(
|
||||
scope: UsageScope,
|
||||
previous: ReadonlyMap<string, KnownReport> = new Map(),
|
||||
): Promise<Map<string, KnownReport>> {
|
||||
private async discover({
|
||||
scope,
|
||||
harness,
|
||||
previous = new Map(),
|
||||
}: DiscoverOptions): Promise<Map<string, KnownReport>> {
|
||||
const discovered = await Promise.all(
|
||||
[...this.sources.values()].map(async (source) => {
|
||||
const reports = new Map<string, KnownReport>();
|
||||
@@ -213,15 +264,18 @@ export class UsageSourceRegistry {
|
||||
const id = `${source.id}:${account.key}`;
|
||||
const known = reports.get(id);
|
||||
// COMPAT(usageLoginHarness): added in v0.11.0, remove after 2027-04-05 once plugin floor >= v0.11.0.
|
||||
const login = { input: account.input, harness: account.harness ?? source.label };
|
||||
const login = {
|
||||
input: account.input,
|
||||
harness: harness ?? account.harness ?? source.label,
|
||||
};
|
||||
if (known) {
|
||||
if (!known.logins.some((existing) => loginKey(existing) === loginKey(login)))
|
||||
if (!known.logins.some((existing) => sameLogin(existing, login)))
|
||||
known.logins.push(login);
|
||||
} else reports.set(id, { source, logins: [login], label: account.label });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed");
|
||||
// A failed store read does not confirm removal. Session discovery has no previous
|
||||
// A failed store read does not confirm removal. Agent session discovery has no previous
|
||||
// mapping because a resumed agent may use a different launch environment.
|
||||
for (const [id, report] of previous) {
|
||||
if (report.source === source) reports.set(id, report);
|
||||
@@ -384,17 +438,55 @@ function legacyError(report: UsageReport, now: number): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// An account found in several scopes is one report; its logins keep discovery order, once each.
|
||||
function mergeReports({ into, reports }: MergeReportsOptions): void {
|
||||
for (const [id, report] of reports) {
|
||||
const known = into.get(id);
|
||||
if (!known) {
|
||||
into.set(id, { ...report, logins: [...report.logins] });
|
||||
continue;
|
||||
}
|
||||
for (const login of report.logins) {
|
||||
if (!known.logins.some((existing) => sameLogin(existing, login))) known.logins.push(login);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes.
|
||||
function loginKey(login: Login): string {
|
||||
return JSON.stringify(login, (_key, value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: value,
|
||||
// The harness only names a login, so the scope that finds it first decides what it is called.
|
||||
function sameLogin(a: Login, b: Login): boolean {
|
||||
return canonicalJson(a.input) === canonicalJson(b.input);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, nested) =>
|
||||
nested !== null && typeof nested === "object" && !Array.isArray(nested)
|
||||
? Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: nested,
|
||||
);
|
||||
}
|
||||
|
||||
function cacheIdentity(id: string, known: KnownReport): string {
|
||||
return `${id}:${createHash("sha256")
|
||||
.update(JSON.stringify([known.label, known.logins.map(loginKey)]))
|
||||
.update(canonicalJson(known.logins.map((login) => login.input)))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Labels describe the current discovery, while cached polls and history belong to login inputs.
|
||||
function presentReport({ entry, known }: PresentReportOptions): UsageReportEntry {
|
||||
return {
|
||||
...entry,
|
||||
sourceLabel: known.source.label,
|
||||
icon: known.source.icon,
|
||||
account: { label: known.label },
|
||||
...(entry.loginErrors
|
||||
? {
|
||||
// Errors are emitted only when every ordered login failed, so positions match inputs.
|
||||
loginErrors: entry.loginErrors.map((error, index) => ({
|
||||
...error,
|
||||
harness: known.logins[index]!.harness,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -492,6 +492,13 @@ Resolve the session account from that session's launch environment. Return `[]`
|
||||
Never scan default stores from session discovery or scan agents from global discovery. Discovery is a query, with no agent
|
||||
lifecycle hooks. Closed agents have no session scope until resumed.
|
||||
|
||||
The host's Usage list also runs session discovery once for each enabled provider that sets its own
|
||||
`env`, so a custom provider's login shows before any of its agents runs. That scope carries the
|
||||
provider's launch environment and no `model`. Its `provider` is the provider it extends, or its own
|
||||
ID when it extends none. The daemon sets the `harness` of each login found this way to the
|
||||
provider's label, so a failed login names its provider. A login that the host's own discovery found
|
||||
first keeps its `harness`.
|
||||
|
||||
Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each
|
||||
input against your schema before calling `fetch()`. The same key in any scope is the same report;
|
||||
fetches share a five-minute cache only when their ordered login inputs match. Each login fetch
|
||||
|
||||
Reference in New Issue
Block a user