fix(ai): explicitly disable Anthropic thinking when off closes #2022
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed OpenAI Responses replay for foreign tool-call item IDs by hashing foreign `function_call.id` values into bounded `fc_<hash>` IDs instead of preserving backend-specific normalized shapes that OpenAI Codex rejects.
|
- Fixed OpenAI Responses replay for foreign tool-call item IDs by hashing foreign `function_call.id` values into bounded `fc_<hash>` 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
|
## [0.61.1] - 2026-03-20
|
||||||
|
|
||||||
|
|||||||
@@ -654,20 +654,25 @@ function buildParams(
|
|||||||
params.tools = convertTools(context.tools, isOAuthToken);
|
params.tools = convertTools(context.tools, isOAuthToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure thinking mode: adaptive (Opus 4.6 and Sonnet 4.6) or budget-based (older models)
|
// Configure thinking mode: adaptive (Opus 4.6 and Sonnet 4.6),
|
||||||
if (options?.thinkingEnabled && model.reasoning) {
|
// budget-based (older models), or explicitly disabled.
|
||||||
if (supportsAdaptiveThinking(model.id)) {
|
if (model.reasoning) {
|
||||||
// Adaptive thinking: Claude decides when and how much to think
|
if (options?.thinkingEnabled) {
|
||||||
params.thinking = { type: "adaptive" };
|
if (supportsAdaptiveThinking(model.id)) {
|
||||||
if (options.effort) {
|
// Adaptive thinking: Claude decides when and how much to think
|
||||||
params.output_config = { effort: options.effort };
|
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 {
|
} else if (options?.thinkingEnabled === false) {
|
||||||
// Budget-based thinking for older models
|
params.thinking = { type: "disabled" };
|
||||||
params.thinking = {
|
|
||||||
type: "enabled",
|
|
||||||
budget_tokens: options.thinkingBudgetTokens || 1024,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
packages/ai/test/anthropic-thinking-disable.test.ts
Normal file
127
packages/ai/test/anthropic-thinking-disable.test.ts
Normal file
@@ -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<AnthropicThinkingPayload> {
|
||||||
|
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<RunResult> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user