From 909f26a26b73fc8cdf3cca52858853e32decdfdc Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 18:43:05 -0800 Subject: [PATCH] fix(kimi-coding): normalize anthropic tool payload format --- CHANGELOG.md | 1 + .../pi-embedded-runner-extraparams.test.ts | 110 +++++++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 113 ++++++++++++++++++ 3 files changed, 224 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f953f6f..a2a484c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - OpenAI Codex OAuth/login hardening: fail OAuth completion early when the returned token is missing `api.responses.write`, and allow `openclaw models auth login --provider openai-codex` to use the built-in OAuth path even when no provider plugins are installed. (#36660) Thanks @driesvints. - OpenAI Codex OAuth/scope request parity: augment the OAuth authorize URL with required API scopes (`api.responses.write`, `model.request`, `api.model.read`) before browser handoff so OAuth tokens include runtime model/request permissions expected by OpenAI API calls. (#24720) Thanks @Skippy-Gunboat. - Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header `ByteString` construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf. +- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub. - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 2c1398d6e..7eed99059 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -497,6 +497,116 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]?.thinking).toEqual({ type: "disabled" }); }); + it("normalizes kimi-coding anthropic tools to OpenAI function format", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + tools: [ + { + name: "read", + description: "Read file", + input_schema: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + { + type: "function", + function: { + name: "exec", + description: "Run command", + parameters: { type: "object", properties: {} }, + }, + }, + ], + tool_choice: { type: "tool", name: "read" }, + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "kimi-coding", "k2p5", undefined, "low"); + + const model = { + api: "anthropic-messages", + provider: "kimi-coding", + id: "k2p5", + baseUrl: "https://api.kimi.com/coding/", + } as Model<"anthropic-messages">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.tools).toEqual([ + { + type: "function", + function: { + name: "read", + description: "Read file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + { + type: "function", + function: { + name: "exec", + description: "Run command", + parameters: { type: "object", properties: {} }, + }, + }, + ]); + expect(payloads[0]?.tool_choice).toEqual({ + type: "function", + function: { name: "read" }, + }); + }); + + it("does not rewrite anthropic tool schema for non-kimi endpoints", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + tools: [ + { + name: "read", + description: "Read file", + input_schema: { type: "object", properties: {} }, + }, + ], + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "anthropic", "claude-sonnet-4-6", undefined, "low"); + + const model = { + api: "anthropic-messages", + provider: "anthropic", + id: "claude-sonnet-4-6", + baseUrl: "https://api.anthropic.com", + } as Model<"anthropic-messages">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.tools).toEqual([ + { + name: "read", + description: "Read file", + input_schema: { type: "object", properties: {} }, + }, + ]); + }); + it("removes invalid negative Google thinkingBudget and maps Gemini 3.1 to thinkingLevel", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index f57bd272d..33b8ab2c6 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -661,6 +661,117 @@ function createMoonshotThinkingWrapper( }; } +function isKimiCodingAnthropicEndpoint(model: { + api?: unknown; + provider?: unknown; + baseUrl?: unknown; +}): boolean { + if (model.api !== "anthropic-messages") { + return false; + } + + if (typeof model.provider === "string" && model.provider.trim().toLowerCase() === "kimi-coding") { + return true; + } + + if (typeof model.baseUrl !== "string" || !model.baseUrl.trim()) { + return false; + } + + try { + const parsed = new URL(model.baseUrl); + const host = parsed.hostname.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + return host.endsWith("kimi.com") && pathname.startsWith("/coding"); + } catch { + const normalized = model.baseUrl.toLowerCase(); + return normalized.includes("kimi.com/coding"); + } +} + +function normalizeKimiCodingToolDefinition(tool: unknown): Record | undefined { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + return undefined; + } + + const toolObj = tool as Record; + if (toolObj.function && typeof toolObj.function === "object") { + return toolObj; + } + + const rawName = typeof toolObj.name === "string" ? toolObj.name.trim() : ""; + if (!rawName) { + return toolObj; + } + + const functionSpec: Record = { + name: rawName, + parameters: + toolObj.input_schema && typeof toolObj.input_schema === "object" + ? toolObj.input_schema + : toolObj.parameters && typeof toolObj.parameters === "object" + ? toolObj.parameters + : { type: "object", properties: {} }, + }; + + if (typeof toolObj.description === "string" && toolObj.description.trim()) { + functionSpec.description = toolObj.description; + } + if (typeof toolObj.strict === "boolean") { + functionSpec.strict = toolObj.strict; + } + + return { + type: "function", + function: functionSpec, + }; +} + +function normalizeKimiCodingToolChoice(toolChoice: unknown): unknown { + if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) { + return toolChoice; + } + + const choice = toolChoice as Record; + if (choice.type === "any") { + return "required"; + } + if (choice.type === "tool" && typeof choice.name === "string" && choice.name.trim()) { + return { + type: "function", + function: { name: choice.name.trim() }, + }; + } + + return toolChoice; +} + +/** + * Kimi Coding's anthropic-messages endpoint expects OpenAI-style tool payloads + * (`tools[].function`) even when messages use Anthropic request framing. + */ +function createKimiCodingAnthropicToolSchemaWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object" && isKimiCodingAnthropicEndpoint(model)) { + const payloadObj = payload as Record; + if (Array.isArray(payloadObj.tools)) { + payloadObj.tools = payloadObj.tools + .map((tool) => normalizeKimiCodingToolDefinition(tool)) + .filter((tool): tool is Record => !!tool); + } + payloadObj.tool_choice = normalizeKimiCodingToolChoice(payloadObj.tool_choice); + } + originalOnPayload?.(payload); + }, + }); + }; +} + /** * Create a streamFn wrapper that adds OpenRouter app attribution headers * and injects reasoning.effort based on the configured thinking level. @@ -922,6 +1033,8 @@ export function applyExtraParamsToAgent( agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, moonshotThinkingType); } + agent.streamFn = createKimiCodingAnthropicToolSchemaWrapper(agent.streamFn); + if (provider === "openrouter") { log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`); // "auto" is a dynamic routing model — we don't know which underlying model