Skip to content

Commit 5e8dc95

Browse files
committed
fix(anthropic): strip thinking blocks from history when thinking is disabled
When a persistent session accumulates thinking blocks from previous runs (thinking enabled) but the current request has thinking disabled, Anthropic rejects the request with a 400 because the history contains thinking content blocks without a matching thinking configuration. This commonly affects cron jobs running in named sessions where the thinking configuration changes between runs. Fix: in convertMessages, when thinkingEnabled is explicitly false, convert thinking blocks to plain text (preserving context) and drop redacted thinking blocks (opaque, no readable content). When thinkingEnabled is undefined (not set), existing behavior is preserved. Fixes #92360
1 parent d481994 commit 5e8dc95

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

src/llm/providers/anthropic.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,100 @@ describe("Anthropic provider", () => {
242242
expect(result.responseModel).toBe("claude-fable-5");
243243
});
244244

245+
it("strips thinking blocks from history when thinking is explicitly disabled", async () => {
246+
let capturedPayload: unknown;
247+
const client = {
248+
messages: {
249+
create: vi.fn(() => ({
250+
asResponse: () =>
251+
Promise.resolve(
252+
createSseResponse([
253+
{
254+
type: "message_start",
255+
message: {
256+
id: "msg_1",
257+
model: "claude-sonnet-4-6",
258+
usage: { input_tokens: 1, output_tokens: 0 },
259+
},
260+
},
261+
{
262+
type: "message_delta",
263+
delta: { stop_reason: "end_turn" },
264+
usage: { input_tokens: 1, output_tokens: 1 },
265+
},
266+
{ type: "message_stop" },
267+
]),
268+
),
269+
})),
270+
},
271+
};
272+
273+
const stream = streamAnthropic(
274+
makeAnthropicModel(),
275+
{
276+
messages: [
277+
{ role: "user", content: "hello", timestamp: 0 },
278+
{
279+
role: "assistant",
280+
provider: "anthropic",
281+
api: "anthropic-messages",
282+
model: "claude-sonnet-4-6",
283+
stopReason: "stop",
284+
timestamp: 0,
285+
usage: {
286+
input: 0,
287+
output: 0,
288+
cacheRead: 0,
289+
cacheWrite: 0,
290+
totalTokens: 0,
291+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
292+
},
293+
content: [
294+
{
295+
type: "thinking",
296+
thinking: "deep reasoning about the answer",
297+
thinkingSignature: "sig_1",
298+
},
299+
{
300+
type: "text",
301+
text: "Here is the answer.",
302+
},
303+
],
304+
},
305+
{ role: "user", content: "again", timestamp: 0 },
306+
],
307+
},
308+
{
309+
apiKey: "***",
310+
client: client as never,
311+
thinkingEnabled: false,
312+
onPayload: (payload) => {
313+
capturedPayload = payload;
314+
},
315+
},
316+
);
317+
318+
await stream.result();
319+
320+
const payload = capturedPayload as { messages: Array<{ role: string; content: unknown[] }> };
321+
const assistantMessage = payload.messages.find((message) => message.role === "assistant");
322+
// Thinking block with signature should be converted to plain text, not sent as thinking type
323+
expect(assistantMessage?.content).toEqual([
324+
{
325+
type: "text",
326+
text: "deep reasoning about the answer",
327+
},
328+
{
329+
type: "text",
330+
text: "Here is the answer.",
331+
},
332+
]);
333+
// No thinking or redacted_thinking blocks should be present
334+
const contentStr = JSON.stringify(assistantMessage?.content);
335+
expect(contentStr).not.toContain('"type":"thinking"');
336+
expect(contentStr).not.toContain('"type":"redacted_thinking"');
337+
});
338+
245339
it.each([
246340
["anthropic", "sk-ant-provider"],
247341
["anthropic-vertex", "vertex-token"],

src/llm/providers/anthropic.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,7 @@ function buildParams(
10801080
isOAuthTokenResult,
10811081
cacheControl,
10821082
messageCacheControlLimit,
1083+
options?.thinkingEnabled,
10831084
),
10841085
max_tokens: options?.maxTokens ?? model.maxTokens,
10851086
stream: true,
@@ -1166,6 +1167,7 @@ function convertMessages(
11661167
isOAuthTokenValue: boolean,
11671168
cacheControl?: CacheControlEphemeral,
11681169
messageCacheControlLimit = 4,
1170+
thinkingEnabled?: boolean,
11691171
): MessageParam[] {
11701172
const params: MessageParam[] = [];
11711173

@@ -1227,6 +1229,18 @@ function convertMessages(
12271229
text: sanitizeSurrogates(block.text),
12281230
});
12291231
} else if (block.type === "thinking") {
1232+
// When thinking is explicitly disabled for the current request, Anthropic
1233+
// rejects history containing thinking/redacted_thinking blocks. Strip them
1234+
// and convert to plain text where possible to preserve context.
1235+
if (thinkingEnabled === false) {
1236+
if (!block.redacted && block.thinking?.trim()) {
1237+
blocks.push({
1238+
type: "text",
1239+
text: sanitizeSurrogates(block.thinking),
1240+
});
1241+
}
1242+
continue;
1243+
}
12301244
// Redacted thinking: pass the opaque payload back as redacted_thinking
12311245
if (block.redacted) {
12321246
blocks.push({

0 commit comments

Comments
 (0)