diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index f05808429a6..92f44302e40 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -1,6 +1,6 @@ import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; -import { normalizeProviderId, normalizeProviderIdForAuth } from "../model-selection.js"; +import { normalizeProviderId, normalizeProviderIdForAuth } from "../provider-id.js"; import { ensureAuthProfileStore, saveAuthProfileStore, diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 0f8f5568618..7cdc52e641c 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -16,6 +16,12 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; import { normalizeGoogleModelId } from "./model-id-normalization.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import { + findNormalizedProviderKey, + findNormalizedProviderValue, + normalizeProviderId, + normalizeProviderIdForAuth, +} from "./provider-id.js"; const log = createSubsystemLogger("model-selection"); @@ -60,71 +66,12 @@ export function legacyModelKey(provider: string, model: string): string | null { return rawKey === canonicalKey ? null : rawKey; } -export function normalizeProviderId(provider: string): string { - const normalized = provider.trim().toLowerCase(); - if (normalized === "z.ai" || normalized === "z-ai") { - return "zai"; - } - if (normalized === "opencode-zen") { - return "opencode"; - } - if (normalized === "opencode-go-auth") { - return "opencode-go"; - } - if (normalized === "qwen") { - return "qwen-portal"; - } - if (normalized === "kimi-code") { - return "kimi-coding"; - } - if (normalized === "bedrock" || normalized === "aws-bedrock") { - return "amazon-bedrock"; - } - // Backward compatibility for older provider naming. - if (normalized === "bytedance" || normalized === "doubao") { - return "volcengine"; - } - return normalized; -} - -/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */ -export function normalizeProviderIdForAuth(provider: string): string { - const normalized = normalizeProviderId(provider); - if (normalized === "volcengine-plan") { - return "volcengine"; - } - if (normalized === "byteplus-plan") { - return "byteplus"; - } - return normalized; -} - -export function findNormalizedProviderValue( - entries: Record | undefined, - provider: string, -): T | undefined { - if (!entries) { - return undefined; - } - const providerKey = normalizeProviderId(provider); - for (const [key, value] of Object.entries(entries)) { - if (normalizeProviderId(key) === providerKey) { - return value; - } - } - return undefined; -} - -export function findNormalizedProviderKey( - entries: Record | undefined, - provider: string, -): string | undefined { - if (!entries) { - return undefined; - } - const providerKey = normalizeProviderId(provider); - return Object.keys(entries).find((key) => normalizeProviderId(key) === providerKey); -} +export { + findNormalizedProviderKey, + findNormalizedProviderValue, + normalizeProviderId, + normalizeProviderIdForAuth, +}; export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { const normalized = normalizeProviderId(provider); diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts new file mode 100644 index 00000000000..354817e8a96 --- /dev/null +++ b/src/agents/provider-id.ts @@ -0,0 +1,65 @@ +export function normalizeProviderId(provider: string): string { + const normalized = provider.trim().toLowerCase(); + if (normalized === "z.ai" || normalized === "z-ai") { + return "zai"; + } + if (normalized === "opencode-zen") { + return "opencode"; + } + if (normalized === "opencode-go-auth") { + return "opencode-go"; + } + if (normalized === "qwen") { + return "qwen-portal"; + } + if (normalized === "kimi-code") { + return "kimi-coding"; + } + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } + // Backward compatibility for older provider naming. + if (normalized === "bytedance" || normalized === "doubao") { + return "volcengine"; + } + return normalized; +} + +/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */ +export function normalizeProviderIdForAuth(provider: string): string { + const normalized = normalizeProviderId(provider); + if (normalized === "volcengine-plan") { + return "volcengine"; + } + if (normalized === "byteplus-plan") { + return "byteplus"; + } + return normalized; +} + +export function findNormalizedProviderValue( + entries: Record | undefined, + provider: string, +): T | undefined { + if (!entries) { + return undefined; + } + const providerKey = normalizeProviderId(provider); + for (const [key, value] of Object.entries(entries)) { + if (normalizeProviderId(key) === providerKey) { + return value; + } + } + return undefined; +} + +export function findNormalizedProviderKey( + entries: Record | undefined, + provider: string, +): string | undefined { + if (!entries) { + return undefined; + } + const providerKey = normalizeProviderId(provider); + return Object.keys(entries).find((key) => normalizeProviderId(key) === providerKey); +} diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts new file mode 100644 index 00000000000..6909bd4cc2c --- /dev/null +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -0,0 +1,14 @@ +import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; +import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; +import { applyPrimaryModel } from "../commands/model-picker.js"; +import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; +import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; + +export { + applyAuthProfileConfig, + applyPrimaryModel, + buildApiKeyCredential, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + validateApiKeyInput, +}; diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index 75fa4afb77d..aa3805aea8f 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -1,9 +1,4 @@ -import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; -import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; -import { applyPrimaryModel } from "../commands/model-picker.js"; -import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; -import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import { upsertAuthProfile } from "../agents/auth-profiles/profiles.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SecretInput } from "../config/types.secrets.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; @@ -34,6 +29,15 @@ type ProviderApiKeyAuthMethodOptions = { applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }; +let providerApiKeyAuthRuntimePromise: + | Promise + | undefined; + +function loadProviderApiKeyAuthRuntime() { + providerApiKeyAuthRuntimePromise ??= import("./provider-api-key-auth.runtime.js"); + return providerApiKeyAuthRuntimePromise; +} + function resolveStringOption(opts: Record | undefined, optionKey: string) { return normalizeOptionalSecretInput(opts?.[optionKey]); } @@ -56,13 +60,14 @@ function resolveProfileIds(params: { return [resolveProfileId(params)]; } -function applyApiKeyConfig(params: { +async function applyApiKeyConfig(params: { ctx: ProviderAuthMethodNonInteractiveContext; providerId: string; profileIds: string[]; defaultModel?: string; applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }) { + const { applyAuthProfileConfig, applyPrimaryModel } = await loadProviderApiKeyAuthRuntime(); let next = params.ctx.config; for (const profileId of params.profileIds) { next = applyAuthProfileConfig(next, { @@ -92,6 +97,12 @@ export function createProviderApiKeyAuthMethod( let capturedSecretInput: SecretInput | undefined; let capturedCredential = false; let capturedMode: "plaintext" | "ref" | undefined; + const { + buildApiKeyCredential, + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + validateApiKeyInput, + } = await loadProviderApiKeyAuthRuntime(); await ensureApiKeyFromOptionEnvOrPrompt({ token: flagValue ?? normalizeOptionalSecretInput(ctx.opts?.token), @@ -171,7 +182,7 @@ export function createProviderApiKeyAuthMethod( } } - return applyApiKeyConfig({ + return await applyApiKeyConfig({ ctx, providerId: params.providerId, profileIds, diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 26c9f847bf9..52e326ddc04 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -1,64 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { resolvePluginWebSearchProviders } from "./web-search-providers.js"; -const loadOpenClawPluginsMock = vi.fn(); - -vi.mock("./loader.js", () => ({ - loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), -})); - describe("resolvePluginWebSearchProviders", () => { - beforeEach(() => { - loadOpenClawPluginsMock.mockReset(); - loadOpenClawPluginsMock.mockReturnValue({ - webSearchProviders: [ - { - pluginId: "google", - provider: { - id: "gemini", - label: "Gemini", - hint: "hint", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://example.com", - autoDetectOrder: 20, - }, - }, - { - pluginId: "brave", - provider: { - id: "brave", - label: "Brave", - hint: "hint", - envVars: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://example.com", - autoDetectOrder: 10, - }, - }, - ], - }); - }); + it("returns bundled providers in auto-detect order", () => { + const providers = resolvePluginWebSearchProviders({}); - it("forwards an explicit env to plugin loading", () => { - const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; - - const providers = resolvePluginWebSearchProviders({ - workspaceDir: "/workspace/explicit", - env, - }); - - expect(providers.map((provider) => provider.id)).toEqual(["brave", "gemini"]); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceDir: "/workspace/explicit", - env, - }), - ); + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "brave:brave", + "google:gemini", + "xai:grok", + "moonshot:kimi", + "perplexity:perplexity", + "firecrawl:firecrawl", + ]); }); it("can augment restrictive allowlists for bundled compatibility", () => { - resolvePluginWebSearchProviders({ + const providers = resolvePluginWebSearchProviders({ config: { plugins: { allow: ["openrouter"], @@ -67,49 +25,30 @@ describe("resolvePluginWebSearchProviders", () => { bundledAllowlistCompat: true, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: expect.arrayContaining(["openrouter", "brave", "perplexity"]), - }), - }), - }), - ); + expect(providers.map((provider) => provider.pluginId)).toEqual([ + "brave", + "google", + "xai", + "moonshot", + "perplexity", + "firecrawl", + ]); }); - it("auto-enables bundled web search provider plugins when entries are missing", () => { - resolvePluginWebSearchProviders({ + it("does not return bundled providers excluded by a restrictive allowlist without compat", () => { + const providers = resolvePluginWebSearchProviders({ config: { plugins: { - entries: { - openrouter: { enabled: true }, - }, + allow: ["openrouter"], }, }, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.objectContaining({ - openrouter: { enabled: true }, - brave: { enabled: true }, - firecrawl: { enabled: true }, - google: { enabled: true }, - moonshot: { enabled: true }, - perplexity: { enabled: true }, - xai: { enabled: true }, - }), - }), - }), - }), - ); + expect(providers).toEqual([]); }); it("preserves explicit bundled provider entry state", () => { - resolvePluginWebSearchProviders({ + const providers = resolvePluginWebSearchProviders({ config: { plugins: { entries: { @@ -119,16 +58,18 @@ describe("resolvePluginWebSearchProviders", () => { }, }); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.objectContaining({ - perplexity: { enabled: false }, - }), - }), - }), - }), - ); + expect(providers.map((provider) => provider.pluginId)).not.toContain("perplexity"); + }); + + it("returns no providers when plugins are globally disabled", () => { + const providers = resolvePluginWebSearchProviders({ + config: { + plugins: { + enabled: false, + }, + }, + }); + + expect(providers).toEqual([]); }); }); diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index c44bb6f2a93..8ff3c90c932 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,14 +1,19 @@ -import { createSubsystemLogger } from "../logging/subsystem.js"; +import { createFirecrawlWebSearchProvider } from "../../extensions/firecrawl/src/firecrawl-search-provider.js"; +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + getTopLevelCredentialValue, + setScopedCredentialValue, + setTopLevelCredentialValue, +} from "../agents/tools/web-search-plugin-factory.js"; import { withBundledPluginAllowlistCompat, withBundledPluginEnablementCompat, } from "./bundled-compat.js"; -import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; -import { createPluginLoaderLogger } from "./logger.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import type { PluginLoadOptions } from "./loader.js"; import type { WebSearchProviderPlugin } from "./types.js"; -const log = createSubsystemLogger("plugins"); - const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "brave", "firecrawl", @@ -18,6 +23,92 @@ const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "xai", ] as const; +const BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY = [ + { + pluginId: "brave", + provider: createPluginBackedWebSearchProvider({ + id: "brave", + label: "Brave Search", + hint: "Structured results · country/language/time filters", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://brave.com/search/api/", + docsUrl: "https://docs.openclaw.ai/brave-search", + autoDetectOrder: 10, + getCredentialValue: getTopLevelCredentialValue, + setCredentialValue: setTopLevelCredentialValue, + }), + }, + { + pluginId: "google", + provider: createPluginBackedWebSearchProvider({ + id: "gemini", + label: "Gemini (Google Search)", + hint: "Google Search grounding · AI-synthesized", + envVars: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://aistudio.google.com/apikey", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 20, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "gemini", value), + }), + }, + { + pluginId: "xai", + provider: createPluginBackedWebSearchProvider({ + id: "grok", + label: "Grok (xAI)", + hint: "xAI web-grounded responses", + envVars: ["XAI_API_KEY"], + placeholder: "xai-...", + signupUrl: "https://console.x.ai/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 30, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "grok", value), + }), + }, + { + pluginId: "moonshot", + provider: createPluginBackedWebSearchProvider({ + id: "kimi", + label: "Kimi (Moonshot)", + hint: "Moonshot web search", + envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + placeholder: "sk-...", + signupUrl: "https://platform.moonshot.cn/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 40, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "kimi", value), + }), + }, + { + pluginId: "perplexity", + provider: createPluginBackedWebSearchProvider({ + id: "perplexity", + label: "Perplexity Search", + hint: "Structured results · domain/country/language/time filters", + envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/settings/api", + docsUrl: "https://docs.openclaw.ai/perplexity", + autoDetectOrder: 50, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "perplexity", value), + }), + }, + { + pluginId: "firecrawl", + provider: createFirecrawlWebSearchProvider(), + }, +] as const; + export function resolvePluginWebSearchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -34,17 +125,17 @@ export function resolvePluginWebSearchProviders(params: { config: allowlistCompat, pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, }); - const registry = loadOpenClawPlugins({ - config, - workspaceDir: params.workspaceDir, - env: params.env, - logger: createPluginLoaderLogger(log), - activate: false, - cache: false, - onlyPluginIds: [...BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS], - }); + const normalizedPlugins = normalizePluginsConfig(config?.plugins); - return registry.webSearchProviders + return BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( + ({ pluginId }) => + resolveEffectiveEnableState({ + id: pluginId, + origin: "bundled", + config: normalizedPlugins, + rootConfig: config, + }).enabled, + ) .map((entry) => ({ ...entry.provider, pluginId: entry.pluginId,