fix(kimi): honor K3 thinking off (#109335)

* fix(kimi): honor K3 thinking off

* chore: defer Kimi release note

* test(kimi): narrow live reasoning blocks
This commit is contained in:
Peter Steinberger
2026-07-16 13:50:15 -07:00
committed by GitHub
parent 6dc3e6055f
commit 2409df768c
13 changed files with 425 additions and 61 deletions

View File

@@ -267,10 +267,11 @@ onboarding.
</Step>
</Steps>
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`:
<AccordionGroup>
<Accordion title="Native thinking mode">
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

View File

@@ -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

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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<AssistantMessage> {
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<AssistantMessage> {
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);
});

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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",
};
}

View File

@@ -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" } }],

View File

@@ -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<string, unknown>;
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),
);
}

View File

@@ -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-"),

View File

@@ -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<PluginManifestRegistry, "plugins"> } = {},
): 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. */