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>
This commit is contained in:
Qichang Zheng
2026-10-17 21:31:49 +08:00
co-authored by Claude Sonnet 5.5
parent 85e195931b
commit 6d41463582
5 changed files with 166 additions and 27 deletions
@@ -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<string, string> }> {
listProviderEnvironments(): Array<{
provider: AgentProvider;
label: string;
env: Record<string, string>;
}> {
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 }),
},
];
@@ -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");
@@ -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)"]],
]);
});
@@ -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<Pick<AgentUsageSession, "provider" | "env">>;
/**
* Enabled providers that set their own env, with the label to show for their logins. Host
* listings discover these like a session.
*/
providerEnvironments?(): Array<Pick<AgentUsageSession, "provider" | "env"> & { 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<string, KnownReport>();
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<Map<string, KnownReport>> {
private async discover(scope: UsageScope, harness?: string): Promise<Map<string, KnownReport>> {
const discovered = await Promise.all(
[...this.sources.values()].map(async (source) => {
const reports = new Map<string, KnownReport>();
@@ -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,
);
}
+3 -1
View File
@@ -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;