mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
feat(tools): add kimi web_search provider
Co-authored-by: adshine <adshine@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, CacheEntry<Record<string, unknown>>>();
|
||||
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<Record<string, unknown>> = [
|
||||
{
|
||||
role: "user",
|
||||
content: params.query,
|
||||
},
|
||||
];
|
||||
const collectedCitations = new Set<string>();
|
||||
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<Record<string, unknown>> {
|
||||
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;
|
||||
|
||||
@@ -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<typeof installMockFetch>) {
|
||||
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<Record<string, unknown>>;
|
||||
};
|
||||
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;
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -545,7 +545,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -216,6 +216,9 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user