Skip to content

Commit aee6dd1

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 f468928 commit aee6dd1

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
@@ -140,6 +140,8 @@ const stream = chat({
140140

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

143+
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.
144+
143145
### Thinking (Extended Thinking)
144146

145147
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
@@ -266,7 +266,12 @@ export class AnthropicTextAdapter<
266266
const { chatOptions, outputSchema } = options
267267
const { logger } = chatOptions
268268

269-
const requestParams = this.mapCommonOptionsToAnthropic(chatOptions)
269+
// `structuredOutput()` issues a non-streaming `messages.create({ stream:
270+
// false })` below, so the defaulted `max_tokens` must stay under the SDK's
271+
// non-streaming 10-minute guard (issue #849) — pass `stream: false`.
272+
const requestParams = this.mapCommonOptionsToAnthropic(chatOptions, {
273+
stream: false,
274+
})
270275

271276
// Create a tool that will capture the structured output
272277
// Anthropic's SDK requires input_schema with type: 'object' literal
@@ -355,6 +360,7 @@ export class AnthropicTextAdapter<
355360

356361
private mapCommonOptionsToAnthropic(
357362
options: TextOptions<AnthropicTextProviderOptions>,
363+
{ stream = true }: { stream?: boolean } = {},
358364
) {
359365
const modelOptions = options.modelOptions
360366

@@ -429,8 +435,12 @@ export class AnthropicTextAdapter<
429435
// that silently truncates long responses with `stop_reason: "max_tokens"`
430436
// (issue #849). `max_tokens` is a ceiling, not a reservation — billing is on
431437
// tokens actually generated, so a higher default costs nothing extra.
438+
// For non-streaming requests (the `structuredOutput()` path) the default is
439+
// clamped to the SDK's non-streaming-safe limit so it doesn't trip the
440+
// "streaming required" 10-minute guard — see getAnthropicDefaultMaxTokens.
432441
const defaultMaxTokens =
433-
modelOptions?.max_tokens ?? getAnthropicDefaultMaxTokens(this.model)
442+
modelOptions?.max_tokens ??
443+
getAnthropicDefaultMaxTokens(this.model, { stream })
434444
const maxTokens =
435445
thinkingBudget && thinkingBudget >= defaultMaxTokens
436446
? thinkingBudget + 1

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

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

779+
/**
780+
* Largest `max_tokens` the Anthropic SDK permits on a **non-streaming**
781+
* request. The SDK refuses to make a non-streaming call it estimates could
782+
* exceed its 10-minute timeout, computed as
783+
* `(60min * max_tokens) / 128_000 > 10min` — i.e. it throws
784+
* `"Streaming is required for operations that may take longer than 10 minutes"`
785+
* once `max_tokens > 128_000 * 10 / 60 ≈ 21_333`
786+
* (`@anthropic-ai/sdk`'s `calculateNonstreamingTimeout`). The text adapter's
787+
* only non-streaming call is the forced-tool `structuredOutput()` request, so
788+
* its defaulted ceiling must stay at or below this; the streaming chat path
789+
* keeps the model's full {@link getAnthropicDefaultMaxTokens} ceiling. We sit
790+
* just under the boundary (`21_333` would round-trip to exactly 10min). This
791+
* caps only the *default* — an explicit oversized `max_tokens` from the caller
792+
* still surfaces the SDK's "use streaming" error, which is the correct signal.
793+
*/
794+
export const ANTHROPIC_MAX_NONSTREAMING_TOKENS = 21_000
795+
779796
/**
780797
* Resolve the default `max_tokens` for a model: its known `max_output_tokens`
781798
* ceiling, or {@link ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS} for unknown models.
782799
* Callers that pass an explicit `max_tokens` bypass this entirely.
800+
*
801+
* Pass `stream: false` for non-streaming requests (the `structuredOutput()`
802+
* path): the result is then clamped to {@link ANTHROPIC_MAX_NONSTREAMING_TOKENS}
803+
* so the defaulted ceiling doesn't trip the SDK's non-streaming 10-minute guard
804+
* (issue #849). Streaming requests (the default) are unaffected and get the
805+
* model's full ceiling.
783806
*/
784-
export function getAnthropicDefaultMaxTokens(model: string): number {
785-
return (
807+
export function getAnthropicDefaultMaxTokens(
808+
model: string,
809+
{ stream = true }: { stream?: boolean } = {},
810+
): number {
811+
const ceiling =
786812
ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS[model] ??
787813
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
788-
)
814+
return stream ? ceiling : Math.min(ceiling, ANTHROPIC_MAX_NONSTREAMING_TOKENS)
789815
}
790816

791817
/**

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
22
import { chat, type Tool, type StreamChunk } from '@tanstack/ai'
33
import { AnthropicTextAdapter } from '../src/adapters/text'
44
import type { AnthropicTextProviderOptions } from '../src/adapters/text'
5+
import { ANTHROPIC_MAX_NONSTREAMING_TOKENS } from '../src/model-meta'
56
import { z } from 'zod'
67

78
const mocks = vi.hoisted(() => {
@@ -543,6 +544,51 @@ describe('Anthropic adapter option mapping', () => {
543544
expect(truncationWarning).toBeUndefined()
544545
})
545546

547+
it('clamps the default max_tokens on the non-streaming structured-output path so it never trips the SDK 10-minute guard (#849)', async () => {
548+
// The structured-output fallback issues a NON-streaming
549+
// `messages.create({ stream: false })`. The Anthropic SDK throws
550+
// "Streaming is required for operations that may take longer than 10
551+
// minutes" once max_tokens exceeds ~21_333, so the defaulted ceiling must
552+
// be clamped here even though the streaming chat path keeps the full 64K.
553+
mocks.betaMessagesCreate.mockResolvedValueOnce({
554+
id: 'msg_structured',
555+
type: 'message',
556+
role: 'assistant',
557+
model: 'claude-3-7-sonnet',
558+
content: [
559+
{
560+
type: 'tool_use',
561+
id: 'toolu_structured_output',
562+
name: 'structured_output',
563+
input: { recommendation: 'Strat', price: 1299 },
564+
},
565+
],
566+
stop_reason: 'tool_use',
567+
usage: { input_tokens: 10, output_tokens: 20 },
568+
})
569+
570+
const adapter = createAdapter('claude-3-7-sonnet')
571+
572+
for await (const _ of chat({
573+
adapter,
574+
messages: [{ role: 'user', content: 'recommend a guitar as json' }],
575+
outputSchema: z.object({
576+
recommendation: z.string(),
577+
price: z.number(),
578+
}),
579+
stream: true,
580+
})) {
581+
// consume stream
582+
}
583+
584+
const [payload] = mocks.betaMessagesCreate.mock.calls[0]!
585+
expect(payload.stream).toBe(false)
586+
// Clamped to the non-streaming limit — NOT claude-3-7-sonnet's full 64K
587+
// streaming ceiling, which would make the SDK throw before the request.
588+
expect(payload.max_tokens).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
589+
expect(payload.max_tokens).toBeLessThanOrEqual(21_333)
590+
})
591+
546592
it('native combined mode (#605): wires outputSchema into output_format alongside tools on Claude 4.5+', async () => {
547593
// Final-turn JSON the model emits when output_format is in play.
548594
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 {
@@ -806,4 +807,41 @@ describe('getAnthropicDefaultMaxTokens (#849)', () => {
806807
1024,
807808
)
808809
})
810+
811+
it('clamps the default to the non-streaming limit for non-streaming requests (#849)', () => {
812+
// The Anthropic SDK refuses non-streaming requests whose `max_tokens`
813+
// could exceed its 10-minute timeout (~21_333). The streaming path keeps
814+
// the full ceiling; the non-streaming (`structuredOutput`) path must clamp.
815+
expect(ANTHROPIC_MAX_NONSTREAMING_TOKENS).toBeLessThanOrEqual(21_333)
816+
817+
// Opus 128K and Sonnet 64K both exceed the non-streaming limit → clamped.
818+
expect(
819+
getAnthropicDefaultMaxTokens('claude-opus-4.8', { stream: false }),
820+
).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
821+
expect(
822+
getAnthropicDefaultMaxTokens('claude-sonnet-4-6', { stream: false }),
823+
).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
824+
// Unknown model fallback (64K) is also above the limit → clamped.
825+
expect(
826+
getAnthropicDefaultMaxTokens('some-future-claude-model', {
827+
stream: false,
828+
}),
829+
).toBe(ANTHROPIC_MAX_NONSTREAMING_TOKENS)
830+
})
831+
832+
it('does not clamp a model whose ceiling is already below the non-streaming limit (#849)', () => {
833+
// claude-3-haiku's 4K ceiling is under the non-streaming limit, so the
834+
// non-streaming path returns the real ceiling, not the (larger) cap.
835+
expect(
836+
getAnthropicDefaultMaxTokens('claude-3-haiku', { stream: false }),
837+
).toBe(4_000)
838+
})
839+
840+
it('keeps the full ceiling for streaming requests (default) (#849)', () => {
841+
expect(
842+
getAnthropicDefaultMaxTokens('claude-opus-4.8', { stream: true }),
843+
).toBe(128_000)
844+
// Omitting the option defaults to streaming.
845+
expect(getAnthropicDefaultMaxTokens('claude-opus-4.8')).toBe(128_000)
846+
})
809847
})

0 commit comments

Comments
 (0)