diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts index dcfd9a10056..0ed37048836 100644 --- a/src/agents/models-config.providers.auth-provenance.test.ts +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -93,4 +93,34 @@ describe("models-config provider auth provenance", () => { profileId: "openai:default", }); }); + + it("resolves plugin-owned synthetic auth through the provider hook", () => { + const auth = createProviderAuthResolver( + {} as NodeJS.ProcessEnv, + { + version: 1, + profiles: {}, + }, + { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-plugin-key", + }, + }, + }, + }, + }, + }, + ); + + expect(auth("xai")).toEqual({ + apiKey: NON_ENV_SECRETREF_MARKER, + discoveryApiKey: "xai-plugin-key", + mode: "api_key", + source: "none", + }); + }); }); diff --git a/src/agents/models-config.providers.secrets.ts b/src/agents/models-config.providers.secrets.ts index c3a179fffd1..a1ed6c2e5b7 100644 --- a/src/agents/models-config.providers.secrets.ts +++ b/src/agents/models-config.providers.secrets.ts @@ -1,6 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; -import { resolveProviderWebSearchPluginConfig } from "../plugin-sdk/provider-web-search.js"; import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { listProfilesForProvider } from "./auth-profiles/profiles.js"; @@ -439,16 +438,15 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op // Providers own any provider-specific fallback auth logic via // resolveSyntheticAuth(...). Discovery/bootstrap callers may consume // non-secret markers from source config, but must never persist plaintext. - const synthetic = - resolveProviderSyntheticAuthWithPlugin({ - provider: params.provider, + const synthetic = resolveProviderSyntheticAuthWithPlugin({ + provider: params.provider, + config: params.config, + context: { config: params.config, - context: { - config: params.config, - provider: params.provider, - providerConfig: params.config?.models?.providers?.[params.provider], - }, - }) ?? resolveXaiConfigFallbackAuth(params); + provider: params.provider, + providerConfig: params.config?.models?.providers?.[params.provider], + }, + }); const apiKey = synthetic?.apiKey?.trim(); if (!apiKey) { return undefined; @@ -467,74 +465,3 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op source: "config", }; } - -function resolveXaiConfigFallbackAuth(params: { provider: string; config?: OpenClawConfig }): - | { - apiKey: string; - source: string; - mode: "api-key"; - } - | undefined { - if (params.provider.trim().toLowerCase() !== "xai") { - return undefined; - } - const xaiPluginEntry = params.config?.plugins?.entries?.xai; - if (xaiPluginEntry?.enabled === false) { - return undefined; - } - const pluginApiKey = normalizeOptionalSecretInput( - resolveProviderWebSearchPluginConfig( - params.config as Record | undefined, - "xai", - )?.apiKey, - ); - if (pluginApiKey) { - return { - apiKey: pluginApiKey, - source: "plugins.entries.xai.config.webSearch.apiKey", - mode: "api-key", - }; - } - const pluginApiKeyRef = coerceSecretRef( - resolveProviderWebSearchPluginConfig( - params.config as Record | undefined, - "xai", - )?.apiKey, - ); - if (pluginApiKeyRef) { - return { - apiKey: - pluginApiKeyRef.source === "env" - ? pluginApiKeyRef.id.trim() - : resolveNonEnvSecretRefApiKeyMarker(pluginApiKeyRef.source), - source: "plugins.entries.xai.config.webSearch.apiKey", - mode: "api-key", - }; - } - const legacyGrokApiKey = normalizeOptionalSecretInput( - (params.config?.tools?.web?.search as { grok?: { apiKey?: unknown } } | undefined | null)?.grok - ?.apiKey, - ); - if (legacyGrokApiKey) { - return { - apiKey: legacyGrokApiKey, - source: "tools.web.search.grok.apiKey", - mode: "api-key", - }; - } - const legacyGrokApiKeyRef = coerceSecretRef( - (params.config?.tools?.web?.search as { grok?: { apiKey?: unknown } } | undefined | null)?.grok - ?.apiKey, - ); - if (legacyGrokApiKeyRef) { - return { - apiKey: - legacyGrokApiKeyRef.source === "env" - ? legacyGrokApiKeyRef.id.trim() - : resolveNonEnvSecretRefApiKeyMarker(legacyGrokApiKeyRef.source), - source: "tools.web.search.grok.apiKey", - mode: "api-key", - }; - } - return undefined; -} diff --git a/src/config/plugin-auto-enable.providers.test.ts b/src/config/plugin-auto-enable.providers.test.ts index fe92a6e9780..f4208a9a787 100644 --- a/src/config/plugin-auto-enable.providers.test.ts +++ b/src/config/plugin-auto-enable.providers.test.ts @@ -209,6 +209,44 @@ describe("applyPluginAutoEnable providers", () => { expect(result.changes).toContain("acme web search configured, enabled automatically."); }); + it("auto-enables third-party plugins when manifest-owned tool config exists", () => { + const result = applyPluginAutoEnable({ + config: { + plugins: { + entries: { + acme: { + config: { + acmeTool: { + enabled: true, + }, + }, + }, + }, + }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { + id: "acme", + channels: [], + contracts: { + tools: ["acme_tool"], + }, + configSchema: { + type: "object", + properties: { + webSearch: { type: "object" }, + acmeTool: { type: "object" }, + }, + }, + }, + ]), + }); + + expect(result.config.plugins?.entries?.acme?.enabled).toBe(true); + expect(result.changes).toContain("acme tool configured, enabled automatically."); + }); + it("auto-enables acpx plugin when ACP is configured", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index add9addf594..d6ef4079cd6 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -7,6 +7,7 @@ import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry import { loadPluginManifestRegistry, resolveManifestContractOwnerPluginId, + type PluginManifestRecord, type PluginManifestRegistry, } from "../plugins/manifest-registry.js"; import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js"; @@ -178,40 +179,47 @@ function hasPluginOwnedWebFetchConfig(cfg: OpenClawConfig, pluginId: string): bo return isRecord(pluginConfig) && isRecord(pluginConfig.webFetch); } -function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean { - const pluginConfig = cfg.plugins?.entries?.xai?.config; - const web = cfg.tools?.web as Record | undefined; - return ( - pluginId === "xai" && - Boolean( - isRecord(web?.x_search) || - (isRecord(pluginConfig) && - (isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))), - ) - ); +function resolvePluginOwnedToolConfigKeys(plugin: PluginManifestRecord): string[] { + if ((plugin.contracts?.tools?.length ?? 0) === 0) { + return []; + } + const properties = isRecord(plugin.configSchema) ? plugin.configSchema.properties : undefined; + if (!isRecord(properties)) { + return []; + } + return Object.keys(properties).filter((key) => key !== "webSearch" && key !== "webFetch"); +} + +function hasPluginOwnedToolConfig(cfg: OpenClawConfig, plugin: PluginManifestRecord): boolean { + const pluginConfig = cfg.plugins?.entries?.[plugin.id]?.config; + if (!isRecord(pluginConfig)) { + return false; + } + return resolvePluginOwnedToolConfigKeys(plugin).some((key) => pluginConfig[key] !== undefined); } function resolveProviderPluginsWithOwnedWebSearch( registry: PluginManifestRegistry, -): ReadonlySet { - return new Set( - registry.plugins - .filter((plugin) => plugin.providers.length > 0) - .filter((plugin) => (plugin.contracts?.webSearchProviders?.length ?? 0) > 0) - .map((plugin) => plugin.id), - ); +): PluginManifestRecord[] { + return registry.plugins + .filter((plugin) => plugin.providers.length > 0) + .filter((plugin) => (plugin.contracts?.webSearchProviders?.length ?? 0) > 0); } function resolveProviderPluginsWithOwnedWebFetch( registry: PluginManifestRegistry, -): ReadonlySet { - return new Set( - registry.plugins - .filter((plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0) - .map((plugin) => plugin.id), +): PluginManifestRecord[] { + return registry.plugins.filter( + (plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0, ); } +function resolvePluginsWithOwnedToolConfig( + registry: PluginManifestRegistry, +): PluginManifestRecord[] { + return registry.plugins.filter((plugin) => (plugin.contracts?.tools?.length ?? 0) > 0); +} + function resolvePluginIdForConfiguredWebFetchProvider( providerId: string | undefined, env: NodeJS.ProcessEnv, @@ -275,6 +283,15 @@ function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean { ); } +function hasConfiguredPluginConfigEntry(cfg: OpenClawConfig): boolean { + const entries = cfg.plugins?.entries; + return ( + !!entries && + typeof entries === "object" && + Object.values(entries).some((entry) => isRecord(entry) && isRecord(entry.config)) + ); +} + function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean { const pluginEntries = cfg.plugins?.entries; if ( @@ -311,6 +328,9 @@ export function configMayNeedPluginAutoEnable( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, ): boolean { + if (hasConfiguredPluginConfigEntry(cfg)) { + return true; + } if (hasPotentialConfiguredChannels(cfg, env)) { return true; } @@ -323,12 +343,7 @@ export function configMayNeedPluginAutoEnable( if (collectModelRefs(cfg).length > 0) { return true; } - const web = cfg.tools?.web as Record | undefined; - if ( - isRecord(web?.x_search) || - hasConfiguredWebSearchPluginEntry(cfg) || - hasConfiguredWebFetchPluginEntry(cfg) - ) { + if (hasConfiguredWebSearchPluginEntry(cfg) || hasConfiguredWebFetchPluginEntry(cfg)) { return true; } return ( @@ -416,16 +431,22 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { }); } - for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(params.registry)) { + for (const plugin of resolveProviderPluginsWithOwnedWebSearch(params.registry)) { + const pluginId = plugin.id; if (hasPluginOwnedWebSearchConfig(params.config, pluginId)) { changes.push({ pluginId, kind: "plugin-web-search-configured" }); } - if (hasPluginOwnedToolConfig(params.config, pluginId)) { + } + + for (const plugin of resolvePluginsWithOwnedToolConfig(params.registry)) { + const pluginId = plugin.id; + if (hasPluginOwnedToolConfig(params.config, plugin)) { changes.push({ pluginId, kind: "plugin-tool-configured" }); } } - for (const pluginId of resolveProviderPluginsWithOwnedWebFetch(params.registry)) { + for (const plugin of resolveProviderPluginsWithOwnedWebFetch(params.registry)) { + const pluginId = plugin.id; if (hasPluginOwnedWebFetchConfig(params.config, pluginId)) { changes.push({ pluginId, kind: "plugin-web-fetch-configured" }); } diff --git a/src/config/plugin-auto-enable.test-helpers.ts b/src/config/plugin-auto-enable.test-helpers.ts index fe88f3292b3..f3e13ab1257 100644 --- a/src/config/plugin-auto-enable.test-helpers.ts +++ b/src/config/plugin-auto-enable.test-helpers.ts @@ -61,8 +61,9 @@ export function makeRegistry( channels: string[]; autoEnableWhenConfiguredProviders?: string[]; modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] }; - contracts?: { webSearchProviders?: string[]; webFetchProviders?: string[] }; + contracts?: { webSearchProviders?: string[]; webFetchProviders?: string[]; tools?: string[] }; providers?: string[]; + configSchema?: Record; channelConfigs?: Record; preferOver?: string[] }>; }>, ): PluginManifestRegistry { @@ -73,6 +74,7 @@ export function makeRegistry( autoEnableWhenConfiguredProviders: plugin.autoEnableWhenConfiguredProviders, modelSupport: plugin.modelSupport, contracts: plugin.contracts, + configSchema: plugin.configSchema, channelConfigs: plugin.channelConfigs, providers: plugin.providers ?? [], skills: [], diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 7f79beebbe0..bfac65315c8 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -88,6 +88,24 @@ describe("model-pricing-cache", () => { expect(new Set(refs).size).toBe(refs.length); }); + it("collects manifest-owned web search plugin model refs without a hardcoded plugin list", () => { + const refs = collectConfiguredModelPricingRefs({ + plugins: { + entries: { + tavily: { + config: { + webSearch: { + model: "tavily/search-preview", + }, + }, + }, + }, + }, + } as OpenClawConfig).map((ref) => modelKey(ref.provider, ref.model)); + + expect(refs).toContain("tavily/search-preview"); + }); + it("loads openrouter pricing and maps provider aliases, wrappers, and anthropic dotted ids", async () => { const config = { agents: { diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index 0bcd725abc6..22009c1114a 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -10,6 +10,7 @@ import { import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginWebSearchConfig } from "../config/plugin-web-search-config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveManifestContractPluginIds } from "../plugins/manifest-registry.js"; import { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js"; import { clearGatewayModelPricingCacheState, @@ -250,6 +251,23 @@ function addProviderModelPair(params: { params.refs.set(modelKey(normalized.provider, normalized.model), normalized); } +function addConfiguredWebSearchPluginModels(params: { + config: OpenClawConfig; + aliasIndex: ReturnType; + refs: Map; +}): void { + for (const pluginId of resolveManifestContractPluginIds({ + contract: "webSearchProviders", + config: params.config, + })) { + addResolvedModelRef({ + raw: resolvePluginWebSearchConfig(params.config, pluginId)?.model as string | undefined, + aliasIndex: params.aliasIndex, + refs: params.refs, + }); + } +} + export function collectConfiguredModelPricingRefs(config: OpenClawConfig): ModelRef[] { const refs = new Map(); const aliasIndex = buildModelAliasIndex({ @@ -289,26 +307,7 @@ export function collectConfiguredModelPricingRefs(config: OpenClawConfig): Model } } - addResolvedModelRef({ - raw: resolvePluginWebSearchConfig(config, "google")?.model as string | undefined, - aliasIndex, - refs, - }); - addResolvedModelRef({ - raw: resolvePluginWebSearchConfig(config, "xai")?.model as string | undefined, - aliasIndex, - refs, - }); - addResolvedModelRef({ - raw: resolvePluginWebSearchConfig(config, "moonshot")?.model as string | undefined, - aliasIndex, - refs, - }); - addResolvedModelRef({ - raw: resolvePluginWebSearchConfig(config, "perplexity")?.model as string | undefined, - aliasIndex, - refs, - }); + addConfiguredWebSearchPluginModels({ config, aliasIndex, refs }); for (const entry of config.tools?.media?.models ?? []) { addProviderModelPair({ provider: entry.provider, model: entry.model, refs });