Skip to content

Commit 989b3e1

Browse files
allquixoticoz-agent
andcommitted
Bedrock: dynamic max output tokens with empirical AWS probe
- Correct per-model maxTokens in bedrockModels (Opus 4.7 = 128K, Opus 4.6 = 128K, Opus 4.5/4.1 = 32K, Sonnet 4.x and Haiku 4.5 = 64K) so the reasoning-budget slider is no longer artificially clamped at 16K. - Add awsModelMaxOutputTokens per-config override and plumb it through resolveBedrockModelInfo + AwsBedrockHandler.getModelById. - New probeBedrockMaxOutputTokens helper that empirically discovers the AWS cap for a given model via a 1-token Converse probe (accept-ceiling, parse AWS hint, or binary search). - Wire requestBedrockMaxTokensProbe / bedrockMaxTokensProbe message types and handler. - New webview MaxOutputTokensControl (slider + numeric input + dynamic step) and BedrockThinkingBudget wrapper that renders Detect/Reset buttons. - Bump src/package.json to 3.53.12. Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent fc966af commit 989b3e1

17 files changed

Lines changed: 978 additions & 39 deletions

packages/types/src/__tests__/bedrock.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
bedrockModels,
1010
expandBedrockTargetsWith1MVariants,
1111
hasBedrock1MContextIndicator,
12+
resolveBedrockModelInfo,
1213
stripBedrock1MContextSuffix,
1314
} from "../providers/bedrock.js"
1415

@@ -46,6 +47,47 @@ describe("Bedrock model catalog", () => {
4647
expect(BEDROCK_1M_CONTEXT_DEFAULT_MODEL_IDS.length).toBe(0)
4748
expect(BEDROCK_1M_CONTEXT_OPT_IN_MODEL_IDS.length).toBe(BEDROCK_1M_CONTEXT_MODEL_IDS.length)
4849
})
50+
51+
it("matches per-model maxTokens to the documented Bedrock caps for current Anthropic models", () => {
52+
// These caps mirror the Anthropic-direct entries in `anthropic.ts`. Bumping them lets the
53+
// reasoning-budget slider extend past the legacy 8K cap (the original bug surfaced by Opus 4.7).
54+
expect((bedrockModels["anthropic.claude-opus-4-7"] as ModelInfo).maxTokens).toBe(128_000)
55+
expect((bedrockModels["anthropic.claude-opus-4-6-v1"] as ModelInfo).maxTokens).toBe(128_000)
56+
expect((bedrockModels["anthropic.claude-opus-4-5-20251101-v1:0"] as ModelInfo).maxTokens).toBe(32_000)
57+
expect((bedrockModels["anthropic.claude-opus-4-1-20250805-v1:0"] as ModelInfo).maxTokens).toBe(32_000)
58+
expect((bedrockModels["anthropic.claude-sonnet-4-6"] as ModelInfo).maxTokens).toBe(64_000)
59+
expect((bedrockModels["anthropic.claude-sonnet-4-5-20250929-v1:0"] as ModelInfo).maxTokens).toBe(64_000)
60+
expect((bedrockModels["anthropic.claude-haiku-4-5-20251001-v1:0"] as ModelInfo).maxTokens).toBe(64_000)
61+
})
62+
})
63+
64+
describe("resolveBedrockModelInfo", () => {
65+
it("prefers the static maxTokens when no override is set", () => {
66+
const { info } = resolveBedrockModelInfo({
67+
baseModelId: "anthropic.claude-opus-4-7",
68+
targetId: "anthropic.claude-opus-4-7",
69+
})
70+
expect(info.maxTokens).toBe(128_000)
71+
})
72+
73+
it("applies maxOutputTokensOverride above the static cap", () => {
74+
const { info } = resolveBedrockModelInfo({
75+
baseModelId: "anthropic.claude-opus-4-7",
76+
targetId: "anthropic.claude-opus-4-7",
77+
maxOutputTokensOverride: 256_000,
78+
})
79+
expect(info.maxTokens).toBe(256_000)
80+
})
81+
82+
it("lets request-time modelMaxTokens still override (lowering for cost control)", () => {
83+
const { info } = resolveBedrockModelInfo({
84+
baseModelId: "anthropic.claude-opus-4-7",
85+
targetId: "anthropic.claude-opus-4-7",
86+
maxOutputTokensOverride: 256_000,
87+
modelMaxTokens: 32_000,
88+
})
89+
expect(info.maxTokens).toBe(32_000)
90+
})
4991
})
5092

