diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e6c0ecfe4..a3a58aa0bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#18822) Thanks @adshine. - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. - Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 6dee999b42e..95e8e878bc7 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -13,6 +13,10 @@ const { resolveGrokModel, resolveGrokInlineCitations, extractGrokContent, + resolveKimiApiKey, + resolveKimiModel, + resolveKimiBaseUrl, + extractKimiCitations, } = __testing; describe("web_search perplexity baseUrl defaults", () => { @@ -242,3 +246,56 @@ describe("web_search grok response parsing", () => { expect(result.annotationCitations).toEqual(["https://example.com/direct"]); }); }); + +describe("web_search kimi config resolution", () => { + it("uses config apiKey when provided", () => { + expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); + }); + + it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => { + withEnv({ KIMI_API_KEY: "kimi-env", MOONSHOT_API_KEY: "moonshot-env" }, () => { + expect(resolveKimiApiKey({})).toBe("kimi-env"); + }); + withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: "moonshot-env" }, () => { + expect(resolveKimiApiKey({})).toBe("moonshot-env"); + }); + }); + + it("returns undefined when no Kimi key is configured", () => { + withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, () => { + expect(resolveKimiApiKey({})).toBeUndefined(); + expect(resolveKimiApiKey(undefined)).toBeUndefined(); + }); + }); + + it("resolves default model and baseUrl", () => { + expect(resolveKimiModel({})).toBe("moonshot-v1-128k"); + expect(resolveKimiBaseUrl({})).toBe("https://api.moonshot.ai/v1"); + }); +}); + +describe("extractKimiCitations", () => { + it("collects unique URLs from search_results and tool arguments", () => { + expect( + extractKimiCitations({ + search_results: [{ url: "https://example.com/a" }, { url: "https://example.com/a" }], + choices: [ + { + message: { + tool_calls: [ + { + function: { + arguments: JSON.stringify({ + search_results: [{ url: "https://example.com/b" }], + url: "https://example.com/c", + }), + }, + }, + ], + }, + }, + ], + }).toSorted(), + ).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 83b0cece160..54845f8a042 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -19,7 +19,7 @@ import { writeCache, } from "./web-shared.js"; -const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "gemini"] as const; +const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "gemini", "kimi"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -32,6 +32,12 @@ const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; const DEFAULT_GROK_MODEL = "grok-4-1-fast"; +const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1"; +const DEFAULT_KIMI_MODEL = "moonshot-v1-128k"; +const KIMI_WEB_SEARCH_TOOL = { + type: "builtin_function", + function: { name: "$web_search" }, +} as const; const SEARCH_CACHE = new Map>>(); const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); @@ -103,6 +109,12 @@ type GrokConfig = { inlineCitations?: boolean; }; +type KimiConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + type GrokSearchResponse = { output?: Array<{ type?: string; @@ -134,6 +146,34 @@ type GrokSearchResponse = { }>; }; +type KimiToolCall = { + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; +}; + +type KimiMessage = { + role?: string; + content?: string; + reasoning_content?: string; + tool_calls?: KimiToolCall[]; +}; + +type KimiSearchResponse = { + choices?: Array<{ + finish_reason?: string; + message?: KimiMessage; + }>; + search_results?: Array<{ + title?: string; + url?: string; + content?: string; + }>; +}; + type PerplexitySearchResponse = { choices?: Array<{ message?: { @@ -271,6 +311,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { docs: "https://docs.openclaw.ai/tools/web", }; } + if (provider === "kimi") { + return { + error: "missing_kimi_api_key", + message: + "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } return { error: "missing_brave_api_key", message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, @@ -292,6 +340,9 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE if (raw === "gemini") { return "gemini"; } + if (raw === "kimi") { + return "kimi"; + } if (raw === "brave") { return "brave"; } @@ -313,7 +364,15 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "gemini"; } - // 3. Perplexity + // 3. Kimi + const kimiConfig = resolveKimiConfig(search); + if (resolveKimiApiKey(kimiConfig)) { + defaultRuntime.log( + 'web_search: no provider configured, auto-detected "kimi" from available API keys', + ); + return "kimi"; + } + // 4. Perplexity const perplexityConfig = resolvePerplexityConfig(search); const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); if (perplexityKey) { @@ -322,7 +381,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "perplexity"; } - // 4. Grok + // 5. Grok const grokConfig = resolveGrokConfig(search); if (resolveGrokApiKey(grokConfig)) { defaultRuntime.log( @@ -473,6 +532,42 @@ function resolveGrokInlineCitations(grok?: GrokConfig): boolean { return grok?.inlineCitations === true; } +function resolveKimiConfig(search?: WebSearchConfig): KimiConfig { + if (!search || typeof search !== "object") { + return {}; + } + const kimi = "kimi" in search ? search.kimi : undefined; + if (!kimi || typeof kimi !== "object") { + return {}; + } + return kimi as KimiConfig; +} + +function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { + const fromConfig = normalizeApiKey(kimi?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnvKimi = normalizeApiKey(process.env.KIMI_API_KEY); + if (fromEnvKimi) { + return fromEnvKimi; + } + const fromEnvMoonshot = normalizeApiKey(process.env.MOONSHOT_API_KEY); + return fromEnvMoonshot || undefined; +} + +function resolveKimiModel(kimi?: KimiConfig): string { + const fromConfig = + kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : ""; + return fromConfig || DEFAULT_KIMI_MODEL; +} + +function resolveKimiBaseUrl(kimi?: KimiConfig): string { + const fromConfig = + kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : ""; + return fromConfig || DEFAULT_KIMI_BASE_URL; +} + function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig { if (!search || typeof search !== "object") { return {}; @@ -783,6 +878,143 @@ async function runGrokSearch(params: { return { content, citations, inlineCitations }; } +function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { + const content = message?.content?.trim(); + if (content) { + return content; + } + const reasoning = message?.reasoning_content?.trim(); + return reasoning || undefined; +} + +function extractKimiCitations(data: KimiSearchResponse): string[] { + const citations = (data.search_results ?? []) + .map((entry) => entry.url?.trim()) + .filter((url): url is string => Boolean(url)); + + for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { + const rawArguments = toolCall.function?.arguments; + if (!rawArguments) { + continue; + } + try { + const parsed = JSON.parse(rawArguments) as { + search_results?: Array<{ url?: string }>; + url?: string; + }; + if (typeof parsed.url === "string" && parsed.url.trim()) { + citations.push(parsed.url.trim()); + } + for (const result of parsed.search_results ?? []) { + if (typeof result.url === "string" && result.url.trim()) { + citations.push(result.url.trim()); + } + } + } catch { + // ignore malformed tool arguments + } + } + + return [...new Set(citations)]; +} + +function buildKimiToolResultContent(data: KimiSearchResponse): string { + return JSON.stringify({ + search_results: (data.search_results ?? []).map((entry) => ({ + title: entry.title ?? "", + url: entry.url ?? "", + content: entry.content ?? "", + })), + }); +} + +async function runKimiSearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: string[] }> { + const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); + const endpoint = `${baseUrl}/chat/completions`; + const messages: Array> = [ + { + role: "user", + content: params.query, + }, + ]; + const collectedCitations = new Set(); + const MAX_ROUNDS = 3; + + for (let round = 0; round < MAX_ROUNDS; round += 1) { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + model: params.model, + messages, + tools: [KIMI_WEB_SEARCH_TOOL], + }), + signal: withTimeout(undefined, params.timeoutSeconds * 1000), + }); + + if (!res.ok) { + return throwWebSearchApiError(res, "Kimi"); + } + + const data = (await res.json()) as KimiSearchResponse; + for (const citation of extractKimiCitations(data)) { + collectedCitations.add(citation); + } + const choice = data.choices?.[0]; + const message = choice?.message; + const text = extractKimiMessageText(message); + const toolCalls = message?.tool_calls ?? []; + + if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { + return { content: text ?? "No response", citations: [...collectedCitations] }; + } + + messages.push({ + role: "assistant", + content: message?.content ?? "", + ...(message?.reasoning_content + ? { + reasoning_content: message.reasoning_content, + } + : {}), + tool_calls: toolCalls, + }); + + const toolContent = buildKimiToolResultContent(data); + let pushedToolResult = false; + for (const toolCall of toolCalls) { + const toolCallId = toolCall.id?.trim(); + if (!toolCallId) { + continue; + } + pushedToolResult = true; + messages.push({ + role: "tool", + tool_call_id: toolCallId, + content: toolContent, + }); + } + + if (!pushedToolResult) { + return { content: text ?? "No response", citations: [...collectedCitations] }; + } + } + + return { + content: "Search completed but no final answer was produced.", + citations: [...collectedCitations], + }; +} + async function runWebSearch(params: { query: string; count: number; @@ -799,15 +1031,19 @@ async function runWebSearch(params: { grokModel?: string; grokInlineCitations?: boolean; geminiModel?: string; + kimiBaseUrl?: string; + kimiModel?: string; }): Promise> { const cacheKey = normalizeCacheKey( params.provider === "brave" ? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}` : params.provider === "perplexity" ? `${params.provider}:${params.query}:${params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}:${params.freshness || "default"}` - : params.provider === "gemini" - ? `${params.provider}:${params.query}:${params.geminiModel ?? DEFAULT_GEMINI_MODEL}` - : `${params.provider}:${params.query}:${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`, + : params.provider === "kimi" + ? `${params.provider}:${params.query}:${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` + : params.provider === "gemini" + ? `${params.provider}:${params.query}:${params.geminiModel ?? DEFAULT_GEMINI_MODEL}` + : `${params.provider}:${params.query}:${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`, ); const cached = readCache(SEARCH_CACHE, cacheKey); if (cached) { @@ -872,6 +1108,33 @@ async function runWebSearch(params: { return payload; } + if (params.provider === "kimi") { + const { content, citations } = await runKimiSearch({ + query: params.query, + apiKey: params.apiKey, + baseUrl: params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL, + model: params.kimiModel ?? DEFAULT_KIMI_MODEL, + timeoutSeconds: params.timeoutSeconds, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.kimiModel ?? DEFAULT_KIMI_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content), + citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + if (params.provider === "gemini") { const geminiResult = await runGeminiSearch({ query: params.query, @@ -979,15 +1242,18 @@ export function createWebSearchTool(options?: { const perplexityConfig = resolvePerplexityConfig(search); const grokConfig = resolveGrokConfig(search); const geminiConfig = resolveGeminiConfig(search); + const kimiConfig = resolveKimiConfig(search); const description = provider === "perplexity" ? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search." : provider === "grok" ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." - : provider === "gemini" - ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + : provider === "kimi" + ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." + : provider === "gemini" + ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; return { label: "Web Search", @@ -1002,9 +1268,11 @@ export function createWebSearchTool(options?: { ? perplexityAuth?.apiKey : provider === "grok" ? resolveGrokApiKey(grokConfig) - : provider === "gemini" - ? resolveGeminiApiKey(geminiConfig) - : resolveSearchApiKey(search); + : provider === "kimi" + ? resolveKimiApiKey(kimiConfig) + : provider === "gemini" + ? resolveGeminiApiKey(geminiConfig) + : resolveSearchApiKey(search); if (!apiKey) { return jsonResult(missingSearchKeyPayload(provider)); @@ -1053,6 +1321,8 @@ export function createWebSearchTool(options?: { grokModel: resolveGrokModel(grokConfig), grokInlineCitations: resolveGrokInlineCitations(grokConfig), geminiModel: resolveGeminiModel(geminiConfig), + kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), + kimiModel: resolveKimiModel(kimiConfig), }); return jsonResult(result); }, @@ -1071,4 +1341,8 @@ export const __testing = { resolveGrokModel, resolveGrokInlineCitations, extractGrokContent, + resolveKimiApiKey, + resolveKimiModel, + resolveKimiBaseUrl, + extractKimiCitations, } as const; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index ff28dbf1103..71858670f7b 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -29,6 +29,22 @@ function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string; baseUr }); } +function createKimiSearchTool(kimiConfig?: { apiKey?: string; baseUrl?: string; model?: string }) { + return createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "kimi", + ...(kimiConfig ? { kimi: kimiConfig } : {}), + }, + }, + }, + }, + sandboxed: true, + }); +} + function parseFirstRequestBody(mockFetch: ReturnType) { const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined; const requestBody = request?.body; @@ -206,6 +222,99 @@ describe("web_search perplexity baseUrl defaults", () => { }); }); +describe("web_search kimi provider", () => { + const priorFetch = global.fetch; + + afterEach(() => { + vi.unstubAllEnvs(); + global.fetch = priorFetch; + }); + + it("returns a setup hint when Kimi key is missing", async () => { + vi.stubEnv("KIMI_API_KEY", ""); + vi.stubEnv("MOONSHOT_API_KEY", ""); + const tool = createKimiSearchTool(); + const result = await tool?.execute?.("call-1", { query: "test" }); + expect(result?.details).toMatchObject({ error: "missing_kimi_api_key" }); + }); + + it("runs the Kimi web_search tool flow and echoes tool results", async () => { + const mockFetch = vi.fn(async (_input: RequestInfo | URL) => { + const idx = mockFetch.mock.calls.length; + if (idx === 1) { + return new Response( + JSON.stringify({ + choices: [ + { + finish_reason: "tool_calls", + message: { + role: "assistant", + content: "", + reasoning_content: "searching", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "$web_search", + arguments: JSON.stringify({ q: "openclaw" }), + }, + }, + ], + }, + }, + ], + search_results: [ + { title: "OpenClaw", url: "https://openclaw.ai/docs", content: "docs" }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + choices: [ + { finish_reason: "stop", message: { role: "assistant", content: "final answer" } }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + global.fetch = withFetchPreconnect(mockFetch); + + const tool = createKimiSearchTool({ + apiKey: "kimi-config-key", + baseUrl: "https://api.moonshot.ai/v1", + model: "moonshot-v1-128k", + }); + const result = await tool?.execute?.("call-1", { query: "latest openclaw release" }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const secondRequest = mockFetch.mock.calls[1]?.[1]; + const secondBody = JSON.parse( + typeof secondRequest?.body === "string" ? secondRequest.body : "{}", + ) as { + messages?: Array>; + }; + const toolMessage = secondBody.messages?.find((message) => message.role === "tool") as + | { content?: string; tool_call_id?: string } + | undefined; + expect(toolMessage?.tool_call_id).toBe("call_1"); + expect(JSON.parse(toolMessage?.content ?? "{}")).toMatchObject({ + search_results: [{ url: "https://openclaw.ai/docs" }], + }); + + const details = result?.details as { + citations?: string[]; + content?: string; + provider?: string; + }; + expect(details.provider).toBe("kimi"); + expect(details.citations).toEqual(["https://openclaw.ai/docs"]); + expect(details.content).toContain("final answer"); + }); +}); + describe("web_search external content wrapping", () => { const priorFetch = global.fetch; diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index e2ad2046dd3..59a698d6dff 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -70,6 +70,25 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + + it("accepts kimi provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + provider: "kimi", + kimi: { + apiKey: "test-key", + baseUrl: "https://api.moonshot.ai/v1", + model: "moonshot-v1-128k", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); }); describe("talk.voiceAliases", () => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3c0ea7d85e3..4d3a6bb160b 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -545,7 +545,7 @@ export const FIELD_HELP: Record = { "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": - 'Search provider ("brave", "perplexity", "grok", or "gemini"). Auto-detected from available API keys if omitted.', + 'Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", @@ -554,7 +554,12 @@ export const FIELD_HELP: Record = { "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', "tools.web.search.grok.apiKey": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", - "tools.web.search.grok.model": 'Grok model override (default: "grok-3").', + "tools.web.search.grok.model": 'Grok model override (default: "grok-4-1-fast").', + "tools.web.search.kimi.apiKey": + "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).", + "tools.web.search.kimi.baseUrl": + 'Kimi base URL override (default: "https://api.moonshot.ai/v1").', + "tools.web.search.kimi.model": 'Kimi model override (default: "moonshot-v1-128k").', "tools.web.search.perplexity.apiKey": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", "tools.web.search.perplexity.baseUrl": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 1891def7732..cb57dff167c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -216,6 +216,9 @@ export const FIELD_LABELS: Record = { "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", "tools.web.search.grok.model": "Grok Search Model", + "tools.web.search.kimi.apiKey": "Kimi Search API Key", + "tools.web.search.kimi.baseUrl": "Kimi Search Base URL", + "tools.web.search.kimi.model": "Kimi Search Model", "tools.web.fetch.enabled": "Enable Web Fetch Tool", "tools.web.fetch.maxChars": "Web Fetch Max Chars", "tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 6366d6581f1..492282f2397 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -430,8 +430,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "perplexity", "grok", or "gemini"). */ - provider?: "brave" | "perplexity" | "grok" | "gemini"; + /** Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). */ + provider?: "brave" | "perplexity" | "grok" | "gemini" | "kimi"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: string; /** Default search results count (1-10). */ @@ -465,6 +465,15 @@ export type ToolsConfig = { /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ model?: string; }; + /** Kimi-specific configuration (used when provider="kimi"). */ + kimi?: { + /** Moonshot/Kimi API key (defaults to KIMI_API_KEY or MOONSHOT_API_KEY env var). */ + apiKey?: string; + /** Base URL for API requests (defaults to "https://api.moonshot.ai/v1"). */ + baseUrl?: string; + /** Model to use (defaults to "moonshot-v1-128k"). */ + model?: string; + }; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index e88c22614f0..0756a271686 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -240,7 +240,13 @@ export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), provider: z - .union([z.literal("brave"), z.literal("perplexity"), z.literal("grok"), z.literal("gemini")]) + .union([ + z.literal("brave"), + z.literal("perplexity"), + z.literal("grok"), + z.literal("gemini"), + z.literal("kimi"), + ]) .optional(), apiKey: z.string().optional().register(sensitive), maxResults: z.number().int().positive().optional(), @@ -269,6 +275,14 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + kimi: z + .object({ + apiKey: z.string().optional().register(sensitive), + baseUrl: z.string().optional(), + model: z.string().optional(), + }) + .strict() + .optional(), }) .strict() .optional();