import type { OpenClawConfig } from "../config/config.js"; import { resolveProviderRuntimePlugin } from "../plugins/provider-runtime.js"; import type { ProviderReplayPolicy, ProviderRuntimeModel } from "../plugins/types.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; repairToolUseResultPairing: boolean; preserveSignatures: boolean; sanitizeThoughtSignatures?: { allowBase64Only?: boolean; includeCamelCase?: boolean; }; sanitizeThinkingSignatures: boolean; dropThinkingBlocks: boolean; applyGoogleTurnOrdering: boolean; validateGeminiTurns: boolean; validateAnthropicTurns: boolean; allowSyntheticToolResults: boolean; }; const DEFAULT_TRANSCRIPT_POLICY: TranscriptPolicy = { sanitizeMode: "images-only", sanitizeToolCallIds: false, toolCallIdMode: undefined, repairToolUseResultPairing: true, preserveSignatures: false, sanitizeThoughtSignatures: undefined, sanitizeThinkingSignatures: false, dropThinkingBlocks: false, applyGoogleTurnOrdering: false, validateGeminiTurns: false, validateAnthropicTurns: false, allowSyntheticToolResults: false, }; function isAnthropicApi(modelApi?: string | null): boolean { return modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream"; } /** * 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; }): 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 = params.modelId?.toLowerCase() ?? ""; 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: true } : {}), ...(isGoogle || isStrictOpenAiCompatible ? { applyAssistantFirstOrderingFix: true } : {}), ...(isGoogle || isStrictOpenAiCompatible ? { validateGeminiTurns: true } : {}), ...(isAnthropic || isStrictOpenAiCompatible ? { validateAnthropicTurns: true } : {}), ...(isGoogle || isAnthropic ? { allowSyntheticToolResults: true } : {}), }; } 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.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.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 } : {}), }; } export function resolveTranscriptPolicy(params: { modelApi?: string | null; provider?: string | null; modelId?: string | null; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; model?: ProviderRuntimeModel; }): TranscriptPolicy { const provider = normalizeProviderId(params.provider ?? ""); const runtimePlugin = 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; if (buildReplayPolicy) { const pluginPolicy = buildReplayPolicy(context); return mergeTranscriptPolicy(pluginPolicy ?? undefined); } return mergeTranscriptPolicy( buildUnownedProviderTransportReplayFallback({ modelApi: params.modelApi, modelId: params.modelId, }), ); }