重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,9 @@
export {
applyMoonshotNativeStreamingUsageCompat,
buildMoonshotProvider,
isNativeMoonshotBaseUrl,
MOONSHOT_BASE_URL,
MOONSHOT_CN_BASE_URL,
MOONSHOT_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
export { MOONSHOT_DEFAULT_MODEL_REF } from "./onboard.js";

View file

@ -0,0 +1,61 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import plugin from "./index.js";
describe("moonshot provider plugin", () => {
it("owns replay policy for OpenAI-compatible Moonshot transports", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(
provider.buildReplayPolicy?.({
provider: "moonshot",
modelApi: "openai-completions",
modelId: "kimi-k2.5",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
});
it("wires moonshot-thinking stream hooks", async () => {
const provider = await registerSingleProviderPlugin(plugin);
let capturedPayload: Record<string, unknown> | undefined;
const baseStreamFn: StreamFn = (model, _context, options) => {
const payload = { config: { thinkingConfig: { thinkingBudget: -1 } } } as Record<
string,
unknown
>;
options?.onPayload?.(payload as never, model as never);
capturedPayload = payload;
return {} as never;
};
const wrapped = provider.wrapStreamFn?.({
provider: "moonshot",
modelId: "kimi-k2.5",
thinkingLevel: "off",
streamFn: baseStreamFn,
} as never);
void wrapped?.(
{
api: "openai-completions",
provider: "moonshot",
id: "kimi-k2.5",
} as Model<"openai-completions">,
{ messages: [] } as Context,
{},
);
expect(capturedPayload).toMatchObject({
config: { thinkingConfig: { thinkingBudget: -1 } },
thinking: { type: "disabled" },
});
});
});

View file

@ -0,0 +1,66 @@
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
import { MOONSHOT_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
import { applyMoonshotNativeStreamingUsageCompat } from "./api.js";
import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
import {
applyMoonshotConfig,
applyMoonshotConfigCn,
MOONSHOT_DEFAULT_MODEL_REF,
} from "./onboard.js";
import { buildMoonshotProvider } from "./provider-catalog.js";
import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
const PROVIDER_ID = "moonshot";
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Moonshot Provider",
description: "Bundled Moonshot provider plugin",
provider: {
label: "Moonshot",
docsPath: "/providers/moonshot",
auth: [
{
methodId: "api-key",
label: "Kimi API key (.ai)",
hint: "Kimi K2.5 + Kimi",
optionKey: "moonshotApiKey",
flagName: "--moonshot-api-key",
envVar: "MOONSHOT_API_KEY",
promptMessage: "Enter Moonshot API key",
defaultModel: MOONSHOT_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyMoonshotConfig(cfg),
wizard: {
groupLabel: "Moonshot AI (Kimi K2.5)",
},
},
{
methodId: "api-key-cn",
label: "Kimi API key (.cn)",
hint: "Kimi K2.5 + Kimi",
optionKey: "moonshotApiKey",
flagName: "--moonshot-api-key",
envVar: "MOONSHOT_API_KEY",
promptMessage: "Enter Moonshot API key (.cn)",
defaultModel: MOONSHOT_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyMoonshotConfigCn(cfg),
wizard: {
groupLabel: "Moonshot AI (Kimi K2.5)",
},
},
],
catalog: {
buildProvider: buildMoonshotProvider,
allowExplicitBaseUrl: true,
},
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
applyMoonshotNativeStreamingUsageCompat(providerConfig),
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
...MOONSHOT_THINKING_STREAM_HOOKS,
},
register(api) {
api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider);
api.registerWebSearchProvider(createKimiWebSearchProvider());
},
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "../../src/media-understanding/audio.test-helpers.ts";
import { describeMoonshotVideo } from "./media-understanding-provider.js";
installPinnedHostnameTestHooks();
describe("describeMoonshotVideo", () => {
it("builds an OpenAI-compatible video request", async () => {
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
choices: [{ message: { content: "video ok" } }],
});
const result = await describeMoonshotVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "moonshot-test",
timeoutMs: 1500,
baseUrl: "https://api.moonshot.ai/v1/",
model: "kimi-k2.5",
headers: { "X-Trace": "1" },
fetchFn,
});
const { url, init } = getRequest();
expect(result.text).toBe("video ok");
expect(result.model).toBe("kimi-k2.5");
expect(url).toBe("https://api.moonshot.ai/v1/chat/completions");
expect(init?.method).toBe("POST");
expect(init?.signal).toBeInstanceOf(AbortSignal);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer moonshot-test");
expect(headers.get("content-type")).toBe("application/json");
expect(headers.get("x-trace")).toBe("1");
const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as {
model?: string;
messages?: Array<{
content?: Array<{ type?: string; text?: string; video_url?: { url?: string } }>;
}>;
};
expect(body.model).toBe("kimi-k2.5");
expect(body.messages?.[0]?.content?.[0]).toMatchObject({
type: "text",
text: "Describe the video.",
});
expect(body.messages?.[0]?.content?.[1]?.type).toBe("video_url");
expect(body.messages?.[0]?.content?.[1]?.video_url?.url).toBe(
`data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`,
);
});
it("falls back to reasoning_content when content is empty", async () => {
const { fetchFn } = createRequestCaptureJsonFetch({
choices: [{ message: { content: "", reasoning_content: "reasoned answer" } }],
});
const result = await describeMoonshotVideo({
buffer: Buffer.from("video"),
fileName: "clip.mp4",
apiKey: "moonshot-test",
timeoutMs: 1000,
fetchFn,
});
expect(result.text).toBe("reasoned answer");
expect(result.model).toBe("kimi-k2.5");
});
});

