From 5a86f6a8d29aadf255ed51255461d67ddffab375 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 28 Aug 2026 16:04:45 +0700 Subject: [PATCH] feat(search): add ollama-search and zai-search with credential fallback Register two web search providers that reuse an existing chat provider's API key instead of requiring their own connection: - ollama-search (POST ollama.com/api/web_search) reuses the `ollama` key - zai-search (POST api.z.ai MCP web_search_prime) reuses the `glm` key A new `credentialFallback` registry field drives this: when a search provider has no connection of its own, the search handler falls back to the linked chat provider's credentials. Also teach getProviderIconSrc to serve .svg logos for providers that ship vector art. --- open-sse/handlers/search/callers.js | 50 ++++++++++++++++++++ open-sse/handlers/search/normalizers.js | 44 +++++++++++++++++ open-sse/providers/registry/index.js | 4 ++ open-sse/providers/registry/ollama-search.js | 35 ++++++++++++++ open-sse/providers/registry/zai-search.js | 34 +++++++++++++ public/providers/zai-search.svg | 5 ++ src/shared/constants/providers.js | 2 +- src/shared/utils/providerIcon.js | 11 ++++- src/sse/handlers/search.js | 17 ++++++- 9 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 open-sse/providers/registry/ollama-search.js create mode 100644 open-sse/providers/registry/zai-search.js create mode 100644 public/providers/zai-search.svg diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index e454d385..f17d318d 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -374,6 +374,54 @@ function buildXquikRequest(config, params) { }; } +// ── Ollama Cloud web_search ────────────────────────────────────────────── +// POST https://ollama.com/api/web_search { query, max_results } +// Response: { results: [{ title, url, content, published_at? }] } +function buildOllamaSearchRequest(config, params) { + const body = { query: params.query, max_results: params.maxResults }; + if (params.country) body.country = params.country; + if (params.language) body.language = params.language; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}), + }, + body: JSON.stringify(body), + }, + }; +} + +// ── Z.AI Coding plan MCP web_search_prime ───────────────────────────────── +// POST https://api.z.ai/api/mcp/web_search_prime/mcp +// JSON-RPC envelope: { jsonrpc, id, method: "tools/call", +// params: { name: "web_search_prime", arguments: { search_query, count } } } +// Response: { result: { content: [{ type: "text", text: "" }] } } +function buildZaiSearchRequest(config, params) { + const body = { + jsonrpc: "2.0", + id: `9r-${Date.now()}`, + method: "tools/call", + params: { + name: "web_search_prime", + arguments: { search_query: params.query, count: params.maxResults }, + }, + }; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}), + }, + body: JSON.stringify(body), + }, + }; +} + // ── Dispatcher ────────────────────────────────────────────────────────── const BUILDERS = { @@ -388,6 +436,8 @@ const BUILDERS = { "youcom": buildYouComRequest, "searxng": buildSearxngRequest, "xquik": buildXquikRequest, + "ollama-search": buildOllamaSearchRequest, + "zai-search": buildZaiSearchRequest, }; /** diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js index 9d7233f4..a2b6a1d8 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.js @@ -240,6 +240,48 @@ function normalizeXquik(data, _query, _searchType) { }; } +function normalizeOllamaSearch(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data?.results) ? data.results : (Array.isArray(data) ? data : []); + const results = items.map((item, idx) => + makeResult("ollama-search", { + title: item.title, + url: item.url, + snippet: item.content || item.snippet || "", + full_text: item.content, + text_format: "text", + published_at: item.published_at || null, + source_type: item.source || null, + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeZaiSearch(data, _query, _searchType) { + const now = new Date().toISOString(); + // MCP envelope: { result: { content: [{ type: "text", text: "" }] } } + let payload = data; + const textContent = data?.result?.content?.[0]?.text; + if (typeof textContent === "string") { + try { payload = JSON.parse(textContent); } catch { payload = {}; } + } + const items = Array.isArray(payload?.results) ? payload.results + : Array.isArray(payload?.news) ? payload.news + : Array.isArray(payload) ? payload + : []; + const results = items.map((item, idx) => + makeResult("zai-search", { + title: item.title, + url: item.link || item.url, + snippet: item.content || "", + published_at: item.publish_date || item.published_at || null, + favicon_url: item.icon || null, + source_type: item.media || null, + }, idx, now) + ); + return { results, totalResults: results.length }; +} + const NORMALIZERS = { "serper": normalizeSerper, "brave-search": normalizeBrave, @@ -252,6 +294,8 @@ const NORMALIZERS = { "youcom": normalizeYouCom, "searxng": normalizeSearxng, "xquik": normalizeXquik, + "ollama-search": normalizeOllamaSearch, + "zai-search": normalizeZaiSearch, }; /** diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 4570aefb..1f4beec7 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -66,6 +66,7 @@ import p63 from "./nebius.js"; import p64 from "./nvidia.js"; import p65 from "./ollama-local.js"; import p66 from "./ollama.js"; +import p123 from "./ollama-search.js"; import p67 from "./openai.js"; import p68 from "./opencode-go.js"; import p69 from "./opencode.js"; @@ -97,6 +98,7 @@ import p95 from "./xai.js"; import p96 from "./xiaomi-mimo.js"; import p97 from "./xiaomi-tokenplan.js"; import p98 from "./youcom.js"; +import p124 from "./zai-search.js"; import p99 from "./alims-intl.js"; import p100 from "./codebuddy-intl.js"; // Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk). @@ -191,6 +193,7 @@ export default [ p64, p65, p66, + p123, p67, p68, p69, @@ -222,6 +225,7 @@ export default [ p96, p97, p98, + p124, p99, p100, // p102, // trae — hidden, no tool calling diff --git a/open-sse/providers/registry/ollama-search.js b/open-sse/providers/registry/ollama-search.js new file mode 100644 index 00000000..f6bceaf9 --- /dev/null +++ b/open-sse/providers/registry/ollama-search.js @@ -0,0 +1,35 @@ +export default { + id: "ollama-search", + alias: "ollama-search", + display: { + name: "Ollama Search", + icon: "cloud", + color: "#ffffff", + textIcon: "OL", + website: "https://ollama.com", + notice: { + text: "Web search via Ollama Cloud subscription. Reuses the API key from the Ollama (chat) provider.", + apiKeyUrl: "https://ollama.com/settings/keys", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + serviceKinds: ["webSearch"], + // Credential fallback: reuses the API key registered under the `ollama` + // chat provider — one key, chat + search. + credentialFallback: "ollama", + searchConfig: { + baseUrl: "https://ollama.com/api/web_search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0, + freeMonthlyQuota: 1000, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 10, + timeoutMs: 10000, + cacheTTLMs: 300000, + }, +}; diff --git a/open-sse/providers/registry/zai-search.js b/open-sse/providers/registry/zai-search.js new file mode 100644 index 00000000..b94f7651 --- /dev/null +++ b/open-sse/providers/registry/zai-search.js @@ -0,0 +1,34 @@ +export default { + id: "zai-search", + alias: "zai-search", + display: { + name: "GLM Coding Search", + icon: "travel_explore", + color: "#2563EB", + textIcon: "GS", + website: "https://z.ai", + notice: { + text: "Web search via the Z.AI Coding plan MCP endpoint. Reuses the API key from the GLM Coding provider.", + apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + serviceKinds: ["webSearch"], + // Credential fallback: reuses the GLM Coding plan API key — one key, chat + search. + credentialFallback: "glm", + searchConfig: { + baseUrl: "https://api.z.ai/api/mcp/web_search_prime/mcp", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0, + freeMonthlyQuota: 0, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 50, + timeoutMs: 10000, + cacheTTLMs: 300000, + }, +}; diff --git a/public/providers/zai-search.svg b/public/providers/zai-search.svg new file mode 100644 index 00000000..a80e4f26 --- /dev/null +++ b/public/providers/zai-search.svg @@ -0,0 +1,5 @@ + + + Z + .AI + diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index dd116d16..618e3b2f 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -5,7 +5,7 @@ import { RISK_NOTICE } from "@/shared/constants/providersDisplay"; const MEDIA_ENTRY_KEYS = [ "serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig", "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", - "searchViaChat", "searchConfig", "fetchConfig", + "searchViaChat", "searchConfig", "fetchConfig", "credentialFallback", "modelsFetcher", "mediaPriority", "hiddenKinds", ]; diff --git a/src/shared/utils/providerIcon.js b/src/shared/utils/providerIcon.js index 32167d14..5d26b57e 100644 --- a/src/shared/utils/providerIcon.js +++ b/src/shared/utils/providerIcon.js @@ -5,8 +5,12 @@ const ICON_ALIASES = { "perplexity-agent": "perplexity", "gitlab-duo": "gitlab", "vercel-ai-gateway": "vercel", + "ollama-search": "ollama", }; +// Providers that ship an .svg logo instead of .png. +const SVG_PROVIDER_IDS = new Set(["zai-search"]); + // Runtime only — first 404 remembers id for the whole session const failedIds = new Set(); @@ -25,10 +29,13 @@ export function resolveProviderIconId(providerId) { return aliased; } -/** `/providers/{id}.png` or null when previously failed. */ +/** `/providers/{id}.{png|svg}` or null when previously failed. */ export function getProviderIconSrc(providerId) { const id = resolveProviderIconId(providerId); - return id ? `/providers/${id}.png` : null; + if (!id) return null; + // svg for vector logos, png for everything else + const ext = SVG_PROVIDER_IDS.has(id) ? "svg" : "png"; + return `/providers/${id}.${ext}`; } /** Call from img onError so later mounts skip the request. */ diff --git a/src/sse/handlers/search.js b/src/sse/handlers/search.js index d8ee6b74..2e464d18 100644 --- a/src/sse/handlers/search.js +++ b/src/sse/handlers/search.js @@ -148,8 +148,23 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey, let lastError = null; let lastStatus = null; + // Credential fallback: some search providers reuse the API key of a related + // chat provider (e.g. ollama-search reuses the `ollama` chat key, zai-search + // reuses the `glm` chat key). When the search provider has no own connection, + // fall back to the linked provider's credentials. + const fallbackProviderId = resolvedProvider.credentialFallback; + while (true) { - const credentials = await getProviderCredentials(providerId, excludeConnectionIds); + let credentials = await getProviderCredentials(providerId, excludeConnectionIds); + + // Fall back to the related chat provider's credentials when this search + // provider has none of its own (one key, chat + search). + if (!credentials && fallbackProviderId) { + credentials = await getProviderCredentials(fallbackProviderId, excludeConnectionIds); + if (credentials) { + log.info("AUTH", `\x1b[32m${providerId} reusing ${fallbackProviderId} credentials\x1b[0m`); + } + } if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) {