mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 05:31:43 +00:00
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:
committed by
GitHub
parent
6dc3e6055f
commit
2409df768c
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
187
extensions/kimi-coding/kimi-coding.live.test.ts
Normal file
187
extensions/kimi-coding/kimi-coding.live.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
extensions/kimi-coding/provider-policy-api.test.ts
Normal file
31
extensions/kimi-coding/provider-policy-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
extensions/kimi-coding/provider-policy-api.ts
Normal file
35
extensions/kimi-coding/provider-policy-api.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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" } }],
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-"),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user