View file

@ -0,0 +1,84 @@
import {
buildOpenAiCompatibleVideoRequestBody,
coerceOpenAiCompatibleVideoText,
describeImageWithModel,
describeImagesWithModel,
resolveMediaUnderstandingString,
type MediaUnderstandingProvider,
type OpenAiCompatibleVideoPayload,
type VideoDescriptionRequest,
type VideoDescriptionResult,
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1";
const DEFAULT_MOONSHOT_VIDEO_MODEL = "kimi-k2.5";
const DEFAULT_MOONSHOT_VIDEO_PROMPT = "Describe the video.";
export async function describeMoonshotVideo(
params: VideoDescriptionRequest,
): Promise<VideoDescriptionResult> {
const fetchFn = params.fetchFn ?? fetch;
const model = resolveMediaUnderstandingString(params.model, DEFAULT_MOONSHOT_VIDEO_MODEL);
const mime = resolveMediaUnderstandingString(params.mime, "video/mp4");
const prompt = resolveMediaUnderstandingString(params.prompt, DEFAULT_MOONSHOT_VIDEO_PROMPT);
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: DEFAULT_MOONSHOT_VIDEO_BASE_URL,
headers: params.headers,
request: params.request,
defaultHeaders: {
"content-type": "application/json",
authorization: `Bearer ${params.apiKey}`,
},
provider: "moonshot",
api: "openai-completions",
capability: "video",
transport: "media-understanding",
});
const url = `${baseUrl}/chat/completions`;
const body = buildOpenAiCompatibleVideoRequestBody({
model,
prompt,
mime,
buffer: params.buffer,
});
const { response: res, release } = await postJsonRequest({
url,
headers,
body,
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
});
try {
await assertOkOrThrowHttpError(res, "Moonshot video description failed");
const payload = (await res.json()) as OpenAiCompatibleVideoPayload;
const text = coerceOpenAiCompatibleVideoText(payload);
if (!text) {
throw new Error("Moonshot video description response missing content");
}
return { text, model };
} finally {
await release();
}
}
export const moonshotMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "moonshot",
capabilities: ["image", "video"],
defaultModels: { image: "kimi-k2.5", video: DEFAULT_MOONSHOT_VIDEO_MODEL },
autoPriority: { video: 20 },
describeImage: describeImageWithModel,
describeImages: describeImagesWithModel,
describeVideo: describeMoonshotVideo,
};

View file

