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) {