mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 20:24:46 +00:00
Summary: - The PR preserves Kimi Coding reasoning_content replay for OpenAI-compatible tool-call follow-up turns, extends replay model-id matching, adds Kimi wrapper/tests, and updates the changelog. - Reproducibility: yes. at source level: current main drops or fails to synthesize reasoning_content for kimi- ... es a concrete Kimi 400 after tool-call history. I did not run a live Kimi request in this read-only review. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head9a4605ee38. - Required merge gates passed before the squash merge. Prepared head SHA:9a4605ee38Review: https://github.com/openclaw/openclaw/pull/82550#issuecomment-4466701075 Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
|
|
import type { ProviderRuntimePluginHandle } from "../plugins/provider-hook-runtime.js";
|
|
import { resolveProviderRuntimePlugin } from "../plugins/provider-hook-runtime.js";
|
|
import { shouldPreserveThinkingBlocks } from "../plugins/provider-replay-helpers.js";
|
|
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
|
|
import type { ProviderReplayPolicy } from "../plugins/types.js";
|
|
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|
import { normalizeProviderId } from "./model-selection.js";
|
|
import { isGoogleModelApi } from "./pi-embedded-helpers/google.js";
|
|
import type { ToolCallIdMode } from "./tool-call-id.js";
|
|
|
|
export type TranscriptSanitizeMode = "full" | "images-only";
|
|
|
|
export type TranscriptPolicy = {
|
|
sanitizeMode: TranscriptSanitizeMode;
|
|
sanitizeToolCallIds: boolean;
|
|
toolCallIdMode?: ToolCallIdMode;
|
|
preserveNativeAnthropicToolUseIds: boolean;
|
|
repairToolUseResultPairing: boolean;
|
|
preserveSignatures: boolean;
|
|
sanitizeThoughtSignatures?: {
|
|
allowBase64Only?: boolean;
|
|
includeCamelCase?: boolean;
|
|
};
|
|
sanitizeThinkingSignatures: boolean;
|
|
dropThinkingBlocks: boolean;
|
|
dropReasoningFromHistory?: boolean;
|
|
applyGoogleTurnOrdering: boolean;
|
|
validateGeminiTurns: boolean;
|
|
validateAnthropicTurns: boolean;
|
|
allowSyntheticToolResults: boolean;
|
|
};
|
|
|
|
export function shouldAllowProviderOwnedThinkingReplay(params: {
|
|
modelApi?: string | null;
|
|
policy: Pick<
|
|
TranscriptPolicy,
|
|
"validateAnthropicTurns" | "preserveSignatures" | "dropThinkingBlocks"
|
|
>;
|
|
}): boolean {
|
|
return (
|
|
isAnthropicApi(params.modelApi) &&
|
|
params.policy.validateAnthropicTurns &&
|
|
params.policy.preserveSignatures &&
|
|
!params.policy.dropThinkingBlocks
|
|
);
|
|
}
|
|
|
|
const DEFAULT_TRANSCRIPT_POLICY: TranscriptPolicy = {
|
|
sanitizeMode: "images-only",
|
|
sanitizeToolCallIds: false,
|
|
toolCallIdMode: undefined,
|
|
preserveNativeAnthropicToolUseIds: false,
|
|
repairToolUseResultPairing: true,
|
|
preserveSignatures: false,
|
|
sanitizeThoughtSignatures: undefined,
|
|
sanitizeThinkingSignatures: false,
|
|
dropThinkingBlocks: false,
|
|
dropReasoningFromHistory: false,
|
|
applyGoogleTurnOrdering: false,
|
|
validateGeminiTurns: false,
|
|
validateAnthropicTurns: false,
|
|
allowSyntheticToolResults: false,
|
|
};
|
|
|
|
function isAnthropicApi(modelApi?: string | null): boolean {
|
|
return modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream";
|
|
}
|
|
|
|
function isOpenAiResponsesCompatibleApi(modelApi?: string | null): boolean {
|
|
return (
|
|
modelApi === "openai-responses" ||
|
|
modelApi === "openai-codex-responses" ||
|
|
modelApi === "azure-openai-responses"
|
|
);
|
|
}
|
|
|
|
function isClaudeFamilyModelId(modelId?: string | null): boolean {
|
|
const id = normalizeLowercaseStringOrEmpty(modelId);
|
|
return /(?:^|[./:_-])claude(?:$|[./:_-])/.test(id);
|
|
}
|
|
|
|
function modelDisablesReasoningEffort(model?: ProviderRuntimeModel): boolean {
|
|
const compat = model?.compat as { supportsReasoningEffort?: boolean } | undefined;
|
|
return compat?.supportsReasoningEffort === false;
|
|
}
|
|
|
|
/**
|
|
* Provides a narrow replay-policy fallback for providers that do not have an
|
|
* owning runtime plugin.
|
|
*
|
|
* This exists to preserve generic custom-provider behavior. Bundled providers
|
|
* should express replay ownership through `buildReplayPolicy` instead.
|
|
*/
|
|
function buildUnownedProviderTransportReplayFallback(params: {
|
|
modelApi?: string | null;
|
|
modelId?: string | null;
|
|
model?: ProviderRuntimeModel;
|
|
}): ProviderReplayPolicy | undefined {
|
|
const isGoogle = isGoogleModelApi(params.modelApi);
|
|
const isAnthropic = isAnthropicApi(params.modelApi);
|
|
const isStrictOpenAiCompatible = params.modelApi === "openai-completions";
|
|
const requiresOpenAiCompatibleToolIdSanitization =
|
|
params.modelApi === "openai-completions" ||
|
|
params.modelApi === "openai-responses" ||
|
|
params.modelApi === "openai-codex-responses" ||
|
|
params.modelApi === "azure-openai-responses";
|
|
|
|
if (
|
|
!isGoogle &&
|
|
!isAnthropic &&
|
|
!isStrictOpenAiCompatible &&
|
|
!requiresOpenAiCompatibleToolIdSanitization
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const modelId = normalizeLowercaseStringOrEmpty(params.modelId);
|
|
const isClaudeOpenAiResponses = isOpenAiResponsesCompatibleApi(params.modelApi)
|
|
? isClaudeFamilyModelId(modelId)
|
|
: false;
|
|
return {
|
|
...(isGoogle || isAnthropic ? { sanitizeMode: "full" as const } : {}),
|
|
...(isGoogle || isAnthropic || requiresOpenAiCompatibleToolIdSanitization
|
|
? {
|
|
sanitizeToolCallIds: true,
|
|
toolCallIdMode: "strict" as const,
|
|
}
|
|
: {}),
|
|
...(isAnthropic ? { preserveSignatures: true } : {}),
|
|
...(isGoogle
|
|
? {
|
|
sanitizeThoughtSignatures: {
|
|
allowBase64Only: true,
|
|
includeCamelCase: true,
|
|
},
|
|
}
|
|
: {}),
|
|
...(isAnthropic && modelId.includes("claude")
|
|
? { dropThinkingBlocks: !shouldPreserveThinkingBlocks(modelId) }
|
|
: {}),
|
|
...(isAnthropic && modelDisablesReasoningEffort(params.model)
|
|
? { dropThinkingBlocks: true }
|
|
: {}),
|
|
...(isStrictOpenAiCompatible
|
|
? { dropReasoningFromHistory: !requiresReasoningContentReplay(params.modelId) }
|
|
: {}),
|
|
...(isGoogle || isStrictOpenAiCompatible ? { applyAssistantFirstOrderingFix: true } : {}),
|
|
...(isGoogle || isStrictOpenAiCompatible ? { validateGeminiTurns: true } : {}),
|
|
...(isAnthropic || isStrictOpenAiCompatible || isClaudeOpenAiResponses
|
|
? { validateAnthropicTurns: true }
|
|
: {}),
|
|
...(isGoogle || isAnthropic || isOpenAiResponsesCompatibleApi(params.modelApi)
|
|
? { allowSyntheticToolResults: true }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
|
|
"kimi-for-coding",
|
|
"kimi-k2.5",
|
|
"kimi-k2.6",
|
|
"kimi-k2-thinking",
|
|
"kimi-k2-thinking-turbo",
|
|
"mimo-v2-pro",
|
|
"mimo-v2-omni",
|
|
"mimo-v2.5",
|
|
"mimo-v2.5-pro",
|
|
"mimo-v2.6-pro",
|
|
]);
|
|
|
|
function requiresReasoningContentReplay(modelId: string | null | undefined): boolean {
|
|
const normalized = normalizeLowercaseStringOrEmpty(modelId);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
const parts = normalized.split("/").filter(Boolean);
|
|
const finalPart = parts[parts.length - 1] ?? normalized;
|
|
const candidates = [finalPart];
|
|
const colonParts = finalPart.split(":").filter(Boolean);
|
|
if (colonParts.length > 1) {
|
|
candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? "");
|
|
}
|
|
return candidates.some((candidate) => REASONING_CONTENT_REPLAY_MODEL_IDS.has(candidate));
|
|
}
|
|
|
|
function mergeTranscriptPolicy(
|
|
policy: ProviderReplayPolicy | undefined,
|
|
basePolicy: TranscriptPolicy = DEFAULT_TRANSCRIPT_POLICY,
|
|
): TranscriptPolicy {
|
|
if (!policy) {
|
|
return basePolicy;
|
|
}
|
|
|
|
return {
|
|
...basePolicy,
|
|
...(policy.sanitizeMode != null ? { sanitizeMode: policy.sanitizeMode } : {}),
|
|
...(typeof policy.sanitizeToolCallIds === "boolean"
|
|
? { sanitizeToolCallIds: policy.sanitizeToolCallIds }
|
|
: {}),
|
|
...(policy.toolCallIdMode ? { toolCallIdMode: policy.toolCallIdMode as ToolCallIdMode } : {}),
|
|
...(typeof policy.preserveNativeAnthropicToolUseIds === "boolean"
|
|
? { preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds }
|
|
: {}),
|
|
...(typeof policy.repairToolUseResultPairing === "boolean"
|
|
? { repairToolUseResultPairing: policy.repairToolUseResultPairing }
|
|
: {}),
|
|
...(typeof policy.preserveSignatures === "boolean"
|
|
? { preserveSignatures: policy.preserveSignatures }
|
|
: {}),
|
|
...(policy.sanitizeThoughtSignatures
|
|
? { sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures }
|
|
: {}),
|
|
...(typeof policy.dropThinkingBlocks === "boolean"
|
|
? { dropThinkingBlocks: policy.dropThinkingBlocks }
|
|
: {}),
|
|
...(typeof policy.dropReasoningFromHistory === "boolean"
|
|
? { dropReasoningFromHistory: policy.dropReasoningFromHistory }
|
|
: {}),
|
|
...(typeof policy.applyAssistantFirstOrderingFix === "boolean"
|
|
? { applyGoogleTurnOrdering: policy.applyAssistantFirstOrderingFix }
|
|
: {}),
|
|
...(typeof policy.validateGeminiTurns === "boolean"
|
|
? { validateGeminiTurns: policy.validateGeminiTurns }
|
|
: {}),
|
|
...(typeof policy.validateAnthropicTurns === "boolean"
|
|
? { validateAnthropicTurns: policy.validateAnthropicTurns }
|
|
: {}),
|
|
...(typeof policy.allowSyntheticToolResults === "boolean"
|
|
? { allowSyntheticToolResults: policy.allowSyntheticToolResults }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
const transcriptPolicyCache = new WeakMap<OpenClawConfig, Map<string, TranscriptPolicy>>();
|
|
|
|
function canCacheTranscriptPolicy(params: {
|
|
config?: OpenClawConfig;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): params is { config: OpenClawConfig; env?: NodeJS.ProcessEnv } {
|
|
if (!params.config) {
|
|
return false;
|
|
}
|
|
return !params.env || params.env === process.env;
|
|
}
|
|
|
|
function resolveTranscriptPolicyCacheKey(params: {
|
|
modelApi?: string | null;
|
|
provider: string;
|
|
modelId?: string | null;
|
|
model?: ProviderRuntimeModel;
|
|
config: OpenClawConfig;
|
|
workspaceDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): string {
|
|
return JSON.stringify({
|
|
provider: params.provider,
|
|
modelApi: params.modelApi ?? "",
|
|
modelId: params.modelId ?? "",
|
|
dropsThinkingForReasoningCompat: modelDisablesReasoningEffort(params.model),
|
|
workspaceDir: params.workspaceDir ?? "",
|
|
pluginControlPlane: resolvePluginControlPlaneFingerprint({
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function resolveTranscriptPolicy(params: {
|
|
modelApi?: string | null;
|
|
provider?: string | null;
|
|
modelId?: string | null;
|
|
config?: OpenClawConfig;
|
|
workspaceDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
model?: ProviderRuntimeModel;
|
|
runtimeHandle?: ProviderRuntimePluginHandle;
|
|
}): TranscriptPolicy {
|
|
const provider = normalizeProviderId(params.provider ?? "");
|
|
const cacheConfig = canCacheTranscriptPolicy(params) ? params.config : undefined;
|
|
const cacheKey = cacheConfig
|
|
? resolveTranscriptPolicyCacheKey({ ...params, provider, config: cacheConfig })
|
|
: undefined;
|
|
if (cacheConfig && cacheKey) {
|
|
const cached = transcriptPolicyCache.get(cacheConfig)?.get(cacheKey);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
}
|
|
const runtimePlugin =
|
|
params.runtimeHandle?.plugin ??
|
|
(provider
|
|
? resolveProviderRuntimePlugin({
|
|
provider,
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
})
|
|
: undefined);
|
|
const context = {
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
provider,
|
|
modelId: params.modelId ?? "",
|
|
modelApi: params.modelApi,
|
|
model: params.model,
|
|
};
|
|
|
|
// Once a provider adopts the replay-policy hook, replay policy should come
|
|
// from the plugin, not from transport-family defaults in core.
|
|
const buildReplayPolicy = runtimePlugin?.buildReplayPolicy;
|
|
const policy = buildReplayPolicy
|
|
? mergeTranscriptPolicy(buildReplayPolicy(context) ?? undefined)
|
|
: mergeTranscriptPolicy(
|
|
buildUnownedProviderTransportReplayFallback({
|
|
modelApi: params.modelApi,
|
|
modelId: params.modelId,
|
|
model: params.model,
|
|
}),
|
|
);
|
|
if (cacheConfig && cacheKey) {
|
|
let configCache = transcriptPolicyCache.get(cacheConfig);
|
|
if (!configCache) {
|
|
configCache = new Map();
|
|
transcriptPolicyCache.set(cacheConfig, configCache);
|
|
}
|
|
configCache.set(cacheKey, policy);
|
|
}
|
|
return policy;
|
|
}
|