feat(ai): ask Anthropic models to think in the dialect their generation takes

Up to 4.5 a thinking budget (with the interleaved-thinking header on tool rounds), from 4.6 adaptive thinking with an effort and summarized display. From the 5 generation thinking cannot be switched off, so off becomes the lowest effort. Temperature is never sent from 4.7 on, nor with thinking, which also removes a 400 on the newer model presets.

Thinking blocks travel to the next round unchanged, signature included; a tool round another provider started runs without thinking. Every text block is joined instead of only the first. The JSON instruction goes only to callers that ask for JSON. Unknown model names learn the dialect and the sampling rule from a refusal.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:26:29 +02:00
1 parent d87b8204f3
commit 6b1bafca3c
7 files changed
+1008 -58

No files matched your search

@@ -72,9 +72,10 @@ describe("AnthropicClient", () => {
},
]);
expect(body.messages).toEqual([expect.objectContaining({ role: "user" })]);
// User message carries the original text plus the JSON-coercion instruction.
expect(body.messages[0].content).toContain("Analyze this data.");
expect(body.messages[0].content.toLowerCase()).toContain("json");
// v1.41 — no `responseFormat: "json"`, no JSON instruction: the user
// message goes out as written. (It used to be appended to every request
// without tools, prose included.)
expect(body.messages[0].content).toBe("Analyze this data.");
});
it("sends no assistant turn for JSON surfaces and instructs the last user turn", async () => {
@@ -0,0 +1,492 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AnthropicClient } from "../anthropic-client";
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
import type {
AiMessage,
AiReasoningEvent,
AiToolDef,
CompletionParams,
CompletionResult,
} from "../types";
// safeFetch's requirePublicHost path runs through undici's own `fetch`.
// Delegate it to the global stub these tests install.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
/**
* v1.41 — Anthropic thinking at the wire. The response fixtures follow the
* documented Messages API shapes (thinking blocks with `signature`,
* `redacted_thinking` with `data`, interleaved text and tool_use):
* https://platform.claude.com/docs/en/build-with-claude/extended-thinking.
* No health data; signatures are placeholders.
*/
const FIXTURES = join(__dirname, "fixtures/reasoning");
const fixture = (name: string) => readFileSync(join(FIXTURES, name), "utf8");
const ok = (name: string) => async () =>
new Response(fixture(name), {
status: 200,
headers: { "content-type": "application/json" },
});
const fail = (status: number, name: string) => async () =>
new Response(fixture(name), { status });
const TOOLS: AiToolDef[] = [
{
name: "get_weather",
description: "Weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
];
function params(extra: Partial<CompletionParams> = {}): CompletionParams {
return {
system: "You are terse.",
messages: [{ role: "user", content: "Weather in the capital of France?" }],
maxTokens: 600,
...extra,
};
}
function sent(mock: ReturnType<typeof vi.fn>, call = 0) {
const init = mock.mock.calls[call][1];
return { body: JSON.parse(init.body), headers: init.headers };
}
const client = (model: string) =>
new AnthropicClient({ apiKey: "sk-ant-test-key", model });
beforeEach(() => {
vi.restoreAllMocks();
resetReasoningDialectCache();
});
describe("Anthropic thinking dialects", () => {
it("keeps today's wire when no reasoning is asked, on a model that takes sampling", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-6").generateCompletion(params());
const { body, headers } = sent(mock);
expect(body.temperature).toBe(0.3);
expect(body.max_tokens).toBe(600);
expect(body).not.toHaveProperty("thinking");
expect(body).not.toHaveProperty("output_config");
expect(headers).not.toHaveProperty("anthropic-beta");
});
it("never sends temperature from 4.7 on, reasoning or not", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-opus-5").generateCompletion(params());
await client("claude-opus-4-7").generateCompletion(params());
await client("claude-sonnet-5").generateCompletion(params());
for (const call of [0, 1, 2]) {
expect(sent(mock, call).body).not.toHaveProperty("temperature");
}
});
it("asks a 4.5 model for a thinking budget and interleaves on tool rounds", async () => {
const mock = vi
.fn()
.mockImplementation(ok("anthropic-thinking-tool-round.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-5").generateCompletion(
params({
tools: TOOLS,
toolChoice: "auto",
reasoning: { effort: "medium", summaries: true },
}),
);
const { body, headers } = sent(mock);
expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 4096 });
// budget_tokens must stay below max_tokens: answer + budget.
expect(body.max_tokens).toBe(600 + 4096);
expect(body).not.toHaveProperty("temperature");
expect(body).not.toHaveProperty("output_config");
expect(body.tool_choice).toEqual({ type: "auto" });
expect(headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14");
});
it("asks 4.6 and later for adaptive thinking with an effort", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
for (const model of [
"claude-sonnet-4-6",
"claude-opus-4-7",
"claude-opus-5",
]) {
await client(model).generateCompletion(
params({
tools: TOOLS,
reasoning: { effort: "high", summaries: true },
}),
);
}
for (const call of [0, 1, 2]) {
const { body, headers } = sent(mock, call);
expect(body.thinking).toEqual({
type: "adaptive",
display: "summarized",
});
expect(body.output_config).toEqual({ effort: "high" });
expect(body.max_tokens).toBe(600 + 12_000);
expect(body).not.toHaveProperty("temperature");
expect(headers).not.toHaveProperty("anthropic-beta");
}
});
it("switches off where it can and falls to low effort where thinking is always on", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const off = { reasoning: { effort: "off" as const, summaries: true } };
await client("claude-opus-4-7").generateCompletion(params(off));
await client("claude-sonnet-4-5").generateCompletion(params(off));
await client("claude-opus-5").generateCompletion(params(off));
expect(sent(mock, 0).body).not.toHaveProperty("thinking");
expect(sent(mock, 0).body).not.toHaveProperty("temperature");
expect(sent(mock, 1).body).not.toHaveProperty("thinking");
expect(sent(mock, 1).body.temperature).toBe(0.3);
expect(sent(mock, 2).body.thinking).toEqual({
type: "adaptive",
display: "summarized",
});
expect(sent(mock, 2).body.output_config).toEqual({ effort: "low" });
});
it("does not ask a model that cannot think", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-3-5-sonnet-latest").generateCompletion(
params({ reasoning: { effort: "high", summaries: true } }),
);
expect(sent(mock).body).not.toHaveProperty("thinking");
expect(sent(mock).body.temperature).toBe(0.3);
});
});
describe("Anthropic thinking replies", () => {
it("returns the summary after the reply and keeps the whole reply as state", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(ok("anthropic-thinking-tool-round.json")),
);
const events: AiReasoningEvent[] = [];
const result = await client("claude-sonnet-4-5").generateCompletion(
params({
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
onReasoning: (e) => events.push(e),
}),
);
const thought =
"**Choosing the city**\nThe capital of France is Paris, so the weather lookup needs Paris.";
expect(events).toEqual([
{ kind: "title", text: "Choosing the city" },
{ kind: "text", text: thought },
{ kind: "done", text: "" },
]);
expect(result.reasoning).toEqual({ summary: [thought], tokens: null });
expect(result.content).toBe("Let me look that up.");
expect(result.finishReason).toBe("tool_calls");
expect(result.providerState).toEqual({
providerType: "anthropic",
model: "claude-sonnet-4-5",
items: JSON.parse(fixture("anthropic-thinking-tool-round.json")).content,
});
});
it("joins every text block, not just the first", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(ok("anthropic-after-tool.json")),
);
const result = await client("claude-sonnet-4-5").generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(result.content).toBe("Paris is at 18 °C and cloudy.");
// A redacted block has no readable text, but it is state.
expect(result.reasoning?.summary).toEqual([]);
expect(result.providerState?.items[0]).toEqual({
type: "redacted_thinking",
data: "redacted-fixture-b1",
});
});
it("no longer tells a prose request to answer in JSON", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-6").generateCompletion(params());
await client("claude-sonnet-4-6").generateCompletion(
params({ responseFormat: "json" }),
);
expect(sent(mock, 0).body.messages[0].content).toBe(
"Weather in the capital of France?",
);
expect(sent(mock, 1).body.messages[0].content).toMatch(
/single JSON object/,
);
});
});
describe("Anthropic thinking round trip", () => {
async function firstRound(
model = "claude-sonnet-4-5",
): Promise<CompletionResult> {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(ok("anthropic-thinking-tool-round.json")),
);
return client(model).generateCompletion(
params({
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
}
function next(
first: CompletionResult,
state = first.providerState,
): AiMessage[] {
return [
{ role: "user", content: "Weather in the capital of France?" },
{
role: "assistant",
content: first.content,
toolCalls: first.toolCalls,
providerState: state,
},
{
role: "tool",
toolCallId: first.toolCalls![0].id,
content: '{"tempC":18}',
},
];
}
it("hands the assistant reply back unchanged, signature included", async () => {
const first = await firstRound();
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-5").generateCompletion(
params({
messages: next(first),
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
const { body } = sent(mock);
expect(body.messages[1]).toEqual({
role: "assistant",
content: JSON.parse(fixture("anthropic-thinking-tool-round.json"))
.content,
});
expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 4096 });
});
it("leads with the thinking blocks when the turn's calls changed", async () => {
const first = await firstRound();
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const messages = next(first);
messages[1] = {
...messages[1],
toolCalls: [{ id: "toolu_other", name: "get_weather", arguments: "{}" }],
};
messages[2] = { ...messages[2], toolCallId: "toolu_other" };
await client("claude-sonnet-4-5").generateCompletion(
params({
messages,
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
const content = sent(mock).body.messages[1].content;
expect(content.map((b: { type: string }) => b.type)).toEqual([
"thinking",
"text",
"tool_use",
]);
expect(content[0].signature).toBe("sig-fixture-a1");
expect(content[2].id).toBe("toolu_other");
});
it("turns thinking off for a tool round another provider started", async () => {
const first = await firstRound();
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const fromCodex = {
providerType: "codex" as const,
model: "gpt-5.5",
items: [{ type: "reasoning" }],
};
const result = await client("claude-sonnet-4-5").generateCompletion(
params({
messages: next(first, fromCodex),
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
const { body, headers } = sent(mock);
expect(body).not.toHaveProperty("thinking");
expect(headers).not.toHaveProperty("anthropic-beta");
// The foreign state never reaches the wire.
expect(JSON.stringify(body)).not.toContain('"reasoning"');
expect(
body.messages[1].content.map((b: { type: string }) => b.type),
).toEqual(["text", "tool_use"]);
// Not a refusal by the provider: no downgrade reported.
expect(result.reasoning?.downgradedTo).toBeUndefined();
});
});
describe("Anthropic thinking refusals", () => {
it("falls from adaptive to a budget on an unknown name, and remembers", async () => {
const mock = vi
.fn()
.mockImplementationOnce(fail(400, "anthropic-adaptive-rejected.json"))
.mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const c = client("my-claude-proxy");
const result = await c.generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(sent(mock, 0).body.thinking.type).toBe("adaptive");
expect(sent(mock, 1).body.thinking).toEqual({
type: "enabled",
budget_tokens: 1024,
});
expect(sent(mock, 1).body).not.toHaveProperty("output_config");
expect(result.content).toBe("Paris is at 18 °C and cloudy.");
expect(result.reasoning?.downgradedTo).toBeUndefined();
await c.generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(sent(mock, 2).body.thinking.type).toBe("enabled");
});
it("answers without thinking when every form is refused", async () => {
const mock = vi
.fn()
.mockImplementationOnce(fail(400, "anthropic-adaptive-rejected.json"))
.mockImplementationOnce(fail(400, "anthropic-adaptive-rejected.json"))
.mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const result = await client("my-claude-proxy").generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(sent(mock, 2).body).not.toHaveProperty("thinking");
expect(result.reasoning?.downgradedTo).toBe("off");
});
it("drops temperature an unknown model refuses, and remembers", async () => {
const mock = vi
.fn()
.mockImplementationOnce(fail(400, "anthropic-temperature-rejected.json"))
.mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const c = client("my-claude-proxy");
await c.generateCompletion(params());
expect(sent(mock, 0).body.temperature).toBe(0.3);
expect(sent(mock, 1).body).not.toHaveProperty("temperature");
await c.generateCompletion(params());
expect(mock).toHaveBeenCalledTimes(3);
expect(sent(mock, 2).body).not.toHaveProperty("temperature");
});
it("raises a 400 that is about something else, once", async () => {
const mock = vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message: "messages: too long",
},
}),
{ status: 400 },
),
);
vi.stubGlobal("fetch", mock);
await expect(
client("claude-opus-5").generateCompletion(
params({ reasoning: { effort: "high", summaries: true } }),
),
).rejects.toMatchObject({ httpStatus: 400, upstream: "anthropic" });
expect(mock).toHaveBeenCalledTimes(1);
});
});
describe("Anthropic adaptive rounds that chose not to think", () => {
it("keeps thinking on for the next round of its own turn", async () => {
const noThought = {
id: "msg_fixture_03",
type: "message",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_fixture_02",
name: "get_weather",
input: { city: "Paris" },
},
],
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20 },
};
const mock = vi
.fn()
.mockImplementationOnce(
async () => new Response(JSON.stringify(noThought), { status: 200 }),
)
.mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const c = client("claude-opus-5");
const reasoning = { effort: "medium" as const, summaries: true };
const first = await c.generateCompletion(
params({ tools: TOOLS, reasoning }),
);
expect(first.providerState?.items).toEqual(noThought.content);
await c.generateCompletion(
params({
tools: TOOLS,
reasoning,
messages: [
{ role: "user", content: "Weather?" },
{
role: "assistant",
content: first.content,
toolCalls: first.toolCalls,
providerState: first.providerState,
},
{ role: "tool", toolCallId: "toolu_fixture_02", content: "{}" },
],
}),
);
expect(sent(mock, 1).body.thinking).toEqual({
type: "adaptive",
display: "summarized",
});
});
});
@@ -0,0 +1,7 @@
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "thinking.type: Input tag 'adaptive' found using 'type' does not match any of the expected tags: 'disabled', 'enabled'"
}
}
@@ -0,0 +1,17 @@
{
"id": "msg_fixture_02",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [
{ "type": "redacted_thinking", "data": "redacted-fixture-b1" },
{ "type": "text", "text": "Paris is at 18 °C" },
{ "type": "text", "text": " and cloudy." }
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 760,
"output_tokens": 41,
"cache_read_input_tokens": 512
}
}
@@ -0,0 +1,7 @@
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "temperature: this model does not accept sampling parameters"
}
}
@@ -0,0 +1,29 @@
{
"id": "msg_fixture_01",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [
{
"type": "thinking",
"thinking": "**Choosing the city**\nThe capital of France is Paris, so the weather lookup needs Paris.",
"signature": "sig-fixture-a1"
},
{
"type": "text",
"text": "Let me look that up."
},
{
"type": "tool_use",
"id": "toolu_fixture_01",
"name": "get_weather",
"input": { "city": "Paris" }
}
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 612,
"output_tokens": 118,
"cache_read_input_tokens": 0
}
}
+452 -55
View File
@@ -5,11 +5,29 @@ import type {
AIProvider,
AiContentPart,
AiMessage,
AiProviderState,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
import {
annotateReasoningDowngrade,
learnedReasoningDialect,
rememberReasoningDialect,
} from "./reasoning/dialect-cache";
import {
REASONING_LEVELS,
REASONING_THINKING_BUDGET,
type ReasoningLevel,
} from "./reasoning/levels";
import {
anthropicRefusesSampling,
anthropicThinkingDialect,
anthropicThinksAlways,
reasoningTitleOf,
type AnthropicThinkingDialect,
} from "./reasoning/support";
/**
* v1.18.9 — Anthropic Messages API content blocks. The text-only path sends a
@@ -42,13 +60,37 @@ type AnthropicContentBlock =
type: "tool_result";
tool_use_id: string;
content: string;
};
}
// v1.41 — `thinking` / `redacted_thinking` blocks of an earlier round,
// replayed byte-for-byte from `providerState` (signature included).
| Record<string, unknown>;
interface AnthropicWireMessage {
role: "user" | "assistant";
content: string | AnthropicContentBlock[];
}
/**
* The thinking blocks a model returned, in the shape the next round must hand
* back: "you must pass thinking blocks back to the API … the complete,
* unmodified block", with `signature` (`thinking`) or `data`
* (`redacted_thinking`). https://platform.claude.com/docs/en/build-with-claude/extended-thinking
*/
function isThinkingBlock(block: unknown): block is Record<string, unknown> {
const type = (block as { type?: unknown } | null)?.type;
return type === "thinking" || type === "redacted_thinking";
}
/** `state` when this client made it for `model`, else `null`. */
function ownState(
state: AiProviderState | undefined,
model: string,
): AiProviderState | null {
return state && state.providerType === "anthropic" && state.model === model
? state
: null;
}
interface AnthropicClientConfig {
apiKey: string;
model: string;
@@ -58,6 +100,27 @@ interface AnthropicClientConfig {
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
const ANTHROPIC_VERSION = "2023-06-01";
/**
* Manual (budget) thinking keeps its thoughts between tool calls only with
* this beta header; adaptive thinking interleaves on its own.
* https://platform.claude.com/docs/en/build-with-claude/extended-thinking#interleaved-thinking
*/
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
/** What one request asks for. */
type ThinkingPlan =
| { kind: "none" }
| { kind: "manual"; budget: number; level: Exclude<ReasoningLevel, "off"> }
| { kind: "adaptive"; effort: Exclude<ReasoningLevel, "off"> };
/** What a model behind an endpoint was learned to refuse. */
interface AnthropicDialect {
/** The thinking form that worked after the name-derived one was refused. */
thinking?: AnthropicThinkingDialect;
/** The model refused `temperature`. */
noSampling?: true;
}
// The only JSON steering on this wire. Current Anthropic models reject an
// assistant-turn prefill (the old `{` trick) with HTTP 400, so the contract
// rides on the instruction and on `extractJsonObject` on the way out.
@@ -104,7 +167,7 @@ function mapParts(parts: AiContentPart[]): AnthropicContentBlock[] {
* a `role:"user"` turn carrying a `tool_result` block (Anthropic's convention).
* An assistant turn with `toolCalls` emits `tool_use` blocks alongside any text.
*/
function mapMessage(m: AiMessage): AnthropicWireMessage {
function mapMessage(m: AiMessage, model: string): AnthropicWireMessage {
if (m.role === "tool") {
return {
role: "user",
@@ -120,6 +183,41 @@ function mapMessage(m: AiMessage): AnthropicWireMessage {
const hasToolCalls =
m.role === "assistant" && !!m.toolCalls && m.toolCalls.length > 0;
// v1.41 — an assistant round that thought. When the stored reply's tool
// calls are exactly the ones this turn carries, the reply goes back as it
// came (interleaved thinking keeps its order); otherwise its thinking
// blocks lead and the text and tool_use blocks are rebuilt behind them.
const state =
m.role === "assistant" ? ownState(m.providerState, model) : null;
if (state) {
const stored = state.items.filter(
(b): b is Record<string, unknown> => b !== null && typeof b === "object",
);
const storedIds = stored
.filter((b) => b.type === "tool_use")
.map((b) => String(b.id))
.sort();
const turnIds = (m.toolCalls ?? []).map((tc) => tc.id).sort();
if (
storedIds.length > 0 &&
storedIds.length === turnIds.length &&
storedIds.every((id, i) => id === turnIds[i])
) {
return { role: "assistant", content: stored };
}
const thinking = stored.filter(isThinkingBlock);
if (thinking.length > 0) {
const rebuilt = mapMessage({ ...m, providerState: undefined }, model);
const rest =
typeof rebuilt.content === "string"
? rebuilt.content.length > 0
? [{ type: "text" as const, text: rebuilt.content }]
: []
: rebuilt.content;
return { role: "assistant", content: [...thinking, ...rest] };
}
}
// Plain-string content with no tool calls stays a bare string on the wire —
// byte-identical to the pre-refactor text-only path. Only build a block array
// when there are content parts or tool_use blocks to carry.
@@ -180,15 +278,17 @@ export class AnthropicClient implements AIProvider {
// JSON body; the object extraction below stays off that path.
const wantsJson = params.responseFormat === "json" && !hasTools;
const wireMessages: AnthropicWireMessage[] =
params.messages.map(mapMessage);
const wireMessages: AnthropicWireMessage[] = params.messages.map((m) =>
mapMessage(m, this.config.model),
);
// JSON-reliability instruction. The pre-refactor client appended this to
// the user prompt UNCONDITIONALLY (not gated on `responseFormat`), so every
// Anthropic caller — including the Coach prose path — saw it. Preserve that
// exactly by appending to the LAST user turn's text. Tools imply the
// non-JSON tool flow, so skip the instruction when tools are present.
if (!hasTools) {
// JSON-reliability instruction, only for callers that asked for JSON.
// v1.41 — it used to ride on every request without tools, so the Coach's
// prose rounds, the judge's final-line verdict and the array-shaped memory
// extractors were all told to "reply with a single JSON object". Every
// object-shaped caller sets `responseFormat: "json"` (the status provider
// does by default), so gating on it changes nothing for them.
if (wantsJson) {
for (let i = wireMessages.length - 1; i >= 0; i -= 1) {
if (wireMessages[i].role !== "user") continue;
const turn = wireMessages[i];
@@ -199,7 +299,7 @@ export class AnthropicClient implements AIProvider {
// end with the instruction text) so the wire stays a single text
// block — byte-identical to the pre-refactor `wrapForJson` output.
const last = turn.content[turn.content.length - 1];
if (last && last.type === "text") {
if (last && last.type === "text" && typeof last.text === "string") {
last.text = `${last.text}${JSON_INSTRUCTION}`;
} else {
turn.content = [
@@ -228,6 +328,7 @@ export class AnthropicClient implements AIProvider {
const tools = hasTools
? buildAnthropicTools(params.tools as AiToolDef[])
: undefined;
// Thinking allows only `auto` and `none`, which is all this maps to.
const toolChoice =
hasTools && params.toolChoice
? params.toolChoice === "none"
@@ -235,40 +336,48 @@ export class AnthropicClient implements AIProvider {
: { type: "auto" as const }
: undefined;
const res = await safeFetch(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.config.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
},
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
temperature: params.temperature ?? 0.3,
system,
messages: wireMessages,
...(tools ? { tools } : {}),
...(toolChoice ? { tool_choice: toolChoice } : {}),
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
// v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS
// check so a private/metadata address is rejected (SSRF/rebinding).
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
// The record owner's response-timeout setting, else the surface value, else 60 s.
{
timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
requirePublicHost: true,
signal: params.signal,
},
);
const dialect = this.readDialect();
let plan = this.planThinking(params, dialect);
let sendTemperature = this.sendsTemperature(plan, dialect);
let res = await this.send(url, params, {
plan,
sendTemperature,
system,
wireMessages,
tools,
toolChoice,
hasTools,
});
// v1.41 — a model that refuses the thinking form or the sampling field it
// was sent is asked again without it, and the endpoint remembers. Each
// step drops something, so the loop ends; the answer is never lost to a
// reasoning parameter.
let rawBody = "";
for (let step = 0; !res.ok && step < 3; step += 1) {
rawBody = await res.text().catch(() => "");
if (res.status !== 400) break;
const learned = this.learnFromRejection(
plan,
sendTemperature,
rawBody,
dialect,
);
if (!learned) break;
plan = this.planThinking(params, dialect);
sendTemperature = this.sendsTemperature(plan, dialect);
rawBody = "";
res = await this.send(url, params, {
plan,
sendTemperature,
system,
wireMessages,
tools,
toolChoice,
hasTools,
});
}
if (!res.ok) {
// Mirror the openai-client body-capture so 4xx/5xx upstream incidents
@@ -276,11 +385,8 @@ export class AnthropicClient implements AIProvider {
// instead of surfacing as an opaque "Anthropic request failed (5xx)".
// Strip anything that looks like an Anthropic API key from the excerpt
// before logging.
const rawBody = await res.text().catch(() => "");
const bodyExcerpt = rawBody
.slice(0, 500)
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "sk-ant-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
if (!rawBody) rawBody = await res.text().catch(() => "");
const bodyExcerpt = redactAnthropicBody(rawBody);
const err = new Error(`Anthropic request failed (${res.status})`);
Object.assign(err, {
httpStatus: res.status,
@@ -295,6 +401,7 @@ export class AnthropicClient implements AIProvider {
content?: Array<{
type: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
@@ -304,16 +411,34 @@ export class AnthropicClient implements AIProvider {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
output_tokens_details?: { thinking_tokens?: number };
};
};
const textBlock = json.content?.find((c) => c.type === "text");
const rawText = textBlock?.text;
const blocks = json.content ?? [];
// v1.41 — every text block, not just the first: with thinking on, a reply
// can carry text before and after its thinking or tool_use blocks. Text
// blocks that follow each other directly are one passage split by the
// API; a non-text block between them is a paragraph break.
let rawText: string | undefined;
let brokenSinceText = false;
for (const block of blocks) {
if (block.type !== "text") {
brokenSinceText = true;
continue;
}
const text = block.text ?? "";
rawText =
rawText === undefined
? text
: `${rawText}${brokenSinceText && text ? "\n\n" : ""}${text}`;
brokenSinceText = false;
}
if (rawText !== undefined && rawText.trim() === "") rawText = undefined;
// v1.20.0 — surface tool_use blocks for F1's loop.
const toolUseBlocks = (json.content ?? []).filter(
(c) => c.type === "tool_use",
);
const toolUseBlocks = blocks.filter((c) => c.type === "tool_use");
const toolCalls: AiToolCall[] | undefined =
toolUseBlocks.length > 0
? toolUseBlocks.map((b) => ({
@@ -338,6 +463,23 @@ export class AnthropicClient implements AIProvider {
throw err;
}
// v1.41 — thinking. The blocks travel to the next round untouched (the
// whole reply, so interleaved order survives); readable summaries go to
// the receiver after the reply, since this client does not stream.
const thinkingBlocks = blocks.filter(isThinkingBlock);
const summary = thinkingBlocks
.map((b) => (typeof b.thinking === "string" ? b.thinking.trim() : ""))
.filter((t) => t.length > 0);
const emit = params.reasoning ? params.onReasoning : undefined;
if (emit) {
for (const text of summary) {
const title = reasoningTitleOf(text);
if (title) emit({ kind: "title", text: title });
emit({ kind: "text", text });
}
emit({ kind: "done", text: "" });
}
// Without a prefill the model returns the whole object, sometimes inside
// a ```json fence or behind a short lead-in sentence; narrow to the
// object so the caller parses it as-is.
@@ -366,6 +508,22 @@ export class AnthropicClient implements AIProvider {
? "stop"
: undefined;
const sentLevel = levelOfPlan(plan);
const reasoning: CompletionResult["reasoning"] = params.reasoning
? {
summary,
// Thinking is billed as output and already inside `output_tokens`.
tokens: json.usage?.output_tokens_details?.thinking_tokens ?? null,
// Only a refusal the endpoint taught counts as a downgrade; a turn
// that started elsewhere and so runs without thinking does not.
...(dialect.thinking !== undefined &&
REASONING_LEVELS.indexOf(sentLevel) <
REASONING_LEVELS.indexOf(params.reasoning.effort)
? { downgradedTo: sentLevel }
: {}),
}
: undefined;
return {
content,
tokensUsed,
@@ -374,6 +532,245 @@ export class AnthropicClient implements AIProvider {
providerType: "anthropic",
...(toolCalls ? { toolCalls } : {}),
finishReason,
...(reasoning ? { reasoning } : {}),
// State goes back whenever the reply thought, and on every tool round
// of a call that asked for reasoning: an adaptive round may choose not
// to think, and the next round must still recognise it as its own.
...(thinkingBlocks.length > 0 || (params.reasoning && toolCalls)
? {
providerState: {
providerType: "anthropic" as const,
model: this.config.model,
items: blocks,
},
}
: {}),
};
}
// ── v1.41 reasoning ───────────────────────────────────────────────────────
private dialectKey() {
return {
provider: "anthropic",
endpoint: this.config.baseUrl ?? DEFAULT_BASE_URL,
model: this.config.model,
};
}
private readDialect(): AnthropicDialect {
const raw = learnedReasoningDialect(this.dialectKey());
if (!raw) return {};
try {
return JSON.parse(raw) as AnthropicDialect;
} catch {
return {};
}
}
/**
* The thinking this request asks for. Absent `params.reasoning` → none,
* today's wire. The dialect comes from the model name (manual budget up to
* 4.5, adaptive effort from 4.6) unless the endpoint taught otherwise.
*
* A turn that began on another provider (or another model) carries
* assistant tool rounds without our thinking blocks; with thinking on, the
* API expects the current tool round to start with one, so thinking stays
* off for the rest of that turn.
*/
private planThinking(
params: CompletionParams,
dialect: AnthropicDialect,
): ThinkingPlan {
const reasoning = params.reasoning;
if (!reasoning) return { kind: "none" };
const form =
dialect.thinking ?? anthropicThinkingDialect(this.config.model);
if (form === "none") return { kind: "none" };
if (this.toolRoundStartedElsewhere(params.messages, form)) {
return { kind: "none" };
}
const level = reasoning.effort;
if (form === "manual") {
return level === "off"
? { kind: "none" }
: { kind: "manual", budget: REASONING_THINKING_BUDGET[level], level };
}
if (level === "off") {
// From the 5 generation thinking cannot be switched off; the lowest
// effort is the honest "off" (`offIsReal: false` in support.ts).
return anthropicThinksAlways(this.config.model)
? { kind: "adaptive", effort: "low" }
: { kind: "none" };
}
return { kind: "adaptive", effort: level };
}
/**
* True when the current tool round (the turns after the last user message)
* has an assistant turn this client did not make for this model: another
* provider, or another model, started it. In manual mode a turn of ours
* that ran without thinking counts too, because a budget-mode continuation
* must open with a thinking block. In adaptive mode the model may have
* chosen not to think in that round, which the API accepts.
*/
private toolRoundStartedElsewhere(
messages: AiMessage[],
form: AnthropicThinkingDialect,
): boolean {
let lastUser = -1;
messages.forEach((m, i) => {
if (m.role === "user") lastUser = i;
});
return messages.slice(lastUser + 1).some((m) => {
if (m.role !== "assistant" || (m.toolCalls?.length ?? 0) === 0) {
return false;
}
const own = ownState(m.providerState, this.config.model);
if (!own) return true;
return form === "manual" && !own.items.some(isThinkingBlock);
});
}
/**
* `temperature` goes out only where the model takes it: never from 4.7 on
* (the 5 generation refuses even the default), never with thinking on, and
* never once the endpoint refused it.
*/
private sendsTemperature(
plan: ThinkingPlan,
dialect: AnthropicDialect,
): boolean {
return (
plan.kind === "none" &&
!dialect.noSampling &&
!anthropicRefusesSampling(this.config.model)
);
}
/**
* Learn from a 400 that names a parameter we sent. Returns false when the
* body is about something else; the caller then raises it as before.
*/
private learnFromRejection(
plan: ThinkingPlan,
sentTemperature: boolean,
body: string,
dialect: AnthropicDialect,
): boolean {
const lower = body.toLowerCase();
let changed: string | null = null;
if (sentTemperature && /temperature|top_p|sampling/.test(lower)) {
dialect.noSampling = true;
changed = "sampling";
} else if (
plan.kind !== "none" &&
/thinking|budget_tokens|adaptive|output_config|effort/.test(lower)
) {
dialect.thinking = plan.kind === "adaptive" ? "manual" : "none";
changed = plan.kind;
}
if (!changed) return false;
rememberReasoningDialect(this.dialectKey(), JSON.stringify(dialect));
annotateReasoningDowngrade(
"anthropic",
changed,
changed === "sampling" ? "no-sampling" : (dialect.thinking ?? "none"),
);
return true;
}
private send(
url: string,
params: CompletionParams,
req: {
plan: ThinkingPlan;
sendTemperature: boolean;
system: unknown;
wireMessages: AnthropicWireMessage[];
tools: unknown;
toolChoice: unknown;
hasTools: boolean;
},
): Promise<Response> {
const { plan } = req;
const answerBudget = params.maxTokens ?? 1000;
// `max_tokens` bounds thinking and answer together, so the thinking
// budget rides on top of the answer budget; manual mode additionally
// requires `budget_tokens < max_tokens`. It is a ceiling, not a charge.
const maxTokens =
plan.kind === "manual"
? answerBudget + plan.budget
: plan.kind === "adaptive"
? answerBudget + REASONING_THINKING_BUDGET[plan.effort]
: answerBudget;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-api-key": this.config.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
};
if (plan.kind === "manual" && req.hasTools) {
headers["anthropic-beta"] = INTERLEAVED_THINKING_BETA;
}
return safeFetch(
url,
{
method: "POST",
headers,
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
body: JSON.stringify({
model: this.config.model,
max_tokens: maxTokens,
...(req.sendTemperature
? { temperature: params.temperature ?? 0.3 }
: {}),
// Manual: a fixed budget. Adaptive: the model decides how much,
// `effort` steers it, and `display: "summarized"` asks for readable
// thinking where the default would leave it empty (5 generation).
// https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking
...(plan.kind === "manual"
? { thinking: { type: "enabled", budget_tokens: plan.budget } }
: plan.kind === "adaptive"
? {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: plan.effort },
}
: {}),
system: req.system,
messages: req.wireMessages,
...(req.tools ? { tools: req.tools } : {}),
...(req.toolChoice ? { tool_choice: req.toolChoice } : {}),
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
// v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS
// check so a private/metadata address is rejected (SSRF/rebinding).
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
// The record owner's response-timeout setting, else the surface value, else 60 s.
{
timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
requirePublicHost: true,
signal: params.signal,
},
);
}
}
/** The level a plan delivers, for `downgradedTo`. */
function levelOfPlan(plan: ThinkingPlan): ReasoningLevel {
return plan.kind === "manual"
? plan.level
: plan.kind === "adaptive"
? plan.effort
: "off";
}
function redactAnthropicBody(rawBody: string): string {
return rawBody
.slice(0, 500)
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "sk-ant-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
}