Skip to content

Commit f468928

Browse files
tombeckenhamclaude
andcommitted
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>
1 parent 33acdd4 commit f468928

9 files changed

Lines changed: 285 additions & 6 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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.

docs/adapters/anthropic.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ const stream = chat({
136136

137137
> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
138138
139+
#### `max_tokens` default
140+
141+
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).
142+
139143
### Thinking (Extended Thinking)
140144

141145
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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,8 @@
431431
{
432432
"label": "Anthropic",
433433
"to": "adapters/anthropic",
434-
"addedAt": "2026-04-15"
434+
"addedAt": "2026-04-15",
435+
"updatedAt": "2026-06-26"
435436
},
436437
{
437438
"label": "Google Gemini",

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

Lines changed: 28 additions & 2 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,
@@ -420,7 +423,14 @@ export class AnthropicTextAdapter<
420423
validProviderOptions.thinking?.type === 'enabled'
421424
? validProviderOptions.thinking.budget_tokens
422425
: undefined
423-
const defaultMaxTokens = modelOptions?.max_tokens ?? 1024
426+
// Anthropic's Messages API *requires* `max_tokens`, so we must always send a
427+
// value. When the caller doesn't specify one, default to the resolved
428+
// model's real output ceiling (from model-meta) rather than a low constant
429+
// that silently truncates long responses with `stop_reason: "max_tokens"`
430+
// (issue #849). `max_tokens` is a ceiling, not a reservation — billing is on
431+
// tokens actually generated, so a higher default costs nothing extra.
432+
const defaultMaxTokens =
433+
modelOptions?.max_tokens ?? getAnthropicDefaultMaxTokens(this.model)
424434
const maxTokens =
425435
thinkingBudget && thinkingBudget >= defaultMaxTokens
426436
? thinkingBudget + 1
@@ -1181,6 +1191,22 @@ export class AnthropicTextAdapter<
11811191
break
11821192
}
11831193
case 'max_tokens': {
1194+
// Surface a warning when the truncating cap was the
1195+
// adapter-supplied default (caller didn't pass `max_tokens`), so
1196+
// the truncation isn't silently attributed to the model "doing
1197+
// nothing" (issue #849). When the caller set `max_tokens`
1198+
// themselves, hitting it is their own deliberate ceiling.
1199+
if (options.modelOptions?.max_tokens == null) {
1200+
const defaultedMaxTokens = getAnthropicDefaultMaxTokens(model)
1201+
logger.warn(
1202+
`anthropic response truncated at the default max_tokens (${defaultedMaxTokens}) for model=${model}; pass maxTokens (or modelOptions.max_tokens) to raise the output ceiling`,
1203+
{
1204+
source: 'anthropic.processAnthropicStream',
1205+
model,
1206+
defaultedMaxTokens,
1207+
},
1208+
)
1209+
}
11841210
yield {
11851211
type: EventType.RUN_ERROR,
11861212
model,

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,61 @@ export const ANTHROPIC_MODELS = [
733733
CLAUDE_OPUS_4_8_FAST.id,
734734
] as const
735735

736+
/**
737+
* Fallback `max_tokens` ceiling for a model whose metadata carries no
738+
* `max_output_tokens` (e.g. an unrecognized model id). Anthropic's Messages
739+
* API *requires* `max_tokens`, so the adapter must always send a value. 64K is
740+
* the output ceiling of the current mainstream Claude tier (Sonnet/Haiku 4.5),
741+
* so it's a sane default for an unknown — almost certainly modern — model and
742+
* avoids silently truncating long generations (issue #849). Recognized models
743+
* use their exact `max_output_tokens` from {@link ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS}
744+
* (e.g. 128K for Opus), so this fallback only ever applies to ids not in the
745+
* map.
746+
*/
747+
export const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000
748+
749+
/**
750+
* Runtime lookup of each model's maximum output-token ceiling, keyed by model
751+
* id. Lets the text adapter default the required `max_tokens` request field to
752+
* the model's real ceiling when the caller doesn't specify one, rather than a
753+
* low constant that truncates responses mid-stream (issue #849).
754+
*
755+
* Kept in sync with {@link ANTHROPIC_MODELS} by `scripts/sync-provider-models.ts`
756+
* — when that script adds a model it also inserts the model's `max_output_tokens`
757+
* here, so a freshly-synced model resolves to its real ceiling rather than the
758+
* fallback above.
759+
*/
760+
const ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
761+
[CLAUDE_OPUS_4_6.id]: CLAUDE_OPUS_4_6.max_output_tokens,
762+
[CLAUDE_OPUS_4_5.id]: CLAUDE_OPUS_4_5.max_output_tokens,
763+
[CLAUDE_SONNET_4_6.id]: CLAUDE_SONNET_4_6.max_output_tokens,
764+
[CLAUDE_SONNET_4_5.id]: CLAUDE_SONNET_4_5.max_output_tokens,
765+
[CLAUDE_HAIKU_4_5.id]: CLAUDE_HAIKU_4_5.max_output_tokens,
766+
[CLAUDE_OPUS_4_1.id]: CLAUDE_OPUS_4_1.max_output_tokens,
767+
[CLAUDE_SONNET_4.id]: CLAUDE_SONNET_4.max_output_tokens,
768+
[CLAUDE_SONNET_3_7.id]: CLAUDE_SONNET_3_7.max_output_tokens,
769+
[CLAUDE_OPUS_4.id]: CLAUDE_OPUS_4.max_output_tokens,
770+
[CLAUDE_HAIKU_3_5.id]: CLAUDE_HAIKU_3_5.max_output_tokens,
771+
[CLAUDE_HAIKU_3.id]: CLAUDE_HAIKU_3.max_output_tokens,
772+
[CLAUDE_OPUS_4_6_FAST.id]: CLAUDE_OPUS_4_6_FAST.max_output_tokens,
773+
[CLAUDE_OPUS_4_7.id]: CLAUDE_OPUS_4_7.max_output_tokens,
774+
[CLAUDE_OPUS_4_7_FAST.id]: CLAUDE_OPUS_4_7_FAST.max_output_tokens,
775+
[CLAUDE_OPUS_4_8.id]: CLAUDE_OPUS_4_8.max_output_tokens,
776+
[CLAUDE_OPUS_4_8_FAST.id]: CLAUDE_OPUS_4_8_FAST.max_output_tokens,
777+
}
778+
779+
/**
780+
* Resolve the default `max_tokens` for a model: its known `max_output_tokens`
781+
* ceiling, or {@link ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS} for unknown models.
782+
* Callers that pass an explicit `max_tokens` bypass this entirely.
783+
*/
784+
export function getAnthropicDefaultMaxTokens(model: string): number {
785+
return (
786+
ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS[model] ??
787+
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
788+
)
789+
}
790+
736791
/**
737792
* Anthropic models that support combining `tools` + JSON-Schema-constrained
738793
* output in a single streaming Messages request (per issue #605). GA'd

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

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ describe('Anthropic adapter option mapping', () => {
444444
expect(payload.top_p).toBe(0.7)
445445
})
446446

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

450450
const adapter = createAdapter('claude-3-7-sonnet')
@@ -457,7 +457,90 @@ describe('Anthropic adapter option mapping', () => {
457457
}
458458

459459
const [payload] = mocks.betaMessagesCreate.mock.calls[0]!
460-
expect(payload.max_tokens).toBe(1024)
460+
// claude-3-7-sonnet's model-meta max_output_tokens is 64_000 — not the old
461+
// hard-coded 1024 floor that silently truncated long responses.
462+
expect(payload.max_tokens).toBe(64_000)
463+
})
464+
465+
it('warns when the default max_tokens cap truncates the response (#849)', async () => {
466+
// Stream that ends with stop_reason: "max_tokens" — the model hit the cap.
467+
const truncatedStream = (async function* () {
468+
yield {
469+
type: 'content_block_start',
470+
index: 0,
471+
content_block: { type: 'text', text: '' },
472+
}
473+
yield {
474+
type: 'content_block_delta',
475+
index: 0,
476+
delta: { type: 'text_delta', text: 'partial output' },
477+
}
478+
yield { type: 'content_block_stop', index: 0 }
479+
yield {
480+
type: 'message_delta',
481+
delta: { stop_reason: 'max_tokens' },
482+
usage: { output_tokens: 64_000 },
483+
}
484+
yield { type: 'message_stop' }
485+
})()
486+
mocks.betaMessagesCreate.mockResolvedValueOnce(truncatedStream)
487+
488+
const adapter = createAdapter('claude-3-7-sonnet')
489+
490+
const logger = {
491+
debug: vi.fn(),
492+
info: vi.fn(),
493+
warn: vi.fn(),
494+
error: vi.fn(),
495+
}
496+
497+
for await (const _ of chat({
498+
adapter,
499+
messages: [{ role: 'user', content: 'Write a long essay' }],
500+
debug: { logger, errors: true },
501+
})) {
502+
// consume stream
503+
}
504+
505+
const truncationWarning = logger.warn.mock.calls.find((call) =>
506+
String(call[0]).includes('truncated at the default max_tokens'),
507+
)
508+
expect(truncationWarning).toBeDefined()
509+
})
510+
511+
it('does not warn about truncation when the caller set max_tokens explicitly (#849)', async () => {
512+
const truncatedStream = (async function* () {
513+
yield {
514+
type: 'message_delta',
515+
delta: { stop_reason: 'max_tokens' },
516+
usage: { output_tokens: 100 },
517+
}
518+
yield { type: 'message_stop' }
519+
})()
520+
mocks.betaMessagesCreate.mockResolvedValueOnce(truncatedStream)
521+
522+
const adapter = createAdapter('claude-3-7-sonnet')
523+
524+
const logger = {
525+
debug: vi.fn(),
526+
info: vi.fn(),
527+
warn: vi.fn(),
528+
error: vi.fn(),
529+
}
530+
531+
for await (const _ of chat({
532+
adapter,
533+
messages: [{ role: 'user', content: 'Hi' }],
534+
modelOptions: { max_tokens: 100 } satisfies AnthropicTextProviderOptions,
535+
debug: { logger, errors: true },
536+
})) {
537+
// consume stream
538+
}
539+
540+
const truncationWarning = logger.warn.mock.calls.find((call) =>
541+
String(call[0]).includes('truncated at the default max_tokens'),
542+
)
543+
expect(truncationWarning).toBeUndefined()
461544
})
462545

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

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { describe, expectTypeOf, it } from 'vitest'
1+
import { describe, expect, expectTypeOf, it } from 'vitest'
2+
import {
3+
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
4+
getAnthropicDefaultMaxTokens,
5+
} from '../src/model-meta'
26
import type {
37
AnthropicChatModelProviderOptionsByName,
48
AnthropicModelInputModalitiesByName,
@@ -780,3 +784,26 @@ describe('Anthropic Model Input Modality Type Assertions', () => {
780784
})
781785
})
782786
})
787+
788+
describe('getAnthropicDefaultMaxTokens (#849)', () => {
789+
it("returns the model's max_output_tokens for known models", () => {
790+
expect(getAnthropicDefaultMaxTokens('claude-opus-4.8')).toBe(128_000)
791+
expect(getAnthropicDefaultMaxTokens('claude-opus-4-6')).toBe(128_000)
792+
expect(getAnthropicDefaultMaxTokens('claude-sonnet-4-6')).toBe(64_000)
793+
expect(getAnthropicDefaultMaxTokens('claude-3-7-sonnet')).toBe(64_000)
794+
expect(getAnthropicDefaultMaxTokens('claude-3-haiku')).toBe(4_000)
795+
})
796+
797+
it('falls back to the safe constant for unknown models', () => {
798+
expect(ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS).toBe(64_000)
799+
expect(getAnthropicDefaultMaxTokens('some-future-claude-model')).toBe(
800+
ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
801+
)
802+
})
803+
804+
it('never returns the old hard-coded 1024 floor for a known model', () => {
805+
expect(getAnthropicDefaultMaxTokens('claude-opus-4.8')).toBeGreaterThan(
806+
1024,
807+
)
808+
})
809+
})

packages/ai/skills/ai-core/adapter-configuration/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,16 @@ Per-provider sampling keys (all live inside `modelOptions`):
285285
some sampling options use provider-native names. Ollama nests all sampling under
286286
`modelOptions.options`.
287287

288+
> **Anthropic `max_tokens` default:** Anthropic's API _requires_ `max_tokens`,
289+
> so the adapter always sends one. When you omit `modelOptions.max_tokens`, it
290+
> defaults to the selected model's full output ceiling (its `max_output_tokens`
291+
> from model metadata — e.g. 64K for Sonnet, 128K for Opus), not a low constant.
292+
> `max_tokens` is a ceiling, not a reservation (billing is per token generated),
293+
> so leaving it unset is the right default for codegen / agentic / long-form
294+
> output and avoids silent `stop_reason: "max_tokens"` truncation. Set it only to
295+
> cap output below the model ceiling. Other providers treat token limits as
296+
> optional and don't apply this flooring.
297+
288298
### 6. Capability Flag: `supportsCombinedToolsAndSchema`
289299

290300
Adapters can declare an optional capability method:

scripts/sync-provider-models.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ interface ProviderConfig {
4343
providerOptionsTypeName: string
4444
/** Name of the input modalities type map */
4545
inputModalitiesTypeName: string
46+
/**
47+
* Name of the runtime `Record<string, number>` mapping model id →
48+
* `max_output_tokens`, if the provider maintains one. Anthropic uses this to
49+
* default the required `max_tokens` request field to the model's real ceiling
50+
* (issue #849); other providers treat token limits as optional and omit it.
51+
*/
52+
maxOutputTokensMapName?: string
4653
/** The supports block template (minus input modalities, which come from OpenRouter) */
4754
referenceSupportsBody: string
4855
/** Valid input modality types for this provider's ModelMeta interface */
@@ -95,6 +102,7 @@ const PROVIDER_MAP: Record<string, ProviderConfig> = {
95102
chatArrayName: 'ANTHROPIC_MODELS',
96103
providerOptionsTypeName: 'AnthropicChatModelProviderOptionsByName',
97104
inputModalitiesTypeName: 'AnthropicModelInputModalitiesByName',
105+
maxOutputTokensMapName: 'ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS',
98106
validInputModalities: ['text', 'image', 'audio', 'video', 'document'],
99107
referenceSupportsBody: ` extended_thinking: true,
100108
priority_tier: true,
@@ -497,6 +505,34 @@ function addToTypeMap(
497505
return content.replace(pattern, () => `${match[1]}\n${newEntries}${match[2]}`)
498506
}
499507

508+
/**
509+
* Add entries to a runtime object literal like:
510+
* const MAP_NAME: Record<string, number> = {
511+
* ...existing entries...
512+
* }
513+
* Used for the Anthropic id → max_output_tokens map (issue #849), which is a
514+
* value declaration rather than a `type` alias.
515+
*/
516+
function addToObjectMap(
517+
content: string,
518+
mapName: string,
519+
entries: Array<string>,
520+
): string {
521+
// Match: const MAP_NAME: Record<string, number> = { ... \n}
522+
const pattern = new RegExp(
523+
`(const ${mapName}: Record<string, number> = \\{[\\s\\S]*?)(\\n\\})`,
524+
)
525+
const match = pattern.exec(content)
526+
if (!match) {
527+
console.warn(` Warning: Could not find object map '${mapName}' in file`)
528+
return content
529+
}
530+
531+
const newEntries = entries.join('\n')
532+
// Use replacer function to prevent $-character interpretation in replacement string
533+
return content.replace(pattern, () => `${match[1]}\n${newEntries}${match[2]}`)
534+
}
535+
500536
// ---------------------------------------------------------------------------
501537
// Git-based change detection
502538
// ---------------------------------------------------------------------------
@@ -693,6 +729,28 @@ async function main() {
693729
)
694730
}
695731

732+
// Add to the id → max_output_tokens runtime map (Anthropic only). Only
733+
// models whose generated constant actually carries `max_output_tokens`
734+
// (i.e. OpenRouter reported a `max_completion_tokens`) get an entry; the
735+
// rest correctly fall through to the map's constant default. Keeps the map
736+
// in lockstep with the chat-model array so a synced model resolves to its
737+
// real ceiling instead of the fallback (issue #849).
738+
if (config.maxOutputTokensMapName) {
739+
const maxOutputEntries = chatModels
740+
.filter(({ model }) => model.top_provider.max_completion_tokens)
741+
.map(
742+
({ constName }) =>
743+
` [${constName}${config.arrayRef}]: ${constName}.max_output_tokens,`,
744+
)
745+
if (maxOutputEntries.length > 0) {
746+
content = addToObjectMap(
747+
content,
748+
config.maxOutputTokensMapName,
749+
maxOutputEntries,
750+
)
751+
}
752+
}
753+
696754
// Write the modified file
697755
await writeFile(config.metaFile, content, 'utf-8')
698756
console.log(` Wrote updated file: ${config.metaFile}`)

0 commit comments

Comments
 (0)