mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user