fix: improve claude cli live discovery

This commit is contained in:
Peter Steinberger
2026-04-06 18:48:59 +01:00
parent 226e1afa4d
commit 7ae8a10087
6 changed files with 261 additions and 8 deletions

View File

@@ -6,6 +6,7 @@ import {
resolvePluginDiscoveryProviders,
runProviderCatalog,
} from "../plugins/provider-discovery.js";
import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js";
import { ensureAuthProfileStore } from "./auth-profiles/store.js";
import {
isNonSecretApiKeyMarker,
@@ -64,7 +65,12 @@ function resolveLiveProviderCatalogTimeoutMs(env: NodeJS.ProcessEnv): number | n
return Number.isFinite(parsed) && parsed > 0 ? parsed : 15_000;
}
function resolveProviderDiscoveryFilter(env: NodeJS.ProcessEnv): string[] | undefined {
function resolveProviderDiscoveryFilter(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] | undefined {
const { config, workspaceDir, env } = params;
const testRaw = env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS?.trim();
if (testRaw) {
const ids = testRaw
@@ -78,15 +84,48 @@ function resolveProviderDiscoveryFilter(env: NodeJS.ProcessEnv): string[] | unde
if (!live) {
return undefined;
}
const raw = env.OPENCLAW_LIVE_PROVIDERS?.trim();
if (!raw || raw === "all") {
const rawValues = [
env.OPENCLAW_LIVE_PROVIDERS?.trim(),
env.OPENCLAW_LIVE_GATEWAY_PROVIDERS?.trim(),
].filter((value): value is string => Boolean(value && value !== "all"));
if (rawValues.length === 0) {
return undefined;
}
const ids = raw
.split(",")
const ids = rawValues
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean);
return ids.length > 0 ? [...new Set(ids)] : undefined;
if (ids.length === 0) {
return undefined;
}
const pluginIds = new Set<string>();
for (const id of ids) {
const owners =
resolveOwningPluginIdsForProvider({
provider: id,
config,
workspaceDir,
env,
}) ?? [];
if (owners.length > 0) {
for (const owner of owners) {
pluginIds.add(owner);
}
continue;
}
pluginIds.add(id);
}
return pluginIds.size > 0
? [...pluginIds].toSorted((left, right) => left.localeCompare(right))
: undefined;
}
export function resolveProviderDiscoveryFilterForTest(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] | undefined {
return resolveProviderDiscoveryFilter(params);
}
function mergeImplicitProviderSet(
@@ -315,7 +354,11 @@ export async function resolveImplicitProviders(
config: params.config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: resolveProviderDiscoveryFilter(env),
onlyPluginIds: resolveProviderDiscoveryFilter({
config: params.config,
workspaceDir: params.workspaceDir,
env,
}),
});
for (const order of PLUGIN_DISCOVERY_ORDERS) {

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolveProviderDiscoveryFilterForTest } from "./models-config.providers.implicit.js";
describe("resolveProviderDiscoveryFilterForTest", () => {
it("maps live provider backend ids to owning plugin ids", () => {
expect(
resolveProviderDiscoveryFilterForTest({
env: {
OPENCLAW_LIVE_TEST: "1",
OPENCLAW_LIVE_PROVIDERS: "claude-cli",
VITEST: "1",
} as NodeJS.ProcessEnv,
}),
).toEqual(["anthropic"]);
});
it("honors gateway live provider filters too", () => {
expect(
resolveProviderDiscoveryFilterForTest({
env: {
OPENCLAW_LIVE_TEST: "1",
OPENCLAW_LIVE_GATEWAY_PROVIDERS: "claude-cli",
VITEST: "1",
} as NodeJS.ProcessEnv,
}),
).toEqual(["anthropic"]);
});
it("keeps explicit plugin-id filters when no owning provider plugin exists", () => {
expect(
resolveProviderDiscoveryFilterForTest({
env: {
OPENCLAW_LIVE_TEST: "1",
OPENCLAW_LIVE_PROVIDERS: "openrouter",
VITEST: "1",
} as NodeJS.ProcessEnv,
}),
).toEqual(["openrouter"]);
});
});

View File

@@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { saveAuthProfileStore } from "./auth-profiles.js";
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({
plugins: [
{
id: "anthropic",
origin: "bundled",
providers: ["anthropic"],
cliBackends: ["claude-cli"],
},
],
diagnostics: [],
})),
);
const resolveProviderSyntheticAuthWithPlugin = vi.hoisted(() =>
vi.fn((params: { provider: string }) =>
params.provider === "claude-cli"
? {
apiKey: "claude-cli-access-token",
source: "Claude CLI native auth",
mode: "oauth" as const,
}
: undefined,
),
);
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
vi.mock("../plugins/provider-runtime.js", () => ({
applyProviderResolvedModelCompatWithPlugins: () => undefined,
applyProviderResolvedTransportWithPlugin: () => undefined,
normalizeProviderResolvedModelWithPlugin: () => undefined,
resolveProviderSyntheticAuthWithPlugin,
resolveExternalAuthProfilesWithPlugins: () => [],
}));
async function withAgentDir(run: (agentDir: string) => Promise<void>): Promise<void> {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-synthetic-auth-"));
try {
await run(agentDir);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
}
describe("pi model discovery synthetic auth", () => {
beforeEach(() => {
vi.resetModules();
loadPluginManifestRegistry.mockClear();
resolveProviderSyntheticAuthWithPlugin.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("mirrors plugin-owned synthetic cli auth into pi auth storage", async () => {
await withAgentDir(async (agentDir) => {
saveAuthProfileStore(
{
version: 1,
profiles: {},
},
agentDir,
);
const { discoverAuthStorage } = await import("./pi-model-discovery.js");
const authStorage = discoverAuthStorage(agentDir);
expect(loadPluginManifestRegistry).toHaveBeenCalled();
expect(resolveProviderSyntheticAuthWithPlugin).toHaveBeenCalledWith({
provider: "claude-cli",
context: {
config: undefined,
provider: "claude-cli",
providerConfig: undefined,
},
});
expect(authStorage.hasAuth("claude-cli")).toBe(true);
await expect(authStorage.getApiKey("claude-cli")).resolves.toBe("claude-cli-access-token");
});
});
});

View File

@@ -6,11 +6,13 @@ import type {
AuthStorage as PiAuthStorage,
ModelRegistry as PiModelRegistry,
} from "@mariozechner/pi-coding-agent";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { normalizeModelCompat } from "../plugins/provider-model-compat.js";
import {
applyProviderResolvedModelCompatWithPlugins,
applyProviderResolvedTransportWithPlugin,
normalizeProviderResolvedModelWithPlugin,
resolveProviderSyntheticAuthWithPlugin,
} from "../plugins/provider-runtime.js";
import type { ProviderRuntimeModel } from "../plugins/types.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
@@ -245,6 +247,36 @@ function resolvePiCredentials(agentDir: string): PiCredentialMap {
key: resolved.apiKey,
};
}
const syntheticProviders = new Set<string>();
for (const plugin of loadPluginManifestRegistry().plugins) {
for (const provider of plugin.providers) {
syntheticProviders.add(provider);
}
for (const backend of plugin.cliBackends) {
syntheticProviders.add(backend);
}
}
for (const provider of syntheticProviders) {
if (credentials[provider]) {
continue;
}
const resolved = resolveProviderSyntheticAuthWithPlugin({
provider,
context: {
config: undefined,
provider,
providerConfig: undefined,
},
});
const apiKey = resolved?.apiKey?.trim();
if (!apiKey) {
continue;
}
credentials[provider] = {
type: "api_key",
key: apiKey,
};
}
return credentials;
}

View File

@@ -12,6 +12,8 @@ function makeProvider(params: {
label?: string;
order?: ProviderDiscoveryOrder;
mode?: "catalog" | "discovery";
aliases?: string[];
hookAliases?: string[];
}): ProviderPlugin {
const hook = {
...(params.order ? { order: params.order } : {}),
@@ -21,6 +23,8 @@ function makeProvider(params: {
id: params.id,
label: params.label ?? params.id,
auth: [],
...(params.aliases ? { aliases: params.aliases } : {}),
...(params.hookAliases ? { hookAliases: params.hookAliases } : {}),
...(params.mode === "discovery" ? { discovery: hook } : { catalog: hook }),
};
}
@@ -154,6 +158,37 @@ describe("normalizePluginDiscoveryResult", () => {
},
},
},
{
name: "maps a single provider result to aliases and hook aliases",
provider: makeProvider({
id: "Anthropic",
aliases: ["anthropic-api"],
hookAliases: ["claude-cli"],
}),
result: {
provider: makeModelProviderConfig({
baseUrl: "https://api.anthropic.com",
api: "anthropic-messages",
}),
},
expected: {
anthropic: {
baseUrl: "https://api.anthropic.com",
api: "anthropic-messages",
models: [],
},
"anthropic-api": {
baseUrl: "https://api.anthropic.com",
api: "anthropic-messages",
models: [],
},
"claude-cli": {
baseUrl: "https://api.anthropic.com",
api: "anthropic-messages",
models: [],
},
},
},
{
name: "normalizes keys for multi-provider discovery results",
provider: makeProvider({ id: "ignored" }),

View File

@@ -62,7 +62,19 @@ export function normalizePluginDiscoveryResult(params: {
}
if ("provider" in result) {
return { [normalizeProviderId(params.provider.id)]: result.provider };
const normalized: Record<string, ModelProviderConfig> = {};
for (const providerId of [
params.provider.id,
...(params.provider.aliases ?? []),
...(params.provider.hookAliases ?? []),
]) {
const normalizedKey = normalizeProviderId(providerId);
if (!normalizedKey) {
continue;
}
normalized[normalizedKey] = result.provider;
}
return normalized;
}
const normalized: Record<string, ModelProviderConfig> = {};