mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
fix(anthropic): omit stale thinking from disabled requests (#92373)
Preserve signed thinking for active Anthropic tool-result continuation while omitting native thinking from completed history when the new request disables or omits thinking. Applies the same replay rule to the legacy SDK provider and managed Anthropic transport. Fixes #92360.
This commit is contained in:
@@ -1585,6 +1585,95 @@ describe("anthropic transport stream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces a completed thinking-only turn when the current request disables thinking", async () => {
|
||||
await runTransportStream(
|
||||
makeAnthropicTransportModel(),
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-6",
|
||||
stopReason: "stop",
|
||||
timestamp: 0,
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "private reasoning",
|
||||
thinkingSignature: "sig_1",
|
||||
},
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "[Reasoning redacted]",
|
||||
thinkingSignature: "opaque_1",
|
||||
redacted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "again" },
|
||||
],
|
||||
} as AnthropicStreamContext,
|
||||
{
|
||||
apiKey: "sk-ant-api",
|
||||
} as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
const payload = latestAnthropicRequest().payload;
|
||||
const assistantMessage = findRecord(payload.messages, (record) => record.role === "assistant");
|
||||
expect(payload.thinking).toEqual({ type: "disabled" });
|
||||
expect(assistantMessage.content).toEqual([
|
||||
{ type: "text", text: "[assistant reasoning omitted]" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves signed thinking for an active tool turn when new thinking is disabled", async () => {
|
||||
await runTransportStream(
|
||||
makeAnthropicTransportModel(),
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "look it up" },
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-6",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 0,
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "call lookup",
|
||||
thinkingSignature: "sig_tool",
|
||||
},
|
||||
{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [{ type: "text", text: "42" }],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
} as AnthropicStreamContext,
|
||||
{
|
||||
apiKey: "sk-ant-api",
|
||||
} as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
const assistantMessage = findRecord(
|
||||
latestAnthropicRequest().payload.messages,
|
||||
(record) => record.role === "assistant",
|
||||
);
|
||||
expect(assistantMessage.content).toEqual([
|
||||
{ type: "thinking", thinking: "call lookup", signature: "sig_tool" },
|
||||
{ type: "tool_use", id: "call_1", name: "lookup", input: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it("backfills empty reasoning_content thinking blocks for compatible Anthropic tool-use replays", async () => {
|
||||
await runTransportStream(
|
||||
makeAnthropicTransportModel({
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { getEnvApiKey } from "../llm/env-api-keys.js";
|
||||
import { calculateCost, clampThinkingLevel } from "../llm/model-utils.js";
|
||||
import {
|
||||
ANTHROPIC_OMITTED_REASONING_TEXT,
|
||||
findActiveAnthropicToolTurnAssistantIndex,
|
||||
} from "../llm/providers/anthropic-thinking-replay.js";
|
||||
import type { AnthropicOptions } from "../llm/providers/anthropic.js";
|
||||
import type {
|
||||
AssistantMessageDiagnostic,
|
||||
@@ -372,11 +376,18 @@ function convertAnthropicMessages(
|
||||
messages: Context["messages"],
|
||||
model: AnthropicTransportModel,
|
||||
isOAuthToken: boolean,
|
||||
options?: { allowReasoningContentReplay?: boolean },
|
||||
options?: {
|
||||
allowReasoningContentReplay?: boolean;
|
||||
replayThinkingEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
const params: Array<Record<string, unknown>> = [];
|
||||
const allowReasoningContentReplay = options?.allowReasoningContentReplay === true;
|
||||
const replayThinkingEnabled = options?.replayThinkingEnabled !== false;
|
||||
const transformedMessages = transformTransportMessages(messages, model, normalizeToolCallId);
|
||||
const activeToolTurnAssistantIndex = replayThinkingEnabled
|
||||
? -1
|
||||
: findActiveAnthropicToolTurnAssistantIndex(transformedMessages);
|
||||
for (let i = 0; i < transformedMessages.length; i += 1) {
|
||||
const msg = transformedMessages[i];
|
||||
if (msg.role === "user") {
|
||||
@@ -428,6 +439,7 @@ function convertAnthropicMessages(
|
||||
if (msg.role === "assistant") {
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
const reasoningContent: string[] = [];
|
||||
let omittedThinking = false;
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
if (block.text.trim().length > 0) {
|
||||
@@ -439,6 +451,12 @@ function convertAnthropicMessages(
|
||||
continue;
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
const thinkingSignature = block.thinkingSignature?.trim();
|
||||
const isReasoningContent = thinkingSignature === "reasoning_content";
|
||||
if (!replayThinkingEnabled && i !== activeToolTurnAssistantIndex && !isReasoningContent) {
|
||||
omittedThinking = true;
|
||||
continue;
|
||||
}
|
||||
if (block.redacted) {
|
||||
blocks.push({
|
||||
type: "redacted_thinking",
|
||||
@@ -446,9 +464,7 @@ function convertAnthropicMessages(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const thinkingSignature = block.thinkingSignature?.trim();
|
||||
const hasNativeThinkingSignature =
|
||||
Boolean(thinkingSignature) && thinkingSignature !== "reasoning_content";
|
||||
const hasNativeThinkingSignature = Boolean(thinkingSignature) && !isReasoningContent;
|
||||
if (block.thinking.trim().length === 0 && !hasNativeThinkingSignature) {
|
||||
continue;
|
||||
}
|
||||
@@ -490,6 +506,9 @@ function convertAnthropicMessages(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blocks.length === 0 && omittedThinking) {
|
||||
blocks.push({ type: "text", text: ANTHROPIC_OMITTED_REASONING_TEXT });
|
||||
}
|
||||
if (blocks.length > 0) {
|
||||
const assistantMsg: Record<string, unknown> = { role: "assistant", content: blocks };
|
||||
if (reasoningContent.length > 0) {
|
||||
@@ -899,6 +918,8 @@ function buildAnthropicParams(
|
||||
isOAuthToken: boolean,
|
||||
options: AnthropicTransportOptions | undefined,
|
||||
) {
|
||||
const fable5 = usesClaudeFable5MessagesContract(model);
|
||||
const replayThinkingEnabled = fable5 || options?.thinkingEnabled === true;
|
||||
const maxTokens = resolveAnthropicMessagesMaxTokens({
|
||||
modelMaxTokens: model.maxTokens,
|
||||
requestedMaxTokens: options?.maxTokens,
|
||||
@@ -920,6 +941,7 @@ function buildAnthropicParams(
|
||||
messages: ensureNonEmptyAnthropicMessages(
|
||||
convertAnthropicMessages(context.messages, model, isOAuthToken, {
|
||||
allowReasoningContentReplay: supportsReasoningContentReplay(model),
|
||||
replayThinkingEnabled,
|
||||
}),
|
||||
),
|
||||
max_tokens: maxTokens,
|
||||
@@ -961,7 +983,6 @@ function buildAnthropicParams(
|
||||
if (context.tools) {
|
||||
params.tools = convertAnthropicTools(context.tools, isOAuthToken);
|
||||
}
|
||||
const fable5 = usesClaudeFable5MessagesContract(model);
|
||||
if (fable5 || model.reasoning || supportsAdaptiveThinking(model)) {
|
||||
if (fable5 || options?.thinkingEnabled) {
|
||||
if (supportsAdaptiveThinking(model)) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
type ReplayMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
toolCallId?: unknown;
|
||||
};
|
||||
|
||||
export const ANTHROPIC_OMITTED_REASONING_TEXT = "[assistant reasoning omitted]";
|
||||
|
||||
function asReplayMessage(value: unknown): ReplayMessage | undefined {
|
||||
return value && typeof value === "object" ? (value as ReplayMessage) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic tool results continue the preceding assistant turn. Preserve that
|
||||
* turn's signed thinking even when the next request disables new thinking.
|
||||
*/
|
||||
export function findActiveAnthropicToolTurnAssistantIndex(messages: readonly unknown[]): number {
|
||||
const toolResultIds = new Set<string>();
|
||||
let index = messages.length - 1;
|
||||
|
||||
while (index >= 0) {
|
||||
const message = asReplayMessage(messages[index]);
|
||||
if (message?.role !== "toolResult") {
|
||||
break;
|
||||
}
|
||||
if (typeof message.toolCallId === "string") {
|
||||
toolResultIds.add(message.toolCallId);
|
||||
}
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
if (toolResultIds.size === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const assistant = asReplayMessage(messages[index]);
|
||||
if (assistant?.role !== "assistant" || !Array.isArray(assistant.content)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const toolCallIds = new Set<string>();
|
||||
for (const block of assistant.content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as { type?: unknown; id?: unknown };
|
||||
if (
|
||||
(record.type === "toolCall" ||
|
||||
record.type === "tool_use" ||
|
||||
record.type === "function_call") &&
|
||||
typeof record.id === "string"
|
||||
) {
|
||||
toolCallIds.add(record.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...toolResultIds].every((toolCallId) => toolCallIds.has(toolCallId)) ? index : -1;
|
||||
}
|
||||
@@ -242,6 +242,148 @@ describe("Anthropic provider", () => {
|
||||
expect(result.responseModel).toBe("claude-fable-5");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "omitted",
|
||||
thinkingEnabled: undefined,
|
||||
expectedThinking: undefined,
|
||||
visibleText: undefined,
|
||||
expectedContent: [{ type: "text", text: "[assistant reasoning omitted]" }],
|
||||
},
|
||||
{
|
||||
label: "explicitly disabled",
|
||||
thinkingEnabled: false,
|
||||
expectedThinking: { type: "disabled" },
|
||||
visibleText: "Visible answer.",
|
||||
expectedContent: [{ type: "text", text: "Visible answer." }],
|
||||
},
|
||||
])(
|
||||
"omits completed-turn thinking when thinking is $label",
|
||||
async ({ thinkingEnabled, expectedThinking, visibleText, expectedContent }) => {
|
||||
let capturedPayload: unknown;
|
||||
const stream = streamAnthropic(
|
||||
makeAnthropicModel(),
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello", timestamp: 0 },
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-6",
|
||||
stopReason: "stop",
|
||||
timestamp: 0,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "private reasoning",
|
||||
thinkingSignature: "sig_1",
|
||||
},
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "[Reasoning redacted]",
|
||||
thinkingSignature: "opaque_1",
|
||||
redacted: true,
|
||||
},
|
||||
...(visibleText ? [{ type: "text" as const, text: visibleText }] : []),
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "again", timestamp: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "sk-ant-provider",
|
||||
thinkingEnabled,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
throw new Error("stop before network");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await stream.result();
|
||||
|
||||
const payload = capturedPayload as {
|
||||
messages: Array<{ role: string; content: unknown[] }>;
|
||||
thinking?: unknown;
|
||||
};
|
||||
expect(payload.thinking).toEqual(expectedThinking);
|
||||
expect(payload.messages.find((message) => message.role === "assistant")?.content).toEqual(
|
||||
expectedContent,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves signed thinking for an active tool turn when new thinking is disabled", async () => {
|
||||
let capturedPayload: unknown;
|
||||
const stream = streamAnthropic(
|
||||
makeAnthropicModel(),
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "look it up", timestamp: 0 },
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-6",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 0,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "call lookup",
|
||||
thinkingSignature: "sig_tool",
|
||||
},
|
||||
{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [{ type: "text", text: "42" }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "sk-ant-provider",
|
||||
thinkingEnabled: false,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
throw new Error("stop before network");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await stream.result();
|
||||
|
||||
const payload = capturedPayload as {
|
||||
messages: Array<{ role: string; content: unknown[] }>;
|
||||
};
|
||||
expect(payload.messages.find((message) => message.role === "assistant")?.content).toEqual([
|
||||
{ type: "thinking", thinking: "call lookup", signature: "sig_tool" },
|
||||
{ type: "tool_use", id: "call_1", name: "lookup", input: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["anthropic", "sk-ant-provider"],
|
||||
["anthropic-vertex", "vertex-token"],
|
||||
|
||||
@@ -50,6 +50,10 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import {
|
||||
ANTHROPIC_OMITTED_REASONING_TEXT,
|
||||
findActiveAnthropicToolTurnAssistantIndex,
|
||||
} from "./anthropic-thinking-replay.js";
|
||||
import { resolveCloudflareBaseUrl } from "./cloudflare.js";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js";
|
||||
@@ -1054,6 +1058,8 @@ function buildParams(
|
||||
isOAuthTokenResult: boolean,
|
||||
options?: AnthropicOptions,
|
||||
): MessageCreateParamsStreaming {
|
||||
const fable5 = usesClaudeFable5MessagesContract(model);
|
||||
const replayThinkingEnabled = fable5 || options?.thinkingEnabled === true;
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention);
|
||||
const system = buildAnthropicSystemBlocks(context.systemPrompt, isOAuthTokenResult, cacheControl);
|
||||
const compat = context.tools?.length ? getAnthropicCompat(model) : undefined;
|
||||
@@ -1080,6 +1086,7 @@ function buildParams(
|
||||
isOAuthTokenResult,
|
||||
cacheControl,
|
||||
messageCacheControlLimit,
|
||||
replayThinkingEnabled,
|
||||
),
|
||||
max_tokens: options?.maxTokens ?? model.maxTokens,
|
||||
stream: true,
|
||||
@@ -1109,7 +1116,6 @@ function buildParams(
|
||||
// Configure thinking mode: always-on adaptive (Fable 5), adaptive (Opus
|
||||
// 4.6+ and Sonnet 4.6),
|
||||
// budget-based (older models), or explicitly disabled.
|
||||
const fable5 = usesClaudeFable5MessagesContract(model);
|
||||
if (fable5 || model.reasoning || supportsAdaptiveThinking(model)) {
|
||||
if (fable5 || options?.thinkingEnabled) {
|
||||
// Default to "summarized" so Opus 4.7+ and Mythos Preview behave like
|
||||
@@ -1166,11 +1172,15 @@ function convertMessages(
|
||||
isOAuthTokenValue: boolean,
|
||||
cacheControl?: CacheControlEphemeral,
|
||||
messageCacheControlLimit = 4,
|
||||
replayThinkingEnabled = true,
|
||||
): MessageParam[] {
|
||||
const params: MessageParam[] = [];
|
||||
|
||||
// Transform messages for cross-provider compatibility
|
||||
const transformedMessages = transformMessages(messages, model, normalizeToolCallId);
|
||||
const activeToolTurnAssistantIndex = replayThinkingEnabled
|
||||
? -1
|
||||
: findActiveAnthropicToolTurnAssistantIndex(transformedMessages);
|
||||
|
||||
for (let i = 0; i < transformedMessages.length; i++) {
|
||||
const msg = transformedMessages[i];
|
||||
@@ -1216,6 +1226,7 @@ function convertMessages(
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
const blocks: ContentBlockParam[] = [];
|
||||
let omittedThinking = false;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
@@ -1227,6 +1238,10 @@ function convertMessages(
|
||||
text: sanitizeSurrogates(block.text),
|
||||
});
|
||||
} else if (block.type === "thinking") {
|
||||
if (!replayThinkingEnabled && i !== activeToolTurnAssistantIndex) {
|
||||
omittedThinking = true;
|
||||
continue;
|
||||
}
|
||||
// Redacted thinking: pass the opaque payload back as redacted_thinking
|
||||
if (block.redacted) {
|
||||
blocks.push({
|
||||
@@ -1270,6 +1285,9 @@ function convertMessages(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blocks.length === 0 && omittedThinking) {
|
||||
blocks.push({ type: "text", text: ANTHROPIC_OMITTED_REASONING_TEXT });
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user