diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index cdf01fa040..db1d748172 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -363,15 +363,20 @@ export class ProviderSnapshotManager { /** * Enabled providers that set their own env, as their sessions report them for usage: the - * provider a custom provider extends, and the environment it launches with. + * provider a custom provider extends, its own label, and the environment it launches with. */ - listProviderEnvironments(): Array<{ provider: AgentProvider; env: Record }> { + listProviderEnvironments(): Array<{ + provider: AgentProvider; + label: string; + env: Record; + }> { 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 }), }, ]; diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index 0717c1bac9..ea2c015ad8 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -2,7 +2,7 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { expect, test } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "./builtin/index.js"; @@ -298,7 +298,22 @@ test("built-in sources deduplicate cross-harness accounts and fall back without } }, 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); @@ -314,6 +329,10 @@ test("#3589: host usage lists each enabled provider's own login without an agent 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" }, }); @@ -343,6 +362,11 @@ test("#3589: host usage lists each enabled provider's own login without an agent 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)", @@ -357,18 +381,35 @@ test("#3589: host usage lists each enabled provider's own login without an agent }, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - const reportIds = async () => - (await client.listUsageReports()).reports.map((entry) => entry.id).sort(); + 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 reportIds()).toEqual([ + 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 reportIds()).toEqual([ + expect(await availableIds()).toEqual([ "claude:default.org", "codex:chatgpt-default", "codex:chatgpt-work", @@ -380,6 +421,44 @@ test("#3589: host usage lists each enabled provider's own login without an agent } }, 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, + { home: path.join(root, "home"), env: {} }, + `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"); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index daab01673d..d78a4f621e 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -559,8 +559,8 @@ 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", env: { CLAUDE_CONFIG_DIR: "/work" } }, - { provider: "claude", env: { CLAUDE_CONFIG_DIR: "/default-copy" } }, + { 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, { @@ -592,3 +592,37 @@ test("#3589: host listings discover each configured provider's environment after 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)"]], + ]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index da8f9545cd..f04092c722 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -33,8 +33,11 @@ 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?(): Array>; + /** + * Enabled providers that set their own env, with the label to show for their logins. Host + * listings discover these like a session. + */ + providerEnvironments?(): Array & { label: string }>; } export interface ListUsageReportsOptions { @@ -100,14 +103,19 @@ export class UsageSourceRegistry { } else { // 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. - const scopes: UsageScope[] = [ - { kind: "global" }, - ...(this.agents.providerEnvironments?.() ?? []).map( - ({ provider, env }): UsageScope => ({ kind: "session", provider, env }), - ), + // 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: Array<{ scope: UsageScope; harness?: string }> = [ + { scope: { kind: "global" } }, + ...(this.agents.providerEnvironments?.() ?? []).map(({ provider, label, env }) => ({ + scope: { kind: "session" as const, provider, env }, + harness: label, + })), ]; const defaults = new Map(); - for (const discovered of await Promise.all(scopes.map((scope) => this.discover(scope)))) + for (const discovered of await Promise.all( + scopes.map(({ scope, harness }) => this.discover(scope, harness)), + )) mergeReports(defaults, discovered); this.defaults = defaults; this.mergeKnown(); @@ -175,7 +183,7 @@ export class UsageSourceRegistry { } } - private async discover(scope: UsageScope): Promise> { + private async discover(scope: UsageScope, harness?: string): Promise> { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { const reports = new Map(); @@ -194,9 +202,12 @@ 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 }); } @@ -355,17 +366,25 @@ function mergeReports( continue; } for (const login of report.logins) { - if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) - known.logins.push(login); + if (!known.logins.some((existing) => sameLogin(existing, login))) known.logins.push(login); } } } // Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes. +// 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 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, + return canonicalJson(login); +} + +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, ); } diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 9177caaefa..4e801f4c15 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -401,7 +401,9 @@ 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. +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;