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
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:
13
extensions/moonshot/README.md
Normal file
13
extensions/moonshot/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Moonshot OpenClaw provider
|
||||
|
||||
Official OpenClaw provider plugin for Moonshot.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
openclaw plugins install @openclaw/moonshot-provider
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
See `docs/providers/moonshot.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/providers/moonshot`.
|
||||
10
extensions/moonshot/api.ts
Normal file
10
extensions/moonshot/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Moonshot API module exposes the plugin public contract.
|
||||
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";
|
||||
166
extensions/moonshot/index.test.ts
Normal file
166
extensions/moonshot/index.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// Moonshot tests cover index plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
|
||||
type MoonshotManifest = {
|
||||
providerAuthAliases?: Record<string, string>;
|
||||
setup?: {
|
||||
providers?: Array<{
|
||||
id?: string;
|
||||
envVars?: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function readManifest(): MoonshotManifest {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as MoonshotManifest;
|
||||
}
|
||||
|
||||
describe("moonshot provider plugin", () => {
|
||||
it("mirrors Kimi web-search env credentials in manifest metadata", () => {
|
||||
const manifestEnvVars =
|
||||
readManifest().setup?.providers?.find((provider) => provider.id === "moonshot")?.envVars ??
|
||||
[];
|
||||
|
||||
expect([...manifestEnvVars].toSorted()).toStrictEqual(
|
||||
[...createKimiWebSearchProvider().envVars].toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("declares shipped Moonshot provider aliases in runtime and manifest metadata", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider.aliases).toEqual(["moonshotai", "moonshot-ai"]);
|
||||
expect(readManifest().providerAuthAliases).toEqual({
|
||||
moonshotai: "moonshot",
|
||||
"moonshot-ai": "moonshot",
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites duplicate tool-call ids with OpenAI-style ids for Moonshot replay", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
const policy = provider.buildReplayPolicy?.({
|
||||
provider: "moonshot",
|
||||
modelApi: "openai-completions",
|
||||
modelId: "kimi-k2.6",
|
||||
} as never);
|
||||
|
||||
expect(policy).toEqual({
|
||||
applyAssistantFirstOrderingFix: true,
|
||||
validateGeminiTurns: true,
|
||||
validateAnthropicTurns: true,
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
duplicateToolCallIdStyle: "openai",
|
||||
});
|
||||
expect(policy).not.toHaveProperty("dropReasoningFromHistory");
|
||||
});
|
||||
|
||||
it("preserves responses-family replay behavior", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
const policy = provider.buildReplayPolicy?.({
|
||||
provider: "moonshot",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "kimi-k2.6",
|
||||
} as never);
|
||||
|
||||
expect(policy).toEqual({
|
||||
applyAssistantFirstOrderingFix: false,
|
||||
validateGeminiTurns: false,
|
||||
validateAnthropicTurns: false,
|
||||
allowSyntheticToolResults: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("wires moonshot-thinking stream hooks", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const capturedStream = createCapturedThinkingConfigStream();
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.6",
|
||||
thinkingLevel: "off",
|
||||
streamFn: capturedStream.streamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "moonshot",
|
||||
id: "kimi-k2.6",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedStream.getCapturedPayload()).toEqual({
|
||||
config: { thinkingConfig: { thinkingBudget: -1 } },
|
||||
thinking: { type: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Kimi K2.7 Code thinking always on without sending a thinking field", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const capturedStream = createCapturedThinkingConfigStream();
|
||||
|
||||
const wrapped = provider.wrapSimpleCompletionStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
thinkingLevel: "off",
|
||||
streamFn: capturedStream.streamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "moonshot",
|
||||
id: "kimi-k2.7-code",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedStream.getCapturedPayload()).toEqual({
|
||||
config: { thinkingConfig: { thinkingBudget: -1 } },
|
||||
});
|
||||
expect(
|
||||
provider.wrapSimpleCompletionStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.6",
|
||||
streamFn: capturedStream.streamFn,
|
||||
} as never),
|
||||
).toBe(capturedStream.streamFn);
|
||||
expect(
|
||||
provider.resolveThinkingProfile?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
reasoning: true,
|
||||
} as never),
|
||||
).toEqual({
|
||||
levels: [{ id: "low", label: "on" }],
|
||||
defaultLevel: "low",
|
||||
preserveWhenCatalogReasoningFalse: true,
|
||||
});
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.6",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
83
extensions/moonshot/index.ts
Normal file
83
extensions/moonshot/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Moonshot plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { buildOpenAICompatibleReplayPolicy } 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 { KIMI_K2_7_CODE_MODEL_ID, resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
|
||||
const PROVIDER_ID = "moonshot";
|
||||
const moonshotThinkingStreamHooks = MOONSHOT_THINKING_STREAM_HOOKS;
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Moonshot Provider",
|
||||
description: "Bundled Moonshot provider plugin",
|
||||
provider: {
|
||||
label: "Moonshot",
|
||||
docsPath: "/providers/moonshot",
|
||||
aliases: ["moonshotai", "moonshot-ai"],
|
||||
auth: [
|
||||
{
|
||||
methodId: "api-key",
|
||||
label: "Kimi API key (.ai)",
|
||||
hint: "Kimi K2.6 + 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.6)",
|
||||
},
|
||||
},
|
||||
{
|
||||
methodId: "api-key-cn",
|
||||
label: "Kimi API key (.cn)",
|
||||
hint: "Kimi K2.6 + 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.6)",
|
||||
},
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildMoonshotProvider,
|
||||
buildStaticProvider: buildMoonshotProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
},
|
||||
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
|
||||
applyMoonshotNativeStreamingUsageCompat(providerConfig),
|
||||
buildReplayPolicy: ({ modelApi, modelId }) =>
|
||||
buildOpenAICompatibleReplayPolicy(modelApi, {
|
||||
modelId,
|
||||
sanitizeToolCallIds: modelApi === "openai-completions",
|
||||
duplicateToolCallIdStyle: "openai",
|
||||
dropReasoningFromHistory: false,
|
||||
}),
|
||||
...moonshotThinkingStreamHooks,
|
||||
wrapSimpleCompletionStreamFn: (ctx) =>
|
||||
ctx.modelId.trim().toLowerCase() === KIMI_K2_7_CODE_MODEL_ID
|
||||
? moonshotThinkingStreamHooks.wrapStreamFn?.(ctx)
|
||||
: ctx.streamFn,
|
||||
resolveThinkingProfile,
|
||||
isModernModelRef: ({ modelId }) => modelId.trim().toLowerCase() === KIMI_K2_7_CODE_MODEL_ID,
|
||||
},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider);
|
||||
api.registerWebSearchProvider(createKimiWebSearchProvider());
|
||||
},
|
||||
});
|
||||
164
extensions/moonshot/media-understanding-provider.test.ts
Normal file
164
extensions/moonshot/media-understanding-provider.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// Moonshot tests cover media understanding provider plugin behavior.
|
||||
import {
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
} from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeMoonshotVideo } from "./media-understanding-provider.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
const chunk = new Uint8Array(params.chunkSize);
|
||||
let readCount = 0;
|
||||
let canceled = false;
|
||||
return {
|
||||
response: new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (readCount >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
readCount += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
getReadCount: () => readCount,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
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.6",
|
||||
headers: { "X-Trace": "1" },
|
||||
fetchFn,
|
||||
});
|
||||
const { url, init } = getRequest();
|
||||
|
||||
expect(result.text).toBe("video ok");
|
||||
expect(result.model).toBe("kimi-k2.6");
|
||||
expect(url).toBe("https://api.moonshot.ai/v1/chat/completions");
|
||||
if (!init) {
|
||||
throw new Error("expected Moonshot request init");
|
||||
}
|
||||
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");
|
||||
|
||||
expect(init.body).toBeTypeOf("string");
|
||||
if (typeof init.body !== "string") {
|
||||
throw new Error("expected Moonshot JSON request body");
|
||||
}
|
||||
const body = JSON.parse(init.body) as {
|
||||
model?: string;
|
||||
messages?: Array<{
|
||||
content?: Array<{ type?: string; text?: string; video_url?: { url?: string } }>;
|
||||
}>;
|
||||
};
|
||||
expect(body.model).toBe("kimi-k2.6");
|
||||
const content = body.messages?.[0]?.content;
|
||||
if (!content) {
|
||||
throw new Error("expected Moonshot user content");
|
||||
}
|
||||
const [textContent] = content;
|
||||
if (!textContent) {
|
||||
throw new Error("expected Moonshot text content");
|
||||
}
|
||||
expect(textContent.type).toBe("text");
|
||||
expect(textContent.text).toBe("Describe the video.");
|
||||
const videoContent = content[1];
|
||||
if (!videoContent) {
|
||||
throw new Error("expected Moonshot video content");
|
||||
}
|
||||
expect(videoContent.type).toBe("video_url");
|
||||
if (!videoContent.video_url) {
|
||||
throw new Error("expected Moonshot video URL payload");
|
||||
}
|
||||
expect(videoContent.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.6");
|
||||
});
|
||||
|
||||
it("bounds successful Moonshot video JSON bodies instead of buffering the whole response", async () => {
|
||||
const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 });
|
||||
|
||||
await expect(
|
||||
describeMoonshotVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
mime: "video/mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1",
|
||||
fetchFn: async () => streamed.response,
|
||||
}),
|
||||
).rejects.toThrow("Moonshot video description failed: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports malformed Moonshot video JSON with a provider-owned error", async () => {
|
||||
const response = new Response("not-json{", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
describeMoonshotVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
mime: "video/mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1",
|
||||
fetchFn: async () => response,
|
||||
}),
|
||||
).rejects.toThrow("Moonshot video description failed: malformed JSON response");
|
||||
});
|
||||
});
|
||||
90
extensions/moonshot/media-understanding-provider.ts
Normal file
90
extensions/moonshot/media-understanding-provider.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// Moonshot provider module implements model/runtime integration.
|
||||
import {
|
||||
buildOpenAiCompatibleVideoRequestBody,
|
||||
coerceOpenAiCompatibleVideoText,
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
resolveMediaUnderstandingString,
|
||||
type MediaUnderstandingProvider,
|
||||
type OpenAiCompatibleVideoPayload,
|
||||
type VideoDescriptionRequest,
|
||||
type VideoDescriptionResult,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { MOONSHOT_DEFAULT_MODEL_ID } from "./provider-catalog.js";
|
||||
|
||||
const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const DEFAULT_MOONSHOT_VIDEO_MODEL = MOONSHOT_DEFAULT_MODEL_ID;
|
||||
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 readProviderJsonResponse<OpenAiCompatibleVideoPayload>(
|
||||
res,
|
||||
"Moonshot video description failed",
|
||||
);
|
||||
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: MOONSHOT_DEFAULT_MODEL_ID, video: DEFAULT_MOONSHOT_VIDEO_MODEL },
|
||||
autoPriority: { video: 20 },
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
describeVideo: describeMoonshotVideo,
|
||||
};
|
||||
364
extensions/moonshot/moonshot.live.test.ts
Normal file
364
extensions/moonshot/moonshot.live.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// Moonshot tests cover moonshot plugin behavior.
|
||||
import {
|
||||
streamSimple,
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
type Model,
|
||||
type Tool,
|
||||
} from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { buildMoonshotProvider, MOONSHOT_CN_BASE_URL } from "./provider-catalog.js";
|
||||
import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
|
||||
const KIMI_SEARCH_KEY =
|
||||
process.env.KIMI_API_KEY?.trim() || process.env.MOONSHOT_API_KEY?.trim() || "";
|
||||
const MOONSHOT_API_KEY = process.env.MOONSHOT_API_KEY?.trim() || "";
|
||||
const describeLive = isLiveTestEnabled() && KIMI_SEARCH_KEY.length > 0 ? describe : describe.skip;
|
||||
const describeModelLive =
|
||||
isLiveTestEnabled() && MOONSHOT_API_KEY.length > 0 ? describe : describe.skip;
|
||||
const KIMI_LIVE_SEARCH_TIMEOUT_SECONDS = 60;
|
||||
|
||||
function isTransientKimiSearchError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
if (error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes("timeout") || message.includes("aborted");
|
||||
}
|
||||
|
||||
function isMoonshotAuthDrift(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("401") &&
|
||||
(message.includes("incorrect api key") ||
|
||||
message.includes("incorrect_api_key") ||
|
||||
message.includes("invalid authentication") ||
|
||||
message.includes("invalid_authentication_error"))
|
||||
);
|
||||
}
|
||||
|
||||
describe("moonshot live auth drift detection", () => {
|
||||
it.each([
|
||||
["401 Incorrect API key provided", true],
|
||||
["401 invalid_authentication_error: Invalid Authentication", true],
|
||||
["401 Permission denied", false],
|
||||
["400 Incorrect API key provided", false],
|
||||
])("classifies %s", (message, expected) => {
|
||||
expect(isMoonshotAuthDrift(new Error(message))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describeLive("moonshot plugin live", () => {
|
||||
it("runs Kimi web search through the provider tool", async () => {
|
||||
const provider = createKimiWebSearchProvider();
|
||||
const tool = provider.createTool?.({
|
||||
config: {},
|
||||
searchConfig: {
|
||||
kimi: { apiKey: KIMI_SEARCH_KEY },
|
||||
cacheTtlMinutes: 0,
|
||||
timeoutSeconds: KIMI_LIVE_SEARCH_TIMEOUT_SECONDS,
|
||||
},
|
||||
} as never);
|
||||
|
||||
let result: { provider?: string; content?: unknown; citations?: unknown } | undefined;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
result = await tool?.execute({ query: "OpenClaw GitHub", count: 1 });
|
||||
lastError = undefined;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isMoonshotAuthDrift(error)) {
|
||||
console.warn("[moonshot:live] skip Kimi web search: auth drift");
|
||||
return;
|
||||
}
|
||||
if (!isTransientKimiSearchError(error) || attempt === 1) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw toLintErrorObject(lastError, "Non-Error thrown");
|
||||
}
|
||||
|
||||
expect(result?.provider).toBe("kimi");
|
||||
expect(typeof result?.content).toBe("string");
|
||||
expect((result!.content as string).length).toBeGreaterThan(20);
|
||||
expect(Array.isArray(result?.citations)).toBe(true);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
function resolveMoonshotModels(modelId: string): Model<"openai-completions">[] {
|
||||
const provider = buildMoonshotProvider();
|
||||
const model = provider.models.find((entry) => entry.id === modelId);
|
||||
if (!model) {
|
||||
throw new Error(`Moonshot catalog does not include ${modelId}`);
|
||||
}
|
||||
const defaultModel = {
|
||||
provider: "moonshot",
|
||||
baseUrl: provider.baseUrl,
|
||||
...model,
|
||||
api: "openai-completions",
|
||||
} as Model<"openai-completions">;
|
||||
return [defaultModel, { ...defaultModel, baseUrl: MOONSHOT_CN_BASE_URL }];
|
||||
}
|
||||
|
||||
function createNoopTool(): Tool {
|
||||
return {
|
||||
name: "noop",
|
||||
description: "Return ok.",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
};
|
||||
}
|
||||
|
||||
async function collectDoneMessage(
|
||||
stream: AsyncIterable<{ type: string; message?: AssistantMessage; error?: AssistantMessage }>,
|
||||
): Promise<AssistantMessage> {
|
||||
let doneMessage: AssistantMessage | undefined;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "error") {
|
||||
throw new Error(event.error?.errorMessage || "Moonshot live request failed");
|
||||
}
|
||||
if (event.type === "done") {
|
||||
doneMessage = event.message;
|
||||
}
|
||||
}
|
||||
if (!doneMessage) {
|
||||
throw new Error("Moonshot live stream ended without a done message");
|
||||
}
|
||||
return doneMessage;
|
||||
}
|
||||
|
||||
describeModelLive("moonshot K2.6 replay live", () => {
|
||||
it("accepts a cross-model tool-call replay after backfilling reasoning_content", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const wrappedStream = provider.wrapStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.6",
|
||||
thinkingLevel: "low",
|
||||
streamFn: streamSimple,
|
||||
} as never);
|
||||
if (!wrappedStream) {
|
||||
throw new Error("Moonshot provider did not register a stream wrapper");
|
||||
}
|
||||
|
||||
const tool = createNoopTool();
|
||||
const replayContext: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call the noop tool.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
stopReason: "toolUse",
|
||||
content: [{ type: "toolCall", id: "call_cross_model", name: "noop", arguments: {} }],
|
||||
timestamp: Date.now(),
|
||||
} as AssistantMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_cross_model",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "The tool returned ok. Reply with exactly: ok",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [tool],
|
||||
};
|
||||
|
||||
const runScenario = async (model: Model<"openai-completions">) => {
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
const response = await collectDoneMessage(
|
||||
wrappedStream(model, replayContext, {
|
||||
apiKey: MOONSHOT_API_KEY,
|
||||
maxTokens: 256,
|
||||
onPayload: (value) => {
|
||||
payload = value as Record<string, unknown>;
|
||||
},
|
||||
}) as AsyncIterable<{
|
||||
type: string;
|
||||
message?: AssistantMessage;
|
||||
error?: AssistantMessage;
|
||||
}>,
|
||||
);
|
||||
|
||||
const messages = payload?.messages as Array<Record<string, unknown>> | undefined;
|
||||
const replayedAssistant = messages?.find(
|
||||
(message) => message.role === "assistant" && Array.isArray(message.tool_calls),
|
||||
);
|
||||
expect(replayedAssistant?.reasoning_content).toBe("");
|
||||
expect(response.stopReason).not.toBe("error");
|
||||
};
|
||||
|
||||
let lastAuthError: unknown;
|
||||
for (const model of resolveMoonshotModels("kimi-k2.6")) {
|
||||
try {
|
||||
await runScenario(model);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMoonshotAuthDrift(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastAuthError = error;
|
||||
}
|
||||
}
|
||||
throw toLintErrorObject(lastAuthError, "Moonshot K2.6 rejected the API key in both regions");
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describeModelLive("moonshot K2.7 Code live", () => {
|
||||
it("omits thinking controls and completes a replayed tool turn", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const wrappedStream = provider.wrapStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
thinkingLevel: "off",
|
||||
extraParams: { thinking: { type: "disabled", keep: "all" } },
|
||||
streamFn: streamSimple,
|
||||
} as never);
|
||||
if (!wrappedStream) {
|
||||
throw new Error("Moonshot provider did not register a stream wrapper");
|
||||
}
|
||||
|
||||
const tool = createNoopTool();
|
||||
const firstUser = {
|
||||
role: "user" as const,
|
||||
content: "Call the noop tool with {}. Do not answer directly.",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const runScenario = async (model: Model<"openai-completions">) => {
|
||||
let firstPayload: Record<string, unknown> | undefined;
|
||||
const first = await collectDoneMessage(
|
||||
wrappedStream(
|
||||
model,
|
||||
{ messages: [firstUser], tools: [tool] },
|
||||
{
|
||||
apiKey: MOONSHOT_API_KEY,
|
||||
maxTokens: 16_000,
|
||||
temperature: 0,
|
||||
onPayload: (payload) => {
|
||||
firstPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
},
|
||||
) as AsyncIterable<{
|
||||
type: string;
|
||||
message?: AssistantMessage;
|
||||
error?: AssistantMessage;
|
||||
}>,
|
||||
);
|
||||
|
||||
expect(firstPayload).toBeDefined();
|
||||
expect(firstPayload).not.toHaveProperty("thinking");
|
||||
expect(firstPayload).not.toHaveProperty("reasoning_effort");
|
||||
expect(firstPayload).not.toHaveProperty("temperature");
|
||||
const reasoning = first.content.find((block) => block.type === "thinking");
|
||||
if (!reasoning || reasoning.type !== "thinking" || reasoning.thinking.length === 0) {
|
||||
throw new Error("Moonshot K2.7 Code did not return captured reasoning");
|
||||
}
|
||||
const toolCall = first.content.find((block) => block.type === "toolCall");
|
||||
if (!toolCall || toolCall.type !== "toolCall") {
|
||||
throw new Error(`Moonshot K2.7 Code did not call noop: ${first.stopReason}`);
|
||||
}
|
||||
expect(toolCall.name).toBe("noop");
|
||||
|
||||
let secondPayload: Record<string, unknown> | undefined;
|
||||
const replayContext: Context = {
|
||||
messages: [
|
||||
firstUser,
|
||||
first,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with exactly: ok",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [tool],
|
||||
};
|
||||
const second = await collectDoneMessage(
|
||||
wrappedStream(model, replayContext, {
|
||||
apiKey: MOONSHOT_API_KEY,
|
||||
maxTokens: 16_000,
|
||||
temperature: 0,
|
||||
onPayload: (payload) => {
|
||||
secondPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
}) as AsyncIterable<{
|
||||
type: string;
|
||||
message?: AssistantMessage;
|
||||
error?: AssistantMessage;
|
||||
}>,
|
||||
);
|
||||
|
||||
expect(secondPayload).toBeDefined();
|
||||
expect(secondPayload).not.toHaveProperty("thinking");
|
||||
expect(secondPayload).not.toHaveProperty("reasoning_effort");
|
||||
expect(secondPayload).not.toHaveProperty("temperature");
|
||||
const text = second.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
expect(text).toMatch(/^ok[.!]?$/i);
|
||||
};
|
||||
|
||||
let lastAuthError: unknown;
|
||||
for (const model of resolveMoonshotModels("kimi-k2.7-code")) {
|
||||
try {
|
||||
await runScenario(model);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMoonshotAuthDrift(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastAuthError = error;
|
||||
}
|
||||
}
|
||||
throw toLintErrorObject(
|
||||
lastAuthError,
|
||||
"Moonshot K2.7 Code rejected the API key in both regions",
|
||||
);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
12
extensions/moonshot/npm-shrinkwrap.json
generated
Normal file
12
extensions/moonshot/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/moonshot-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/moonshot-provider",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
extensions/moonshot/onboard.ts
Normal file
39
extensions/moonshot/onboard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Moonshot setup module handles plugin onboarding behavior.
|
||||
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 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);
|
||||
}
|
||||
232
extensions/moonshot/openclaw.plugin.json
Normal file
232
extensions/moonshot/openclaw.plugin.json
Normal file
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"id": "moonshot",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providerCatalogEntry": "./provider-discovery.ts",
|
||||
"providers": ["moonshot"],
|
||||
"providerAuthAliases": {
|
||||
"moonshotai": "moonshot",
|
||||
"moonshot-ai": "moonshot"
|
||||
},
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "moonshot-native",
|
||||
"baseUrls": ["https://api.moonshot.ai/v1", "https://api.moonshot.cn/v1"]
|
||||
}
|
||||
],
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"moonshot": {
|
||||
"family": "moonshot",
|
||||
"compatibilityFamily": "moonshot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"moonshot": {
|
||||
"openRouter": {
|
||||
"provider": "moonshotai"
|
||||
},
|
||||
"liteLLM": {
|
||||
"provider": "moonshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelCatalog": {
|
||||
"aliases": {
|
||||
"moonshotai": {
|
||||
"provider": "moonshot"
|
||||
},
|
||||
"moonshot-ai": {
|
||||
"provider": "moonshot"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"moonshot": {
|
||||
"baseUrl": "https://api.moonshot.ai/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "kimi-k2.6",
|
||||
"name": "Kimi K2.6",
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0.95,
|
||||
"output": 4,
|
||||
"cacheRead": 0.16,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.7-code",
|
||||
"name": "Kimi K2.7 Code",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0.95,
|
||||
"output": 4,
|
||||
"cacheRead": 0.19,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"name": "Kimi K2.5",
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0.6,
|
||||
"output": 3,
|
||||
"cacheRead": 0.1,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2-thinking",
|
||||
"name": "Kimi K2 Thinking",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2-thinking-turbo",
|
||||
"name": "Kimi K2 Thinking Turbo",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2-turbo",
|
||||
"name": "Kimi K2 Turbo",
|
||||
"input": ["text"],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"moonshot": "static"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "moonshot",
|
||||
"envVars": ["MOONSHOT_API_KEY", "KIMI_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "moonshot",
|
||||
"method": "api-key",
|
||||
"choiceId": "moonshot-api-key",
|
||||
"choiceLabel": "Moonshot API key (.ai)",
|
||||
"groupId": "moonshot",
|
||||
"groupLabel": "Moonshot AI (Kimi K2.6)",
|
||||
"groupHint": "Kimi K2.6",
|
||||
"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.6)",
|
||||
"groupHint": "Kimi K2.6",
|
||||
"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"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"moonshot": {
|
||||
"capabilities": ["image", "video"],
|
||||
"defaultModels": {
|
||||
"image": "kimi-k2.6",
|
||||
"video": "kimi-k2.6"
|
||||
},
|
||||
"autoPriority": {
|
||||
"video": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": ["string", "object"]
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
extensions/moonshot/package.json
Normal file
35
extensions/moonshot/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/moonshot-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Moonshot provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/moonshot-provider",
|
||||
"npmSpec": "@openclaw/moonshot-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.9"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
}
|
||||
}
|
||||
98
extensions/moonshot/provider-catalog.test.ts
Normal file
98
extensions/moonshot/provider-catalog.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Moonshot tests cover provider catalog plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMoonshotNativeStreamingUsageCompat,
|
||||
buildMoonshotProvider,
|
||||
MOONSHOT_BASE_URL,
|
||||
MOONSHOT_CN_BASE_URL,
|
||||
} from "./api.js";
|
||||
|
||||
type MoonshotProvider = ReturnType<typeof buildMoonshotProvider>;
|
||||
type MoonshotModel = MoonshotProvider["models"][number];
|
||||
|
||||
function requireMoonshotModel(provider: MoonshotProvider, modelId: string): MoonshotModel {
|
||||
const model = provider.models.find((candidate) => candidate.id === modelId);
|
||||
if (!model) {
|
||||
throw new Error(`expected Moonshot model ${modelId}`);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function requireFirstMoonshotModel(provider: MoonshotProvider): MoonshotModel {
|
||||
const model = provider.models[0];
|
||||
if (!model) {
|
||||
throw new Error("expected first Moonshot model");
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function requireMoonshotCompat(model: MoonshotModel): NonNullable<MoonshotModel["compat"]> {
|
||||
if (!model.compat) {
|
||||
throw new Error(`expected Moonshot model ${model.id} compat`);
|
||||
}
|
||||
return model.compat;
|
||||
}
|
||||
|
||||
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.6",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2-thinking",
|
||||
"kimi-k2-thinking-turbo",
|
||||
"kimi-k2-turbo",
|
||||
]);
|
||||
expect(requireMoonshotModel(provider, "kimi-k2.6").cost).toEqual({
|
||||
input: 0.95,
|
||||
output: 4,
|
||||
cacheRead: 0.16,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
expect(requireMoonshotModel(provider, "kimi-k2.7-code")).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
cost: {
|
||||
input: 0.95,
|
||||
output: 4,
|
||||
cacheRead: 0.19,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
});
|
||||
expect(requireMoonshotModel(provider, "kimi-k2.5").cost).toEqual({
|
||||
input: 0.6,
|
||||
output: 3,
|
||||
cacheRead: 0.1,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("opts native Moonshot baseUrls into streaming usage only inside the extension", () => {
|
||||
const defaultProvider = applyMoonshotNativeStreamingUsageCompat(buildMoonshotProvider());
|
||||
expect(
|
||||
requireMoonshotCompat(requireFirstMoonshotModel(defaultProvider)).supportsUsageInStreaming,
|
||||
).toBe(true);
|
||||
|
||||
const cnProvider = applyMoonshotNativeStreamingUsageCompat({
|
||||
...buildMoonshotProvider(),
|
||||
baseUrl: MOONSHOT_CN_BASE_URL,
|
||||
});
|
||||
expect(
|
||||
requireMoonshotCompat(requireFirstMoonshotModel(cnProvider)).supportsUsageInStreaming,
|
||||
).toBe(true);
|
||||
|
||||
const customProvider = applyMoonshotNativeStreamingUsageCompat({
|
||||
...buildMoonshotProvider(),
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
});
|
||||
expect(
|
||||
"supportsUsageInStreaming" in (requireFirstMoonshotModel(customProvider).compat ?? {}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
35
extensions/moonshot/provider-catalog.ts
Normal file
35
extensions/moonshot/provider-catalog.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Moonshot provider module implements model/runtime integration.
|
||||
import {
|
||||
buildManifestModelProviderConfig,
|
||||
applyProviderNativeStreamingUsageCompat,
|
||||
supportsNativeStreamingUsageCompat,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
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.6";
|
||||
|
||||
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 buildManifestModelProviderConfig({
|
||||
providerId: "moonshot",
|
||||
catalog: manifest.modelCatalog.providers.moonshot,
|
||||
});
|
||||
}
|
||||
35
extensions/moonshot/provider-contract-api.ts
Normal file
35
extensions/moonshot/provider-contract-api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Moonshot API module exposes the plugin public contract.
|
||||
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",
|
||||
aliases: ["moonshotai", "moonshot-ai"],
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "Kimi API key (.ai)",
|
||||
hint: "Kimi K2.6 + Kimi",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
groupLabel: "Moonshot AI (Kimi K2.6)",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "api-key-cn",
|
||||
kind: "api_key",
|
||||
label: "Kimi API key (.cn)",
|
||||
hint: "Kimi K2.6 + Kimi",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
groupLabel: "Moonshot AI (Kimi K2.6)",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
19
extensions/moonshot/provider-discovery.ts
Normal file
19
extensions/moonshot/provider-discovery.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Moonshot provider module implements model/runtime integration.
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildMoonshotProvider } from "./provider-catalog.js";
|
||||
|
||||
const moonshotProviderDiscovery: ProviderPlugin = {
|
||||
id: "moonshot",
|
||||
label: "Moonshot",
|
||||
docsPath: "/providers/moonshot",
|
||||
aliases: ["moonshotai", "moonshot-ai"],
|
||||
auth: [],
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({
|
||||
provider: buildMoonshotProvider(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default moonshotProviderDiscovery;
|
||||
21
extensions/moonshot/provider-policy-api.ts
Normal file
21
extensions/moonshot/provider-policy-api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Moonshot policy module exposes model-specific thinking controls before runtime registration.
|
||||
import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export const KIMI_K2_7_CODE_MODEL_ID = "kimi-k2.7-code";
|
||||
|
||||
export function resolveThinkingProfile(context: ProviderDefaultThinkingPolicyContext) {
|
||||
if (context.modelId.trim().toLowerCase() === KIMI_K2_7_CODE_MODEL_ID) {
|
||||
return {
|
||||
levels: [{ id: "low" as const, label: "on" }],
|
||||
defaultLevel: "low" as const,
|
||||
preserveWhenCatalogReasoningFalse: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
levels: [
|
||||
{ id: "off" as const, label: "off" },
|
||||
{ id: "low" as const, label: "on" },
|
||||
],
|
||||
defaultLevel: "off" as const,
|
||||
};
|
||||
}
|
||||
520
extensions/moonshot/src/kimi-web-search-provider.runtime.ts
Normal file
520
extensions/moonshot/src/kimi-web-search-provider.runtime.ts
Normal 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 };
|
||||
298
extensions/moonshot/src/kimi-web-search-provider.test.ts
Normal file
298
extensions/moonshot/src/kimi-web-search-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
69
extensions/moonshot/src/kimi-web-search-provider.ts
Normal file
69
extensions/moonshot/src/kimi-web-search-provider.ts
Normal 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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
3
extensions/moonshot/test-api.ts
Normal file
3
extensions/moonshot/test-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Moonshot API module exposes the plugin public contract.
|
||||
export { testing, testing as __testing } from "./src/kimi-web-search-provider.runtime.js";
|
||||
export { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
16
extensions/moonshot/tsconfig.json
Normal file
16
extensions/moonshot/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
29
extensions/moonshot/web-search-contract-api.ts
Normal file
29
extensions/moonshot/web-search-contract-api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Moonshot API module exposes the plugin public contract.
|
||||
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,
|
||||
};
|
||||
}
|
||||
2
extensions/moonshot/web-search-provider.ts
Normal file
2
extensions/moonshot/web-search-provider.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Moonshot provider module implements model/runtime integration.
|
||||
export { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
Reference in New Issue
Block a user