mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
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:
co-authored by
Claude Sonnet 5.5
parent
85e195931b
commit
6d41463582
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user