Skip to content

Commit 9872d34

Browse files
tombeckenhamclaude
andcommitted
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>
1 parent 9ad1e04 commit 9872d34

6 files changed

Lines changed: 135 additions & 5 deletions

File tree

.changeset/anthropic-max-tokens-default.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,11 @@ The adapter also now logs a warning when a response is truncated while using the
1313
defaulted (caller-unspecified) cap, so the truncation isn't silently attributed
1414
to the model "doing nothing". Callers that set `modelOptions.max_tokens`
1515
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ const stream = chat({
141141

142142
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).
143143

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+
144146
### Thinking (Extended Thinking)
145147

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

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,12 @@ export class AnthropicTextAdapter<
346346
const { chatOptions, outputSchema } = options
347347
const { logger } = chatOptions
348348

349-
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+
})
350355

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

436441
private mapCommonOptionsToAnthropic(
437442
options: TextOptions<AnthropicTextProviderOptions>,
443+
{ stream = true }: { stream?: boolean } = {},
438444
) {
439445
const modelOptions = options.modelOptions
440446

@@ -509,8 +515,12 @@ export class AnthropicTextAdapter<
509515
// that silently truncates long responses with `stop_reason: "max_tokens"`
510516
// (issue #849). `max_tokens` is a ceiling, not a reservation — billing is on
511517
// 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.
512521
const defaultMaxTokens =
513-
modelOptions?.max_tokens ?? getAnthropicDefaultMaxTokens(this.model)
522+
modelOptions?.max_tokens ??
523+
getAnthropicDefaultMaxTokens(this.model, { stream })
514524
const maxTokens =
515525
thinkingBudget && thinkingBudget >= defaultMaxTokens
516526
? thinkingBudget + 1

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -549,16 +549,42 @@ const ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
549549
[CLAUDE_OPUS_4_8_FAST.id]: CLAUDE_OPUS_4_8_FAST.max_output_tokens,
550550
}
551551

552+
/**
553+
* Largest `max_tokens` the Anthropic SDK permits on a **non-streaming**
554+
* request. The SDK refuses to make a non-streaming call it estimates could
555+
* exceed its 10-minute timeout, computed as
556+
* `(60min * max_tokens) / 128_000 > 10min` — i.e. it throws
557+
* `"Streaming is required for operations that may take longer than 10 minutes"`
558+
* once `max_tokens > 128_000 * 10 / 60 ≈ 21_333`
559+
* (`@anthropic-ai/sdk`'s `calculateNonstreamingTimeout`). The text adapter's
560+
* only non-streaming call is the forced-tool `structuredOutput()` request, so
561+
* its defaulted ceiling must stay at or below this; the streaming chat path
562+
* keeps the model's full {@link getAnthropicDefaultMaxTokens} ceiling. We sit
563+
* just under the boundary (`21_333` would round-trip to exactly 10min). This
564+
* caps only the *default* — an explicit oversized `max_tokens` from the caller
565+
* still surfaces the SDK's "use streaming" error, which is the correct signal.
566+
*/
567+
export const ANTHROPIC_MAX_NONSTREAMING_TOKENS = 21_000
568+
552569
/**
553570
* Resolve the default `max_tokens` for a model: its known `max_output_tokens`
554571
* ceiling, or {@link ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS} for unknown models.
555572
* Callers that pass an explicit `max_tokens` bypass this entirely.
573+
*
574+
* Pass `stream: false` for non-streaming requests (the `structuredOutput()`
575+
* path): the result is then clamped to {@link ANTHROPIC_MAX_NONSTREAMING_TOKENS}
576+
* so the defaulted ceiling doesn't trip the SDK's non-streaming 10-minute guard
577+
* (issue #849). Streaming requests (the default) are unaffected and get the
578+
* model's full ceiling.
556579
*/
557-
export function getAnthropicDefaultMaxTokens(model: string): number {
558-
return (
580+
export function getAnthropicDefaultMaxTokens(
581+
model: string,
582+
{ stream = true }: { stream?: boolean } = {},
583+
): number {
584+
const ceiling =
559585
ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS[model] ??
560586
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
561-
)
587+
return stream ? ceiling : Math.min(ceiling, ANTHROPIC_MAX_NONSTREAMING_TOKENS)
562588
}
563589

564590
/**

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

Lines changed: 46 additions & 0 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(() => {
@@ -549,6 +550,51 @@ describe('Anthropic adapter option mapping', () => {
549550
expect(truncationWarning).toBeUndefined()
550551
})
551552

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-3-7-sonnet',
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-3-7-sonnet')
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-3-7-sonnet'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)
596+
})
597+
552598
it('native combined mode (#605): wires outputSchema into output_format alongside tools on Claude 4.5+', async () => {
553599
// Final-turn JSON the model emits when output_format is in play.
554600
const finalJson = JSON.stringify({ city: 'Berlin', temp: 18 })

packages/ai-anthropic/tests/model-meta.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, expectTypeOf, it } from 'vitest'
22
import {
33
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
4+
ANTHROPIC_MAX_NONSTREAMING_TOKENS,
45
getAnthropicDefaultMaxTokens,
56
} from '../src/model-meta'
67
import type {
@@ -351,4 +352,41 @@ describe('getAnthropicDefaultMaxTokens (#849)', () => {
351352
1024,
352353
)
353354
})
355+
356+
it('clamps the default to the non-streaming limit for non-streaming requests (#849)', () => {
357+
// The Anthropic SDK refuses non-streaming requests whose `max_tokens`
358+
// could exceed its 10-minute timeout (~21_333). The streaming path keeps
359+
// the full ceiling; the non-streaming (`structuredOutput`) path must clamp.
360+
expect(ANTHROPIC_MAX_NONSTREAMING_TOKENS).toBeLessThanOrEqual(21_333)
361+
362+
// Opus 128K and Sonnet 64K both exceed the non-streaming limit → clamped.
363+
expect(
364+
getAnthropicDefaultMaxTokens('claude-opus-4.8', { stream: false }),
365+
).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
366+
expect(
367+
getAnthropicDefaultMaxTokens('claude-sonnet-4-6', { stream: false }),
368+
).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
369+
// Unknown model fallback (64K) is also above the limit → clamped.
370+
expect(
371+
getAnthropicDefaultMaxTokens('some-future-claude-model', {
372+
stream: false,
373+
}),
374+
).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
375+
})
376+
377+
it('does not clamp a model whose ceiling is already below the non-streaming limit (#849)', () => {
378+
// claude-3-haiku's 4K ceiling is under the non-streaming limit, so the
379+
// non-streaming path returns the real ceiling, not the (larger) cap.
380+
expect(
381+
getAnthropicDefaultMaxTokens('claude-3-haiku', { stream: false }),
382+
).toBe(4_000)
383+
})
384+
385+
it('keeps the full ceiling for streaming requests (default) (#849)', () => {
386+
expect(
387+
getAnthropicDefaultMaxTokens('claude-opus-4.8', { stream: true }),
388+
).toBe(128_000)
389+
// Omitting the option defaults to streaming.
390+
expect(getAnthropicDefaultMaxTokens('claude-opus-4.8')).toBe(128_000)
391+
})
354392
})

0 commit comments

Comments
 (0)