Skip to content

Commit e72dadb

Browse files
Pick-catclaudesteipete
authored
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 <[email protected]> * 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 <[email protected]> * fix(anthropic): normalize legacy thinking budgets Co-authored-by: Pick-cat <[email protected]> --------- Co-authored-by: Claude Opus 4.7 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 07a7655 commit e72dadb

10 files changed

Lines changed: 282 additions & 32 deletions

File tree

extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,26 @@ describe("createMantleAnthropicStreamFn", () => {
214214
expect(streamOptions.effort).toBe("low");
215215
});
216216

217+
it("disables legacy thinking when the adjusted budget is below 1024", () => {
218+
const model = createTestModel({
219+
id: "anthropic.claude-haiku-4-5",
220+
name: "Claude Haiku 4.5",
221+
reasoning: true,
222+
maxTokens: 1500,
223+
});
224+
const deps = createTestDeps();
225+
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
226+
227+
void createMantleAnthropicStreamFn(deps)(
228+
model,
229+
{ messages: [] },
230+
{ apiKey: "bedrock-bearer-token", reasoning: "low" },
231+
);
232+
233+
expect(firstStreamOptions(deps)).toMatchObject({ maxTokens: 1500, thinkingEnabled: false });
234+
expect(firstStreamOptions(deps)).not.toHaveProperty("thinkingBudgetTokens");
235+
});
236+
217237
it.each([
218238
{ reasoning: undefined, effort: "high" },
219239
{ reasoning: "off" as const, effort: "low" },

extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,18 @@ export function createMantleAnthropicStreamFn(deps?: {
210210
reasoning,
211211
options?.thinkingBudgets,
212212
);
213+
const adaptiveThinking = requiresClaudeMythosAdaptiveThinking(model);
214+
const thinkingEnabled = adaptiveThinking || adjusted.thinkingBudget >= 1024;
213215
return streamFn(model as Model<"anthropic-messages">, context, {
214216
...base,
215217
client: streamClient,
216218
maxTokens: adjusted.maxTokens,
217-
thinkingEnabled: true,
218-
...(requiresClaudeMythosAdaptiveThinking(model) ? { effort: reasoning } : {}),
219-
thinkingBudgetTokens: adjusted.thinkingBudget,
219+
thinkingEnabled,
220+
...(adaptiveThinking
221+
? { effort: reasoning }
222+
: thinkingEnabled
223+
? { thinkingBudgetTokens: adjusted.thinkingBudget }
224+
: {}),
220225
});
221226
};
222227
}

extensions/amazon-bedrock/stream.runtime.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,24 @@ describe("Bedrock thinking effort mapping", () => {
204204
expect(testing.buildAdditionalModelRequestFields(model, options)).toBeUndefined();
205205
});
206206

207+
it.each([
208+
{ reasoning: "minimal" as const, maxTokens: 1024 },
209+
{ reasoning: "low" as const, maxTokens: 1500 },
210+
])(
211+
"disables legacy thinking when $reasoning exceeds the $maxTokens token cap",
212+
({ reasoning, maxTokens }) => {
213+
const model = bedrockModel({
214+
id: "anthropic.claude-haiku-4-5-v1:0",
215+
name: "Claude Haiku 4.5",
216+
maxTokens,
217+
});
218+
const options = testing.resolveSimpleBedrockOptions(model, { reasoning });
219+
220+
expect(options).toMatchObject({ maxTokens, reasoning: "off" });
221+
expect(testing.buildAdditionalModelRequestFields(model, options)).toBeUndefined();
222+
},
223+
);
224+
207225
it("uses the model maxTokens cap for adaptive Claude thinking requests", () => {
208226
const model = bedrockModel({
209227
id: "us.anthropic.claude-opus-4-8",

extensions/amazon-bedrock/stream.runtime.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,14 @@ function resolveSimpleBedrockOptions(
438438
options.thinkingBudgets,
439439
);
440440

441+
if (adjusted.thinkingBudget < 1024) {
442+
return {
443+
...base,
444+
maxTokens: adjusted.maxTokens,
445+
reasoning: "off",
446+
} satisfies BedrockOptions;
447+
}
448+
441449
return {
442450
...base,
443451
maxTokens: adjusted.maxTokens,

extensions/anthropic-vertex/stream-runtime.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,25 @@ describe("createAnthropicVertexStreamFn", () => {
371371
expect(transportOptions.effort).toBe("max");
372372
});
373373

374+
it("disables manual thinking when the configured budget is below 1024", () => {
375+
const { deps, streamAnthropicMock } = createStreamDeps();
376+
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
377+
const model = makeModel({ id: "claude-haiku-4-5", maxTokens: 8192 });
378+
379+
void streamFn(
380+
model,
381+
{ messages: [] },
382+
{
383+
reasoning: "low",
384+
thinkingBudgets: { low: 512 },
385+
},
386+
);
387+
388+
const transportOptions = streamTransportOptions(streamAnthropicMock);
389+
expect(transportOptions.thinkingEnabled).toBe(false);
390+
expect(transportOptions).not.toHaveProperty("thinkingBudgetTokens");
391+
});
392+
374393
it("preserves native max reasoning for Sonnet 4.6", () => {
375394
const { deps, streamAnthropicMock } = createStreamDeps();
376395
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);

extensions/anthropic-vertex/stream-runtime.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,17 @@ export function createAnthropicVertexStreamFn(
200200
contractModelId,
201201
) as AnthropicVertexEffort;
202202
} else {
203-
opts.thinkingEnabled = true;
204203
const budgets = options?.thinkingBudgets;
205-
opts.thinkingBudgetTokens =
204+
const thinkingBudgetTokens =
206205
(budgets && reasoning in budgets
207206
? budgets[reasoning as keyof typeof budgets]
208207
: undefined) ?? 10000;
208+
const requestMaxTokens = opts.maxTokens ?? transportModel.maxTokens;
209+
opts.thinkingEnabled =
210+
thinkingBudgetTokens >= 1024 && thinkingBudgetTokens < requestMaxTokens;
211+
if (opts.thinkingEnabled) {
212+
opts.thinkingBudgetTokens = thinkingBudgetTokens;
213+
}
209214
}
210215
} else if (mandatoryAdaptiveThinking) {
211216
opts.thinkingEnabled = true;

packages/ai/src/providers/anthropic.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,6 +1648,98 @@ describe("Anthropic provider", () => {
16481648
expect((capturedPayload as { output_config?: unknown }).output_config).toBeUndefined();
16491649
});
16501650

1651+
it("resolves thinking as disabled when the legacy budget collapses below 1024", async () => {
1652+
// reasoning:true so the builder enters the thinking block, but an id that
1653+
// does not match the adaptive-thinking regex so the budget-based path is used.
1654+
const model = makeAnthropicModel({
1655+
id: "claude-haiku-4-5",
1656+
name: "Claude Haiku 4.5",
1657+
reasoning: true,
1658+
maxTokens: 1024,
1659+
});
1660+
let capturedPayload: unknown;
1661+
const stream = streamSimpleAnthropic(
1662+
model,
1663+
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
1664+
{
1665+
apiKey: "sk-ant-provider",
1666+
reasoning: "minimal",
1667+
onPayload: (payload) => {
1668+
capturedPayload = payload;
1669+
throw new Error("stop before network");
1670+
},
1671+
},
1672+
);
1673+
await stream.result();
1674+
expect((capturedPayload as { thinking?: unknown }).thinking).toEqual({ type: "disabled" });
1675+
});
1676+
1677+
it("resolves thinking as disabled when the legacy budget is positive but sub-minimum", async () => {
1678+
const model = makeAnthropicModel({
1679+
id: "claude-haiku-4-5",
1680+
name: "Claude Haiku 4.5",
1681+
reasoning: true,
1682+
maxTokens: 1500,
1683+
});
1684+
let capturedPayload: unknown;
1685+
const stream = streamSimpleAnthropic(
1686+
model,
1687+
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
1688+
{
1689+
apiKey: "sk-ant-provider",
1690+
reasoning: "low",
1691+
onPayload: (payload) => {
1692+
capturedPayload = payload;
1693+
throw new Error("stop before network");
1694+
},
1695+
},
1696+
);
1697+
await stream.result();
1698+
expect((capturedPayload as { thinking?: unknown }).thinking).toEqual({ type: "disabled" });
1699+
});
1700+
1701+
it.each([
1702+
{ budgetTokens: 512, maxTokens: 8192 },
1703+
{ budgetTokens: 1024, maxTokens: 1024 },
1704+
])(
1705+
"normalizes raw manual thinking budget $budgetTokens below max $maxTokens",
1706+
async ({ budgetTokens, maxTokens }) => {
1707+
const model = makeAnthropicModel({
1708+
id: "claude-haiku-4-5",
1709+
name: "Claude Haiku 4.5",
1710+
maxTokens: 8192,
1711+
});
1712+
let capturedPayload: unknown;
1713+
const stream = streamAnthropic(
1714+
model,
1715+
{
1716+
messages: [{ role: "user", content: "hello", timestamp: 0 }],
1717+
tools: [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }],
1718+
},
1719+
{
1720+
apiKey: "sk-ant-provider",
1721+
maxTokens,
1722+
temperature: 0.2,
1723+
thinkingEnabled: true,
1724+
thinkingBudgetTokens: budgetTokens,
1725+
toolChoice: "any",
1726+
onPayload: (payload) => {
1727+
capturedPayload = payload;
1728+
throw new Error("stop before network");
1729+
},
1730+
},
1731+
);
1732+
1733+
await stream.result();
1734+
1735+
expect(capturedPayload).toMatchObject({
1736+
thinking: { type: "disabled" },
1737+
temperature: 0.2,
1738+
tool_choice: { type: "any" },
1739+
});
1740+
},
1741+
);
1742+
16511743
it.each(["claude-opus-4-8", "claude-mythos-preview"])(
16521744
"restores default sampling for %s after payload hooks",
16531745
async (modelId) => {

0 commit comments

Comments
 (0)