diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md index 3e02e75d5ac2..504c98c00cb1 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -267,10 +267,11 @@ onboarding. - Kimi K3 always uses deep thinking at `max`. OpenClaw ignores stale K2 - thinking toggles for `kimi/k3` and `kimi/k3[1m]` and sends the K3 - adaptive/max request contract. The 1M model requires an Allegretto or - higher Kimi membership; use `kimi/k3` on Moderato. + Kimi Code K3 defaults to deep thinking at `max`. `/think off` sends + `thinking.type: "disabled"`; `/think max` sends K3's adaptive-thinking + request with max effort. Stale lower thinking levels resolve to the + supported `max` level. The 1M model requires an Allegretto or higher Kimi + membership; use `kimi/k3` on Moderato. See the official [Kimi Code model table](https://www.kimi.com/code/docs/en/kimi-code/models.html) for current plan availability. @@ -349,12 +350,18 @@ Config lives under `plugins.entries.moonshot.config.webSearch`: - Kimi K3 always reasons at maximum effort. OpenClaw exposes only `/think max`, - sends `reasoning_effort: "max"`, and ignores stale lower or `off` settings. - K3 supports `auto`, `none`, `required`, and pinned tool choices, so OpenClaw - preserves the requested `tool_choice`. For multi-turn tool use, OpenClaw - preserves the assistant reasoning content required by Moonshot's replay - contract. + Moonshot API Kimi K3 always reasons at maximum effort. OpenClaw exposes only + `/think max`, sends `reasoning_effort: "max"`, and ignores stale lower or + `off` settings. + + Kimi Code K3 exposes `/think off|max`. Its Anthropic-compatible endpoint + receives `thinking.type: "disabled"` for off, or adaptive thinking with + `output_config.effort: "max"` for max. This applies to both `kimi/k3` and + `kimi/k3[1m]`. + Moonshot API K3 supports `auto`, `none`, `required`, and pinned tool choices, + so OpenClaw preserves the requested `tool_choice`. For multi-turn tool use, + OpenClaw preserves the assistant reasoning content required by Moonshot's + replay contract. Kimi K2.7 Code always uses native thinking. Moonshot requires clients to omit the `thinking` field for this model, so OpenClaw exposes only `on` and diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 630c9eda104a..b614784fc169 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -38,7 +38,7 @@ title: "Thinking levels" - Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family. - MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path. - Z.AI (`zai/*`) is binary (`on`/`off`) for most GLM models. GLM-5.2 is the exception: it exposes `/think off|low|high|max`, maps `low` and `high` to Z.AI `reasoning_effort: "high"`, and maps `max` to `reasoning_effort: "max"`. - - Kimi K3 always thinks at `max`. Moonshot API (`moonshot/kimi-k3`) sends `reasoning_effort: "max"`, omits the K2 `thinking` field and fixed sampling overrides, and preserves K3-supported tool choices. Kimi Code (`kimi/k3` and `kimi/k3[1m]`) sends adaptive thinking with max effort and ignores stale K2 thinking toggles. Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) also always thinks, exposes only `on`, and omits both outbound `thinking` and `reasoning_effort`. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When K2 thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`. + - Moonshot API Kimi K3 (`moonshot/kimi-k3`) always thinks at `max`, sends `reasoning_effort: "max"`, omits the K2 `thinking` field and fixed sampling overrides, and preserves K3-supported tool choices. Kimi Code K3 (`kimi/k3` and `kimi/k3[1m]`) exposes `/think off|max`: off sends `thinking.type: "disabled"`, while max sends adaptive thinking with max effort. Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) always thinks, exposes only `on`, and omits both outbound `thinking` and `reasoning_effort`. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When K2 thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`. ## Resolution order diff --git a/extensions/kimi-coding/index.test.ts b/extensions/kimi-coding/index.test.ts index e203e2efd171..a15fa9df3bf9 100644 --- a/extensions/kimi-coding/index.test.ts +++ b/extensions/kimi-coding/index.test.ts @@ -44,7 +44,7 @@ describe("kimi provider plugin", () => { }); }); - it.each(["k3", "k3[1m]"])("forces %s to max thinking", async (modelId) => { + it.each(["k3", "k3[1m]"])("exposes %s off and max thinking", async (modelId) => { const provider = await registerSingleProviderPlugin(plugin); expect( @@ -54,7 +54,10 @@ describe("kimi provider plugin", () => { reasoning: true, } as never), ).toEqual({ - levels: [{ id: "max", label: "max" }], + levels: [ + { id: "off", label: "off" }, + { id: "max", label: "max" }, + ], defaultLevel: "max", preserveWhenCatalogReasoningFalse: true, }); diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 5ca0fabea1ec..1e13057eff97 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -5,11 +5,8 @@ import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import type { SecretInput } from "openclaw/plugin-sdk/secret-input"; import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; -import { - buildKimiCodingProvider, - isKimiK3ModelId, - normalizeKimiCodingModelId, -} from "./provider-catalog.js"; +import { buildKimiCodingProvider, normalizeKimiCodingModelId } from "./provider-catalog.js"; +import { isKimiK3ModelId, resolveThinkingProfile } from "./provider-policy-api.js"; import { KIMI_REPLAY_POLICY } from "./replay-policy.js"; import { wrapKimiProviderStream } from "./stream.js"; @@ -105,20 +102,7 @@ export default definePluginEntry({ const normalizedId = normalizeKimiCodingModelId(model.id); return normalizedId === model.id ? undefined : { ...model, id: normalizedId }; }, - resolveThinkingProfile: ({ modelId }) => - isKimiK3ModelId(modelId) - ? { - levels: [{ id: "max", label: "max" }], - defaultLevel: "max", - preserveWhenCatalogReasoningFalse: true, - } - : { - levels: [ - { id: "off", label: "off" }, - { id: "low", label: "on" }, - ], - defaultLevel: "off", - }, + resolveThinkingProfile, wrapSimpleCompletionStreamFn: (ctx) => isKimiK3ModelId(ctx.modelId) ? wrapKimiProviderStream(ctx) : ctx.streamFn, wrapStreamFn: wrapKimiProviderStream, diff --git a/extensions/kimi-coding/kimi-coding.live.test.ts b/extensions/kimi-coding/kimi-coding.live.test.ts new file mode 100644 index 000000000000..30af724eb2e9 --- /dev/null +++ b/extensions/kimi-coding/kimi-coding.live.test.ts @@ -0,0 +1,187 @@ +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 { buildKimiCodingProvider } from "./provider-catalog.js"; + +const describeLive = + isLiveTestEnabled() && process.env.KIMI_API_KEY?.trim() ? describe : describe.skip; + +async function collectDoneMessage( + stream: AsyncIterable<{ type: string; message?: AssistantMessage; error?: AssistantMessage }>, +): Promise { + let doneMessage: AssistantMessage | undefined; + for await (const event of stream) { + if (event.type === "error") { + throw new Error(event.error?.errorMessage || "Kimi live request failed"); + } + if (event.type === "done") { + doneMessage = event.message; + } + } + if (!doneMessage) { + throw new Error("Kimi live stream ended without a done message"); + } + return doneMessage; +} + +function resolveModel(modelId: "k3" | "k3[1m]"): Model<"anthropic-messages"> { + const provider = buildKimiCodingProvider(); + const definition = provider.models.find((model) => model.id === modelId); + if (!definition) { + throw new Error(`Missing model ${modelId}`); + } + return { + provider: "kimi", + baseUrl: provider.baseUrl, + headers: provider.headers, + ...definition, + api: "anthropic-messages", + } as Model<"anthropic-messages">; +} + +function countContentChars(message: AssistantMessage, type: "text" | "thinking"): number { + return message.content.reduce((total, block) => { + if (type === "text" && block.type === "text") { + return total + block.text.length; + } + if (type === "thinking" && block.type === "thinking") { + return total + block.thinking.length; + } + return total; + }, 0); +} + +async function runReasoningScenario(params: { + modelId: "k3" | "k3[1m]"; + thinkingLevel: "off" | "max"; +}): Promise { + const registered = await registerSingleProviderPlugin(plugin); + const wrapped = registered.wrapStreamFn?.({ + provider: "kimi", + modelId: params.modelId, + thinkingLevel: params.thinkingLevel, + extraParams: { thinking: params.thinkingLevel === "off" ? "off" : "enabled" }, + streamFn: streamSimple, + } as never); + if (!wrapped) { + throw new Error("Missing Kimi stream wrapper"); + } + + const context: Context = { + messages: [ + { + role: "user", + content: "Reply with exactly LIVE_OK and no punctuation.", + timestamp: Date.now(), + }, + ], + }; + return collectDoneMessage( + wrapped(resolveModel(params.modelId), context, { + apiKey: process.env.KIMI_API_KEY?.trim() ?? "", + maxTokens: 4096, + }) as AsyncIterable<{ + type: string; + message?: AssistantMessage; + error?: AssistantMessage; + }>, + ); +} + +describeLive("Kimi Code K3 reasoning live", () => { + it.each(["k3", "k3[1m]"] as const)( + "%s honors off and max reasoning", + async (modelId) => { + const off = await runReasoningScenario({ modelId, thinkingLevel: "off" }); + expect(countContentChars(off, "thinking")).toBe(0); + expect(countContentChars(off, "text")).toBeGreaterThan(0); + + const max = await runReasoningScenario({ modelId, thinkingLevel: "max" }); + expect(countContentChars(max, "thinking")).toBeGreaterThan(0); + expect(countContentChars(max, "text")).toBeGreaterThan(0); + }, + 180_000, + ); + + it("preserves reasoning across a K3 tool-result replay", async () => { + const registered = await registerSingleProviderPlugin(plugin); + const wrapped = registered.wrapStreamFn?.({ + provider: "kimi", + modelId: "k3", + thinkingLevel: "max", + streamFn: streamSimple, + } as never); + if (!wrapped) { + throw new Error("Missing Kimi stream wrapper"); + } + + const tool: Tool = { + name: "noop", + description: "Return ok.", + parameters: Type.Object({}, { additionalProperties: false }), + }; + const firstUser = { + role: "user" as const, + content: "Call the noop tool with an empty object. Do not answer directly.", + timestamp: Date.now(), + }; + const first = await collectDoneMessage( + wrapped( + resolveModel("k3"), + { messages: [firstUser], tools: [tool] }, + { apiKey: process.env.KIMI_API_KEY?.trim() ?? "", maxTokens: 4096 }, + ) as AsyncIterable<{ + type: string; + message?: AssistantMessage; + error?: AssistantMessage; + }>, + ); + expect(countContentChars(first, "thinking")).toBeGreaterThan(0); + const toolCall = first.content.find((block) => block.type === "toolCall"); + if (!toolCall || toolCall.type !== "toolCall") { + throw new Error(`Kimi K3 did not call noop: ${first.stopReason}`); + } + expect(toolCall.name).toBe("noop"); + + const second = await collectDoneMessage( + wrapped( + resolveModel("k3"), + { + 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 LIVE_OK and no punctuation.", + timestamp: Date.now(), + }, + ], + tools: [tool], + }, + { apiKey: process.env.KIMI_API_KEY?.trim() ?? "", maxTokens: 4096 }, + ) as AsyncIterable<{ + type: string; + message?: AssistantMessage; + error?: AssistantMessage; + }>, + ); + expect(countContentChars(second, "text")).toBeGreaterThan(0); + }, 180_000); +}); diff --git a/extensions/kimi-coding/provider-catalog.test.ts b/extensions/kimi-coding/provider-catalog.test.ts index 824e6169fd5e..69d3b12d2e46 100644 --- a/extensions/kimi-coding/provider-catalog.test.ts +++ b/extensions/kimi-coding/provider-catalog.test.ts @@ -1,10 +1,7 @@ // Kimi Coding tests cover provider catalog plugin behavior. import { describe, expect, it } from "vitest"; -import { - buildKimiCodingProvider, - isKimiK3ModelId, - normalizeKimiCodingModelId, -} from "./provider-catalog.js"; +import { buildKimiCodingProvider, normalizeKimiCodingModelId } from "./provider-catalog.js"; +import { isKimiK3ModelId } from "./provider-policy-api.js"; describe("kimi provider catalog", () => { it("builds the bundled Kimi coding defaults", () => { diff --git a/extensions/kimi-coding/provider-catalog.ts b/extensions/kimi-coding/provider-catalog.ts index cf0c0a42a4f1..8da13716e3b5 100644 --- a/extensions/kimi-coding/provider-catalog.ts +++ b/extensions/kimi-coding/provider-catalog.ts @@ -3,13 +3,13 @@ import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import { KIMI_K3_MODEL_IDS } from "./provider-policy-api.js"; const KIMI_BASE_URL = "https://api.kimi.com/coding/"; const KIMI_CODING_USER_AGENT = "claude-code/0.1.0"; const KIMI_DEFAULT_MODEL_ID = "kimi-for-coding"; // Kimi's Claude Code endpoint uses k3[1m] as the wire id for the 1M plan; // normalizing it to k3 would lose the server-side context entitlement signal. -const KIMI_K3_MODEL_IDS = ["k3", "k3[1m]"] as const; const KIMI_LEGACY_MODEL_IDS = ["kimi-code", "k2p5"] as const; const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; @@ -75,10 +75,6 @@ export function normalizeKimiCodingModelId(modelId: string): string { : modelId; } -export function isKimiK3ModelId(modelId: string): boolean { - return KIMI_K3_MODEL_IDS.includes(modelId as (typeof KIMI_K3_MODEL_IDS)[number]); -} - export const KIMI_CODING_BASE_URL = KIMI_BASE_URL; export const KIMI_CODING_DEFAULT_MODEL_ID = KIMI_DEFAULT_MODEL_ID; export const KIMI_CODING_LEGACY_MODEL_IDS = KIMI_LEGACY_MODEL_IDS; diff --git a/extensions/kimi-coding/provider-policy-api.test.ts b/extensions/kimi-coding/provider-policy-api.test.ts new file mode 100644 index 000000000000..98b4a65bad81 --- /dev/null +++ b/extensions/kimi-coding/provider-policy-api.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isKimiK3ModelId, resolveThinkingProfile } from "./provider-policy-api.js"; + +describe("Kimi Code provider policy", () => { + it.each(["k3", "k3[1m]"])("exposes off and max for %s", (modelId) => { + expect(resolveThinkingProfile({ provider: "kimi", modelId })).toEqual({ + levels: [ + { id: "off", label: "off" }, + { id: "max", label: "max" }, + ], + defaultLevel: "max", + preserveWhenCatalogReasoningFalse: true, + }); + }); + + it("keeps legacy Kimi Code thinking binary and off by default", () => { + expect(resolveThinkingProfile({ provider: "kimi", modelId: "kimi-for-coding" })).toEqual({ + levels: [ + { id: "off", label: "off" }, + { id: "low", label: "on" }, + ], + defaultLevel: "off", + }); + }); + + it("recognizes K3 wire ids case-insensitively", () => { + expect(isKimiK3ModelId("K3")).toBe(true); + expect(isKimiK3ModelId("k3[1M]")).toBe(true); + expect(isKimiK3ModelId("kimi-for-coding")).toBe(false); + }); +}); diff --git a/extensions/kimi-coding/provider-policy-api.ts b/extensions/kimi-coding/provider-policy-api.ts new file mode 100644 index 000000000000..373ffa70fa18 --- /dev/null +++ b/extensions/kimi-coding/provider-policy-api.ts @@ -0,0 +1,35 @@ +// Kimi Code policy module exposes model-specific thinking controls before runtime registration. +import type { + ProviderDefaultThinkingPolicyContext, + ProviderThinkingProfile, +} from "openclaw/plugin-sdk/plugin-entry"; + +export const KIMI_K3_MODEL_IDS = ["k3", "k3[1m]"] as const; + +export function isKimiK3ModelId(modelId: string): boolean { + return KIMI_K3_MODEL_IDS.includes( + modelId.trim().toLowerCase() as (typeof KIMI_K3_MODEL_IDS)[number], + ); +} + +export function resolveThinkingProfile({ + modelId, +}: ProviderDefaultThinkingPolicyContext): ProviderThinkingProfile { + if (isKimiK3ModelId(modelId)) { + return { + levels: [ + { id: "off", label: "off" }, + { id: "max", label: "max" }, + ], + defaultLevel: "max", + preserveWhenCatalogReasoningFalse: true, + }; + } + return { + levels: [ + { id: "off", label: "off" }, + { id: "low", label: "on" }, + ], + defaultLevel: "off", + }; +} diff --git a/extensions/kimi-coding/stream.test.ts b/extensions/kimi-coding/stream.test.ts index 8798f3e5165c..dde2622e19b9 100644 --- a/extensions/kimi-coding/stream.test.ts +++ b/extensions/kimi-coding/stream.test.ts @@ -268,7 +268,7 @@ describe("kimi tool-call markup wrapper", () => { }); }); - it.each(["k3", "k3[1m]"])("forces %s adaptive max thinking", (modelId) => { + it.each(["k3", "k3[1m]"])("defaults %s to adaptive max thinking", (modelId) => { const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({ thinking: { type: "disabled", budget_tokens: 8192 }, output_config: { effort: "low", format: { type: "json_schema" } }, @@ -280,8 +280,6 @@ describe("kimi tool-call markup wrapper", () => { const wrapped = wrapKimiProviderStream({ provider: "kimi", modelId, - extraParams: { thinking: "off" }, - thinkingLevel: "off", streamFn: baseStreamFn, } as never); @@ -296,11 +294,78 @@ describe("kimi tool-call markup wrapper", () => { ); expect(getCapturedPayload()).toEqual({ - thinking: { type: "adaptive" }, + thinking: { type: "adaptive", display: "summarized" }, output_config: { effort: "max", format: { type: "json_schema" } }, }); }); + it.each([ + { modelId: "k3", extraParams: undefined, thinkingLevel: "off" }, + { modelId: "k3", extraParams: { thinking: "off" }, thinkingLevel: "max" }, + { modelId: "k3[1m]", extraParams: undefined, thinkingLevel: "off" }, + { modelId: "k3[1m]", extraParams: { thinking: "off" }, thinkingLevel: "max" }, + ] as const)("honors $modelId thinking off", ({ modelId, extraParams, thinkingLevel }) => { + const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({ + thinking: { type: "adaptive" }, + output_config: { effort: "max", format: { type: "json_schema" } }, + reasoning: { effort: "max" }, + reasoning_effort: "max", + reasoningEffort: "max", + }); + + const wrapped = wrapKimiProviderStream({ + provider: "kimi", + modelId, + extraParams, + thinkingLevel, + streamFn: baseStreamFn, + } as never); + + void wrapped( + { + api: "anthropic-messages", + provider: "kimi", + id: modelId, + } as Model<"anthropic-messages">, + KIMI_CONTEXT, + {}, + ); + + expect(getCapturedPayload()).toEqual({ + thinking: { type: "disabled" }, + output_config: { format: { type: "json_schema" } }, + }); + }); + + it.each(["k3", "k3[1m]"])( + "lets explicit %s thinking enablement override session off", + (modelId) => { + const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream(); + const wrapped = wrapKimiProviderStream({ + provider: "kimi", + modelId, + extraParams: { thinking: "enabled" }, + thinkingLevel: "off", + streamFn: baseStreamFn, + } as never); + + void wrapped( + { + api: "anthropic-messages", + provider: "kimi", + id: modelId, + } as Model<"anthropic-messages">, + KIMI_CONTEXT, + {}, + ); + + expect(getCapturedPayload()).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "max" }, + }); + }, + ); + it("strips Anthropic cache_control markers before Kimi requests are sent", () => { const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({ system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral", ttl: "1h" } }], diff --git a/extensions/kimi-coding/stream.ts b/extensions/kimi-coding/stream.ts index 1eb45f8d0ecc..773ab5734ac3 100644 --- a/extensions/kimi-coding/stream.ts +++ b/extensions/kimi-coding/stream.ts @@ -8,7 +8,7 @@ import { import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { isKimiK3ModelId } from "./provider-catalog.js"; +import { isKimiK3ModelId } from "./provider-policy-api.js"; const TOOL_CALLS_SECTION_BEGIN = "<|tool_calls_section_begin|>"; const TOOL_CALLS_SECTION_END = "<|tool_calls_section_end|>"; @@ -188,6 +188,17 @@ function resolveKimiThinkingConfig(params: { : { type: "enabled", budget_tokens: levelBudgetTokens }; } +function resolveKimiK3ThinkingType(params: { + configuredThinking: unknown; + thinkingLevel?: KimiThinkingLevel; +}): KimiThinkingType { + const configured = normalizeKimiThinkingConfig(params.configuredThinking); + if (configured) { + return configured.type; + } + return params.thinkingLevel === "off" ? "disabled" : "enabled"; +} + function stripTaggedToolCallCounter(value: string): string { return value.trim().replace(/:\d+$/, ""); } @@ -371,19 +382,34 @@ function createKimiToolCallMarkupWrapper(baseStreamFn: StreamFn | undefined): St function createKimiThinkingWrapper( baseStreamFn: StreamFn | undefined, thinkingConfig: KimiThinkingConfig | KimiThinkingType, + k3ThinkingType: KimiThinkingType, ): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => { if (model.api === "anthropic-messages" && isKimiK3ModelId(model.id)) { - // Kimi Code K3 uses Claude's adaptive/max contract, not K2's budget toggle. - // Forcing this after onPayload prevents stale K2 settings from disabling K3 thinking. - payloadObj.thinking = { type: "adaptive" }; const outputConfig = payloadObj.output_config; - payloadObj.output_config = - outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) - ? { ...outputConfig, effort: "max" } - : { effort: "max" }; + if (k3ThinkingType === "disabled") { + payloadObj.thinking = { type: "disabled" }; + if (outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig)) { + const nextOutputConfig = { ...outputConfig } as Record; + delete nextOutputConfig.effort; + if (Object.keys(nextOutputConfig).length > 0) { + payloadObj.output_config = nextOutputConfig; + } else { + delete payloadObj.output_config; + } + } else { + delete payloadObj.output_config; + } + } else { + // Kimi Code's Anthropic endpoint uses adaptive thinking plus max effort for K3. + payloadObj.thinking = { type: "adaptive", display: "summarized" }; + payloadObj.output_config = + outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) + ? { ...outputConfig, effort: "max" } + : { effort: "max" }; + } delete payloadObj.reasoning; delete payloadObj.reasoning_effort; delete payloadObj.reasoningEffort; @@ -455,5 +481,11 @@ export function wrapKimiProviderStream(ctx: ProviderWrapStreamFnContext): Stream configuredThinking: ctx.extraParams?.thinking, thinkingLevel: ctx.thinkingLevel, }); - return createKimiToolCallMarkupWrapper(createKimiThinkingWrapper(ctx.streamFn, thinkingConfig)); + const k3ThinkingType = resolveKimiK3ThinkingType({ + configuredThinking: ctx.extraParams?.thinking, + thinkingLevel: ctx.thinkingLevel, + }); + return createKimiToolCallMarkupWrapper( + createKimiThinkingWrapper(ctx.streamFn, thinkingConfig, k3ThinkingType), + ); } diff --git a/src/plugins/provider-public-artifacts.test.ts b/src/plugins/provider-public-artifacts.test.ts index 0d7a1d898518..54c32ae187db 100644 --- a/src/plugins/provider-public-artifacts.test.ts +++ b/src/plugins/provider-public-artifacts.test.ts @@ -132,6 +132,24 @@ describe("provider public artifacts", () => { }); }); + it("loads Kimi Code K3 thinking policy before runtime registration", () => { + const surface = resolveBundledProviderPolicySurface("kimi"); + + expect( + surface?.resolveThinkingProfile?.({ + provider: "kimi", + modelId: "k3", + }), + ).toEqual({ + levels: [ + { id: "off", label: "off" }, + { id: "max", label: "max" }, + ], + defaultLevel: "max", + preserveWhenCatalogReasoningFalse: true, + }); + }); + it("loads trusted official external provider policy before runtime registration", () => { const bundledPluginsDir = fs.mkdtempSync( path.join(os.tmpdir(), "openclaw-empty-bundled-plugins-"), diff --git a/src/plugins/provider-public-artifacts.ts b/src/plugins/provider-public-artifacts.ts index 512c2d770ada..0ee6a00a91f1 100644 --- a/src/plugins/provider-public-artifacts.ts +++ b/src/plugins/provider-public-artifacts.ts @@ -1,3 +1,4 @@ +import path from "node:path"; // Extracts provider public artifacts from plugin metadata. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; @@ -8,10 +9,10 @@ import { type BundledProviderPolicySurface, } from "./provider-policy-surface.js"; -function resolveBundledProviderPolicyPluginId( +function resolveBundledProviderPolicyPlugin( providerId: string, options: { manifestRegistry?: Pick } = {}, -): string | null { +): PluginManifestRegistry["plugins"][number] | null { const normalizedProviderId = normalizeProviderId(providerId); if (!normalizedProviderId) { return null; @@ -29,7 +30,7 @@ function resolveBundledProviderPolicyPluginId( continue; } if (pluginOwnsProviderPolicyRef(plugin, normalizedProviderId)) { - return plugin.id; + return plugin; } } @@ -73,11 +74,19 @@ export function resolveBundledProviderPolicySurface( if (directSurface) { return directSurface; } - const ownerPluginId = resolveBundledProviderPolicyPluginId(normalizedProviderId, options); - if (!ownerPluginId || ownerPluginId === normalizedProviderId) { + const ownerPlugin = resolveBundledProviderPolicyPlugin(normalizedProviderId, options); + if (ownerPlugin) { + const ownerSurface = resolveDirectBundledProviderPolicySurface(ownerPlugin.id); + if (ownerSurface) { + return ownerSurface; + } + } + if (!ownerPlugin) { return null; } - return resolveDirectBundledProviderPolicySurface(ownerPluginId); + // A stable plugin id can differ from its stock directory name. Use the + // registry-owned root basename so its pre-runtime policy stays discoverable. + return resolveDirectBundledProviderPolicySurface(path.basename(ownerPlugin.rootDir)); } /** Resolves provider policy hooks from bundled or trusted official plugin artifacts. */