Skip to content

Commit 9be8fa4

Browse files
tombeckenhamclaude
andauthored
fix(anthropic): default max_tokens to the model's output ceiling (#849) (#853)
* fix(anthropic): default max_tokens to the model's output ceiling (#849) Anthropic's Messages API requires `max_tokens`, so the text adapter must always send a value. It previously hard-coded `?? 1024` when the caller didn't pass one, silently truncating any non-trivial generation mid-stream with `stop_reason: "max_tokens"`. Now default to the resolved model's real `max_output_tokens` from model-meta (e.g. 64K Sonnet, 128K Opus), falling back to 64K for unrecognized ids. `max_tokens` is a ceiling, not a reservation, so this costs nothing extra. Also log a warning when a response is truncated while using the defaulted cap, so it isn't silently read as the model "doing nothing"; callers that set `max_tokens` explicitly are unaffected. The new id -> max_output_tokens map is kept in lockstep with ANTHROPIC_MODELS by `scripts/sync-provider-models.ts`, so a freshly-synced model resolves to its real ceiling rather than the fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(anthropic): clamp non-streaming structured-output max_tokens default (#849) The #849 default of the model's full output ceiling broke the non-streaming `structuredOutput()` path: the Anthropic SDK refuses a non-streaming request whose `max_tokens` could exceed its 10-minute timeout (~21,333 tokens), so `chat({ outputSchema })` on any fallback-path model threw "Streaming is required for operations that may take longer than 10 minutes". `getAnthropicDefaultMaxTokens(model, { stream })` now clamps the default to `ANTHROPIC_MAX_NONSTREAMING_TOKENS` when `stream: false`; the streaming chat path keeps the model's full ceiling. An explicit oversized `max_tokens` still surfaces the SDK's "use streaming" error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(anthropic): align #849 max_tokens map and tests with synced model metadata Main's OpenRouter metadata sync (e14586b) retired claude-3.x / claude-4 / -fast model ids and added Fable 5 / Sonnet 5, so after rebasing: - ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS again mirrors ANTHROPIC_MODELS exactly (removed retired entries, added claude-fable-5 / claude-sonnet-5). - Tests use current ids (claude-opus-4-8, claude-opus-4-1, claude-sonnet-4-5, claude-opus-4-5 for the 32K ceiling case). - The 'ceiling below the non-streaming limit' test relied on claude-3-haiku's 4K ceiling; no current model sits under 21K, so it now asserts every model in the lineup clamps on the non-streaming path instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5deda27 commit 9be8fa4

9 files changed

