Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 96a47b42e0 fix(usage): preserve saved usage when a provider is renamed
Polling identity belongs to the ordered login inputs. Apply current display labels when returning cached or pending reports so renaming a configured provider keeps cached values and successful history.
2026-11-08 06:52:04 +00:00
paseo-bot[bot]andQichang Zheng bde2cfadf1 test(usage): cover configured accounts with an empty host store
The host listing must discover enabled custom providers even when the built-in provider is disabled and no default login exists.

Co-authored-by: Qichang Zheng <qichang.zheng2000@gmail.com>
2026-11-08 06:41:33 +00:00
paseo-bot[bot] 7a428d5774 Merge remote-tracking branch 'origin/main' into diagnose/3589-contributor-6b87ee9f 2026-11-08 06:37:04 +00:00
qichangzheng 83582a0537 Merge main into fix/usage-screen-provider-logins 2026-10-30 19:12:15 +08:00
Qichang ZhengandClaude Opus 5.5 abec60eb72 fix(usage): key each host scope's kept accounts by launch environment only
Renaming a provider no longer drops what its scope kept, providers that share
an environment are discovered once under the first one's name, and
unregistering a source is covered by a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 14:57:17 +08:00
Qichang ZhengandClaude Opus 5.5 e8fc388867 Merge main into fix/usage-screen-provider-logins
#7246 keeps the host's last discovered accounts when a source's discovery
fails. Host listings now discover several scopes (the host's own plus each
provider env), so each scope keeps its own last result, keyed by its launch
environment. A provider's failed read keeps that provider's logins; a changed
env or a disabled provider keeps nothing, and the host's failed read keeps
only the host's logins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 14:48:43 +08:00
Qichang ZhengandClaude Opus 5.5 88b7e096a6 refactor(usage): name the provider usage types and pass options objects
Follow the server conventions: the provider environments list returns a named
ProviderUsageEnvironment, and the registry's discover() and mergeReports() and
the e2e injectedUsageSources() helper take a single options object. No
behavior change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:33:56 +08:00
Qichang ZhengandClaude Sonnet 5.5 6d41463582 fix(usage): name each failed provider login in the host's Usage list
A provider whose token has expired has no account to read, so the host list
showed one more card for it, and every such card read "Claude: Login expired".
A login found through a provider is now named after that provider ("Claude
(work): Login expired ..."). A login the host's own discovery already found
keeps its name, and logins are compared by their store input only, so a login
that several scopes find is still listed once.

The Usage e2e test inherited ANTHROPIC_BASE_URL, OPENAI_BASE_URL and the
Bedrock and Vertex switches from the shell through the daemon's environment,
and failed on a machine that sets any of them. It now clears them, and a second
test pins the rule it was tripping over: a provider whose agents talk to another
API host has no login in the host list.

Refs #3589

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 21:31:49 +08:00
Qichang ZhengandClaude Opus 5.5 85e195931b fix(usage): list each custom provider's own login in Usage
Usage and `paseo usage` read only the daemon's default logins. A provider
that sets CLAUDE_CONFIG_DIR or CODEX_HOME in its env had no card until one
of its agents loaded its context window details.

Host listings now also run session discovery once for each enabled
provider that sets its own env, with the environment it launches with.
That is the scope its agents already send, so the usage sources and the
protocol are unchanged. Accounts still group by the source's account key:
a login that several providers share, or that is also the daemon's
default, stays one card, with the host's own login first in its fallback
order.

Refs #3589

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 21:31:49 +08:00
7 changed files with 602 additions and 70 deletions
@@ -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>,
+1
View File
@@ -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,
})),
}
: {}),
};
}
+7
View File
@@ -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