diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index a15693e24a8..88b7abb9c44 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -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(); + 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) { diff --git a/src/agents/models-config.providers.live-filter.test.ts b/src/agents/models-config.providers.live-filter.test.ts new file mode 100644 index 00000000000..2e562b77f17 --- /dev/null +++ b/src/agents/models-config.providers.live-filter.test.ts @@ -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"]); + }); +}); diff --git a/src/agents/pi-model-discovery.synthetic-auth.test.ts b/src/agents/pi-model-discovery.synthetic-auth.test.ts new file mode 100644 index 00000000000..3185e9b705b --- /dev/null +++ b/src/agents/pi-model-discovery.synthetic-auth.test.ts @@ -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): Promise { + 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"); + }); + }); +}); diff --git a/src/agents/pi-model-discovery.ts b/src/agents/pi-model-discovery.ts index 2e3255c3729..5f908551949 100644 --- a/src/agents/pi-model-discovery.ts +++ b/src/agents/pi-model-discovery.ts @@ -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(); + 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; } diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 8574dea8ca9..ce6075dfeec 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -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" }), diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 6aec8457647..cd7ca3cc3da 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -62,7 +62,19 @@ export function normalizePluginDiscoveryResult(params: { } if ("provider" in result) { - return { [normalizeProviderId(params.provider.id)]: result.provider }; + const normalized: Record = {}; + 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 = {};