Lines changed: 413 additions & 7 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@tanstack/ai-anthropic': patch
3+
---
4+
5+
Default Anthropic `max_tokens` to the selected model's real output ceiling
6+
(`max_output_tokens` from model metadata — e.g. 64K for Sonnet, 128K for Opus)
7+
when the caller doesn't pass one, instead of a hard-coded `1024` that silently
8+
truncated long responses with `stop_reason: "max_tokens"` (#849). Unknown
9+
models fall back to a safe constant. `max_tokens` is a ceiling, not a
10+
reservation, so this costs nothing unless the model genuinely produces more.
11+
12+
The adapter also now logs a warning when a response is truncated while using the
13+
defaulted (caller-unspecified) cap, so the truncation isn't silently attributed
14+
to the model "doing nothing". Callers that set `modelOptions.max_tokens`
15+
explicitly are unaffected.
16+
17+
The non-streaming structured-output path (`structuredOutput()`) clamps this
18+
default to the Anthropic SDK's non-streaming-safe limit (~21K tokens). The SDK
19+
refuses a non-streaming request whose `max_tokens` could exceed its 10-minute
20+
timeout, so without the clamp the full-ceiling default would make every
21+
`chat({ outputSchema })` call on a fallback-path model throw "Streaming is
22+
required for operations that may take longer than 10 minutes". The streaming
23+
chat path keeps the model's full ceiling.

docs/adapters/anthropic.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ const stream = chat({
137137

138138
> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
139139
140+
#### `max_tokens` default
141+
142+
Anthropic's Messages API _requires_ `max_tokens` on every request, so the adapter always sends a value. When you don't set `modelOptions.max_tokens`, it defaults to the selected model's full output ceiling (`max_output_tokens` from the model metadata — e.g. 64K for Sonnet, 128K for Opus), falling back to a safe constant for unrecognized models. `max_tokens` is a ceiling, not a reservation — billing is on tokens actually generated — so this default costs nothing extra and avoids the silent mid-response truncation (`stop_reason: "max_tokens"`) that a low default would cause. Set `max_tokens` explicitly only when you want to _cap_ output below the model ceiling. If a response is truncated while using the default cap, the adapter logs a warning (visible with [debug logging](../advanced/debug-logging) enabled).
143+
144+
One exception: structured output (`chat({ outputSchema })`) on models that use the non-streaming finalization path clamps this default to ~21K tokens. The Anthropic SDK rejects a non-streaming request whose `max_tokens` could exceed its 10-minute timeout, so the full ceiling can't be used there. Streaming chat is unaffected. To raise the structured-output ceiling toward a model's true max, stream the response.
145+
140146
### Thinking (Extended Thinking)
141147

142148
Enable extended thinking with a token budget. This allows Claude to show its reasoning process, which is streamed as `thinking` chunks:

docs/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@
510510
"label": "Anthropic",
511511
"to": "adapters/anthropic",
512512
"addedAt": "2026-04-15",
513-
"updatedAt": "2026-07-02"
513+
"updatedAt": "2026-07-04"
514514
},
515515
{
516516
"label": "Google Gemini",

packages/ai-anthropic/src/adapters/text.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
generateId,
1111
getAnthropicApiKeyFromEnv,
1212
} from '../utils'
13-
import { ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS } from '../model-meta'
13+
import {
14+
ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS,
15+
getAnthropicDefaultMaxTokens,
16+
} from '../model-meta'
1417
import type {
1518
ANTHROPIC_MODELS,
1619
AnthropicChatModelProviderOptionsByName,
@@ -343,7 +346,12 @@ export class AnthropicTextAdapter<
343346
const { chatOptions, outputSchema } = options
344347
const { logger } = chatOptions
345348

346-
const requestParams = this.mapCommonOptionsToAnthropic(chatOptions)
349+
// `structuredOutput()` issues a non-streaming `messages.create({ stream:
350+
// false })` below, so the defaulted `max_tokens` must stay under the SDK's
351+
// non-streaming 10-minute guard (issue #849) — pass `stream: false`.
352+
const requestParams = this.mapCommonOptionsToAnthropic(chatOptions, {
353+
stream: false,
354+
})
347355

348356
// Create a tool that will capture the structured output
349357
// Anthropic's SDK requires input_schema with type: 'object' literal
@@ -432,6 +440,7 @@ export class AnthropicTextAdapter<
432440

433441
private mapCommonOptionsToAnthropic(
434442
options: TextOptions<AnthropicTextProviderOptions>,
443+
{ stream = true }: { stream?: boolean } = {},
435444
) {
436445
const modelOptions = options.modelOptions
437446

@@ -500,7 +509,18 @@ export class AnthropicTextAdapter<
500509
validProviderOptions.thinking?.type === 'enabled'
501510
? validProviderOptions.thinking.budget_tokens
502511
: undefined
503-
const defaultMaxTokens = modelOptions?.max_tokens ?? 1024
512+
// Anthropic's Messages API *requires* `max_tokens`, so we must always send a
513+
// value. When the caller doesn't specify one, default to the resolved
514+
// model's real output ceiling (from model-meta) rather than a low constant
515+
// that silently truncates long responses with `stop_reason: "max_tokens"`
516+
// (issue #849). `max_tokens` is a ceiling, not a reservation — billing is on
517+
// tokens actually generated, so a higher default costs nothing extra.
518+
// For non-streaming requests (the `structuredOutput()` path) the default is
519+
// clamped to the SDK's non-streaming-safe limit so it doesn't trip the
520+
// "streaming required" 10-minute guard — see getAnthropicDefaultMaxTokens.
521+
const defaultMaxTokens =
522+
modelOptions?.max_tokens ??
523+
getAnthropicDefaultMaxTokens(this.model, { stream })
504524
const maxTokens =
505525
thinkingBudget && thinkingBudget >= defaultMaxTokens
506526
? thinkingBudget + 1
@@ -1346,6 +1366,22 @@ export class AnthropicTextAdapter<
13461366
break
13471367
}
13481368
case 'max_tokens': {
1369+
// Surface a warning when the truncating cap was the
1370+
// adapter-supplied default (caller didn't pass `max_tokens`), so
1371+
// the truncation isn't silently attributed to the model "doing
1372+
// nothing" (issue #849). When the caller set `max_tokens`
1373+
// themselves, hitting it is their own deliberate ceiling.
1374+
if (options.modelOptions?.max_tokens == null) {
1375+
const defaultedMaxTokens = getAnthropicDefaultMaxTokens(model)
1376+
logger.warn(
1377+
`anthropic response truncated at the default max_tokens (${defaultedMaxTokens}) for model=${model}; pass maxTokens (or modelOptions.max_tokens) to raise the output ceiling`,
1378+
{
1379+
source: 'anthropic.processAnthropicStream',
1380+
model,
1381+
defaultedMaxTokens,
1382+
},
1383+
)
1384+
}
13491385
yield {
13501386
type: EventType.RUN_ERROR,
13511387
model,

packages/ai-anthropic/src/model-meta.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,81 @@ export const ANTHROPIC_MODELS = [
506506
CLAUDE_SONNET_5.id,
507507
] as const
508508

509+
/**
510+
* Fallback `max_tokens` ceiling for a model whose metadata carries no
511+
* `max_output_tokens` (e.g. an unrecognized model id). Anthropic's Messages
512+
* API *requires* `max_tokens`, so the adapter must always send a value. 64K is
513+
* the output ceiling of the current mainstream Claude tier (Sonnet/Haiku 4.5),
514+
* so it's a sane default for an unknown — almost certainly modern — model and
515+
* avoids silently truncating long generations (issue #849). Recognized models
516+
* use their exact `max_output_tokens` from {@link ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS}
517+
* (e.g. 128K for Opus), so this fallback only ever applies to ids not in the
518+
* map.
519+
*/
520+
export const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000
521+
522+
/**
523+
* Runtime lookup of each model's maximum output-token ceiling, keyed by model
524+
* id. Lets the text adapter default the required `max_tokens` request field to
525+
* the model's real ceiling when the caller doesn't specify one, rather than a
526+
* low constant that truncates responses mid-stream (issue #849).
527+
*
528+
* Kept in sync with {@link ANTHROPIC_MODELS} by `scripts/sync-provider-models.ts`
529+
* — when that script adds a model it also inserts the model's `max_output_tokens`
530+
* here, so a freshly-synced model resolves to its real ceiling rather than the
531+
* fallback above.
532+
*/
533+
const ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
534+
[CLAUDE_OPUS_4_6.id]: CLAUDE_OPUS_4_6.max_output_tokens,
535+
[CLAUDE_OPUS_4_5.id]: CLAUDE_OPUS_4_5.max_output_tokens,
536+
[CLAUDE_SONNET_4_6.id]: CLAUDE_SONNET_4_6.max_output_tokens,
537+
[CLAUDE_SONNET_4_5.id]: CLAUDE_SONNET_4_5.max_output_tokens,
538+
[CLAUDE_HAIKU_4_5.id]: CLAUDE_HAIKU_4_5.max_output_tokens,
539+
[CLAUDE_OPUS_4_1.id]: CLAUDE_OPUS_4_1.max_output_tokens,
540+
[CLAUDE_OPUS_4_7.id]: CLAUDE_OPUS_4_7.max_output_tokens,
541+
[CLAUDE_OPUS_4_8.id]: CLAUDE_OPUS_4_8.max_output_tokens,
542+
[CLAUDE_FABLE_5.id]: CLAUDE_FABLE_5.max_output_tokens,
543+
[CLAUDE_SONNET_5.id]: CLAUDE_SONNET_5.max_output_tokens,
544+
}
545+
546+
/**
547+
* Largest `max_tokens` the Anthropic SDK permits on a **non-streaming**
548+
* request. The SDK refuses to make a non-streaming call it estimates could
549+
* exceed its 10-minute timeout, computed as
550+
* `(60min * max_tokens) / 128_000 > 10min` — i.e. it throws
551+
* `"Streaming is required for operations that may take longer than 10 minutes"`
552+
* once `max_tokens > 128_000 * 10 / 60 ≈ 21_333`
553+
* (`@anthropic-ai/sdk`'s `calculateNonstreamingTimeout`). The text adapter's
554+
* only non-streaming call is the forced-tool `structuredOutput()` request, so
555+
* its defaulted ceiling must stay at or below this; the streaming chat path
556+
* keeps the model's full {@link getAnthropicDefaultMaxTokens} ceiling. We sit
557+
* just under the boundary (`21_333` would round-trip to exactly 10min). This
558+
* caps only the *default* — an explicit oversized `max_tokens` from the caller
559+
* still surfaces the SDK's "use streaming" error, which is the correct signal.
560+
*/
561+
export const ANTHROPIC_MAX_NONSTREAMING_TOKENS = 21_000
562+
563+
/**
564+
* Resolve the default `max_tokens` for a model: its known `max_output_tokens`
565+
* ceiling, or {@link ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS} for unknown models.
566+
* Callers that pass an explicit `max_tokens` bypass this entirely.
567+
*
568+
* Pass `stream: false` for non-streaming requests (the `structuredOutput()`
569+
* path): the result is then clamped to {@link ANTHROPIC_MAX_NONSTREAMING_TOKENS}
570+
* so the defaulted ceiling doesn't trip the SDK's non-streaming 10-minute guard
571+
* (issue #849). Streaming requests (the default) are unaffected and get the
572+
* model's full ceiling.
573+
*/
574+
export function getAnthropicDefaultMaxTokens(
575+
model: string,
576+
{ stream = true }: { stream?: boolean } = {},
577+
): number {
578+
const ceiling =
579+
ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS[model] ??
580+
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
581+
return stream ? ceiling : Math.min(ceiling, ANTHROPIC_MAX_NONSTREAMING_TOKENS)
582+
}
583+
509584
/**
510585
* Anthropic models that support combining `tools` + JSON-Schema-constrained
511586
* output in a single streaming Messages request (per issue #605). GA'd

packages/ai-anthropic/tests/anthropic-adapter.test.ts

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@tanstack/ai'
99
import { AnthropicTextAdapter } from '../src/adapters/text'
1010
import type { AnthropicTextProviderOptions } from '../src/adapters/text'
11+
import { ANTHROPIC_MAX_NONSTREAMING_TOKENS } from '../src/model-meta'
1112
import { z } from 'zod'
1213

1314
const mocks = vi.hoisted(() => {
@@ -450,7 +451,7 @@ describe('Anthropic adapter option mapping', () => {
450451
expect(payload.top_p).toBe(0.7)
451452
})
452453

453-
it('defaults max_tokens to 1024 when not provided via modelOptions', async () => {
454+
it("defaults max_tokens to the model's max_output_tokens when not provided via modelOptions (#849)", async () => {
454455
mocks.betaMessagesCreate.mockResolvedValueOnce(createTextStream('ok'))
455456

456457
const adapter = createAdapter('claude-opus-4-1')
@@ -463,7 +464,135 @@ describe('Anthropic adapter option mapping', () => {
463464
}
464465

465466
const [payload] = mocks.betaMessagesCreate.mock.calls[0]!
466-
expect(payload.max_tokens).toBe(1024)
467+
// claude-opus-4-1's model-meta max_output_tokens is 64_000 — not the old
468+
// hard-coded 1024 floor that silently truncated long responses.
469+
expect(payload.max_tokens).toBe(64_000)
470+
})
471+
472+
it('warns when the default max_tokens cap truncates the response (#849)', async () => {
473+
// Stream that ends with stop_reason: "max_tokens" — the model hit the cap.
474+
const truncatedStream = (async function* () {
475+
yield {
476+
type: 'content_block_start',
477+
index: 0,
478+
content_block: { type: 'text', text: '' },
479+
}
480+
yield {
481+
type: 'content_block_delta',
482+
index: 0,
483+
delta: { type: 'text_delta', text: 'partial output' },
484+
}
485+
yield { type: 'content_block_stop', index: 0 }
486+
yield {
487+
type: 'message_delta',
488+
delta: { stop_reason: 'max_tokens' },
489+
usage: { output_tokens: 64_000 },
490+
}
491+
yield { type: 'message_stop' }
492+
})()
493+
mocks.betaMessagesCreate.mockResolvedValueOnce(truncatedStream)
494+
495+
const adapter = createAdapter('claude-opus-4-1')
496+
497+
const logger = {
498+
debug: vi.fn(),
499+
info: vi.fn(),
500+
warn: vi.fn(),
501+
error: vi.fn(),
502+
}
503+
504+
for await (const _ of chat({
505+
adapter,
506+
messages: [{ role: 'user', content: 'Write a long essay' }],
507+
debug: { logger, errors: true },
508+
})) {
509+
// consume stream
510+
}
511+
512+
const truncationWarning = logger.warn.mock.calls.find((call) =>
513+
String(call[0]).includes('truncated at the default max_tokens'),
514+
)
515+
expect(truncationWarning).toBeDefined()
516+
})
517+
518+
it('does not warn about truncation when the caller set max_tokens explicitly (#849)', async () => {
519+
const truncatedStream = (async function* () {
520+
yield {
521+
type: 'message_delta',
522+
delta: { stop_reason: 'max_tokens' },
523+
usage: { output_tokens: 100 },
524+
}
525+
yield { type: 'message_stop' }
526+
})()
527+
mocks.betaMessagesCreate.mockResolvedValueOnce(truncatedStream)
528+
529+
const adapter = createAdapter('claude-opus-4-1')
530+
531+
const logger = {
532+
debug: vi.fn(),
533+
info: vi.fn(),
534+
warn: vi.fn(),
535+
error: vi.fn(),
536+
}
537+
538+
for await (const _ of chat({
539+
adapter,
540+
messages: [{ role: 'user', content: 'Hi' }],
541+
modelOptions: { max_tokens: 100 } satisfies AnthropicTextProviderOptions,
542+
debug: { logger, errors: true },
543+
})) {
544+
// consume stream
545+
}
546+
547+
const truncationWarning = logger.warn.mock.calls.find((call) =>
548+
String(call[0]).includes('truncated at the default max_tokens'),
549+
)
550+
expect(truncationWarning).toBeUndefined()
551+
})
552+
553+
it('clamps the default max_tokens on the non-streaming structured-output path so it never trips the SDK 10-minute guard (#849)', async () => {
554+
// The structured-output fallback issues a NON-streaming
555+
// `messages.create({ stream: false })`. The Anthropic SDK throws
556+
// "Streaming is required for operations that may take longer than 10
557+
// minutes" once max_tokens exceeds ~21_333, so the defaulted ceiling must
558+
// be clamped here even though the streaming chat path keeps the full 64K.
559+
mocks.betaMessagesCreate.mockResolvedValueOnce({
560+
id: 'msg_structured',
561+
type: 'message',
562+
role: 'assistant',
563+
model: 'claude-opus-4-1',
564+
content: [
565+
{
566+
type: 'tool_use',
567+
id: 'toolu_structured_output',
568+
name: 'structured_output',
569+
input: { recommendation: 'Strat', price: 1299 },
570+
},
571+
],
572+
stop_reason: 'tool_use',
573+
usage: { input_tokens: 10, output_tokens: 20 },
574+
})
575+
576+
const adapter = createAdapter('claude-opus-4-1')
577+
578+
for await (const _ of chat({
579+
adapter,
580+
messages: [{ role: 'user', content: 'recommend a guitar as json' }],
581+
outputSchema: z.object({
582+
recommendation: z.string(),
583+
price: z.number(),
584+
}),
585+
stream: true,
586+
})) {
587+
// consume stream
588+
}
589+
590+
const [payload] = mocks.betaMessagesCreate.mock.calls[0]!
591+
expect(payload.stream).toBe(false)
592+
// Clamped to the non-streaming limit — NOT claude-opus-4-1's full 64K
593+
// streaming ceiling, which would make the SDK throw before the request.
594+
expect(payload.max_tokens).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
595+
expect(payload.max_tokens).toBeLessThanOrEqual(21_333)
467596
})
468597

469598
it('native combined mode (#605): wires outputSchema into output_format alongside tools on Claude 4.5+', async () => {

0 commit comments

Comments
 (0)