fix(moonshot): preserve native Kimi tool_call IDs in openai-completions replay

This commit is contained in:
dulingxiao
2026-04-22 14:34:18 +08:00
committed by Peter Steinberger
parent 23a448986f
commit c4dea58712
7 changed files with 73 additions and 17 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.<name>:<index>`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314.
- Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code.
- Codex harness: drop invalid legacy app-server `serviceTier` values such as `"priority"` before native thread and turn requests, while keeping supported Codex tiers limited to `"fast"` and `"flex"`. Fixes #64815.
- Codex harness: show bounded, sanitized permission target samples in app-server approval prompts, so native permission requests keep their specific hosts, roots, and paths visible without leaking home usernames or URL credentials. (#70340) Thanks @Lucenx9.

View File

@@ -5,22 +5,22 @@ import { createCapturedThinkingConfigStream } from "../../test/helpers/plugins/s
import plugin from "./index.js";
describe("moonshot provider plugin", () => {
it("owns replay policy for OpenAI-compatible Moonshot transports", async () => {
it("owns replay policy for OpenAI-compatible Moonshot transports without mangling native Kimi tool_call IDs", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(
provider.buildReplayPolicy?.({
provider: "moonshot",
modelApi: "openai-completions",
modelId: "kimi-k2.6",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
const policy = provider.buildReplayPolicy?.({
provider: "moonshot",
modelApi: "openai-completions",
modelId: "kimi-k2.6",
} as never);
expect(policy).toMatchObject({
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
expect(policy).not.toHaveProperty("sanitizeToolCallIds");
expect(policy).not.toHaveProperty("toolCallIdMode");
});
it("wires moonshot-thinking stream hooks", async () => {

View File

@@ -1,5 +1,5 @@
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { MOONSHOT_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
import { applyMoonshotNativeStreamingUsageCompat } from "./api.js";
import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
@@ -57,7 +57,13 @@ export default defineSingleProviderPluginEntry({
},
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
applyMoonshotNativeStreamingUsageCompat(providerConfig),
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
// Kimi K2+ returns native tool_call IDs shaped like `functions.<name>:<index>`.
// Sanitizing them to alphanumeric-only breaks Kimi's serving-layer matching in
// multi-turn replay. See openclaw/openclaw#62319.
...buildProviderReplayFamilyHooks({
family: "openai-compatible",
sanitizeToolCallIds: false,
}),
...MOONSHOT_THINKING_STREAM_HOOKS,
resolveThinkingProfile: () => ({
levels: [

View File

@@ -191,6 +191,23 @@ describe("buildProviderReplayFamilyHooks", () => {
validateGeminiTurns: true,
});
const nativeIdsHooks = buildProviderReplayFamilyHooks({
family: "openai-compatible",
sanitizeToolCallIds: false,
});
const nativeIdsPolicy = nativeIdsHooks.buildReplayPolicy?.({
provider: "moonshot",
modelApi: "openai-completions",
modelId: "kimi-k2.6",
} as never);
expect(nativeIdsPolicy).toMatchObject({
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
expect(nativeIdsPolicy).not.toHaveProperty("sanitizeToolCallIds");
expect(nativeIdsPolicy).not.toHaveProperty("toolCallIdMode");
expect(
PASSTHROUGH_GEMINI_REPLAY_HOOKS.buildReplayPolicy?.({
provider: "openrouter",

View File

@@ -118,7 +118,7 @@ type ProviderReplayFamilyHooks = Pick<
>;
type BuildProviderReplayFamilyHooksOptions =
| { family: "openai-compatible" }
| { family: "openai-compatible"; sanitizeToolCallIds?: boolean }
| { family: "anthropic-by-model" }
| { family: "native-anthropic-by-model" }
| { family: "google-gemini" }
@@ -132,11 +132,13 @@ export function buildProviderReplayFamilyHooks(
options: BuildProviderReplayFamilyHooksOptions,
): ProviderReplayFamilyHooks {
switch (options.family) {
case "openai-compatible":
case "openai-compatible": {
const policyOptions = { sanitizeToolCallIds: options.sanitizeToolCallIds };
return {
buildReplayPolicy: (ctx: ProviderReplayPolicyContext) =>
buildOpenAICompatibleReplayPolicy(ctx.modelApi),
buildOpenAICompatibleReplayPolicy(ctx.modelApi, policyOptions),
};
}
case "anthropic-by-model":
return {
buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) =>

View File

@@ -22,6 +22,32 @@ describe("provider replay helpers", () => {
});
});
it("omits tool-call id sanitization when opted out for openai-completions", () => {
const policy = buildOpenAICompatibleReplayPolicy("openai-completions", {
sanitizeToolCallIds: false,
});
expect(policy).toMatchObject({
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
expect(policy).not.toHaveProperty("sanitizeToolCallIds");
expect(policy).not.toHaveProperty("toolCallIdMode");
});
it("omits tool-call id sanitization when opted out for openai-responses", () => {
const policy = buildOpenAICompatibleReplayPolicy("openai-responses", {
sanitizeToolCallIds: false,
});
expect(policy).toMatchObject({
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
});
expect(policy).not.toHaveProperty("sanitizeToolCallIds");
expect(policy).not.toHaveProperty("toolCallIdMode");
});
it("builds strict anthropic replay policy", () => {
expect(buildStrictAnthropicReplayPolicy({ dropThinkingBlocks: true })).toMatchObject({
sanitizeMode: "full",

View File

@@ -11,6 +11,7 @@ import type {
export function buildOpenAICompatibleReplayPolicy(
modelApi: string | null | undefined,
options: { sanitizeToolCallIds?: boolean } = {},
): ProviderReplayPolicy | undefined {
if (
modelApi !== "openai-completions" &&
@@ -21,9 +22,12 @@ export function buildOpenAICompatibleReplayPolicy(
return undefined;
}
const sanitizeToolCallIds = options.sanitizeToolCallIds ?? true;
return {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
...(sanitizeToolCallIds
? { sanitizeToolCallIds: true, toolCallIdMode: "strict" as const }
: {}),
...(modelApi === "openai-completions"
? {
applyAssistantFirstOrderingFix: true,