Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,520 @@
// Moonshot provider module implements model/runtime integration.
import {
createProviderHttpError,
readProviderJsonObjectResponse,
} from "openclaw/plugin-sdk/provider-http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readPositiveIntegerParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderSetupContext,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
isRecord,
normalizeOptionalString,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
isNativeMoonshotBaseUrl,
MOONSHOT_BASE_URL,
MOONSHOT_CN_BASE_URL,
MOONSHOT_DEFAULT_MODEL_ID,
} from "../provider-catalog.js";
const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL;
const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID;
/** Models that require explicit thinking disablement for web search.
* Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded
* because they default to thinking-enabled and disabling it would defeat their
* purpose; they are also unlikely to be used for web search. */
const KIMI_THINKING_MODELS = new Set(["kimi-k2.6", "kimi-k2.5"]);
const KIMI_WEB_SEARCH_TOOL = {
type: "builtin_function",
function: { name: "$web_search" },
} as const;
type KimiConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
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 KimiSearchResult = {
content: string;
citations: string[];
grounded: boolean;
};
function throwMalformedKimiResponse(): never {
throw new Error("Kimi API error: malformed JSON response");
}
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
const kimi = searchConfig?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
return (
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
);
}
function resolveKimiModel(kimi?: KimiConfig): string {
const model = normalizeOptionalString(kimi?.model) ?? "";
return model || DEFAULT_KIMI_SEARCH_MODEL;
}
function trimTrailingSlashes(url: string): string {
return url.replace(/\/+$/, "");
}
function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string {
const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? "";
if (explicitBaseUrl) {
return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL;
}
const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl;
if (typeof moonshotBaseUrl === "string") {
const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim());
if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) {
return normalizedMoonshotBaseUrl;
}
}
return DEFAULT_KIMI_BASE_URL;
}
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 searchResults = data.search_results ?? [];
if (!Array.isArray(searchResults)) {
throwMalformedKimiResponse();
}
const citations = searchResults
.map((entry) => (isRecord(entry) && typeof entry.url === "string" ? entry.url.trim() : ""))
.filter((url): url is string => Boolean(url));
const choices = data.choices ?? [];
if (!Array.isArray(choices)) {
throwMalformedKimiResponse();
}
const firstChoice = choices[0];
const message = firstChoice && isRecord(firstChoice.message) ? firstChoice.message : undefined;
const toolCalls = message?.tool_calls ?? [];
if (!Array.isArray(toolCalls)) {
throwMalformedKimiResponse();
}
for (const toolCall of toolCalls) {
if (!isRecord(toolCall) || !isRecord(toolCall.function)) {
continue;
}
const rawArguments = toolCall.function.arguments;
if (typeof rawArguments !== "string" || !rawArguments) {
continue;
}
try {
const parsed = JSON.parse(rawArguments) as {
search_results?: Array<{ url?: string }>;
url?: string;
};
const parsedUrl = normalizeOptionalString(parsed.url);
if (parsedUrl) {
citations.push(parsedUrl);
}
for (const result of parsed.search_results ?? []) {
const resultUrl = normalizeOptionalString(result.url);
if (resultUrl) {
citations.push(resultUrl);
}
}
} catch {
// ignore malformed tool arguments
}
}
return uniqueStrings(citations);
}
function hasKimiSearchResults(data: KimiSearchResponse): boolean {
const searchResults = data.search_results ?? [];
if (!Array.isArray(searchResults)) {
throwMalformedKimiResponse();
}
return searchResults.some(
(entry) =>
isRecord(entry) &&
(Boolean(normalizeOptionalString(entry.url)) ||
Boolean(normalizeOptionalString(entry.title)) ||
Boolean(normalizeOptionalString(entry.content))),
);
}
function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined {
const rawArguments = toolCall.function?.arguments;
if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) {
return undefined;
}
return rawArguments;
}
async function runKimiSearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
}): Promise<KimiSearchResult> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
const collectedCitations = new Set<string>();
let hasGroundingEvidence = false;
for (let round = 0; round < 3; round += 1) {
const next = await withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}),
messages,
tools: [KIMI_WEB_SEARCH_TOOL],
}),
},
},
async (
res,
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
if (!res.ok) {
throw await createProviderHttpError(res, "Kimi API error");
}
const data = (await readProviderJsonObjectResponse(
res,
"Kimi API error",
)) as KimiSearchResponse;
if (!Array.isArray(data.choices)) {
throwMalformedKimiResponse();
}
if (hasKimiSearchResults(data)) {
hasGroundingEvidence = true;
}
for (const citation of extractKimiCitations(data)) {
collectedCitations.add(citation);
}
if (collectedCitations.size > 0) {
hasGroundingEvidence = true;
}
const choice = data.choices?.[0];
if (!isRecord(choice) || !isRecord(choice.message)) {
throwMalformedKimiResponse();
}
const message = choice?.message;
const text = extractKimiMessageText(message);
const toolCalls = message?.tool_calls ?? [];
if (!Array.isArray(toolCalls)) {
throwMalformedKimiResponse();
}
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
if (!text) {
throwMalformedKimiResponse();
}
return {
done: true,
content: text,
citations: [...collectedCitations],
};
}
messages.push({
role: "assistant",
content: message?.content ?? "",
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
tool_calls: toolCalls,
});
let pushed = false;
for (const toolCall of toolCalls) {
const toolCallId = toolCall.id?.trim();
const toolCallName = toolCall.function?.name?.trim();
const toolContent = extractKimiToolResultContent(toolCall);
if (!toolCallId || !toolCallName || !toolContent) {
continue;
}
if (toolCallName === KIMI_WEB_SEARCH_TOOL.function.name) {
hasGroundingEvidence = true;
}
pushed = true;
messages.push({
role: "tool",
tool_call_id: toolCallId,
name: toolCallName,
content: toolContent,
});
}
if (!pushed) {
if (!text) {
throwMalformedKimiResponse();
}
return {
done: true,
content: text,
citations: [...collectedCitations],
};
}
return { done: false };
},
);
if (next.done) {
return { content: next.content, citations: next.citations, grounded: hasGroundingEvidence };
}
}
return {
content: "Search completed but no final answer was produced.",
citations: [...collectedCitations],
grounded: hasGroundingEvidence,
};
}
export async function executeKimiWebSearchProviderTool(
ctx: { config?: OpenClawConfig; searchConfig?: SearchConfigRecord },
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const searchConfig = mergeScopedSearchConfig(
ctx.searchConfig,
"kimi",
resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"),
);
const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "kimi");
if (unsupportedResponse) {
return unsupportedResponse;
}
const kimiConfig = resolveKimiConfig(searchConfig);
const apiKey = resolveKimiApiKey(kimiConfig);
if (!apiKey) {
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. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(args, "query", { required: true });
const count =
readPositiveIntegerParam(args, "count", {
max: MAX_SEARCH_COUNT,
message: `count must be an integer from 1 to ${MAX_SEARCH_COUNT}.`,
}) ??
searchConfig?.maxResults ??
undefined;
const model = resolveKimiModel(kimiConfig);
const baseUrl = resolveKimiBaseUrl(kimiConfig, ctx.config);
const cacheKey = buildSearchCacheKey([
"kimi",
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
baseUrl,
model,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const result = await runKimiSearch({
query,
apiKey,
baseUrl,
model,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
if (!result.grounded) {
return {
error: "kimi_web_search_ungrounded",
message:
"Kimi returned a chat completion without native web-search grounding. Retry the query, switch to a structured provider such as Brave, or use web_fetch/browser for a specific URL.",
query,
provider: "kimi",
model,
docs: "https://docs.openclaw.ai/tools/kimi-search",
tookMs: Date.now() - start,
};
}
const payload = {
query,
provider: "kimi",
model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "kimi",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
}
export async function runKimiSearchProviderSetup(
ctx: WebSearchProviderSetupContext,
): Promise<WebSearchProviderSetupContext["config"]> {
const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot");
const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? "";
// Normalize trailing slashes so initialValue matches canonical option values.
const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, "");
const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? "";
// Region selection (baseUrl)
const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl);
const regionOptions: Array<{ value: string; label: string; hint?: string }> = [];
if (isCustomBaseUrl) {
regionOptions.push({
value: normalizedBaseUrl,
label: `Keep current (${normalizedBaseUrl})`,
hint: "custom endpoint",
});
}
regionOptions.push(
{
value: MOONSHOT_BASE_URL,
label: "Moonshot API key (.ai)",
hint: "api.moonshot.ai",
},
{
value: MOONSHOT_CN_BASE_URL,
label: "Moonshot API key (.cn)",
hint: "api.moonshot.cn",
},
);
const regionChoice = await ctx.prompter.select<string>({
message: "Kimi API region",
options: regionOptions,
initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL,
});
const baseUrl = regionChoice;
// Model selection
const currentModelLabel = existingModel
? `Keep current (moonshot/${existingModel})`
: `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`;
const modelChoice = await ctx.prompter.select<string>({
message: "Kimi web search model",
options: [
{
value: "__keep__",
label: currentModelLabel,
},
{
value: "__custom__",
label: "Enter model manually",
},
{
value: DEFAULT_KIMI_SEARCH_MODEL,
label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`,
},
],
initialValue: "__keep__",
});
let model: string;
if (modelChoice === "__keep__") {
model = existingModel || DEFAULT_KIMI_SEARCH_MODEL;
} else if (modelChoice === "__custom__") {
const customModel = await ctx.prompter.text({
message: "Kimi model name",
initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL,
placeholder: DEFAULT_KIMI_SEARCH_MODEL,
});
model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL;
} else {
model = modelChoice;
}
// Write baseUrl and model into plugins.entries.moonshot.config.webSearch
const next = { ...ctx.config };
setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl);
setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model);
return next;
}
export const testing = {
resolveKimiApiKey,
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
hasKimiSearchResults,
extractKimiToolResultContent,
} as const;
export { testing as __testing };

View File

@@ -0,0 +1,298 @@
// Moonshot tests cover kimi web search provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { testing } from "../test-api.js";
import { createKimiWebSearchProvider } from "./kimi-web-search-provider.js";
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
function withEnv(overrides: Record<string, string>, run: () => void): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(overrides)) {
previous.set(key, process.env[key]);
process.env[key] = value;
}
try {
run();
} finally {
for (const [key, value] of previous) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
async function executeKimiSearch(query: string): Promise<Record<string, unknown>> {
const provider = createKimiWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
return await tool.execute({ query });
}
function expectStringFieldContains(result: Record<string, unknown>, field: string, text: string) {
const value = result[field];
expect(typeof value).toBe("string");
expect(value).toContain(text);
}
describe("kimi web search provider", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("points missing-key users to fetch/browser alternatives", async () => {
await withEnvAsync({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, async () => {
const provider = createKimiWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({ query: "OpenClaw docs" });
expect(result.error).toBe("missing_kimi_api_key");
expectStringFieldContains(
result,
"message",
"use web_fetch for a specific URL or the browser tool",
);
});
});
it("uses configured model and base url overrides with sane defaults", () => {
expect(testing.resolveKimiModel()).toBe("kimi-k2.6");
expect(testing.resolveKimiModel({ model: "kimi-k2" })).toBe("kimi-k2");
expect(testing.resolveKimiBaseUrl()).toBe("https://api.moonshot.ai/v1");
expect(testing.resolveKimiBaseUrl({ baseUrl: "https://kimi.example/v1" })).toBe(
"https://kimi.example/v1",
);
});
it("inherits native Moonshot chat baseUrl when kimi baseUrl is unset", () => {
const cnConfig = {
models: { providers: { moonshot: { baseUrl: "https://api.moonshot.cn/v1" } } },
} as unknown as OpenClawConfig;
const cnConfigWithTrailingSlash = {
models: { providers: { moonshot: { baseUrl: "https://api.moonshot.cn/v1/" } } },
} as unknown as OpenClawConfig;
expect(testing.resolveKimiBaseUrl(undefined, cnConfig)).toBe("https://api.moonshot.cn/v1");
expect(testing.resolveKimiBaseUrl(undefined, cnConfigWithTrailingSlash)).toBe(
"https://api.moonshot.cn/v1",
);
});
it("does not inherit non-native Moonshot baseUrl for web search", () => {
const proxyConfig = {
models: { providers: { moonshot: { baseUrl: "https://proxy.example/v1" } } },
} as unknown as OpenClawConfig;
expect(testing.resolveKimiBaseUrl(undefined, proxyConfig)).toBe("https://api.moonshot.ai/v1");
});
it("keeps explicit kimi baseUrl over models.providers.moonshot.baseUrl", () => {
const moonshotConfig = {
models: { providers: { moonshot: { baseUrl: "https://api.moonshot.cn/v1" } } },
} as unknown as OpenClawConfig;
expect(
testing.resolveKimiBaseUrl({ baseUrl: "https://api.moonshot.ai/v1" }, moonshotConfig),
).toBe("https://api.moonshot.ai/v1");
});
it("extracts unique citations from search results and tool call arguments", () => {
expect(
testing.extractKimiCitations({
search_results: [{ url: "https://a.test" }, { url: "https://b.test" }],
choices: [
{
message: {
tool_calls: [
{
function: {
arguments: JSON.stringify({
url: "https://a.test",
search_results: [{ url: "https://c.test" }],
}),
},
},
],
},
},
],
}),
).toEqual(["https://a.test", "https://b.test", "https://c.test"]);
});
it("returns a structured failure for ungrounded chat-only responses", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
choices: [
{
finish_reason: "stop",
message: { content: "I cannot browse the internet." },
},
],
}),
);
vi.stubGlobal("fetch", fetchMock);
await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => {
const result = await executeKimiSearch("kimi ungrounded chat fallback");
expect(result.error).toBe("kimi_web_search_ungrounded");
expect(result.provider).toBe("kimi");
expectStringFieldContains(result, "message", "without native web-search grounding");
});
});
it("reports malformed Kimi API JSON with a stable provider error", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("{ nope"));
vi.stubGlobal("fetch", fetchMock);
await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => {
await expect(executeKimiSearch("kimi malformed response")).rejects.toThrow(
"Kimi API error: malformed JSON response",
);
});
});
it("rejects wrong-root Kimi success JSON with a stable provider error", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => {
await expect(executeKimiSearch("kimi wrong root response")).rejects.toThrow(
"Kimi API error: malformed JSON response",
);
});
});
it("rejects Kimi success JSON without a final message", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ choices: [] }));
vi.stubGlobal("fetch", fetchMock);
await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => {
await expect(executeKimiSearch("kimi missing final message")).rejects.toThrow(
"Kimi API error: malformed JSON response",
);
});
});
it("accepts final responses backed by Kimi web search tool replay", async () => {
const toolArguments = JSON.stringify({
query: "OpenClaw GitHub repository",
usage: { total_tokens: 1200 },
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
choices: [
{
finish_reason: "tool_calls",
message: {
content: "",
tool_calls: [
{
id: "call-1",
function: {
name: "$web_search",
arguments: toolArguments,
},
},
],
},
},
],
}),
)
.mockResolvedValueOnce(
jsonResponse({
choices: [
{
finish_reason: "stop",
message: { content: "OpenClaw is available on GitHub." },
},
],
}),
);
vi.stubGlobal("fetch", fetchMock);
await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => {
const result = await executeKimiSearch("kimi grounded tool replay");
expect(result.provider).toBe("kimi");
expectStringFieldContains(result, "content", "OpenClaw is available on GitHub.");
expect(result.citations).toEqual([]);
expect(result).not.toHaveProperty("error");
});
});
it("accepts final responses with search result citations", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
search_results: [{ title: "OpenClaw", url: "https://github.com/openclaw/openclaw" }],
choices: [
{
finish_reason: "stop",
message: { content: "OpenClaw is on GitHub." },
},
],
}),
);
vi.stubGlobal("fetch", fetchMock);
await withEnvAsync({ KIMI_API_KEY: "kimi-test-key" }, async () => {
const result = await executeKimiSearch("kimi grounded citation");
expect(result.provider).toBe("kimi");
expectStringFieldContains(result, "content", "OpenClaw is on GitHub.");
expect(result.citations).toEqual(["https://github.com/openclaw/openclaw"]);
expect(result).not.toHaveProperty("error");
});
});
it("returns original tool arguments as tool content", () => {
const rawArguments = ' {"query":"MacBook Neo","usage":{"total_tokens":123}} ';
expect(
testing.extractKimiToolResultContent({
function: {
arguments: rawArguments,
},
}),
).toBe(rawArguments);
expect(
testing.extractKimiToolResultContent({
function: {
arguments: " ",
},
}),
).toBeUndefined();
});
it("uses config apiKey when provided", () => {
expect(testing.resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key");
});
it("falls back to env apiKey", () => {
withEnv({ [kimiApiKeyEnv]: "kimi-env-key" }, () => {
expect(testing.resolveKimiApiKey({})).toBe("kimi-env-key");
});
});
});

View File

@@ -0,0 +1,69 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Moonshot provider module implements model/runtime integration.
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
type WebSearchProviderSetupContext,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
const KIMI_CREDENTIAL_PATH = "plugins.entries.moonshot.config.webSearch.apiKey";
const loadKimiWebSearchProviderRuntime = createLazyRuntimeModule(
() => import("./kimi-web-search-provider.runtime.js"),
);
const KimiSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "integer",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
country: { type: "string", description: "Not supported by Kimi." },
language: { type: "string", description: "Not supported by Kimi." },
freshness: { type: "string", description: "Not supported by Kimi." },
date_after: { type: "string", description: "Not supported by Kimi." },
date_before: { type: "string", description: "Not supported by Kimi." },
},
} satisfies Record<string, unknown>;
async function runKimiSearchProviderSetup(
ctx: WebSearchProviderSetupContext,
): Promise<WebSearchProviderSetupContext["config"]> {
const runtime = await loadKimiWebSearchProviderRuntime();
return await runtime.runKimiSearchProviderSetup(ctx);
}
export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "kimi",
label: "Kimi (Moonshot)",
hint: "Requires Moonshot / Kimi API key · Moonshot web search",
onboardingScopes: ["text-inference"],
credentialLabel: "Moonshot / Kimi API key",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 40,
credentialPath: KIMI_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: KIMI_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "kimi" },
configuredCredential: { pluginId: "moonshot" },
}),
runSetup: runKimiSearchProviderSetup,
createTool: (ctx) => ({
description:
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
parameters: KimiSearchSchema,
execute: async (args) => {
const { executeKimiWebSearchProviderTool } = await loadKimiWebSearchProviderRuntime();
return await executeKimiWebSearchProviderTool(ctx, args);
},
}),
};
}