OpenAI o-series max_completion_tokens Exhausted by Reasoning Tokens
The most confusing o-series failure: you set max_completion_tokens=500, the request completes with finish_reason="length", and the response has zero visible content. All 500 tokens were burned inside the model on hidden reasoning. Here's the token budget model and how to size limits so the model actually has room to answer.
By Sana K. · Last updated Aug 14, 2026 · OpenAI · Page #140
o3, o4-mini) and GPT-5 with thinking, max_completion_tokens (Chat Completions) or max_output_tokens (Responses) caps the sum of visible output + hidden reasoning tokens. Reasoning is billed at output rates but invisible; a single high-effort call can consume 20K-100K reasoning tokens. Fix by (a) setting the budget to at least 4× your expected visible output, (b) monitoring usage.completion_tokens_details.reasoning_tokens, and (c) using reasoning_effort to control depth.Real error messages you'll see
# response.choices[0].message.content == ""
# response.choices[0].finish_reason == "length"
# response.usage.completion_tokens_details.reasoning_tokens == 500 (out of 500 budget)
# Root cause: entire budget went to hidden reasoning; nothing left for visible output.
# Billed for 45,000 completion_tokens on a call that returned 1,200 visible tokens.
# Root cause: 43,800 hidden reasoning tokens billed at output rate.
# Fix: cap max_completion_tokens, monitor reasoning_tokens, lower reasoning_effort.
# stream ends with response.incomplete
# event.response.incomplete_details.reason == "max_output_tokens"
# event.response.usage.output_tokens_details.reasoning_tokens == full budget
# Same bug as above, expressed via Responses API terminal event.
Reasoning token budget by effort level (o3 typical)
| reasoning_effort | Reasoning tokens (typical range) | Latency added |
|---|---|---|
none (GPT-5 only) | 0 (behaves like non-reasoning) | ~0s |
low | 500 - 3,000 | 1-3s |
medium | 3,000 - 15,000 | 5-10s |
high | 15,000 - 100,000+ | 20-60s+ |
minimal (GPT-5 only) | 100 - 800 | <1s |
Root causes (ranked by frequency)
Based on OpenAI developer reports; percentages sum to 100%.
- 26%
max_completion_tokenstoo tight. Set to what you'd use on a non-reasoning model (500-2K). Reasoning tokens consume it before visible output starts. Rule of thumb: budget ≥ 4× expected visible output. - 19%Not monitoring
reasoning_tokens. Bill arrives 5-10× higher than expected because reasoning tokens are billed at output rate but hidden from your logs unless explicitly tracked. - 15%Wrong parameter name for the API surface.
max_tokenssilently ignored on reasoning models; must usemax_completion_tokens(Chat Completions) ormax_output_tokens(Responses). - 12%Reasoning effort not tuned. Defaulting to
mediumorhighon every call — 90% of calls don't need that depth. Route by task complexity. - 10%Chaining hides the compounding cost. Every turn in a
previous_response_idchain generates fresh reasoning. Long chats can accumulate 500K+ reasoning tokens. - 9%No fallback when incomplete.
finish_reason="length"should trigger a retry with larger budget (or lower effort), not be treated as success with empty content. - 5%Streaming makes exhaustion silent. No text deltas ever fire because reasoning consumed the budget. Consumer waits for chunks that never arrive; only
response.completed/response.incompletereveals the issue. - 4%Using
o4-miniand expecting reasoning_effort.o4-minidoes not supportreasoning_effort— it uses a fixed internal budget. Setting the param is a no-op.
How to fix it
Size the budget for reasoning + visible output — never <5K on medium effort
The primary fix for empty responses.
Set max_completion_tokens (or max_output_tokens) to accommodate both reasoning and visible output. For medium effort, budget at least 15,000-25,000; for high, 40,000+. When the budget is tight, downgrade to low effort or a non-reasoning model. Read usage.completion_tokens_details.reasoning_tokens after every call to calibrate.
from openai import OpenAI
client = OpenAI()
# ❌ BUG — same budget you'd use on gpt-4.1
resp_bad = client.chat.completions.create(
model="o3",
messages=[{"role": "user", "content": "Explain this stack trace: ..."}],
max_completion_tokens=500, # <-- 500 total, reasoning eats it all
)
print(repr(resp_bad.choices[0].message.content))
print(resp_bad.choices[0].finish_reason) # "length"
print(resp_bad.usage.completion_tokens_details.reasoning_tokens) # 500
# ✅ SIZED — reasoning budget + response budget
resp = client.chat.completions.create(
model="o3",
messages=[{"role": "user", "content": "Explain this stack trace: ..."}],
reasoning_effort="medium",
max_completion_tokens=20_000, # 15K reasoning + 5K visible headroom
)
print(resp.choices[0].message.content)
print(f"Reasoning tokens: {resp.usage.completion_tokens_details.reasoning_tokens}")
print(f"Visible output: {resp.usage.completion_tokens - resp.usage.completion_tokens_details.reasoning_tokens}")
# ✅ Adaptive budget — retry with larger cap if truncated
def robust_reasoning_call(model: str, messages: list, effort: str = "medium"):
initial_budgets = {"low": 5_000, "medium": 20_000, "high": 60_000}
budget = initial_budgets[effort]
for attempt in range(3):
resp = client.chat.completions.create(
model=model,
messages=messages,
reasoning_effort=effort,
max_completion_tokens=budget,
)
choice = resp.choices[0]
content = choice.message.content or ""
if choice.finish_reason != "length":
return resp
# Truncated — check if reasoning burned the budget
reasoning_used = resp.usage.completion_tokens_details.reasoning_tokens
if reasoning_used >= budget * 0.9:
budget *= 2 # double budget for retry
continue
return resp
# Still truncated — either lower effort or accept truncation
raise RuntimeError(f"Model kept exhausting budget even at {budget} tokens")
# ✅ Track cost per call — reasoning tokens are billed at output rate
def call_with_cost_tracking(prompt: str):
resp = client.chat.completions.create(
model="o3",
messages=[{"role": "user", "content": prompt}],
reasoning_effort="medium",
max_completion_tokens=20_000,
)
u = resp.usage
reasoning = u.completion_tokens_details.reasoning_tokens
visible = u.completion_tokens - reasoning
print(f" input tokens: {u.prompt_tokens}")
print(f" reasoning tokens: {reasoning} (billed at output rate)")
print(f" visible tokens: {visible}")
print(f" ratio (reasoning/visible): {reasoning / max(visible, 1):.1f}x")
return resp.choices[0].message.content
# ✅ Responses API equivalent
resp_r = client.responses.create(
model="o3",
input="Explain this stack trace: ...",
reasoning={"effort": "medium"},
max_output_tokens=20_000, # NOTE: max_output_tokens, not max_completion_tokens
)
u = resp_r.usage
print(f"Reasoning: {u.output_tokens_details.reasoning_tokens}")
print(f"Visible: {u.output_tokens - u.output_tokens_details.reasoning_tokens}")
max_completion_tokens on Chat Completions, max_output_tokens on Responses. Both cap reasoning + visible tokens. Use max_tokens on neither — it's deprecated on reasoning models.Route reasoning_effort by task complexity
Fixes 10x cost overruns without losing quality.
Most calls don't need medium or high effort. Route explicitly: quick lookups → low (or non-reasoning model); code review → medium; multi-file architecture → high. On GPT-5 models, minimal and none are additional low-cost options. o4-mini does not support reasoning_effort — use its fixed internal budget.
from openai import OpenAI
from enum import Enum
client = OpenAI()
class TaskType(str, Enum):
QUICK_LOOKUP = "quick_lookup" # <200 tokens visible
CODE_REVIEW = "code_review" # 500-2K tokens, moderate depth
ARCHITECTURE = "architecture" # deep analysis, 2K+ tokens
SIMPLE_CHAT = "simple_chat" # no reasoning needed
EFFORT_MAP = {
TaskType.QUICK_LOOKUP: ("o3", "low", 5_000),
TaskType.CODE_REVIEW: ("o3", "medium", 20_000),
TaskType.ARCHITECTURE: ("o3", "high", 60_000),
TaskType.SIMPLE_CHAT: ("gpt-5.4", None, 2_000), # no reasoning
}
def call(task_type: TaskType, prompt: str) -> str:
model, effort, budget = EFFORT_MAP[task_type]
kwargs = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_completion_tokens": budget,
}
if effort:
kwargs["reasoning_effort"] = effort
resp = client.chat.completions.create(**kwargs)
return resp.choices[0].message.content
# Use it
answer = call(TaskType.QUICK_LOOKUP, "What is a callback URL?")
review = call(TaskType.CODE_REVIEW, f"Review this function: {code}")
# ✅ GPT-5 with minimal / none — very cheap reasoning modes
resp = client.responses.create(
model="gpt-5.4",
input="What time is it in Tokyo?",
reasoning={"effort": "minimal"}, # ~100-800 reasoning tokens
max_output_tokens=2_000,
)
# ✅ GPT-5 with effort=none — behaves like non-reasoning model
resp = client.responses.create(
model="gpt-5.4",
input="Translate: Hello",
reasoning={"effort": "none"}, # no reasoning, latency-sensitive
max_output_tokens=500,
)
# ✅ o4-mini — fixed internal budget, reasoning_effort is a no-op
# Do NOT pass reasoning_effort; use it as a normal reasoning model.
resp = client.chat.completions.create(
model="o4-mini",
messages=[{"role": "user", "content": "Explain event loops."}],
max_completion_tokens=15_000,
# reasoning_effort=... # ignored / warning
)
# ✅ Route via a classifier for high-volume production
def classify_and_call(user_input: str) -> str:
"""Cheap classifier picks the right depth."""
classifier = client.chat.completions.create(
model="gpt-5.4-nano", # cheap classifier
messages=[{
"role": "user",
"content": (
f"Classify complexity: 'quick_lookup' (fact/lookup), "
f"'code_review' (analyze code), "
f"'architecture' (multi-file design), "
f"'simple_chat' (chit-chat). "
f"Only output the label.\n\nInput: {user_input}"
),
}],
max_tokens=10,
)
task = TaskType(classifier.choices[0].message.content.strip())
return call(task, user_input)
o4-mini's fixed internal reasoning budget delivers strong benchmark performance but you lose the effort dial. If you need per-call control, use o3. If you want cheapest reasoning with GPT-5, use reasoning.effort="minimal".Detect and handle finish_reason="length" / response.incomplete
Never treat truncated responses as success.
Truncated responses look like success to naïve consumers — they get a completion, no exception. Always check finish_reason (Chat Completions) or incomplete_details.reason (Responses). Retry with larger budget when reasoning exhausted it; raise a real error when even the retry fails.
from openai import OpenAI
client = OpenAI()
class ReasoningExhausted(Exception):
"""Raised when the model burned budget on reasoning without producing output."""
def safe_reasoning_call(
prompt: str,
model: str = "o3",
effort: str = "medium",
initial_budget: int = 20_000,
max_retries: int = 2,
) -> str:
budget = initial_budget
for attempt in range(max_retries + 1):
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
reasoning_effort=effort,
max_completion_tokens=budget,
)
choice = resp.choices[0]
content = choice.message.content or ""
reasoning_used = resp.usage.completion_tokens_details.reasoning_tokens
if choice.finish_reason == "stop":
# Normal completion
return content
if choice.finish_reason == "length":
if reasoning_used >= budget * 0.95:
# Reasoning ate the budget
if attempt < max_retries:
budget *= 2
continue
raise ReasoningExhausted(
f"Model burned {reasoning_used}/{budget} tokens on reasoning "
f"across {attempt+1} attempts. Consider lower effort or shorter prompts."
)
# Content was written but truncated — return partial
return content + "\n\n[TRUNCATED]"
if choice.finish_reason == "content_filter":
raise RuntimeError("Response blocked by content filter")
return content
# ✅ Responses API — same pattern with different field names
from openai import BadRequestError
def safe_responses_call(prompt: str, effort: str = "medium", budget: int = 20_000):
resp = client.responses.create(
model="o3",
input=prompt,
reasoning={"effort": effort},
max_output_tokens=budget,
)
if resp.status == "incomplete":
reason = resp.incomplete_details.reason if resp.incomplete_details else None
reasoning_tokens = resp.usage.output_tokens_details.reasoning_tokens
if reason == "max_output_tokens" and reasoning_tokens >= budget * 0.95:
raise ReasoningExhausted(
f"Reasoning exhausted budget ({reasoning_tokens}/{budget})"
)
return resp.output_text
# ✅ Streaming — detect exhaustion at the terminal event
def stream_with_exhaustion_check(prompt: str):
stream = client.responses.create(
model="o3",
input=prompt,
reasoning={"effort": "medium"},
max_output_tokens=20_000,
stream=True,
)
text = []
for event in stream:
if event.type == "response.output_text.delta":
chunk = getattr(event, "delta", "")
text.append(chunk)
print(chunk, end="", flush=True)
elif event.type == "response.incomplete":
reason = event.response.incomplete_details.reason
reasoning = event.response.usage.output_tokens_details.reasoning_tokens
if reason == "max_output_tokens" and not text:
print(f"\n[EMPTY: {reasoning} reasoning tokens consumed]")
raise ReasoningExhausted("stream ended empty due to reasoning exhaustion")
elif event.type == "response.completed":
return "".join(text)
# ✅ Log exhaustion for observability
import logging
def log_reasoning_metrics(resp):
u = resp.usage
reasoning = u.completion_tokens_details.reasoning_tokens
visible = u.completion_tokens - reasoning
logging.info(
"reasoning_call model=%s reasoning_tokens=%d visible_tokens=%d ratio=%.2f",
resp.model, reasoning, visible, reasoning / max(visible, 1),
)
# Alert if ratio > 20 (unusually reasoning-heavy)
if reasoning / max(visible, 1) > 20:
logging.warning("high reasoning/visible ratio on %s", resp.id)
Prevention checklist
- Budget for reasoning:
max_completion_tokens(ormax_output_tokens) ≥ 4× expected visible output; ≥15K for medium effort, ≥40K for high. - Read
usage.completion_tokens_details.reasoning_tokens(oroutput_tokens_detailson Responses) after every call — that's where hidden cost lives. - Never use
max_tokenson reasoning models — it's ignored. Usemax_completion_tokens(Chat Completions) ormax_output_tokens(Responses). - Route
reasoning_effortper task: quick lookups →low; code review →medium; architecture →high. GPT-5 also supportsminimalandnone. - Never treat
finish_reason="length"orresponse.incompleteas success. Retry with larger budget or lower effort. - Alert on reasoning/visible ratio >20× — indicates workload mismatched to effort level.
o4-miniignoresreasoning_effort. Useo3when you need per-call effort control.
Frequently asked questions
OpenAI doesn't expose the raw reasoning content — only a count in usage.completion_tokens_details.reasoning_tokens and, on request, a natural-language summary via reasoning.summary="auto". The raw chain-of-thought stays inside the model. You're billed for the tokens either way. If you need visibility, opt into the summary; if you just need cost tracking, read the token counts.
Yes for output-side budget (max_completion_tokens / max_output_tokens) — reasoning + visible output share that cap. No for input-side context — the reasoning tokens don't consume your prompt context window. On very long chats via previous_response_id, prior reasoning items chain forward too, which accelerates subsequent turns but doesn't inflate your input token count.
No — o-series models reject temperature, top_p, presence_penalty, and frequency_penalty. The model uses internal sampling and these controls don't apply. Attempting to pass them returns a 400 with "unsupported parameter". See error #142 for the full list of unsupported params and their alternatives.
You can't estimate reasoning tokens precisely in advance — they depend on how much the model deliberates. Practical approach: run the call with a small sample of representative prompts, record the reasoning-token distribution, then multiply by call volume to project cost. For high-volume workloads, keep a rolling average of reasoning tokens per prompt category and use that for capacity planning.
Yes, significantly — chained calls with the same reasoning-model family preserve prior reasoning items, so the model builds on them rather than re-deriving from scratch. This is the main efficiency argument for using previous_response_id with o-series models. Chaining to a non-reasoning model drops the reasoning items; chaining back to a reasoning model has to start over.