5193
describe("expandBedrockTargetsWith1MVariants", () => {

packages/types/src/provider-settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@ const bedrockSchema = apiModelIdProviderModelSchema.extend({
234234
.enum(["foundation-model", "system-profile", "application-profile", "custom-arn", "prompt-router", "unknown"])
235235
.optional(),
236236
awsModelContextWindow: z.number().optional(),
237+
// Empirically detected (or manually entered) per-config cap on the model's max output tokens.
238+
// Takes precedence over the static `bedrockModels.<id>.maxTokens` table when present and overrides
239+
// the static cap inside `resolveBedrockModelInfo`. The user-facing slider also widens to this value
240+
// so future Anthropic releases that lift their output ceiling don't require a code change.
241+
awsModelMaxOutputTokens: z.number().optional(),
237242
awsBedrockEndpointEnabled: z.boolean().optional(),
238243
awsBedrockEndpoint: z.string().optional(),
239244
awsBedrock1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window.

packages/types/src/providers/bedrock.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export const bedrockDefaultPromptRouterModelId: BedrockModelId = "anthropic.clau
1414
// feature.
1515
export const bedrockModels = {
1616
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
17-
maxTokens: 8192,
17+
// Mirrors anthropic-direct cap; AWS Bedrock accepts the same upstream maximum.
18+
maxTokens: 64_000,
1819
contextWindow: 200_000,
1920
supportsImages: true,
2021
supportsPromptCache: true,
@@ -28,7 +29,8 @@ export const bedrockModels = {
2829
cachableFields: ["system", "messages", "tools"],
2930
},
3031
"anthropic.claude-sonnet-4-6": {
31-
maxTokens: 8192,
32+
// Mirrors anthropic-direct cap; AWS Bedrock accepts the same upstream maximum.
33+
maxTokens: 64_000,
3234
contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07'
3335
supportsImages: true,
3436
supportsPromptCache: true,
@@ -116,7 +118,8 @@ export const bedrockModels = {
116118
cachableFields: ["system"],
117119
},
118120
"anthropic.claude-sonnet-4-20250514-v1:0": {
119-
maxTokens: 8192,
121+
// Mirrors anthropic-direct cap; AWS Bedrock accepts the same upstream maximum.
122+
maxTokens: 64_000,
120123
contextWindow: 200_000,
121124
supportsImages: true,
122125
supportsPromptCache: true,
@@ -130,7 +133,8 @@ export const bedrockModels = {
130133
cachableFields: ["system", "messages", "tools"],
131134
},
132135
"anthropic.claude-opus-4-1-20250805-v1:0": {
133-
maxTokens: 8192,
136+
// Mirrors anthropic-direct cap; AWS Bedrock accepts the same upstream maximum.
137+
maxTokens: 32_000,
134138
contextWindow: 200_000,
135139
supportsImages: true,
136140
supportsPromptCache: true,
@@ -144,7 +148,8 @@ export const bedrockModels = {
144148
cachableFields: ["system", "messages", "tools"],
145149
},
146150
"anthropic.claude-opus-4-6-v1": {
147-
maxTokens: 8192,
151+
// Mirrors anthropic-direct cap; AWS Bedrock accepts the same upstream maximum.
152+
maxTokens: 128_000,
148153
contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07'
149154
supportsImages: true,
150155
supportsPromptCache: true,
@@ -168,7 +173,11 @@ export const bedrockModels = {
168173
],
169174
},
170175
"anthropic.claude-opus-4-7": {
171-
maxTokens: 8192,
176+
// Opus 4.7 ships with a 128K-token max output on Bedrock
177+
// (https://builder.aws.com/content/3Cl90CMMnqzCrkk6mXcmnGo1WTG/claude-opus-47-on-amazon-bedrock-apis-features-and-migration-guide).
178+
// We default to that ceiling here so the reasoning-budget slider isn't artificially
179+
// clamped to the legacy 8K floor used by older Anthropic-on-Bedrock entries.
180+
maxTokens: 128_000,
172181
// Opus 4.7 natively supports 1M context (no beta flag required) with FLAT $5/$25
173182
// pricing at any context length. We still keep a tier entry so the dropdown can
174183
// show a "128K" vs "1M" choice - the tier just toggles the context window the UI
@@ -198,7 +207,8 @@ export const bedrockModels = {
198207
],
199208
},
200209
"anthropic.claude-opus-4-5-20251101-v1:0": {
201-
maxTokens: 8192,
210+
// Mirrors anthropic-direct cap; AWS Bedrock accepts the same upstream maximum.
211+
maxTokens: 32_000,
202212
contextWindow: 200_000,
203213
supportsImages: true,
204214
supportsPromptCache: true,
@@ -266,7 +276,8 @@ export const bedrockModels = {
266276
cachableFields: ["system", "messages", "tools"],
267277
},
268278
"anthropic.claude-haiku-4-5-20251001-v1:0": {
269-
maxTokens: 8192,
279+
// Mirrors anthropic-direct cap; AWS Bedrock accepts the same upstream maximum.
280+
maxTokens: 64_000,
270281
contextWindow: 200_000,
271282
supportsImages: true,
272283
supportsPromptCache: true,
@@ -870,12 +881,18 @@ export const resolveBedrockModelInfo = ({
870881
optIn1MContext,
871882
modelMaxTokens,
872883
contextWindowOverride,
884+
maxOutputTokensOverride,
873885
}: {
874886
baseModelId?: string
875887
targetId?: string
876888
optIn1MContext?: boolean
889+
// Request-time "how many tokens to ask for" knob (slider value). Mirrors the historic behaviour.
877890
modelMaxTokens?: number
878891
contextWindowOverride?: number
892+
// Static cap override (e.g. empirically detected by the AWS probe). When set, this widens the
893+
// effective `info.maxTokens` ceiling that downstream UI and request builders see, even if the
894+
// user has not explicitly bumped the slider.
895+
maxOutputTokensOverride?: number
879896
}): { baseModelId: string; info: ModelInfo; uses1MContext: boolean; contextSource: BedrockContextSource } => {
880897
const resolvedBaseModelId = parseBedrockBaseModelId(baseModelId || targetId || bedrockDefaultModelId)
881898

@@ -906,6 +923,12 @@ export const resolveBedrockModelInfo = ({
906923
}
907924
}
908925

926+
// Apply the static-cap override BEFORE the request-time `modelMaxTokens` so users can
927+
// explicitly request fewer tokens than the model's headroom (e.g. cost control) without
928+
// having the override silently clobber their slider value.
929+
if (maxOutputTokensOverride && maxOutputTokensOverride > 0) {
930+
info.maxTokens = maxOutputTokensOverride
931+
}
909932
if (modelMaxTokens && modelMaxTokens > 0) {
910933
info.maxTokens = modelMaxTokens
911934
}

packages/types/src/vscode-extension-host.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface ExtensionMessage {
4444
| "listApiConfig"
4545
| "routerModels"
4646
| "bedrockDiscovery"
47+
| "bedrockMaxTokensProbe"
4748
| "openAiModels"
4849
| "ollamaModels"
4950
| "lmStudioModels"
@@ -138,6 +139,13 @@ export interface ExtensionMessage {
138139
clineMessage?: ClineMessage
139140
routerModels?: RouterModels
140141
bedrockDiscovery?: BedrockDiscoveredTarget[]
142+
/** Empirically detected cap returned by `requestBedrockMaxTokensProbe`. */
143+
bedrockMaxTokensProbe?: {
144+
maxOutputTokens: number
145+
source: "accepted" | "hint" | "binary-search"
146+
attempts: number
147+
modelId: string
148+
}
141149
openAiModels?: string[]
142150
ollamaModels?: ModelRecord
143151
lmStudioModels?: ModelRecord
@@ -451,6 +459,7 @@ export interface WebviewMessage {
451459
| "flushRouterModels"
452460
| "requestRouterModels"
453461
| "requestBedrockDiscovery"
462+
| "requestBedrockMaxTokensProbe"
454463
| "requestOpenAiModels"
455464
| "requestOllamaModels"
456465
| "requestLmStudioModels"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// npx vitest src/api/providers/__tests__/bedrock-max-tokens-probe.spec.ts
2+
3+
import { describe, expect, it, vi } from "vitest"
4+
5+
import { probeBedrockMaxOutputTokens, BEDROCK_MAX_OUTPUT_PROBE_CEILING } from "../bedrock-discovery"
6+
7+
const baseOptions = {
8+
awsRegion: "us-west-2",
9+
awsAccessKey: "AKIA",
10+
awsSecretKey: "secret",
11+
} as const
12+
13+
const buildValidationError = (message: string) => {
14+
const err = new Error(message)
15+
err.name = "ValidationException"
16+
;(err as any).$metadata = { httpStatusCode: 400 }
17+
return err
18+
}
19+
20+
describe("probeBedrockMaxOutputTokens", () => {
21+
it("returns the ceiling when AWS accepts the first probe", async () => {
22+
const runProbe = vi.fn().mockResolvedValue(undefined)
23+
24+
const result = await probeBedrockMaxOutputTokens({
25+
options: baseOptions,
26+
modelId: "anthropic.claude-opus-4-7",
27+
probeCeiling: 256_000,
28+
runProbe,
29+
})
30+
31+
expect(result).toEqual({
32+
maxOutputTokens: 256_000,
33+
source: "accepted",
34+
attempts: 1,
35+
})
36+
expect(runProbe).toHaveBeenCalledTimes(1)
37+
expect(runProbe).toHaveBeenCalledWith(256_000)
38+
})
39+
40+
it("recovers the cap from a parsable AWS error message hint", async () => {
41+
const runProbe = vi
42+
.fn()
43+
.mockRejectedValueOnce(
44+
buildValidationError("max_tokens: 256000 must be less than or equal to 128000 for this model"),
45+
)
46+
.mockResolvedValueOnce(undefined)
47+
48+
const result = await probeBedrockMaxOutputTokens({
49+
options: baseOptions,
50+
modelId: "anthropic.claude-opus-4-7",
51+
probeCeiling: 256_000,
52+
runProbe,
53+
})
54+
55+
expect(result.maxOutputTokens).toBe(128_000)
56+
expect(result.source).toBe("hint")
57+
expect(result.attempts).toBe(2)
58+
expect(runProbe).toHaveBeenNthCalledWith(2, 128_000)
59+
})
60+
61+
it("falls back to binary search when no hint is present", async () => {
62+
// Simulate a model that caps at 65_536. AWS rejects anything above with an opaque message.
63+
const cap = 65_536
64+
const runProbe = vi.fn(async (maxTokens: number) => {
65+
if (maxTokens > cap) {
66+
throw buildValidationError("max_tokens validation failed: invalid request")
67+
}
68+
return undefined
69+
})
70+
71+
const result = await probeBedrockMaxOutputTokens({
72+
options: baseOptions,
73+
modelId: "anthropic.claude-haiku-4-5-20251001-v1:0",
74+
probeCeiling: 200_000,
75+
runProbe,
76+
})
77+
78+
// Binary search should converge to the exact cap (within 1 token).
79+
expect(result.source).toBe("binary-search")
80+
expect(result.maxOutputTokens).toBeLessThanOrEqual(cap)
81+
expect(result.maxOutputTokens).toBeGreaterThan(cap - 4) // tight bound
82+
expect(result.attempts).toBeGreaterThan(2)
83+
})
84+
85+
it("propagates non-validation errors immediately", async () => {
86+
const networkError = Object.assign(new Error("connect ETIMEDOUT"), { name: "TimeoutError" })
87+
const runProbe = vi.fn().mockRejectedValue(networkError)
88+
89+
await expect(
90+
probeBedrockMaxOutputTokens({
91+
options: baseOptions,
92+
modelId: "anthropic.claude-opus-4-7",
93+
probeCeiling: 256_000,
94+
runProbe,
95+
}),
96+
).rejects.toMatchObject({ message: "connect ETIMEDOUT" })
97+
// We never recover from non-validation errors, so only one probe should have been issued.
98+
expect(runProbe).toHaveBeenCalledTimes(1)
99+
})
100+
101+
it("rejects when AWS region is missing", async () => {
102+
await expect(
103+
probeBedrockMaxOutputTokens({
104+
options: { ...baseOptions, awsRegion: undefined },
105+
modelId: "anthropic.claude-opus-4-7",
106+
runProbe: vi.fn(),
107+
}),
108+
).rejects.toThrow(/region/i)
109+
})
110+
111+
it("respects the documented probe ceiling default", () => {
112+
expect(BEDROCK_MAX_OUTPUT_PROBE_CEILING).toBe(1_000_000)
113+
})
114+
})

0 commit comments

Comments
 (0)