Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,24 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
// always exceeds the budget within a model-safe ceiling, reserving room for visible output.
const maxOut = parsed.options.maxOutputTokens ?? omittedMaxTokens;
const wantBudget = reasoningBudget(effectiveReasoning);
const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM));
// Clamp to what the TARGET can emit, not to a flat 32k. REASONING_MAX_TOKENS_CEILING
// dates from Claude 4.x models that really stopped near 32k; Opus 4.6 and Sonnet 4.6
// document 128K and Haiku 4.5 64K, so the flat ceiling cut an explicit caller
// `max_tokens` to a quarter of what the model supports — the adaptive branch above
// honours the caller's figure, and this one did not.
//
// `configuredMaxOut` only ever RAISES this ceiling. It is two different things at once:
// a capability statement on the canonical Anthropic entries (documented Messages maxima)
// and a plain fallback budget for omitted requests anywhere else. Letting it lower the
// ceiling would read an operator's cheap `defaultMaxOutputTokens: 8192` as "this model
// cannot emit more" and clamp an explicit 128000 request to 8192 — below what this path
// sent before and with the thinking budget squeezed to 4096. Taking the larger of the two
// keeps the historical ceiling as a floor, so a low fallback budget cannot regress a
// caller who asked for more.
const statedCeiling = typeof configuredMaxOut === "number" && configuredMaxOut > 0
? Math.max(configuredMaxOut, REASONING_MAX_TOKENS_CEILING)
: REASONING_MAX_TOKENS_CEILING;
const maxTokens = Math.min(statedCeiling, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM));
const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR));
body.max_tokens = maxTokens;
body.thinking = { type: "enabled", budget_tokens: budget };
Expand Down
44 changes: 44 additions & 0 deletions tests/adapters/anthropic/anthropic-reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,50 @@ describe("anthropic extended-thinking gate", () => {
expect(b.max_tokens as number).toBe(64000);
});

// The adaptive branch above already honours an explicit limit above 32k. Budget thinking did
// not: it clamped to a flat REASONING_MAX_TOKENS_CEILING (32000) even for Opus 4.6 and Sonnet
// 4.6, which document 128K output, so a caller asking for 128000 silently got a quarter of it.
test("budget thinking clamps to the model's real maximum, not a flat 32k", async () => {
const seeded = {
...provider,
defaultMaxOutputTokens: 64_000,
modelMaxOutputTokens: { "claude-opus-4-6": 128_000, "claude-sonnet-4-6": 128_000 },
};
for (const modelId of ["claude-opus-4-6", "claude-sonnet-4-6"]) {
for (const asked of [undefined, 128_000]) {
const b = await bodyOf(parsed("high", asked === undefined ? {} : { maxOutputTokens: asked }, modelId), seeded);
expect(b.max_tokens as number, modelId).toBe(128_000);
expect(b.max_tokens as number).toBeGreaterThan((b.thinking as { budget_tokens: number }).budget_tokens);
}
}
// Haiku 4.5 really is 64K, so it must be capped there rather than raised to 128000.
const haiku = await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-haiku-4-5"), seeded);
expect(haiku.max_tokens as number).toBe(64_000);
// A small explicit limit is still only lifted to budget+headroom, exactly as before.
expect((await bodyOf(parsed("high", { maxOutputTokens: 10_000 }, "claude-opus-4-6"), seeded)).max_tokens).toBe(24_576);
// With no configured maxima the flat 32k ceiling remains the fallback.
const bare = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "apiKey" } as unknown as OcxProviderConfig;
expect((await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-opus-4-6"), bare)).max_tokens).toBe(32_000);
});

// `defaultMaxOutputTokens` is a fallback budget for omitted requests, not a statement that the
// model cannot emit more. Reading it as a capability ceiling would let a deliberately cheap
// budget clamp an explicit request BELOW what this path sent before, squeezing the thinking
// budget with it, so the configured value may only ever raise the ceiling.
test("a low fallback budget never lowers the ceiling for a larger explicit request", async () => {
const cheap = { ...provider, defaultMaxOutputTokens: 8192 } as unknown as OcxProviderConfig;
const b = await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-opus-4-6"), cheap);
// Regression: this returned max_tokens 8192 / budget 4096, worse than the 32000/16384 the
// flat ceiling produced, because the fallback budget was treated as a capability.
expect(b.max_tokens as number).toBe(32_000);
expect((b.thinking as { budget_tokens: number }).budget_tokens).toBe(16_384);
// The budget still decides an OMITTED request, which is what it is actually for.
expect((await bodyOf(parsed("none", {}, "claude-opus-4-6"), cheap)).max_tokens).toBe(8192);
// A model whose stated maximum really is higher still gets it.
const seeded = { ...cheap, modelMaxOutputTokens: { "claude-opus-4-6": 128_000 } } as unknown as OcxProviderConfig;
expect((await bodyOf(parsed("high", { maxOutputTokens: 128_000 }, "claude-opus-4-6"), seeded)).max_tokens).toBe(128_000);
});

test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => {
const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } };
// No reasoning: the configured budget is the wire max_tokens.
Expand Down
Loading