diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f41417d755e..9454aec7f0f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -132,6 +132,7 @@ "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", "anthropic-sonnet-5-5-contract.test.ts": "adapters/anthropic", + "anthropic-output-maxima.test.ts": "adapters/anthropic", "anthropic-parallel-tool-disable.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing", diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 51369022bf1..bbbb0cb1c1d 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -20,6 +20,7 @@ import { ANTHROPIC_MODEL_CONTEXT_WINDOWS, ANTHROPIC_MODEL_INPUT_MODALITIES, ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS, ANTHROPIC_MODEL_REASONING_EFFORTS, ZAI_GLM_52_REASONING_EFFORTS, ZAI_GLM_53_REASONING_EFFORTS, @@ -489,6 +490,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // Codex omits max_output_tokens; without a provider budget the Anthropic adapter // falls back to 8192, which truncates long answers with stop_reason=max_tokens. defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + modelMaxOutputTokens: { ...ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS }, defaultModel: "claude-sonnet-5", // Claude fast mode on the subscription lane (Claude Code `/fast`): the OAuth route accepts // `speed` and gates it on account entitlement (usage credits / org enablement), probed live @@ -516,6 +518,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ modelInputModalities: { ...ANTHROPIC_MODEL_INPUT_MODALITIES }, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + modelMaxOutputTokens: { ...ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS }, defaultModel: "claude-sonnet-5", fastWire: ANTHROPIC_FAST_WIRE, modelSupportsServiceTier: { ...ANTHROPIC_FAST_MODELS }, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index b2a8ce82ed9..9b480828a4a 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -19,9 +19,38 @@ export const ANTHROPIC_MODEL_INPUT_MODALITIES: Record = Object ANTHROPIC_MODELS.map(id => [id, ["text", "image"]]), ); // Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x -// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a -// larger request never over-allocates; it only stops the 8192 truncation. +// through Opus 5 and Fable 5), so this stays the floor for a model id not listed in +// ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS below. export const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; +/** + * Per-model synchronous Messages API output maxima, each read from that model's own overview + * page on platform.claude.com (`/docs/en/models//overview`), which states the number in + * its header line and again in the Capabilities table. Deliberately NOT the 300K Message + * Batches extended-output beta, which needs `output-300k-2026-03-24` and does not apply to the + * synchronous route these providers use. + * + * Every seeded Claude model except Haiku 4.5 is 128K. Haiku 4.5 is genuinely 64K and is left + * out so it keeps inheriting ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS. + * + * Without this the 64000 default stood in for all of them, which understates the real ceiling + * by half on every Opus, Sonnet and Fable model. `resolveOutputCeiling` is the value the + * adapter budgets against when a caller declares no `max_tokens`, so a 128K-capable model + * stopped at 64000 with `stop_reason: max_tokens`; the same figure feeds the combo admission + * reserve in `checkComboTargetInputAdmission`, where understating a target's output makes its + * headroom arithmetic wrong. + */ +export const ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS: Record = { + "claude-fable-5-1": 128_000, + "claude-fable-5": 128_000, + "claude-sonnet-5-5": 128_000, + "claude-sonnet-5": 128_000, + "claude-opus-5-5": 128_000, + "claude-opus-5": 128_000, + "claude-opus-4-8": 128_000, + "claude-opus-4-7": 128_000, + "claude-opus-4-6": 128_000, + "claude-sonnet-4-6": 128_000, +}; /** * The effort rungs opencodex exposes for native Anthropic models. Without this the * providers advertised no ladder at all, so every client that keys its effort control off diff --git a/tests/adapters/anthropic/anthropic-output-maxima.test.ts b/tests/adapters/anthropic/anthropic-output-maxima.test.ts new file mode 100644 index 00000000000..1f423102b78 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-output-maxima.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ANTHROPIC_MODELS, + ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS, +} from "../../../src/providers/registry/model-seeds"; +import { resolveModelPolicy } from "../../../src/providers/resolved-model-policy"; +import { resolveOutputCeiling } from "../../../src/server/responses/input-admission"; + +// Anthropic's per-model overview pages (platform.claude.com/docs/en/models//overview, +// read 2026-09-30) put the synchronous Messages API maximum at 128K for every seeded Claude +// model except Haiku 4.5, which is 64K. The 300K figure on those pages belongs to the Message +// Batches extended-output beta and does not apply to the synchronous route. +const HIGH_OUTPUT = 128_000; +const HAIKU = "claude-haiku-4-5"; +const PROVIDERS = ["anthropic", "anthropic-apikey"] as const; + +const entry = (name: string) => getProviderRegistryEntry(name)!; + +function policy(providerName: string, modelId: string) { + const registryEntry = entry(providerName); + const authMode = providerName === "anthropic" ? "oauth" : "key"; + return resolveModelPolicy({ + providerName, + modelId, + provider: { adapter: registryEntry.adapter, baseUrl: registryEntry.baseUrl, authMode }, + registryEntry, + transportMatchedRegistry: true, + effectiveAuth: { authMode }, + }); +} + +describe("Anthropic per-model output maxima", () => { + test("the table covers every seeded model except the one that really is 64K", () => { + expect(Object.keys(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS).sort()) + .toEqual(ANTHROPIC_MODELS.filter(id => id !== HAIKU).sort()); + for (const maximum of Object.values(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS)) { + expect(maximum).toBe(HIGH_OUTPUT); + } + // Haiku 4.5 carries no entry on purpose, so it must still resolve the default and not 128K. + expect(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS[HAIKU]).toBeUndefined(); + expect(ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS).toBe(64_000); + }); + + for (const providerName of PROVIDERS) { + test(`${providerName} resolves each model's real ceiling rather than the 64K default`, () => { + expect(entry(providerName).modelMaxOutputTokens).toEqual(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS); + const provider = providerConfigSeed(entry(providerName)); + for (const modelId of ANTHROPIC_MODELS) { + const expected = modelId === HAIKU ? ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS : HIGH_OUTPUT; + expect(policy(providerName, modelId).model.maxOutputTokens, modelId).toBe(expected); + // The admission path reads the ceiling through its own resolver, so assert that too: + // it is what the adapter budgets against when a caller declares no max_tokens, and what + // the combo reserve is computed from. + expect(resolveOutputCeiling(provider, providerName, modelId)).toBe(expected); + } + }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a74fbe1897e..ac3ad1f2837 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -138,6 +138,7 @@ "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", "anthropic-sonnet-5-5-contract.test.ts": "adapters/anthropic", + "anthropic-output-maxima.test.ts": "adapters/anthropic", "anthropic-parallel-tool-disable.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing",