diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5ee22d9c..13e5a265 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Fixed OpenAI Responses replay for foreign tool-call item IDs by hashing foreign `function_call.id` values into bounded `fc_` IDs instead of preserving backend-specific normalized shapes that OpenAI Codex rejects. +- Fixed Anthropic thinking disable handling to send `thinking: { type: "disabled" }` for reasoning-capable models when thinking is explicitly off, and added payload and env-gated end-to-end coverage for the Anthropic provider ([#2022](https://github.com/badlogic/pi-mono/issues/2022)) ## [0.61.1] - 2026-03-20 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 4af0c6ff..9b78f98c 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -654,20 +654,25 @@ function buildParams( params.tools = convertTools(context.tools, isOAuthToken); } - // Configure thinking mode: adaptive (Opus 4.6 and Sonnet 4.6) or budget-based (older models) - if (options?.thinkingEnabled && model.reasoning) { - if (supportsAdaptiveThinking(model.id)) { - // Adaptive thinking: Claude decides when and how much to think - params.thinking = { type: "adaptive" }; - if (options.effort) { - params.output_config = { effort: options.effort }; + // Configure thinking mode: adaptive (Opus 4.6 and Sonnet 4.6), + // budget-based (older models), or explicitly disabled. + if (model.reasoning) { + if (options?.thinkingEnabled) { + if (supportsAdaptiveThinking(model.id)) { + // Adaptive thinking: Claude decides when and how much to think + params.thinking = { type: "adaptive" }; + if (options.effort) { + params.output_config = { effort: options.effort }; + } + } else { + // Budget-based thinking for older models + params.thinking = { + type: "enabled", + budget_tokens: options.thinkingBudgetTokens || 1024, + }; } - } else { - // Budget-based thinking for older models - params.thinking = { - type: "enabled", - budget_tokens: options.thinkingBudgetTokens || 1024, - }; + } else if (options?.thinkingEnabled === false) { + params.thinking = { type: "disabled" }; } } diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts new file mode 100644 index 00000000..8b154d48 --- /dev/null +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { streamSimple } from "../src/stream.js"; +import type { Context, Model } from "../src/types.js"; + +interface AnthropicThinkingPayload { + thinking?: { type: string; budget_tokens?: number }; + output_config?: { effort?: string }; +} + +function makePayloadCaptureContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +async function capturePayload(model: Model<"anthropic-messages">): Promise { + let capturedPayload: AnthropicThinkingPayload | undefined; + const payloadCaptureModel: Model<"anthropic-messages"> = { + ...model, + baseUrl: "http://127.0.0.1:9", + }; + + const s = streamSimple(payloadCaptureModel, makePayloadCaptureContext(), { + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicThinkingPayload; + return payload; + }, + }); + + await s.result(); + + if (!capturedPayload) { + throw new Error("Expected payload to be captured before request failure"); + } + + return capturedPayload; +} + +interface RunResult { + thinkingEventCount: number; + thinkingCharCount: number; + text: string; + contentTypes: string[]; +} + +function makeE2EContext(): Context { + return { + systemPrompt: "You are a precise assistant. Follow the requested output format exactly.", + messages: [ + { + role: "user", + content: + "Before replying, carefully solve 36863 * 5279 internally. Then reply with the word pong repeated exactly 40 times, separated by single spaces. Do not add any other text.", + timestamp: Date.now(), + }, + ], + }; +} + +function countPongs(text: string): number { + return text.match(/\bpong\b/gi)?.length ?? 0; +} + +async function runWithoutReasoning(model: Model<"anthropic-messages">): Promise { + const s = streamSimple(model, makeE2EContext(), { + temperature: 0, + maxTokens: 160, + }); + + let thinkingEventCount = 0; + let thinkingCharCount = 0; + + for await (const event of s) { + if (event.type === "thinking_start" || event.type === "thinking_end") { + thinkingEventCount += 1; + } + if (event.type === "thinking_delta") { + thinkingEventCount += 1; + thinkingCharCount += event.delta.length; + } + } + + const response = await s.result(); + expect(response.stopReason, response.errorMessage).toBe("stop"); + + const text = response.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("") + .trim(); + + return { + thinkingEventCount, + thinkingCharCount, + text, + contentTypes: response.content.map((block) => block.type), + }; +} + +describe("Anthropic thinking disable payload", () => { + it("sends thinking.type=disabled for budget-based reasoning models when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-sonnet-4-5")); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + }); + + it("sends thinking.type=disabled for adaptive reasoning models when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6")); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + }); +}); + +describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic thinking disable E2E", () => { + it("disables thinking for Claude reasoning models", { retry: 2, timeout: 30000 }, async () => { + const result = await runWithoutReasoning(getModel("anthropic", "claude-sonnet-4-5")); + + expect(result.thinkingEventCount).toBe(0); + expect(result.thinkingCharCount).toBe(0); + expect(result.contentTypes).not.toContain("thinking"); + expect(countPongs(result.text)).toBeGreaterThanOrEqual(35); + }); +});