diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 5da3a86e..51cd9797 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -90,7 +90,7 @@ _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( {"name": "olostep", "label": "Olostep", "credential": "api_key"}, {"name": "bocha", "label": "Bocha", "credential": "api_key"}, {"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"}, - {"name": "keenable", "label": "Keenable", "credential": "api_key"}, + {"name": "keenable", "label": "Keenable", "credential": "optional_api_key"}, ) _WEB_SEARCH_PROVIDER_BY_NAME = { provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS @@ -1305,15 +1305,17 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]: raise WebUISettingsError("base_url is required") set_search_value("base_url", base_url) set_search_value("api_key", "") - else: - api_key = _query_first_alias(query, "api_key", "apiKey") - api_key = api_key.strip() if api_key is not None else None - if not api_key and previous_provider == provider_name and search_config.api_key: + elif credential in {"api_key", "optional_api_key"}: + raw_api_key = _query_first_alias(query, "api_key", "apiKey") + api_key = raw_api_key.strip() if raw_api_key is not None else None + if api_key is None and previous_provider == provider_name and search_config.api_key: api_key = search_config.api_key - if not api_key: + if credential == "api_key" and not api_key: raise WebUISettingsError("api_key is required") - set_search_value("api_key", api_key) + set_search_value("api_key", api_key or "") set_search_value("base_url", "") + else: + raise WebUISettingsError("unknown web search credential type") max_results = _query_first_alias(query, "max_results", "maxResults") if max_results is not None: diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 084c6c2a..f001f657 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -20,6 +20,7 @@ from nanobot.webui.settings_api import ( update_network_safety_settings, update_provider_settings, update_transcription_settings, + update_web_search_settings, ) DYNAMIC_PROVIDER_NAME = "my-company-api" @@ -402,6 +403,44 @@ def test_settings_payload_includes_exec_path_flags( assert payload["advanced"]["exec_path_append_set"] is True +def test_update_web_search_settings_accepts_keenable_without_api_key( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.tools.web.search.provider = "brave" + config.tools.web.search.api_key = "brave-key" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = update_web_search_settings({"provider": ["keenable"]}) + + saved = load_config(config_path) + assert saved.tools.web.search.provider == "keenable" + assert saved.tools.web.search.api_key == "" + option = next(item for item in payload["web_search"]["providers"] if item["name"] == "keenable") + assert option["credential"] == "optional_api_key" + + +def test_update_web_search_settings_can_clear_optional_api_key( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.tools.web.search.provider = "keenable" + config.tools.web.search.api_key = "keen-key" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + update_web_search_settings({"provider": ["keenable"], "api_key": [""]}) + + saved = load_config(config_path) + assert saved.tools.web.search.provider == "keenable" + assert saved.tools.web.search.api_key == "" + + def test_settings_payload_includes_effective_transcription_config( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 1b3c03a7..ec33c564 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -456,6 +456,16 @@ function webSearchFormFromPayload( }; } +type WebSearchProviderOption = SettingsPayload["web_search"]["providers"][number]; + +function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean { + return provider?.credential === "api_key" || provider?.credential === "optional_api_key"; +} + +function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean { + return provider?.credential === "api_key"; +} + function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate { return { enabled: payload.image_generation.enabled, @@ -1154,11 +1164,11 @@ export function SettingsView({ const apiKey = webSearchForm.apiKey?.trim() ?? ""; const baseUrl = webSearchForm.baseUrl?.trim() ?? ""; const hasExistingSecret = - provider.credential === "api_key" && + webSearchProviderAcceptsApiKey(provider) && webSearchForm.provider === settings.web_search.provider && !!settings.web_search.api_key_hint; - if (provider.credential === "api_key" && !apiKey && !hasExistingSecret) { + if (webSearchProviderRequiresApiKey(provider) && !apiKey && !hasExistingSecret) { setError(t("settings.byok.webSearch.apiKeyRequired")); return; } @@ -1178,7 +1188,12 @@ export function SettingsView({ timeout: webSearchForm.timeout, useJinaReader: webSearchForm.useJinaReader, }; - if (provider.credential === "api_key" && apiKey) update.apiKey = apiKey; + if ( + webSearchProviderAcceptsApiKey(provider) && + (apiKey || (provider.credential === "optional_api_key" && webSearchKeyEditing)) + ) { + update.apiKey = apiKey; + } if (provider.credential === "base_url") update.baseUrl = baseUrl; const payload = await updateWebSearchSettings(token, update); applyPayload(payload); @@ -1909,6 +1924,10 @@ function OverviewSettings({ const webSearchCredentialStatus = webSearchProvider?.credential === "none" ? tx("settings.byok.webSearch.noCredentialRequired", "No key required") + : webSearchProvider?.credential === "optional_api_key" + ? settings.web_search.api_key_hint + ? tx("settings.values.configured", "Configured") + : tx("settings.byok.webSearch.noCredentialRequired", "No key required") : webSearchProvider?.credential === "base_url" ? settings.web_search.base_url ? tx("settings.values.configured", "Configured") @@ -3218,10 +3237,10 @@ function WebSettings({ settings.web_search.providers.find((provider) => provider.name === form.provider) ?? settings.web_search.providers[0]; const hasExistingSecret = - selectedProvider?.credential === "api_key" && + webSearchProviderAcceptsApiKey(selectedProvider) && form.provider === settings.web_search.provider && !!settings.web_search.api_key_hint; - const showKeyInput = selectedProvider?.credential === "api_key" && (!hasExistingSecret || keyEditing); + const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing); const apiKey = form.apiKey?.trim() ?? ""; const baseUrl = form.baseUrl?.trim() ?? ""; const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader; @@ -3234,7 +3253,7 @@ function WebSettings({ effectiveJinaReader !== settings.web.fetch.use_jina_reader; const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader; const missingCredential = - selectedProvider?.credential === "api_key" + webSearchProviderRequiresApiKey(selectedProvider) ? !apiKey && !hasExistingSecret : selectedProvider?.credential === "base_url" ? !baseUrl @@ -3267,7 +3286,7 @@ function WebSettings({ ) : null} - {selectedProvider?.credential === "api_key" ? ( + {webSearchProviderAcceptsApiKey(selectedProvider) ? ( ; }; web: { diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 85a35b95..8545f521 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -159,7 +159,7 @@ const installedAnyGen = { function renderSettingsView( options: { - initialSection?: "overview" | "apps" | "automations" | "advanced" | "models"; + initialSection?: "overview" | "apps" | "automations" | "advanced" | "models" | "browser"; initialSettings?: SettingsPayload; showSidebar?: boolean; onSettingsChange?: (payload: SettingsPayload) => void; @@ -890,6 +890,60 @@ describe("SettingsView Apps catalog", () => { ); }); + it("saves optional-key web search providers without an API key", async () => { + const payload = { + ...settingsPayload(), + web_search: { + ...settingsPayload().web_search, + provider: "duckduckgo", + providers: [ + { name: "duckduckgo", label: "DuckDuckGo", credential: "none" as const }, + { name: "keenable", label: "Keenable", credential: "optional_api_key" as const }, + ], + }, + }; + const updatedPayload = { + ...payload, + web_search: { + ...payload.web_search, + provider: "keenable", + }, + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(payload); + if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 }); + if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); + if ( + url === + "/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true" + ) { + return jsonResponse(updatedPayload); + } + return { ok: false, status: 404, json: async () => ({}) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + + renderSettingsView({ initialSection: "browser" }); + + fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" })); + const saveButton = screen + .getAllByRole("button", { name: "Save" }) + .find((button) => !(button as HTMLButtonElement).disabled); + if (!saveButton) throw new Error("enabled Save button was not found"); + fireEvent.click(saveButton); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ), + ); + }); + it("uses native host safety copy on the native surface", async () => { const payload = { ...settingsPayload(),