OpenAI o-series max_completion_tokens Exhausted by Reasoning Tokens — Fix Guide (2026)
Reasoning Models · Token Budget Severity: High

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.

TL;DROn o-series (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

Empty response with finish_reason=length
Empty response with finish_reason=length
# 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.
Cost 10× higher than expected
Cost 10× higher than expected
# 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.
response.incomplete on Responses API
response.incomplete on Responses API
# 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_effortReasoning tokens (typical range)Latency added
none (GPT-5 only)0 (behaves like non-reasoning)~0s
low500 - 3,0001-3s
medium3,000 - 15,0005-10s
high15,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_tokens too 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_tokens silently ignored on reasoning models; must use max_completion_tokens (Chat Completions) or max_output_tokens (Responses).
  • 12%
    Reasoning effort not tuned. Defaulting to medium or high on 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_id chain 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.incomplete reveals the issue.
  • 4%
    Using o4-mini and expecting reasoning_effort. o4-mini does not support reasoning_effort — it uses a fixed internal budget. Setting the param is a no-op.

How to fix it

Fix #1

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.

sized_budget.pypython
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}")
Note: Parameter names differ by API surface: 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.
Fix #2

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.

effort_routing.pypython
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)
Note: 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".
Fix #3

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.

truncation_handling.pypython
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)
Note: Log the reasoning/visible ratio on every reasoning call. Sustained ratios above 10-20× indicate either wrong effort level for the workload or prompts that trigger excessive deliberation. Both are cost problems worth investigating early.

Prevention checklist

  • Budget for reasoning: max_completion_tokens (or max_output_tokens) ≥ 4× expected visible output; ≥15K for medium effort, ≥40K for high.
  • Read usage.completion_tokens_details.reasoning_tokens (or output_tokens_details on Responses) after every call — that's where hidden cost lives.
  • Never use max_tokens on reasoning models — it's ignored. Use max_completion_tokens (Chat Completions) or max_output_tokens (Responses).
  • Route reasoning_effort per task: quick lookups → low; code review → medium; architecture → high. GPT-5 also supports minimal and none.
  • Never treat finish_reason="length" or response.incomplete as success. Retry with larger budget or lower effort.
  • Alert on reasoning/visible ratio >20× — indicates workload mismatched to effort level.
  • o4-mini ignores reasoning_effort. Use o3 when you need per-call effort control.

Frequently asked questions

Why can't I see the reasoning tokens?

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.

Do reasoning tokens count against my context window?

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.

Can I use temperature or top_p with o-series models?

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.

How do I estimate cost before I run the call?

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.

Does chaining via previous_response_id reduce reasoning cost on subsequent turns?

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.

Related errors