diff --git a/src/lib/ai/__tests__/anthropic-client.test.ts b/src/lib/ai/__tests__/anthropic-client.test.ts index e5761064f..a3e754b41 100644 --- a/src/lib/ai/__tests__/anthropic-client.test.ts +++ b/src/lib/ai/__tests__/anthropic-client.test.ts @@ -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 () => { diff --git a/src/lib/ai/__tests__/anthropic-reasoning.test.ts b/src/lib/ai/__tests__/anthropic-reasoning.test.ts new file mode 100644 index 000000000..5253feb10 --- /dev/null +++ b/src/lib/ai/__tests__/anthropic-reasoning.test.ts @@ -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(); + 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 { + return { + system: "You are terse.", + messages: [{ role: "user", content: "Weather in the capital of France?" }], + maxTokens: 600, + ...extra, + }; +} + +function sent(mock: ReturnType, 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 { + 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", + }); + }); +}); diff --git a/src/lib/ai/__tests__/fixtures/reasoning/anthropic-adaptive-rejected.json b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-adaptive-rejected.json new file mode 100644 index 000000000..3f028eb73 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-adaptive-rejected.json @@ -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'" + } +} diff --git a/src/lib/ai/__tests__/fixtures/reasoning/anthropic-after-tool.json b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-after-tool.json new file mode 100644 index 000000000..325cd97dc --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-after-tool.json @@ -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 + } +} diff --git a/src/lib/ai/__tests__/fixtures/reasoning/anthropic-temperature-rejected.json b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-temperature-rejected.json new file mode 100644 index 000000000..3d93c3cb0 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-temperature-rejected.json @@ -0,0 +1,7 @@ +{ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "temperature: this model does not accept sampling parameters" + } +} diff --git a/src/lib/ai/__tests__/fixtures/reasoning/anthropic-thinking-tool-round.json b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-thinking-tool-round.json new file mode 100644 index 000000000..a76ad54d0 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/anthropic-thinking-tool-round.json @@ -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 + } +} diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 6f4b460fe..558985f2a 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -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; 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 { + 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 } + | { kind: "adaptive"; effort: Exclude }; + +/** 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 => 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; @@ -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 { + 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 = { + "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***"); }