fix(webui): allow optional Keenable search key
This commit is contained in:
@@ -90,7 +90,7 @@ _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
|||||||
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
||||||
{"name": "bocha", "label": "Bocha", "credential": "api_key"},
|
{"name": "bocha", "label": "Bocha", "credential": "api_key"},
|
||||||
{"name": "volcengine", "label": "Volcengine Search", "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 = {
|
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
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")
|
raise WebUISettingsError("base_url is required")
|
||||||
set_search_value("base_url", base_url)
|
set_search_value("base_url", base_url)
|
||||||
set_search_value("api_key", "")
|
set_search_value("api_key", "")
|
||||||
else:
|
elif credential in {"api_key", "optional_api_key"}:
|
||||||
api_key = _query_first_alias(query, "api_key", "apiKey")
|
raw_api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||||
api_key = api_key.strip() if api_key is not None else None
|
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
||||||
if not api_key and previous_provider == provider_name and search_config.api_key:
|
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
||||||
api_key = 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")
|
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", "")
|
set_search_value("base_url", "")
|
||||||
|
else:
|
||||||
|
raise WebUISettingsError("unknown web search credential type")
|
||||||
|
|
||||||
max_results = _query_first_alias(query, "max_results", "maxResults")
|
max_results = _query_first_alias(query, "max_results", "maxResults")
|
||||||
if max_results is not None:
|
if max_results is not None:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from nanobot.webui.settings_api import (
|
|||||||
update_network_safety_settings,
|
update_network_safety_settings,
|
||||||
update_provider_settings,
|
update_provider_settings,
|
||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
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
|
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(
|
def test_settings_payload_includes_effective_transcription_config(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -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 {
|
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
|
||||||
return {
|
return {
|
||||||
enabled: payload.image_generation.enabled,
|
enabled: payload.image_generation.enabled,
|
||||||
@@ -1154,11 +1164,11 @@ export function SettingsView({
|
|||||||
const apiKey = webSearchForm.apiKey?.trim() ?? "";
|
const apiKey = webSearchForm.apiKey?.trim() ?? "";
|
||||||
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
|
const baseUrl = webSearchForm.baseUrl?.trim() ?? "";
|
||||||
const hasExistingSecret =
|
const hasExistingSecret =
|
||||||
provider.credential === "api_key" &&
|
webSearchProviderAcceptsApiKey(provider) &&
|
||||||
webSearchForm.provider === settings.web_search.provider &&
|
webSearchForm.provider === settings.web_search.provider &&
|
||||||
!!settings.web_search.api_key_hint;
|
!!settings.web_search.api_key_hint;
|
||||||
|
|
||||||
if (provider.credential === "api_key" && !apiKey && !hasExistingSecret) {
|
if (webSearchProviderRequiresApiKey(provider) && !apiKey && !hasExistingSecret) {
|
||||||
setError(t("settings.byok.webSearch.apiKeyRequired"));
|
setError(t("settings.byok.webSearch.apiKeyRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1178,7 +1188,12 @@ export function SettingsView({
|
|||||||
timeout: webSearchForm.timeout,
|
timeout: webSearchForm.timeout,
|
||||||
useJinaReader: webSearchForm.useJinaReader,
|
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;
|
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
||||||
const payload = await updateWebSearchSettings(token, update);
|
const payload = await updateWebSearchSettings(token, update);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
@@ -1909,6 +1924,10 @@ function OverviewSettings({
|
|||||||
const webSearchCredentialStatus =
|
const webSearchCredentialStatus =
|
||||||
webSearchProvider?.credential === "none"
|
webSearchProvider?.credential === "none"
|
||||||
? tx("settings.byok.webSearch.noCredentialRequired", "No key required")
|
? 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"
|
: webSearchProvider?.credential === "base_url"
|
||||||
? settings.web_search.base_url
|
? settings.web_search.base_url
|
||||||
? tx("settings.values.configured", "Configured")
|
? 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.find((provider) => provider.name === form.provider) ??
|
||||||
settings.web_search.providers[0];
|
settings.web_search.providers[0];
|
||||||
const hasExistingSecret =
|
const hasExistingSecret =
|
||||||
selectedProvider?.credential === "api_key" &&
|
webSearchProviderAcceptsApiKey(selectedProvider) &&
|
||||||
form.provider === settings.web_search.provider &&
|
form.provider === settings.web_search.provider &&
|
||||||
!!settings.web_search.api_key_hint;
|
!!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 apiKey = form.apiKey?.trim() ?? "";
|
||||||
const baseUrl = form.baseUrl?.trim() ?? "";
|
const baseUrl = form.baseUrl?.trim() ?? "";
|
||||||
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
|
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
|
||||||
@@ -3234,7 +3253,7 @@ function WebSettings({
|
|||||||
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
||||||
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
|
||||||
const missingCredential =
|
const missingCredential =
|
||||||
selectedProvider?.credential === "api_key"
|
webSearchProviderRequiresApiKey(selectedProvider)
|
||||||
? !apiKey && !hasExistingSecret
|
? !apiKey && !hasExistingSecret
|
||||||
: selectedProvider?.credential === "base_url"
|
: selectedProvider?.credential === "base_url"
|
||||||
? !baseUrl
|
? !baseUrl
|
||||||
@@ -3267,7 +3286,7 @@ function WebSettings({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedProvider?.credential === "api_key" ? (
|
{webSearchProviderAcceptsApiKey(selectedProvider) ? (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={t("settings.byok.apiKey")}
|
title={t("settings.byok.apiKey")}
|
||||||
description={t("settings.byok.webSearch.apiKeyHelp")}
|
description={t("settings.byok.webSearch.apiKeyHelp")}
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ export interface SettingsPayload {
|
|||||||
providers: Array<{
|
providers: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
credential: "none" | "api_key" | "base_url";
|
credential: "none" | "api_key" | "optional_api_key" | "base_url";
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
web: {
|
web: {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ const installedAnyGen = {
|
|||||||
|
|
||||||
function renderSettingsView(
|
function renderSettingsView(
|
||||||
options: {
|
options: {
|
||||||
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models";
|
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models" | "browser";
|
||||||
initialSettings?: SettingsPayload;
|
initialSettings?: SettingsPayload;
|
||||||
showSidebar?: boolean;
|
showSidebar?: boolean;
|
||||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
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 () => {
|
it("uses native host safety copy on the native surface", async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...settingsPayload(),
|
...settingsPayload(),
|
||||||
|
|||||||
Reference in New Issue
Block a user