openclaw/src/agents/transcript-policy.ts
spiceoogway ccac2aeec2 Fix issue #4718: Enforce 40-char limit for OpenAI tool call IDs
OpenAI's API requires tool_call IDs to be <= 40 characters, but Clawdbot
was not enforcing this limit for OpenAI providers after commit 870bfa94e.

Changes:
- Re-enable tool call ID sanitization for OpenAI in transcript-policy.ts
- The sanitization preserves tool_use/tool_result pairing via stable mapping
- Add test case for 439-char ID (similar to reported 438-char issue)
- Update test expectations to reflect OpenAI now gets sanitized IDs

Root cause: Commit 870bfa94e disabled sanitization to "preserve Pi pairing",
but the sanitization logic already preserves pairing correctly via the
resolve() function in tool-call-id.ts.

Fixes openclaw/openclaw#4718
2026-01-30 10:56:36 -05:00

115 lines
4.0 KiB
TypeScript

import { isAntigravityClaude, isGoogleModelApi } from "./pi-embedded-helpers/google.js";
import { normalizeProviderId } from "./model-selection.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;
};
normalizeAntigravityThinkingBlocks: boolean;
applyGoogleTurnOrdering: boolean;
validateGeminiTurns: boolean;
validateAnthropicTurns: boolean;
allowSyntheticToolResults: boolean;
};
const MISTRAL_MODEL_HINTS = [
"mistral",
"mixtral",
"codestral",
"pixtral",
"devstral",
"ministral",
"mistralai",
];
const OPENAI_MODEL_APIS = new Set([
"openai",
"openai-completions",
"openai-responses",
"openai-codex-responses",
]);
const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]);
function isOpenAiApi(modelApi?: string | null): boolean {
if (!modelApi) return false;
return OPENAI_MODEL_APIS.has(modelApi);
}
function isOpenAiProvider(provider?: string | null): boolean {
if (!provider) return false;
return OPENAI_PROVIDERS.has(normalizeProviderId(provider));
}
function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean {
if (modelApi === "anthropic-messages") return true;
const normalized = normalizeProviderId(provider ?? "");
// MiniMax now uses openai-completions API, not anthropic-messages
return normalized === "anthropic";
}
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
const provider = normalizeProviderId(params.provider ?? "");
if (provider === "mistral") return true;
const modelId = (params.modelId ?? "").toLowerCase();
if (!modelId) return false;
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
}
export function resolveTranscriptPolicy(params: {
modelApi?: string | null;
provider?: string | null;
modelId?: string | null;
}): TranscriptPolicy {
const provider = normalizeProviderId(params.provider ?? "");
const modelId = params.modelId ?? "";
const isGoogle = isGoogleModelApi(params.modelApi);
const isAnthropic = isAnthropicApi(params.modelApi, provider);
const isOpenAi = isOpenAiProvider(provider) || (!provider && isOpenAiApi(params.modelApi));
const isMistral = isMistralModel({ provider, modelId });
const isOpenRouterGemini =
(provider === "openrouter" || provider === "opencode") &&
modelId.toLowerCase().includes("gemini");
const isAntigravityClaudeModel = isAntigravityClaude({
api: params.modelApi,
provider,
modelId,
});
const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini;
// OpenAI requires tool call IDs <= 40 chars (issue #4718)
const sanitizeToolCallIds = isGoogle || isMistral || isOpenAi;
const toolCallIdMode: ToolCallIdMode | undefined = isMistral
? "strict9"
: sanitizeToolCallIds
? "strict"
: undefined;
const repairToolUseResultPairing = isGoogle || isAnthropic;
const sanitizeThoughtSignatures = isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true }
: undefined;
const normalizeAntigravityThinkingBlocks = isAntigravityClaudeModel;
return {
sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only",
sanitizeToolCallIds,
toolCallIdMode,
repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing,
preserveSignatures: isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures,
normalizeAntigravityThinkingBlocks,
applyGoogleTurnOrdering: !isOpenAi && isGoogle,
validateGeminiTurns: !isOpenAi && isGoogle,
validateAnthropicTurns: !isOpenAi && isAnthropic,
allowSyntheticToolResults: !isOpenAi && (isGoogle || isAnthropic),
};
}