fix(kimi-coding): normalize anthropic tool payload format

This commit is contained in:
Vignesh Natarajan
2026-03-05 18:43:05 -08:00
parent b39ca7eccb
commit 909f26a26b
3 changed files with 224 additions and 0 deletions

View File

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

View File

@@ -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<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
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<StreamFn>;
};
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<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
tools: [
{
name: "read",
description: "Read file",
input_schema: { type: "object", properties: {} },
},
],
};
options?.onPayload?.(payload);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
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<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -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<string, unknown> | undefined {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
return undefined;
}
const toolObj = tool as Record<string, unknown>;
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<string, unknown> = {
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<string, unknown>;
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<string, unknown>;
if (Array.isArray(payloadObj.tools)) {
payloadObj.tools = payloadObj.tools
.map((tool) => normalizeKimiCodingToolDefinition(tool))
.filter((tool): tool is Record<string, unknown> => !!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