fix(anthropic): resolve thinking as disabled when legacy budget is below 1024 (#101415)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(anthropic): normalize legacy thinking budgets

Co-authored-by: Pick-cat <huang.ting3@xydigit.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
pick-cat
2026-07-07 15:42:40 +08:00
committed by GitHub
parent 07a765522a
commit e72dadbb3b
10 changed files with 282 additions and 32 deletions

View File

@@ -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" },

View File

@@ -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 }
: {}),
});
};
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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);

View File

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

View File

@@ -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) => {

View File

@@ -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<AnthropicMessagesCompat> {
// 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<AssistantMessageEvent>(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<string, string> | 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<string, unknown>, 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,
};
}

View File

@@ -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",

View File

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