From 1e19f22dbdbcd1a0bf40d81985aecb7f3b965c8c Mon Sep 17 00:00:00 2001 From: Chinmay Rawat <88652081+Chinmayrawat15@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:05:16 -0700 Subject: [PATCH] fix: prompt caching breaks on Claude Opus 5 and Sonnet 5 (#121283) * fix: prompt caching breaks on Claude Opus 5 and Sonnet 5 shouldPreserveThinkingBlocks() matched modern Claude ids with a hardcoded family list plus /claude-[5-9]/ future-proofing. That regex assumes the generation follows the prefix (claude-5-x), but shipped generation-5 ids place the family in between (claude-opus-5, claude-sonnet-5, claude-mythos-5), so none matched and dropThinkingBlocks flipped to true. Signed thinking blocks were stripped from replayed history, diverging every request and invalidating the Anthropic prompt cache on the default opus/sonnet aliases. Read the generation from the id instead, preserving blocks for generation 4 and newer. This handles both id shapes and matches the idiom already used in audit-extra.sync.ts and live-model-filter.ts. Closes #121251 * fix: correct Claude thinking replay contract Use the canonical Claude model identity and exact Anthropic preservation contract when deciding whether replay may retain prior thinking blocks. Carry deployment metadata through provider-family and fallback paths, and include canonicalModelId in the transcript policy cache key. Release note: Restore prompt-cache reuse for Claude Opus 5 and Sonnet 5 while continuing to strip unsupported Sonnet and Haiku 4.5 thinking history. * refactor: remove unused replay compatibility helper Use the canonical Claude drop predicate at the remaining test call sites and remove the unconsumed preservation export so the dependency/dead-code gate stays clean. * fix: keep Claude replay contract internal Use canonical llm-core identity resolution without exporting a new llm-core capability, avoiding unintended Plugin SDK API drift. --------- Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> --- src/agents/transcript-policy.test.ts | 69 +++++++++++++++--- src/agents/transcript-policy.ts | 10 ++- src/plugin-sdk/provider-model-shared.test.ts | 5 +- src/plugin-sdk/provider-model-shared.ts | 8 +-- src/plugins/provider-replay-helpers.test.ts | 35 +++++++-- src/plugins/provider-replay-helpers.ts | 75 ++++++++------------ 6 files changed, 133 insertions(+), 69 deletions(-) diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 2d58ccf4232d..c23d31fd183f 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -69,8 +69,7 @@ vi.mock("../plugins/provider-hook-runtime.js", async () => { repairToolUseResultPairing: true, validateAnthropicTurns: true, allowSyntheticToolResults: true, - ...(modelId.includes("claude") && - !replayHelpers.shouldPreserveThinkingBlocks(modelId) + ...(replayHelpers.shouldDropClaudeThinkingBlocks(modelId) ? { dropThinkingBlocks: true } : {}), }; @@ -92,8 +91,7 @@ vi.mock("../plugins/provider-hook-runtime.js", async () => { repairToolUseResultPairing: true, validateAnthropicTurns: true, allowSyntheticToolResults: true, - ...(modelId.includes("claude") && - !replayHelpers.shouldPreserveThinkingBlocks(modelId) + ...(replayHelpers.shouldDropClaudeThinkingBlocks(modelId) ? { dropThinkingBlocks: true } : {}), }; @@ -438,7 +436,6 @@ describe("resolveTranscriptPolicy", () => { }); it("preserves thinking blocks for newer Claude models in unowned Anthropic transport fallback", () => { - // Opus 4.6 via custom proxy: should NOT drop thinking blocks const opus46 = resolveTranscriptPolicy({ provider: "custom-anthropic-proxy", modelId: "claude-opus-4-6", @@ -446,15 +443,20 @@ describe("resolveTranscriptPolicy", () => { }); expect(opus46.dropThinkingBlocks).toBe(false); - // Sonnet 4.5 via custom proxy: should NOT drop + const opus5 = resolveTranscriptPolicy({ + provider: "custom-anthropic-proxy", + modelId: "claude-opus-5", + modelApi: "anthropic-messages", + }); + expect(opus5.dropThinkingBlocks).toBe(false); + const sonnet45 = resolveTranscriptPolicy({ provider: "custom-anthropic-proxy", modelId: "claude-sonnet-4-5-20250929", modelApi: "anthropic-messages", }); - expect(sonnet45.dropThinkingBlocks).toBe(false); + expect(sonnet45.dropThinkingBlocks).toBe(true); - // Legacy Sonnet 3.7 via custom proxy: SHOULD drop const sonnet37 = resolveTranscriptPolicy({ provider: "custom-anthropic-proxy", modelId: "claude-3-7-sonnet-20250219", @@ -463,6 +465,57 @@ describe("resolveTranscriptPolicy", () => { expect(sonnet37.dropThinkingBlocks).toBe(true); }); + it("uses canonical deployment metadata in unowned Anthropic transport fallback", () => { + const policy = resolveTranscriptPolicy({ + provider: "custom-anthropic-proxy", + modelId: "prod-opus", + modelApi: "anthropic-messages", + model: makeOpenAiCompatibleReasoningModel({ + id: "prod-opus", + name: "Production Opus", + provider: "custom-anthropic-proxy", + api: "anthropic-messages", + params: { canonicalModelId: "claude-opus-5" }, + }), + }); + + expect(policy.dropThinkingBlocks).toBe(false); + }); + + it("does not reuse cached Anthropic policies across canonical model identities", () => { + const config = {} as OpenClawConfig; + const model = makeOpenAiCompatibleReasoningModel({ + id: "production-claude", + name: "Production Claude", + provider: "custom-anthropic-proxy", + api: "anthropic-messages", + }); + + const sonnet45 = resolveTranscriptPolicy({ + config, + provider: "custom-anthropic-proxy", + modelId: model.id, + modelApi: model.api, + model: { + ...model, + params: { canonicalModelId: "claude-sonnet-4-5-20250929" }, + }, + }); + const opus5 = resolveTranscriptPolicy({ + config, + provider: "custom-anthropic-proxy", + modelId: model.id, + modelApi: model.api, + model: { + ...model, + params: { canonicalModelId: "claude-opus-5" }, + }, + }); + + expect(sonnet45.dropThinkingBlocks).toBe(true); + expect(opus5.dropThinkingBlocks).toBe(false); + }); + it("strips thinking blocks for unowned Anthropic-compatible models that opt out of reasoning", () => { const policy = resolveTranscriptPolicy({ provider: "qiniu", diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 4f08c9ab918e..9365f6f0cac3 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -8,7 +8,7 @@ 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 { shouldDropClaudeThinkingBlocks } from "../plugins/provider-replay-helpers.js"; import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import type { ProviderReplayPolicy } from "../plugins/types.js"; import { isGoogleModelApi } from "./embedded-agent-helpers/google.js"; @@ -162,8 +162,8 @@ function buildUnownedProviderTransportReplayFallback(params: { }, } : {}), - ...(isAnthropic && modelId.includes("claude") - ? { dropThinkingBlocks: !shouldPreserveThinkingBlocks(modelId) } + ...(isAnthropic && shouldDropClaudeThinkingBlocks(modelId, params.model) + ? { dropThinkingBlocks: true } : {}), ...(isAnthropic && modelDisablesReasoningEffort(params.model) ? { dropThinkingBlocks: true } @@ -289,6 +289,10 @@ function resolveTranscriptPolicyCacheKey(params: { provider: params.provider, modelApi: params.modelApi ?? "", modelId: params.modelId ?? "", + canonicalModelId: + typeof params.model?.params?.canonicalModelId === "string" + ? params.model.params.canonicalModelId + : "", dropsThinkingForReasoningCompat: modelDisablesReasoningEffort(params.model), preservesReasoningContentReplay: params.model?.reasoning === true, workspaceDir: params.workspaceDir ?? "", diff --git a/src/plugin-sdk/provider-model-shared.test.ts b/src/plugin-sdk/provider-model-shared.test.ts index 257a28b01c6e..910e51585f10 100644 --- a/src/plugin-sdk/provider-model-shared.test.ts +++ b/src/plugin-sdk/provider-model-shared.test.ts @@ -222,7 +222,10 @@ describe("buildProviderReplayFamilyHooks", () => { ctx: { provider: "anthropic-vertex", modelApi: "anthropic-messages", - modelId: "claude-sonnet-4-6", + modelId: "prod-opus", + model: { + params: { canonicalModelId: "claude-opus-5" }, + }, }, match: { validateAnthropicTurns: true, diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index ac7aaa8d892f..6c30a2b0db6e 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -407,13 +407,13 @@ export function buildProviderReplayFamilyHooks( } case "anthropic-by-model": return { - buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) => - buildAnthropicReplayPolicyForModel(modelId), + buildReplayPolicy: ({ modelId, model }: ProviderReplayPolicyContext) => + buildAnthropicReplayPolicyForModel(modelId, model), }; case "native-anthropic-by-model": return { - buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) => - buildNativeAnthropicReplayPolicyForModel(modelId), + buildReplayPolicy: ({ modelId, model }: ProviderReplayPolicyContext) => + buildNativeAnthropicReplayPolicyForModel(modelId, model), }; case "google-gemini": return { diff --git a/src/plugins/provider-replay-helpers.test.ts b/src/plugins/provider-replay-helpers.test.ts index 64ce305add10..a68b5561d90b 100644 --- a/src/plugins/provider-replay-helpers.test.ts +++ b/src/plugins/provider-replay-helpers.test.ts @@ -128,27 +128,50 @@ describe("provider replay helpers", () => { ); }); - it("preserves thinking blocks for Claude Opus 4.5+ and Sonnet 4.5+ models", () => { - // These models should NOT drop thinking blocks + it("preserves thinking blocks only for Claude models with native history support", () => { for (const modelId of [ "claude-fable-5", "claude-opus-4-5-20251101", "claude-opus-4-6", - "claude-sonnet-4-5-20250929", "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", + "claude-opus-5", + "claude-sonnet-5", + "claude-mythos-5", + "us.anthropic.claude-opus-5-20260101-v1:0", ]) { const policy = buildAnthropicReplayPolicyForModel(modelId); expect(policy).not.toHaveProperty("dropThinkingBlocks"); } - // These legacy models SHOULD drop thinking blocks - for (const modelId of ["claude-3-7-sonnet-20250219", "claude-3-5-sonnet-20240620"]) { + for (const modelId of [ + "claude-opus-4-1", + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5-20251001", + "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet-20240620", + "claude-3-opus-20240229", + "claude-opus-50", + "claude-sonnet-50", + "claude-sonnet-4-60", + ]) { const policy = buildAnthropicReplayPolicyForModel(modelId); expect(policy.dropThinkingBlocks).toBe(true); } }); + it("uses canonical deployment metadata for Claude replay policy", () => { + expect( + buildAnthropicReplayPolicyForModel("prod-opus", { + params: { canonicalModelId: "claude-opus-5" }, + }), + ).not.toHaveProperty("dropThinkingBlocks"); + expect( + buildAnthropicReplayPolicyForModel("prod-sonnet", { + params: { canonicalModelId: "claude-sonnet-4-5-20250929" }, + }), + ).toHaveProperty("dropThinkingBlocks", true); + }); + it("builds native Anthropic replay policy with selective tool-call id preservation", () => { // Sonnet 4.6 preserves thinking blocks const policy46 = buildNativeAnthropicReplayPolicyForModel("claude-sonnet-4-6"); diff --git a/src/plugins/provider-replay-helpers.ts b/src/plugins/provider-replay-helpers.ts index 7d8de195692a..d3d25ed2246a 100644 --- a/src/plugins/provider-replay-helpers.ts +++ b/src/plugins/provider-replay-helpers.ts @@ -1,7 +1,9 @@ // Provides shared replay-policy helpers for provider plugins. +import { resolveClaudeModelIdentity, resolveClaudeOpus5ModelIdentity } from "@openclaw/llm-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { AgentMessage } from "../agents/runtime/index.js"; import { sanitizeGoogleAssistantFirstOrdering } from "../shared/google-turn-ordering.js"; +import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import type { ProviderReasoningOutputMode, ProviderReplayPolicy, @@ -92,59 +94,40 @@ export function buildStrictAnthropicReplayPolicy( }; } -/** - * Returns true for Claude models that preserve thinking blocks in context - * natively (Fable 5, Opus 4.5+, Sonnet 4.5+, Haiku 4.5+). For these models, - * dropping thinking blocks from prior turns breaks replay and prompt caching. - * - * See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking#differences-in-thinking-across-model-versions - * - * @deprecated Anthropic-family provider replay helper; prefer provider-local replay hooks. - */ -export function shouldPreserveThinkingBlocks(modelId?: string): boolean { - const id = normalizeLowercaseStringOrEmpty(modelId); - if (!id.includes("claude")) { - return false; - } - - // Models that preserve thinking blocks natively (Claude 4.5+): - // - claude-fable-5 - // - claude-opus-4-x (opus-4-5, opus-4-6, ...) - // - claude-sonnet-4-x (sonnet-4-5, sonnet-4-6, ...) - // Note: "sonnet-4" is safe — legacy "claude-3-5-sonnet" does not contain "sonnet-4" - // - claude-haiku-4-x (haiku-4-5, ...) - // Models that require dropping thinking blocks: - // - claude-3-7-sonnet, claude-3-5-sonnet, and earlier - if ( - id.includes("fable-5") || - id.includes("opus-4") || - id.includes("sonnet-4") || - id.includes("haiku-4") - ) { - return true; - } - - // Future-proofing: claude-5-x, claude-6-x etc. should also preserve - if (/claude-[5-9]/.test(id) || /claude-\d{2,}/.test(id)) { - return true; - } - - return false; +/** @deprecated Anthropic-family provider replay helper; prefer provider-local replay hooks. */ +export function shouldDropClaudeThinkingBlocks( + modelId?: string, + model?: Pick, +): boolean { + const ref = { id: modelId, params: model?.params }; + const canonicalId = resolveClaudeModelIdentity(ref); + const isClaude = + canonicalId.startsWith("claude-") || resolveClaudeOpus5ModelIdentity(ref) !== undefined; + const preservesThinking = + resolveClaudeOpus5ModelIdentity(ref) !== undefined || + /(?:^|-)claude-(?:fable-5|mythos-(?:5|preview)|opus-4-(?:5|6|7|8)|sonnet-(?:5|4-6))(?=$|[^a-z0-9])/.test( + canonicalId, + ); + return isClaude && !preservesThinking; } /** @deprecated Anthropic-family provider replay helper; prefer provider-local replay hooks. */ -export function buildAnthropicReplayPolicyForModel(modelId?: string): ProviderReplayPolicy { - const isClaude = normalizeLowercaseStringOrEmpty(modelId).includes("claude"); +export function buildAnthropicReplayPolicyForModel( + modelId?: string, + model?: Pick, +): ProviderReplayPolicy { return buildStrictAnthropicReplayPolicy({ - dropThinkingBlocks: isClaude && !shouldPreserveThinkingBlocks(modelId), + dropThinkingBlocks: shouldDropClaudeThinkingBlocks(modelId, model), }); } /** @deprecated Anthropic-family provider replay helper; prefer provider-local replay hooks. */ -export function buildNativeAnthropicReplayPolicyForModel(modelId?: string): ProviderReplayPolicy { - const isClaude = normalizeLowercaseStringOrEmpty(modelId).includes("claude"); +export function buildNativeAnthropicReplayPolicyForModel( + modelId?: string, + model?: Pick, +): ProviderReplayPolicy { return buildStrictAnthropicReplayPolicy({ - dropThinkingBlocks: isClaude && !shouldPreserveThinkingBlocks(modelId), + dropThinkingBlocks: shouldDropClaudeThinkingBlocks(modelId, model), sanitizeToolCallIds: true, preserveNativeAnthropicToolUseIds: true, }); @@ -156,12 +139,10 @@ export function buildHybridAnthropicOrOpenAIReplayPolicy( options: { anthropicModelDropThinkingBlocks?: boolean } = {}, ): ProviderReplayPolicy | undefined { if (ctx.modelApi === "anthropic-messages" || ctx.modelApi === "bedrock-converse-stream") { - const isClaude = normalizeLowercaseStringOrEmpty(ctx.modelId).includes("claude"); return buildStrictAnthropicReplayPolicy({ dropThinkingBlocks: options.anthropicModelDropThinkingBlocks && - isClaude && - !shouldPreserveThinkingBlocks(ctx.modelId), + shouldDropClaudeThinkingBlocks(ctx.modelId, ctx.model), }); }