@ -0,0 +1,46 @@
import {
createDefaultModelPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import {
buildMoonshotProvider,
MOONSHOT_BASE_URL,
MOONSHOT_CN_BASE_URL,
MOONSHOT_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
const moonshotPresetAppliers = createDefaultModelPresetAppliers<[string]>({
primaryModelRef: MOONSHOT_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig, baseUrl: string) => {
const defaultModel = buildMoonshotProvider().models[0];
if (!defaultModel) {
return null;
}
return {
providerId: "moonshot",
api: "openai-completions",
baseUrl,
defaultModel,
defaultModelId: MOONSHOT_DEFAULT_MODEL_ID,
aliases: [{ modelRef: MOONSHOT_DEFAULT_MODEL_REF, alias: "Kimi" }],
};
},
});
export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return moonshotPresetAppliers.applyProviderConfig(cfg, MOONSHOT_BASE_URL);
}
export function applyMoonshotProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return moonshotPresetAppliers.applyProviderConfig(cfg, MOONSHOT_CN_BASE_URL);
}
export function applyMoonshotConfig(cfg: OpenClawConfig): OpenClawConfig {
return moonshotPresetAppliers.applyConfig(cfg, MOONSHOT_BASE_URL);
}
export function applyMoonshotConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return moonshotPresetAppliers.applyConfig(cfg, MOONSHOT_CN_BASE_URL);
}

View file

@ -0,0 +1,76 @@
{
"id": "moonshot",
"enabledByDefault": true,
"providers": ["moonshot"],
"providerAuthEnvVars": {
"moonshot": ["MOONSHOT_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "moonshot",
"method": "api-key",
"choiceId": "moonshot-api-key",
"choiceLabel": "Moonshot API key (.ai)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.5)",
"groupHint": "Kimi K2.5",
"optionKey": "moonshotApiKey",
"cliFlag": "--moonshot-api-key",
"cliOption": "--moonshot-api-key <key>",
"cliDescription": "Moonshot API key"
},
{
"provider": "moonshot",
"method": "api-key-cn",
"choiceId": "moonshot-api-key-cn",
"choiceLabel": "Moonshot API key (.cn)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.5)",
"groupHint": "Kimi K2.5",
"optionKey": "moonshotApiKey",
"cliFlag": "--moonshot-api-key",
"cliOption": "--moonshot-api-key <key>",
"cliDescription": "Moonshot API key"
}
],
"uiHints": {
"webSearch.apiKey": {
"label": "Kimi Search API Key",
"help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).",
"sensitive": true
},
"webSearch.baseUrl": {
"label": "Kimi Search Base URL",
"help": "Kimi base URL override."
},
"webSearch.model": {
"label": "Kimi Search Model",
"help": "Kimi model override."
}
},
"contracts": {
"mediaUnderstandingProviders": ["moonshot"],
"webSearchProviders": ["kimi"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
},
"model": {
"type": "string"
}
}
}
}
}
}

View file

