From e72dadbb3bb15e7baa42a6bb91514749c3f3aaf9 Mon Sep 17 00:00:00 2001 From: pick-cat Date: Tue, 7 Jul 2026 15:42:40 +0800 Subject: [PATCH] fix(anthropic): resolve thinking as disabled when legacy budget is below 1024 (#101415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(anthropic): resolve thinking as disabled when legacy budget is below 1024 When adjustMaxTokensForThinking collapses the thinking budget below the Anthropic minimum (1024), option resolution now sets thinkingEnabled to false instead of always forcing it to true. This keeps every downstream consumer (payload, replay, temperature, tool-choice) consistent — they all see the same disabled state instead of an enabled flag with a missing or API-rejected thinking block. || → ?? in both builders is defensive: the resolution layer already prevents invalid budgets from reaching the builder through the normal path, but ?? preserves an explicit zero when the builder is called directly. Co-Authored-By: Claude Opus 4.7 * fix(anthropic): guard raw streamAnthropic builder path against sub-minimum budgets Add budget guards in both Anthropic payload builders so direct streamAnthropic and bundled-plugin callers (e.g. Mantle) that bypass option resolution also get the disabled-state rule instead of producing API-rejected { type: "enabled", budget_tokens: < 1024 } requests. Add proof-anthropic-thinking-budget.mts driving real production functions with terminal output and negative control. Co-Authored-By: Claude Opus 4.7 * fix(anthropic): normalize legacy thinking budgets Co-authored-by: Pick-cat --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: Peter Steinberger --- .../mantle-anthropic.runtime.test.ts | 20 ++++ .../mantle-anthropic.runtime.ts | 11 ++- .../amazon-bedrock/stream.runtime.test.ts | 18 ++++ extensions/amazon-bedrock/stream.runtime.ts | 8 ++ .../anthropic-vertex/stream-runtime.test.ts | 19 ++++ extensions/anthropic-vertex/stream-runtime.ts | 9 +- packages/ai/src/providers/anthropic.test.ts | 92 +++++++++++++++++++ packages/ai/src/providers/anthropic.ts | 80 +++++++++++----- src/agents/anthropic-transport-stream.test.ts | 48 ++++++++++ src/agents/anthropic-transport-stream.ts | 9 +- 10 files changed, 282 insertions(+), 32 deletions(-) diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts index caa3f5af24e6..9a4941d60d10 100644 --- a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts @@ -214,6 +214,26 @@ describe("createMantleAnthropicStreamFn", () => { expect(streamOptions.effort).toBe("low"); }); + it("disables legacy thinking when the adjusted budget is below 1024", () => { + const model = createTestModel({ + id: "anthropic.claude-haiku-4-5", + name: "Claude Haiku 4.5", + reasoning: true, + maxTokens: 1500, + }); + const deps = createTestDeps(); + deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never); + + void createMantleAnthropicStreamFn(deps)( + model, + { messages: [] }, + { apiKey: "bedrock-bearer-token", reasoning: "low" }, + ); + + expect(firstStreamOptions(deps)).toMatchObject({ maxTokens: 1500, thinkingEnabled: false }); + expect(firstStreamOptions(deps)).not.toHaveProperty("thinkingBudgetTokens"); + }); + it.each([ { reasoning: undefined, effort: "high" }, { reasoning: "off" as const, effort: "low" }, diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts index 8fd0224e8c4f..938e946243a6 100644 --- a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts @@ -210,13 +210,18 @@ export function createMantleAnthropicStreamFn(deps?: { reasoning, options?.thinkingBudgets, ); + const adaptiveThinking = requiresClaudeMythosAdaptiveThinking(model); + const thinkingEnabled = adaptiveThinking || adjusted.thinkingBudget >= 1024; return streamFn(model as Model<"anthropic-messages">, context, { ...base, client: streamClient, maxTokens: adjusted.maxTokens, - thinkingEnabled: true, - ...(requiresClaudeMythosAdaptiveThinking(model) ? { effort: reasoning } : {}), - thinkingBudgetTokens: adjusted.thinkingBudget, + thinkingEnabled, + ...(adaptiveThinking + ? { effort: reasoning } + : thinkingEnabled + ? { thinkingBudgetTokens: adjusted.thinkingBudget } + : {}), }); }; } diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts index 10a2a28bfba2..c46168fcba21 100644 --- a/extensions/amazon-bedrock/stream.runtime.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -204,6 +204,24 @@ describe("Bedrock thinking effort mapping", () => { expect(testing.buildAdditionalModelRequestFields(model, options)).toBeUndefined(); }); + it.each([ + { reasoning: "minimal" as const, maxTokens: 1024 }, + { reasoning: "low" as const, maxTokens: 1500 }, + ])( + "disables legacy thinking when $reasoning exceeds the $maxTokens token cap", + ({ reasoning, maxTokens }) => { + const model = bedrockModel({ + id: "anthropic.claude-haiku-4-5-v1:0", + name: "Claude Haiku 4.5", + maxTokens, + }); + const options = testing.resolveSimpleBedrockOptions(model, { reasoning }); + + expect(options).toMatchObject({ maxTokens, reasoning: "off" }); + expect(testing.buildAdditionalModelRequestFields(model, options)).toBeUndefined(); + }, + ); + it("uses the model maxTokens cap for adaptive Claude thinking requests", () => { const model = bedrockModel({ id: "us.anthropic.claude-opus-4-8", diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index 52e07d3cf993..65ebb47ca4f6 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -438,6 +438,14 @@ function resolveSimpleBedrockOptions( options.thinkingBudgets, ); + if (adjusted.thinkingBudget < 1024) { + return { + ...base, + maxTokens: adjusted.maxTokens, + reasoning: "off", + } satisfies BedrockOptions; + } + return { ...base, maxTokens: adjusted.maxTokens, diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index e82ee09fba8a..912401a124fd 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -371,6 +371,25 @@ describe("createAnthropicVertexStreamFn", () => { expect(transportOptions.effort).toBe("max"); }); + it("disables manual thinking when the configured budget is below 1024", () => { + const { deps, streamAnthropicMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); + const model = makeModel({ id: "claude-haiku-4-5", maxTokens: 8192 }); + + void streamFn( + model, + { messages: [] }, + { + reasoning: "low", + thinkingBudgets: { low: 512 }, + }, + ); + + const transportOptions = streamTransportOptions(streamAnthropicMock); + expect(transportOptions.thinkingEnabled).toBe(false); + expect(transportOptions).not.toHaveProperty("thinkingBudgetTokens"); + }); + it("preserves native max reasoning for Sonnet 4.6", () => { const { deps, streamAnthropicMock } = createStreamDeps(); const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); diff --git a/extensions/anthropic-vertex/stream-runtime.ts b/extensions/anthropic-vertex/stream-runtime.ts index 48e19d83968a..a0abbfea8b79 100644 --- a/extensions/anthropic-vertex/stream-runtime.ts +++ b/extensions/anthropic-vertex/stream-runtime.ts @@ -200,12 +200,17 @@ export function createAnthropicVertexStreamFn( contractModelId, ) as AnthropicVertexEffort; } else { - opts.thinkingEnabled = true; const budgets = options?.thinkingBudgets; - opts.thinkingBudgetTokens = + const thinkingBudgetTokens = (budgets && reasoning in budgets ? budgets[reasoning as keyof typeof budgets] : undefined) ?? 10000; + const requestMaxTokens = opts.maxTokens ?? transportModel.maxTokens; + opts.thinkingEnabled = + thinkingBudgetTokens >= 1024 && thinkingBudgetTokens < requestMaxTokens; + if (opts.thinkingEnabled) { + opts.thinkingBudgetTokens = thinkingBudgetTokens; + } } } else if (mandatoryAdaptiveThinking) { opts.thinkingEnabled = true; diff --git a/packages/ai/src/providers/anthropic.test.ts b/packages/ai/src/providers/anthropic.test.ts index 41002658048f..d9327e5870eb 100644 --- a/packages/ai/src/providers/anthropic.test.ts +++ b/packages/ai/src/providers/anthropic.test.ts @@ -1648,6 +1648,98 @@ describe("Anthropic provider", () => { expect((capturedPayload as { output_config?: unknown }).output_config).toBeUndefined(); }); + it("resolves thinking as disabled when the legacy budget collapses below 1024", async () => { + // reasoning:true so the builder enters the thinking block, but an id that + // does not match the adaptive-thinking regex so the budget-based path is used. + const model = makeAnthropicModel({ + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + reasoning: true, + maxTokens: 1024, + }); + let capturedPayload: unknown; + const stream = streamSimpleAnthropic( + model, + { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + { + apiKey: "sk-ant-provider", + reasoning: "minimal", + onPayload: (payload) => { + capturedPayload = payload; + throw new Error("stop before network"); + }, + }, + ); + await stream.result(); + expect((capturedPayload as { thinking?: unknown }).thinking).toEqual({ type: "disabled" }); + }); + + it("resolves thinking as disabled when the legacy budget is positive but sub-minimum", async () => { + const model = makeAnthropicModel({ + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + reasoning: true, + maxTokens: 1500, + }); + let capturedPayload: unknown; + const stream = streamSimpleAnthropic( + model, + { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + { + apiKey: "sk-ant-provider", + reasoning: "low", + onPayload: (payload) => { + capturedPayload = payload; + throw new Error("stop before network"); + }, + }, + ); + await stream.result(); + expect((capturedPayload as { thinking?: unknown }).thinking).toEqual({ type: "disabled" }); + }); + + it.each([ + { budgetTokens: 512, maxTokens: 8192 }, + { budgetTokens: 1024, maxTokens: 1024 }, + ])( + "normalizes raw manual thinking budget $budgetTokens below max $maxTokens", + async ({ budgetTokens, maxTokens }) => { + const model = makeAnthropicModel({ + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + maxTokens: 8192, + }); + let capturedPayload: unknown; + const stream = streamAnthropic( + model, + { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }], + }, + { + apiKey: "sk-ant-provider", + maxTokens, + temperature: 0.2, + thinkingEnabled: true, + thinkingBudgetTokens: budgetTokens, + toolChoice: "any", + onPayload: (payload) => { + capturedPayload = payload; + throw new Error("stop before network"); + }, + }, + ); + + await stream.result(); + + expect(capturedPayload).toMatchObject({ + thinking: { type: "disabled" }, + temperature: 0.2, + tool_choice: { type: "any" }, + }); + }, + ); + it.each(["claude-opus-4-8", "claude-mythos-preview"])( "restores default sampling for %s after payload hooks", async (modelId) => { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 5f78177c898e..ee5054bceccc 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -231,6 +231,7 @@ export type AnthropicThinkingDisplay = "summarized" | "omitted"; const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; +const ANTHROPIC_MIN_THINKING_BUDGET_TOKENS = 1024; function getAnthropicCompat(model: Model<"anthropic-messages">): Required { // Auto-detect session affinity and cache control support from provider @@ -504,6 +505,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti ) => { const stream = new AssistantMessageEventStream(); const requestContext = prepareClaudeSonnet5RequestContext(model, context); + const requestOptions = normalizeAnthropicThinkingOptions(model, options); void (async () => { const output: AssistantMessage = { @@ -527,7 +529,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti // to expose until the terminal stop reason is known. const refusalBuffer = usesClaudeStreamingRefusalContract(model) ? createDeferredEventBuffer(stream, () => - notifyLlmRequestActivity(options?.signal), + notifyLlmRequestActivity(requestOptions?.signal), ) : undefined; const eventSink = refusalBuffer ?? stream; @@ -544,11 +546,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti // caller-owned headers. let serverSideFallback = false; - if (options?.client) { - client = options.client; + if (requestOptions?.client) { + client = requestOptions.client; isOAuth = false; } else { - const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + const apiKey = requestOptions?.apiKey ?? getEnvApiKey(model.provider) ?? ""; let copilotDynamicHeaders: Record | undefined; if (model.provider === "github-copilot") { @@ -559,16 +561,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti }); } - const cacheRetention = options?.cacheRetention ?? resolveCacheRetention(); - const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + const cacheRetention = requestOptions?.cacheRetention ?? resolveCacheRetention(); + const cacheSessionId = cacheRetention === "none" ? undefined : requestOptions?.sessionId; const created = createClient( model, apiKey, - options?.thinkingEnabled === true, - options?.interleavedThinking ?? true, + requestOptions?.thinkingEnabled === true, + requestOptions?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, requestContext), - options?.headers, + requestOptions?.headers, copilotDynamicHeaders, cacheSessionId, ); @@ -576,23 +578,31 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti isOAuth = created.isOAuthToken; serverSideFallback = created.serverSideFallback; } - const builtParams = buildParams(model, requestContext, isOAuth, options, serverSideFallback); + const builtParams = buildParams( + model, + requestContext, + isOAuth, + requestOptions, + serverSideFallback, + ); let params = builtParams.params; const toolProjection = builtParams.toolProjection; - const nextParams = await options?.onPayload?.(params, model); + const nextParams = await requestOptions?.onPayload?.(params, model); if (nextParams !== undefined) { params = nextParams as MessageCreateParamsStreaming; } applyClaudeRequestContract(params as unknown as Record, model); - const requestOptions = { - ...(options?.signal ? { signal: options.signal } : {}), - ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), + const sdkRequestOptions = { + ...(requestOptions?.signal ? { signal: requestOptions.signal } : {}), + ...(requestOptions?.timeoutMs !== undefined ? { timeout: requestOptions.timeoutMs } : {}), + ...(requestOptions?.maxRetries !== undefined + ? { maxRetries: requestOptions.maxRetries } + : {}), }; const response = await client.messages - .create({ ...params, stream: true }, requestOptions) + .create({ ...params, stream: true }, sdkRequestOptions) .asResponse(); - await options?.onResponse?.( + await requestOptions?.onResponse?.( { status: response.status, headers: headersToRecord(response.headers) }, model, ); @@ -605,7 +615,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti for await (const event of iterateAnthropicEvents( response, - options?.signal, + requestOptions?.signal, refusalBuffer !== undefined, )) { if (event.type === "message_start") { @@ -904,7 +914,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti } } - if (options?.signal?.aborted) { + if (requestOptions?.signal?.aborted) { throw new Error("Request was aborted"); } @@ -925,7 +935,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti refusalBuffer.discard(); output.content = []; } - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.stopReason = requestOptions?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); @@ -955,6 +965,25 @@ function supportsAdaptiveThinking(model: Model<"anthropic-messages">): boolean { return supportsClaudeAdaptiveThinking(model); } +function normalizeAnthropicThinkingOptions( + model: Model<"anthropic-messages">, + options: AnthropicOptions | undefined, +): AnthropicOptions | undefined { + if (options?.thinkingEnabled !== true || supportsAdaptiveThinking(model)) { + return options; + } + + const budgetTokens = options.thinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET_TOKENS; + const maxTokens = options.maxTokens ?? model.maxTokens; + if (budgetTokens >= ANTHROPIC_MIN_THINKING_BUDGET_TOKENS && budgetTokens < maxTokens) { + return options; + } + + // Manual thinking is one request-wide mode: replay, sampling, tool choice, + // headers, and payload construction must all observe the disabled state. + return { ...options, thinkingEnabled: false, thinkingBudgetTokens: undefined }; +} + function supportsNativeXhighEffort(model: Model<"anthropic-messages">): boolean { return supportsClaudeNativeXhighEffort(model); } @@ -1070,11 +1099,14 @@ export const streamSimpleAnthropic: StreamFunction< options?.thinkingBudgets, ); + // Sub-minimum budgets (< 1024) resolve to thinking disabled so downstream + // consumers (payload, replay, temperature, tool-choice) see consistent state. + const thinkingEnabled = adjusted.thinkingBudget >= 1024; return streamAnthropic(model, context, { ...base, maxTokens: adjusted.maxTokens, - thinkingEnabled: true, - thinkingBudgetTokens: adjusted.thinkingBudget, + thinkingEnabled, + thinkingBudgetTokens: thinkingEnabled ? adjusted.thinkingBudget : undefined, } satisfies AnthropicOptions); }; @@ -1349,10 +1381,10 @@ function buildParams( : { effort }; } } else { - // Budget-based thinking for older models + // Budget-based thinking for older models. params.thinking = { type: "enabled", - budget_tokens: options?.thinkingBudgetTokens || 1024, + budget_tokens: options?.thinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET_TOKENS, display, }; } diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index db5df8a76a49..eb85aafc4215 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -3349,6 +3349,54 @@ describe("anthropic transport stream", () => { expect(payload.output_config).toBeUndefined(); }); + it("resolves thinking as disabled when the legacy budget collapses to zero", async () => { + // reasoning:true so the builder enters the thinking block, but an id that + // does not match the adaptive-thinking regex so the budget-based path is used. + const model = makeAnthropicTransportModel({ + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + reasoning: true, + maxTokens: 1024, + }); + + await runTransportStream( + model, + { + messages: [{ role: "user", content: "hello" }], + } as AnthropicStreamContext, + { + apiKey: "test-token", + reasoning: "minimal", + } as AnthropicStreamOptions, + ); + + const payload = latestAnthropicRequest().payload; + expect(payload.thinking).toEqual({ type: "disabled" }); + }); + + it("resolves thinking as disabled when the legacy budget is positive but sub-minimum", async () => { + const model = makeAnthropicTransportModel({ + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + reasoning: true, + maxTokens: 1500, + }); + + await runTransportStream( + model, + { + messages: [{ role: "user", content: "hello" }], + } as AnthropicStreamContext, + { + apiKey: "test-token", + reasoning: "low", + } as AnthropicStreamOptions, + ); + + const payload = latestAnthropicRequest().payload; + expect(payload.thinking).toEqual({ type: "disabled" }); + }); + it("honors provider effort restrictions for transport runs", async () => { const model = makeAnthropicTransportModel({ id: "claude-opus-4.7-1m-internal", diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 304472a1f77a..8376c5f774cf 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -1091,7 +1091,7 @@ function buildAnthropicParams( } else { params.thinking = { type: "enabled", - budget_tokens: options?.thinkingBudgetTokens || 1024, + budget_tokens: options?.thinkingBudgetTokens ?? 1024, }; } } else if (options?.thinkingEnabled === false) { @@ -1178,9 +1178,12 @@ function resolveAnthropicTransportOptions( reasoningLevel: reasoning, customBudgets: options?.thinkingBudgets, }); + // Sub-minimum budgets (< 1024) resolve to thinking disabled so downstream + // consumers (payload, replay, temperature, tool-choice) see consistent state. + const thinkingEnabled = adjusted.thinkingBudget >= 1024; resolved.maxTokens = adjusted.maxTokens; - resolved.thinkingEnabled = true; - resolved.thinkingBudgetTokens = adjusted.thinkingBudget; + resolved.thinkingEnabled = thinkingEnabled; + resolved.thinkingBudgetTokens = thinkingEnabled ? adjusted.thinkingBudget : undefined; return resolved; }