Ollama/Kimi: apply Moonshot payload compatibility (#44274)

* Runner: extend Moonshot payload compat to Ollama Kimi

* Changelog: note Ollama Kimi tool routing

* Tests: cover Ollama Kimi payload compat

* Runner: narrow Ollama Kimi payload compat
This commit is contained in:
Vincent Koc
2026-03-12 14:17:01 -04:00
committed by GitHub
parent 2d42588a18
commit 1492ad20a9
4 changed files with 143 additions and 2 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.
- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.
- Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (`GHSA-99qw-6mr3-36qr`)(#44174) Thanks @lintsinghua and @vincentkoc.
- Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.

View File

@@ -695,6 +695,33 @@ describe("applyExtraParamsToAgent", () => {
expect(payloads[0]?.tool_choice).toBe("auto");
});
it("disables thinking instead of broadening pinned Moonshot tool_choice", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
tool_choice: { type: "tool", name: "read" },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "moonshot", "kimi-k2.5", undefined, "low");
const model = {
api: "openai-completions",
provider: "moonshot",
id: "kimi-k2.5",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.thinking).toEqual({ type: "disabled" });
expect(payloads[0]?.tool_choice).toEqual({ type: "tool", name: "read" });
});
it("respects explicit Moonshot thinking param from model config", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
@@ -732,6 +759,85 @@ describe("applyExtraParamsToAgent", () => {
expect(payloads[0]?.thinking).toEqual({ type: "disabled" });
});
it("applies Moonshot payload compatibility to Ollama Kimi cloud models", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = { tool_choice: "required" };
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "ollama", "kimi-k2.5:cloud", undefined, "low");
const model = {
api: "openai-completions",
provider: "ollama",
id: "kimi-k2.5:cloud",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.thinking).toEqual({ type: "enabled" });
expect(payloads[0]?.tool_choice).toBe("auto");
});
it("maps thinkingLevel=off for Ollama Kimi cloud models through Moonshot compatibility", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "ollama", "kimi-k2.5:cloud", undefined, "off");
const model = {
api: "openai-completions",
provider: "ollama",
id: "kimi-k2.5:cloud",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.thinking).toEqual({ type: "disabled" });
});
it("disables thinking instead of broadening pinned Ollama Kimi cloud tool_choice", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
tool_choice: { type: "function", function: { name: "read" } },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "ollama", "kimi-k2.5:cloud", undefined, "low");
const model = {
api: "openai-completions",
provider: "ollama",
id: "kimi-k2.5:cloud",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.thinking).toEqual({ type: "disabled" });
expect(payloads[0]?.tool_choice).toEqual({
type: "function",
function: { name: "read" },
});
});
it("does not rewrite tool schema for kimi-coding (native Anthropic format)", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -16,6 +16,7 @@ import {
createMoonshotThinkingWrapper,
createSiliconFlowThinkingWrapper,
resolveMoonshotThinkingType,
shouldApplyMoonshotPayloadCompat,
shouldApplySiliconFlowThinkingOffCompat,
} from "./moonshot-stream-wrappers.js";
import {
@@ -373,7 +374,7 @@ export function applyExtraParamsToAgent(
agent.streamFn = createSiliconFlowThinkingWrapper(agent.streamFn);
}
if (provider === "moonshot") {
if (shouldApplyMoonshotPayloadCompat({ provider, modelId })) {
const moonshotThinkingType = resolveMoonshotThinkingType({
configuredThinking: merged?.thinking,
thinkingLevel,

View File

@@ -35,6 +35,14 @@ function isMoonshotToolChoiceCompatible(toolChoice: unknown): boolean {
return false;
}
function isPinnedToolChoice(toolChoice: unknown): boolean {
if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) {
return false;
}
const typeValue = (toolChoice as Record<string, unknown>).type;
return typeValue === "tool" || typeValue === "function";
}
export function shouldApplySiliconFlowThinkingOffCompat(params: {
provider: string;
modelId: string;
@@ -47,6 +55,27 @@ export function shouldApplySiliconFlowThinkingOffCompat(params: {
);
}
export function shouldApplyMoonshotPayloadCompat(params: {
provider: string;
modelId: string;
}): boolean {
const normalizedProvider = params.provider.trim().toLowerCase();
const normalizedModelId = params.modelId.trim().toLowerCase();
if (normalizedProvider === "moonshot") {
return true;
}
// Ollama Cloud exposes Kimi variants through OpenAI-compatible model IDs such
// as `kimi-k2.5:cloud`, but they still need the same payload normalization as
// native Moonshot endpoints when thinking/tool_choice are enabled together.
return (
normalizedProvider === "ollama" &&
normalizedModelId.startsWith("kimi-k") &&
normalizedModelId.includes(":cloud")
);
}
export function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
@@ -103,7 +132,11 @@ export function createMoonshotThinkingWrapper(
effectiveThinkingType === "enabled" &&
!isMoonshotToolChoiceCompatible(payloadObj.tool_choice)
) {
payloadObj.tool_choice = "auto";
if (payloadObj.tool_choice === "required") {
payloadObj.tool_choice = "auto";
} else if (isPinnedToolChoice(payloadObj.tool_choice)) {
payloadObj.thinking = { type: "disabled" };
}
}
}
return originalOnPayload?.(payload, model);