@ -0,0 +1,15 @@
{
"name": "@openclaw/moonshot-provider",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw Moonshot provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
applyMoonshotNativeStreamingUsageCompat,
buildMoonshotProvider,
MOONSHOT_BASE_URL,
MOONSHOT_CN_BASE_URL,
} from "./api.js";
describe("moonshot provider catalog", () => {
it("builds the bundled Moonshot provider defaults", () => {
const provider = buildMoonshotProvider();
expect(provider.baseUrl).toBe(MOONSHOT_BASE_URL);
expect(provider.api).toBe("openai-completions");
expect(provider.models.map((model) => model.id)).toEqual([
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
"kimi-k2-turbo",
]);
});
it("opts native Moonshot baseUrls into streaming usage only inside the extension", () => {
const defaultProvider = applyMoonshotNativeStreamingUsageCompat(buildMoonshotProvider());
expect(defaultProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true);
const cnProvider = applyMoonshotNativeStreamingUsageCompat({
...buildMoonshotProvider(),
baseUrl: MOONSHOT_CN_BASE_URL,
});
expect(cnProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true);
const customProvider = applyMoonshotNativeStreamingUsageCompat({
...buildMoonshotProvider(),
baseUrl: "https://proxy.example.com/v1",
});
expect(customProvider.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
});
});

View file

@ -0,0 +1,82 @@
import {
applyProviderNativeStreamingUsageCompat,
supportsNativeStreamingUsageCompat,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
export const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1";
export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 262144;
const MOONSHOT_DEFAULT_MAX_TOKENS = 262144;
const MOONSHOT_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const MOONSHOT_MODEL_CATALOG = [
{
id: "kimi-k2.5",
name: "Kimi K2.5",
reasoning: false,
input: ["text", "image"],
cost: MOONSHOT_DEFAULT_COST,
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
},
{
id: "kimi-k2-thinking",
name: "Kimi K2 Thinking",
reasoning: true,
input: ["text"],
cost: MOONSHOT_DEFAULT_COST,
contextWindow: 262144,
maxTokens: 262144,
},
{
id: "kimi-k2-thinking-turbo",
name: "Kimi K2 Thinking Turbo",
reasoning: true,
input: ["text"],
cost: MOONSHOT_DEFAULT_COST,
contextWindow: 262144,
maxTokens: 262144,
},
{
id: "kimi-k2-turbo",
name: "Kimi K2 Turbo",
reasoning: false,
input: ["text"],
cost: MOONSHOT_DEFAULT_COST,
contextWindow: 256000,
maxTokens: 16384,
},
] as const;
export function isNativeMoonshotBaseUrl(baseUrl: string | undefined): boolean {
return supportsNativeStreamingUsageCompat({
providerId: "moonshot",
baseUrl,
});
}
export function applyMoonshotNativeStreamingUsageCompat(
provider: ModelProviderConfig,
): ModelProviderConfig {
return applyProviderNativeStreamingUsageCompat({
providerId: "moonshot",
providerConfig: provider,
});
}
export function buildMoonshotProvider(): ModelProviderConfig {
return {
baseUrl: MOONSHOT_BASE_URL,
api: "openai-completions",
models: MOONSHOT_MODEL_CATALOG.map((model) =>
Object.assign({}, model, { input: [...model.input] }),
),
};
}

View file

@ -0,0 +1,33 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
export function createMoonshotProvider(): ProviderPlugin {
return {
id: "moonshot",
label: "Moonshot",
docsPath: "/providers/moonshot",
auth: [
{
id: "api-key",
kind: "api_key",
label: "Kimi API key (.ai)",
hint: "Kimi K2.5 + Kimi",
run: noopAuth,
wizard: {
groupLabel: "Moonshot AI (Kimi K2.5)",
},
},
{
id: "api-key-cn",
kind: "api_key",
label: "Kimi API key (.cn)",
hint: "Kimi K2.5 + Kimi",
run: noopAuth,
wizard: {
groupLabel: "Moonshot AI (Kimi K2.5)",
},
},
],
};
}

View file

@ -0,0 +1,414 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderSetupContext,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-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.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;
}>;
};
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 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;
};
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 [...new Set(citations)];
}
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<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
const collectedCitations = new Set<string>();
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) {
const detail = await res.text();
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
}
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 { done: true, content: text ?? "No response", 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;
}
pushed = true;
messages.push({
role: "tool",
tool_call_id: toolCallId,
name: toolCallName,
content: toolContent,
});
}
if (!pushed) {
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
}
return { done: false };
},
);
if (next.done) {
return { content: next.content, citations: next.citations };
}
}
return {
content: "Search completed but no final answer was produced.",
citations: [...collectedCitations],
};
}
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"),
) as SearchConfigRecord | undefined;
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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? 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),
});
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,
extractKimiToolResultContent,
} as const;

View file

@ -0,0 +1,103 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import { withEnv } from "openclaw/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { __testing } from "../test-api.js";
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
describe("kimi web search provider", () => {
it("uses configured model and base url overrides with sane defaults", () => {
expect(__testing.resolveKimiModel()).toBe("kimi-k2.5");
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 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,71 @@
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";
type KimiWebSearchProviderRuntime = typeof import("./kimi-web-search-provider.runtime.js");
let kimiWebSearchProviderRuntimePromise: Promise<KimiWebSearchProviderRuntime> | undefined;
function loadKimiWebSearchProviderRuntime(): Promise<KimiWebSearchProviderRuntime> {
kimiWebSearchProviderRuntimePromise ??= import("./kimi-web-search-provider.runtime.js");
return kimiWebSearchProviderRuntimePromise;
}
const KimiSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
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);
},
}),
};
}

View file

@ -0,0 +1,2 @@
export { __testing } from "./src/kimi-web-search-provider.runtime.js";
export { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View file

@ -0,0 +1,28 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.moonshot.config.webSearch.apiKey";
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,
...createWebSearchProviderContractFields({
credentialPath,
searchCredential: { type: "scoped", scopeId: "kimi" },
configuredCredential: { pluginId: "moonshot" },
}),
createTool: () => null,
};
}

View file

@ -0,0 +1 @@
export { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";