import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues } from "./resolve.js"; import { pushInactiveSurfaceWarning, pushWarning, type ResolverContext, type SecretDefaults, } from "./runtime-shared.js"; const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret type RuntimeWebProviderSource = "configured" | "auto-detect" | "none"; export type RuntimeWebDiagnosticCode = | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" | "WEB_SEARCH_AUTODETECT_SELECTED" | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; export type RuntimeWebDiagnostic = { code: RuntimeWebDiagnosticCode; message: string; path?: string; }; export type RuntimeWebSearchMetadata = { providerConfigured?: WebSearchProvider; providerSource: RuntimeWebProviderSource; selectedProvider?: WebSearchProvider; selectedProviderKeySource?: SecretResolutionSource; perplexityTransport?: "search_api" | "chat_completions"; diagnostics: RuntimeWebDiagnostic[]; }; export type RuntimeWebFetchFirecrawlMetadata = { active: boolean; apiKeySource: SecretResolutionSource; diagnostics: RuntimeWebDiagnostic[]; }; export type RuntimeWebToolsMetadata = { search: RuntimeWebSearchMetadata; fetch: { firecrawl: RuntimeWebFetchFirecrawlMetadata; }; diagnostics: RuntimeWebDiagnostic[]; }; type FetchConfig = NonNullable["web"] extends infer Web ? Web extends { fetch?: infer Fetch } ? Fetch : undefined : undefined; type SecretResolutionResult = { value?: string; source: SecretResolutionSource; secretRefConfigured: boolean; unresolvedRefReason?: string; fallbackEnvVar?: string; fallbackUsedAfterRefFailure: boolean; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizeProvider(value: unknown): WebSearchProvider | undefined { if (typeof value !== "string") { return undefined; } const normalized = value.trim().toLowerCase(); if ( normalized === "brave" || normalized === "gemini" || normalized === "grok" || normalized === "kimi" || normalized === "perplexity" ) { return normalized; } return undefined; } function readNonEmptyEnvValue( env: NodeJS.ProcessEnv, names: string[], ): { value?: string; envVar?: string } { for (const envVar of names) { const value = normalizeSecretInput(env[envVar]); if (value) { return { value, envVar }; } } return {}; } function buildUnresolvedReason(params: { path: string; kind: "unresolved" | "non-string" | "empty"; refLabel: string; }): string { if (params.kind === "non-string") { return `${params.path} SecretRef resolved to a non-string value.`; } if (params.kind === "empty") { return `${params.path} SecretRef resolved to an empty value.`; } return `${params.path} SecretRef is unresolved (${params.refLabel}).`; } async function resolveSecretInputWithEnvFallback(params: { sourceConfig: OpenClawConfig; context: ResolverContext; defaults: SecretDefaults | undefined; value: unknown; path: string; envVars: string[]; }): Promise { const { ref } = resolveSecretInputRef({ value: params.value, defaults: params.defaults, }); if (!ref) { const configValue = normalizeSecretInput(params.value); if (configValue) { return { value: configValue, source: "config", secretRefConfigured: false, fallbackUsedAfterRefFailure: false, }; } const fallback = readNonEmptyEnvValue(params.context.env, params.envVars); if (fallback.value) { return { value: fallback.value, source: "env", fallbackEnvVar: fallback.envVar, secretRefConfigured: false, fallbackUsedAfterRefFailure: false, }; } return { source: "missing", secretRefConfigured: false, fallbackUsedAfterRefFailure: false, }; } const refLabel = `${ref.source}:${ref.provider}:${ref.id}`; let resolvedFromRef: string | undefined; let unresolvedRefReason: string | undefined; try { const resolved = await resolveSecretRefValues([ref], { config: params.sourceConfig, env: params.context.env, cache: params.context.cache, }); const resolvedValue = resolved.get(secretRefKey(ref)); if (typeof resolvedValue !== "string") { unresolvedRefReason = buildUnresolvedReason({ path: params.path, kind: "non-string", refLabel, }); } else { resolvedFromRef = normalizeSecretInput(resolvedValue); if (!resolvedFromRef) { unresolvedRefReason = buildUnresolvedReason({ path: params.path, kind: "empty", refLabel, }); } } } catch { unresolvedRefReason = buildUnresolvedReason({ path: params.path, kind: "unresolved", refLabel, }); } if (resolvedFromRef) { return { value: resolvedFromRef, source: "secretRef", secretRefConfigured: true, fallbackUsedAfterRefFailure: false, }; } const fallback = readNonEmptyEnvValue(params.context.env, params.envVars); if (fallback.value) { return { value: fallback.value, source: "env", fallbackEnvVar: fallback.envVar, unresolvedRefReason, secretRefConfigured: true, fallbackUsedAfterRefFailure: true, }; } return { source: "missing", unresolvedRefReason, secretRefConfigured: true, fallbackUsedAfterRefFailure: false, }; } function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined { if (!apiKey) { return undefined; } const normalized = apiKey.toLowerCase(); if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { return "direct"; } if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { return "openrouter"; } return undefined; } function resolvePerplexityRuntimeTransport(params: { keyValue?: string; keySource: SecretResolutionSource; fallbackEnvVar?: string; configValue: unknown; }): "search_api" | "chat_completions" | undefined { const config = isRecord(params.configValue) ? params.configValue : undefined; const configuredBaseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.trim() : ""; const configuredModel = typeof config?.model === "string" ? config.model.trim() : ""; const baseUrl = (() => { if (configuredBaseUrl) { return configuredBaseUrl; } if (params.keySource === "env") { if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") { return PERPLEXITY_DIRECT_BASE_URL; } if (params.fallbackEnvVar === "OPENROUTER_API_KEY") { return DEFAULT_PERPLEXITY_BASE_URL; } } if ((params.keySource === "config" || params.keySource === "secretRef") && params.keyValue) { const inferred = inferPerplexityBaseUrlFromApiKey(params.keyValue); return inferred === "openrouter" ? DEFAULT_PERPLEXITY_BASE_URL : PERPLEXITY_DIRECT_BASE_URL; } return DEFAULT_PERPLEXITY_BASE_URL; })(); const hasLegacyOverride = Boolean(configuredBaseUrl || configuredModel); const direct = (() => { try { return new URL(baseUrl).hostname.toLowerCase() === "api.perplexity.ai"; } catch { return false; } })(); return hasLegacyOverride || !direct ? "chat_completions" : "search_api"; } function ensureObject(target: Record, key: string): Record { const current = target[key]; if (isRecord(current)) { return current; } const next: Record = {}; target[key] = next; return next; } function setResolvedWebSearchApiKey(params: { resolvedConfig: OpenClawConfig; provider: WebSearchProvider; value: string; }): void { const tools = ensureObject(params.resolvedConfig as Record, "tools"); const web = ensureObject(tools, "web"); const search = ensureObject(web, "search"); if (params.provider === "brave") { search.apiKey = params.value; return; } const providerConfig = ensureObject(search, params.provider); providerConfig.apiKey = params.value; } function setResolvedFirecrawlApiKey(params: { resolvedConfig: OpenClawConfig; value: string; }): void { const tools = ensureObject(params.resolvedConfig as Record, "tools"); const web = ensureObject(tools, "web"); const fetch = ensureObject(web, "fetch"); const firecrawl = ensureObject(fetch, "firecrawl"); firecrawl.apiKey = params.value; } function envVarsForProvider(provider: WebSearchProvider): string[] { if (provider === "brave") { return ["BRAVE_API_KEY"]; } if (provider === "gemini") { return ["GEMINI_API_KEY"]; } if (provider === "grok") { return ["XAI_API_KEY"]; } if (provider === "kimi") { return ["KIMI_API_KEY", "MOONSHOT_API_KEY"]; } return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]; } function resolveProviderKeyValue( search: Record, provider: WebSearchProvider, ): unknown { if (provider === "brave") { return search.apiKey; } const scoped = search[provider]; if (!isRecord(scoped)) { return undefined; } return scoped.apiKey; } function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean { return Boolean( resolveSecretInputRef({ value, defaults, }).ref, ); } export async function resolveRuntimeWebTools(params: { sourceConfig: OpenClawConfig; resolvedConfig: OpenClawConfig; context: ResolverContext; }): Promise { const defaults = params.sourceConfig.secrets?.defaults; const diagnostics: RuntimeWebDiagnostic[] = []; const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; const web = isRecord(tools?.web) ? tools.web : undefined; const search = isRecord(web?.search) ? web.search : undefined; const searchMetadata: RuntimeWebSearchMetadata = { providerSource: "none", diagnostics: [], }; const searchEnabled = search?.enabled !== false; const rawProvider = typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; const configuredProvider = normalizeProvider(rawProvider); if (rawProvider && !configuredProvider) { const diagnostic: RuntimeWebDiagnostic = { code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", message: `tools.web.search.provider is "${rawProvider}". Falling back to auto-detect precedence.`, path: "tools.web.search.provider", }; diagnostics.push(diagnostic); searchMetadata.diagnostics.push(diagnostic); pushWarning(params.context, { code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", path: "tools.web.search.provider", message: diagnostic.message, }); } if (configuredProvider) { searchMetadata.providerConfigured = configuredProvider; searchMetadata.providerSource = "configured"; } if (searchEnabled && search) { const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS]; const unresolvedWithoutFallback: Array<{ provider: WebSearchProvider; path: string; reason: string; }> = []; let selectedProvider: WebSearchProvider | undefined; let selectedResolution: SecretResolutionResult | undefined; for (const provider of candidates) { const path = provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; const value = resolveProviderKeyValue(search, provider); const resolution = await resolveSecretInputWithEnvFallback({ sourceConfig: params.sourceConfig, context: params.context, defaults, value, path, envVars: envVarsForProvider(provider), }); if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) { const diagnostic: RuntimeWebDiagnostic = { code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", message: `${path} SecretRef could not be resolved; using ${resolution.fallbackEnvVar ?? "env fallback"}. ` + (resolution.unresolvedRefReason ?? "").trim(), path, }; diagnostics.push(diagnostic); searchMetadata.diagnostics.push(diagnostic); pushWarning(params.context, { code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", path, message: diagnostic.message, }); } if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) { unresolvedWithoutFallback.push({ provider, path, reason: resolution.unresolvedRefReason, }); } if (configuredProvider) { selectedProvider = provider; selectedResolution = resolution; if (resolution.value) { setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, provider, value: resolution.value, }); } break; } if (resolution.value) { selectedProvider = provider; selectedResolution = resolution; setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, provider, value: resolution.value, }); break; } } const failUnresolvedSearchNoFallback = (unresolved: { path: string; reason: string }) => { const diagnostic: RuntimeWebDiagnostic = { code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", message: unresolved.reason, path: unresolved.path, }; diagnostics.push(diagnostic); searchMetadata.diagnostics.push(diagnostic); pushWarning(params.context, { code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", path: unresolved.path, message: unresolved.reason, }); throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`); }; if (configuredProvider) { const unresolved = unresolvedWithoutFallback[0]; if (unresolved) { failUnresolvedSearchNoFallback(unresolved); } } else { if (!selectedProvider && unresolvedWithoutFallback.length > 0) { failUnresolvedSearchNoFallback(unresolvedWithoutFallback[0]); } if (selectedProvider) { const diagnostic: RuntimeWebDiagnostic = { code: "WEB_SEARCH_AUTODETECT_SELECTED", message: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`, path: "tools.web.search.provider", }; diagnostics.push(diagnostic); searchMetadata.diagnostics.push(diagnostic); } } if (selectedProvider) { searchMetadata.selectedProvider = selectedProvider; searchMetadata.selectedProviderKeySource = selectedResolution?.source; if (!configuredProvider) { searchMetadata.providerSource = "auto-detect"; } if (selectedProvider === "perplexity") { searchMetadata.perplexityTransport = resolvePerplexityRuntimeTransport({ keyValue: selectedResolution?.value, keySource: selectedResolution?.source ?? "missing", fallbackEnvVar: selectedResolution?.fallbackEnvVar, configValue: search.perplexity, }); } } } if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) { for (const provider of WEB_SEARCH_PROVIDERS) { if (provider === searchMetadata.selectedProvider) { continue; } const path = provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; const value = resolveProviderKeyValue(search, provider); if (!hasConfiguredSecretRef(value, defaults)) { continue; } pushInactiveSurfaceWarning({ context: params.context, path, details: `tools.web.search auto-detected provider is "${searchMetadata.selectedProvider}".`, }); } } else if (search && !searchEnabled) { for (const provider of WEB_SEARCH_PROVIDERS) { const path = provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; const value = resolveProviderKeyValue(search, provider); if (!hasConfiguredSecretRef(value, defaults)) { continue; } pushInactiveSurfaceWarning({ context: params.context, path, details: "tools.web.search is disabled.", }); } } if (searchEnabled && search && configuredProvider) { for (const provider of WEB_SEARCH_PROVIDERS) { if (provider === configuredProvider) { continue; } const path = provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; const value = resolveProviderKeyValue(search, provider); if (!hasConfiguredSecretRef(value, defaults)) { continue; } pushInactiveSurfaceWarning({ context: params.context, path, details: `tools.web.search.provider is "${configuredProvider}".`, }); } } const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined; const firecrawl = isRecord(fetch?.firecrawl) ? fetch.firecrawl : undefined; const fetchEnabled = fetch?.enabled !== false; const firecrawlEnabled = firecrawl?.enabled !== false; const firecrawlActive = Boolean(fetchEnabled && firecrawlEnabled); const firecrawlPath = "tools.web.fetch.firecrawl.apiKey"; let firecrawlResolution: SecretResolutionResult = { source: "missing", secretRefConfigured: false, fallbackUsedAfterRefFailure: false, }; const firecrawlDiagnostics: RuntimeWebDiagnostic[] = []; if (firecrawlActive) { firecrawlResolution = await resolveSecretInputWithEnvFallback({ sourceConfig: params.sourceConfig, context: params.context, defaults, value: firecrawl?.apiKey, path: firecrawlPath, envVars: ["FIRECRAWL_API_KEY"], }); if (firecrawlResolution.value) { setResolvedFirecrawlApiKey({ resolvedConfig: params.resolvedConfig, value: firecrawlResolution.value, }); } if (firecrawlResolution.secretRefConfigured) { if (firecrawlResolution.fallbackUsedAfterRefFailure) { const diagnostic: RuntimeWebDiagnostic = { code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", message: `${firecrawlPath} SecretRef could not be resolved; using ${firecrawlResolution.fallbackEnvVar ?? "env fallback"}. ` + (firecrawlResolution.unresolvedRefReason ?? "").trim(), path: firecrawlPath, }; diagnostics.push(diagnostic); firecrawlDiagnostics.push(diagnostic); pushWarning(params.context, { code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", path: firecrawlPath, message: diagnostic.message, }); } if (!firecrawlResolution.value && firecrawlResolution.unresolvedRefReason) { const diagnostic: RuntimeWebDiagnostic = { code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", message: firecrawlResolution.unresolvedRefReason, path: firecrawlPath, }; diagnostics.push(diagnostic); firecrawlDiagnostics.push(diagnostic); pushWarning(params.context, { code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", path: firecrawlPath, message: firecrawlResolution.unresolvedRefReason, }); throw new Error( `[WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK] ${firecrawlResolution.unresolvedRefReason}`, ); } } } else { if (hasConfiguredSecretRef(firecrawl?.apiKey, defaults)) { pushInactiveSurfaceWarning({ context: params.context, path: firecrawlPath, details: !fetchEnabled ? "tools.web.fetch is disabled." : "tools.web.fetch.firecrawl.enabled is false.", }); firecrawlResolution = { source: "secretRef", secretRefConfigured: true, fallbackUsedAfterRefFailure: false, }; } else { const configuredInlineValue = normalizeSecretInput(firecrawl?.apiKey); if (configuredInlineValue) { firecrawlResolution = { value: configuredInlineValue, source: "config", secretRefConfigured: false, fallbackUsedAfterRefFailure: false, }; } else { const envFallback = readNonEmptyEnvValue(params.context.env, ["FIRECRAWL_API_KEY"]); if (envFallback.value) { firecrawlResolution = { value: envFallback.value, source: "env", fallbackEnvVar: envFallback.envVar, secretRefConfigured: false, fallbackUsedAfterRefFailure: false, }; } } } } return { search: searchMetadata, fetch: { firecrawl: { active: firecrawlActive, apiKeySource: firecrawlResolution.source, diagnostics: firecrawlDiagnostics, }, }, diagnostics, }; }