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; }