fix(kimi-coding): normalize anthropic tool payload format
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user