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>
This commit is contained in:
Chinmay Rawat
2026-08-12 20:05:16 -07:00
committed by GitHub
parent 812bbd8884
commit 1e19f22dbd
6 changed files with 133 additions and 69 deletions
+61 -8
View File
@@ -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",
+7 -3
View File
@@ -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 ?? "",
+4 -1
View File
@@ -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,
+4 -4
View File
@@ -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 {
+29 -6
View File
@@ -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");
+28 -47
View File
@@ -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<ProviderRuntimeModel, "params">,
): 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<ProviderRuntimeModel, "params">,
): 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<ProviderRuntimeModel, "params">,
): 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),
});
}