fix(think): extend 16K output headroom to OpenAI reasoning models (#3916)

maxOutputTokensFor() gave the 16000-token output budget only to
thinking-by-default Anthropic Claude 5 models; every other model kept the
4000 default. OpenAI reasoning models (the gpt-5 family and the numbered
o-series) bill internal reasoning tokens as output tokens counting against
max_tokens, so the 4000 cap starved think's actual answer the same way.

Add OPENAI_REASONING_MODEL_RE alongside the existing Claude-5 regex,
deliberately scoped to the gpt-5 family + numbered o-series with the
non-reasoning ChatGPT -chat snapshots (gpt-5-chat-latest,
gpt-5.2-chat-latest) excluded. Defaults (4000/16000) are unchanged and no
config knob is added; gpt-4o, other providers, and provider-prefixed
spellings outside openai:/openai/ keep 4000.

Tests pin the new 16000 cases (colon + slash forms), the chat-snapshot and
codex-mini-latest exclusions, and name/version boundaries (gpt-50, o3foo,
bare model names, openrouter-nested ids).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Masa
2026-08-09 19:14:00 +07:00
committed by GitHub
co-authored by Claude Fable 5
parent f8878be54e
commit 485c4773ca
2 changed files with 47 additions and 5 deletions
+12 -1
View File
@@ -171,8 +171,19 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 4000;
// keeps 4000.
const THINKING_DEFAULT_MAX_OUTPUT_TOKENS = 16000;
const THINKING_BY_DEFAULT_MODEL_RE = /^anthropic[:/]claude-[a-z0-9]+-5(?:[.-]|$)/i;
// OpenAI reasoning models spend output budget on internal reasoning tokens
// the same way — reasoning tokens are billed as output and count against
// `max_tokens` — so they get the same headroom. Deliberately scoped to the
// gpt-5 family and the numbered o-series only; anything else (gpt-4o, the
// non-reasoning `*-chat` snapshots like gpt-5-chat-latest, other providers'
// reasoning models routed through their own recipes) keeps the conservative
// 4000 default.
const OPENAI_REASONING_MODEL_RE = /^openai[:/](?:gpt-5|o[0-9]+)(?:[.-]|$)/i;
const OPENAI_CHAT_SNAPSHOT_RE = /-chat(?:-|$)/i; // gpt-5-chat-latest, gpt-5.2-chat-latest
export function maxOutputTokensFor(modelStr: string): number {
return THINKING_BY_DEFAULT_MODEL_RE.test(modelStr)
const openaiReasoning =
OPENAI_REASONING_MODEL_RE.test(modelStr) && !OPENAI_CHAT_SNAPSHOT_RE.test(modelStr);
return THINKING_BY_DEFAULT_MODEL_RE.test(modelStr) || openaiReasoning
? THINKING_DEFAULT_MAX_OUTPUT_TOKENS
: DEFAULT_MAX_OUTPUT_TOKENS;
}
+35 -4
View File
@@ -1,9 +1,10 @@
/**
* Pins `maxOutputTokensFor` — the per-model output-token budget `runThink`
* passes to `client.create`. Thinking-by-default Claude 5 models
* (`anthropic:claude-*-5`) spend a large share of the budget on internal
* reasoning before emitting an answer, so the 4000 default left `think` with
* empty/truncated text. They now get 16000; everything else stays 4000.
* (`anthropic:claude-*-5`) and OpenAI reasoning models (gpt-5 family,
* o-series) spend a large share of the budget on internal reasoning before
* emitting an answer, so the 4000 default left `think` with empty/truncated
* text. They get 16000; everything else stays 4000.
*/
import { describe, test, expect } from 'bun:test';
import { maxOutputTokensFor } from '../src/core/think/index.ts';
@@ -17,12 +18,42 @@ describe('maxOutputTokensFor — thinking-default headroom', () => {
expect(maxOutputTokensFor('anthropic/claude-sonnet-5')).toBe(16000); // slash form
});
test('non-Claude-5 and non-Anthropic keep 4000', () => {
test('OpenAI reasoning models (gpt-5 family, o-series) get 16000', () => {
expect(maxOutputTokensFor('openai:gpt-5')).toBe(16000);
expect(maxOutputTokensFor('openai:gpt-5.2')).toBe(16000);
expect(maxOutputTokensFor('openai:gpt-5.5')).toBe(16000);
expect(maxOutputTokensFor('openai:gpt-5-mini')).toBe(16000);
expect(maxOutputTokensFor('openai:o1')).toBe(16000);
expect(maxOutputTokensFor('openai:o3')).toBe(16000);
expect(maxOutputTokensFor('openai:o4-mini')).toBe(16000);
expect(maxOutputTokensFor('openai/gpt-5.2')).toBe(16000); // slash form
});
test('non-Claude-5 and non-reasoning models keep 4000', () => {
expect(maxOutputTokensFor('anthropic:claude-opus-4-8')).toBe(4000);
expect(maxOutputTokensFor('anthropic:claude-haiku-4-5')).toBe(4000);
expect(maxOutputTokensFor('anthropic:claude-sonnet-4-6')).toBe(4000);
expect(maxOutputTokensFor('anthropic:claude-3-haiku')).toBe(4000);
expect(maxOutputTokensFor('openai:gpt-4o')).toBe(4000);
expect(maxOutputTokensFor('openai:gpt-4o-mini')).toBe(4000);
expect(maxOutputTokensFor('openai:gpt-4.1')).toBe(4000);
// Non-reasoning ChatGPT snapshots of the gpt-5 family stay at 4000.
expect(maxOutputTokensFor('openai:gpt-5-chat-latest')).toBe(4000);
expect(maxOutputTokensFor('openai:gpt-5.2-chat-latest')).toBe(4000);
// Scope is the gpt-5 family + numbered o-series only — other OpenAI
// reasoning-capable ids (e.g. codex-mini-latest) keep the conservative
// default until deliberately added.
expect(maxOutputTokensFor('openai:codex-mini-latest')).toBe(4000);
// Version/name boundaries: `gpt-50` and `o3foo` are not gpt-5 / o3.
expect(maxOutputTokensFor('openai:gpt-50')).toBe(4000);
expect(maxOutputTokensFor('openai:o3foo')).toBe(4000);
// Other providers' reasoning models are out of scope here — the routed
// provider recipe, not this budget, is what changes for them.
expect(maxOutputTokensFor('deepseek:deepseek-reasoner')).toBe(4000);
// Prefix must be the openai provider — a bare model name or another
// provider's gpt-5 spelling doesn't match.
expect(maxOutputTokensFor('o3')).toBe(4000);
expect(maxOutputTokensFor('gpt-5.2')).toBe(4000);
expect(maxOutputTokensFor('openrouter:openai/gpt-5.2')).toBe(4000);
});
});