Chat Completions overbilling or injecting on empty tools list API parameter, GPT-5.x worse

Issue

Include "tools": [] in the exact same request to Responses vs Chat Completions - significantly different billing.

Expected:

  • identical context window placements
  • identical billing
  • an empty tools list treated as inert and no context additions

Symptom:

  • 96 tokens are being added on Chat Completions with an empty tools list with gpt 5 models such as gpt-5.6 and gpt-5.4-mini. Potentially 96 tokens of useless distracting injection.
  • 12 tokens are being added on gpt-4.1, “input_tokens”: 26 → “prompt_tokens”: 38

This does not occur on Responses.

Comparative API calls:

- Responses -

ASSISTANT MESSAGE
hello

USAGE

{
  "input_tokens": 25,
  "input_tokens_details": {
    "cache_write_tokens": 0,
    "cached_tokens": 0
  },
  "output_tokens": 5,
  "output_tokens_details": {
    "reasoning_tokens": 0
  },
  "total_tokens": 30
}

- Chat Completions -

ASSISTANT MESSAGE
hello

USAGE

{
  "prompt_tokens": 121,
  "completion_tokens": 4,
  "total_tokens": 125,
  "prompt_tokens_details": {
    "cached_tokens": 0,
    "cache_write_tokens": 0,
    "audio_tokens": 0
  },
  "completion_tokens_details": {
    "reasoning_tokens": 0,
    "audio_tokens": 0,
    "accepted_prediction_tokens": 0,
    "rejected_prediction_tokens": 0
  }
}

Replication Python script

import json
import os
import httpx


def post_json(url, payload):
    response = httpx.post(
        url,
        headers={
            "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
        },
        json=payload,
        timeout=120.0,
    )

    if response.is_error:
        raise RuntimeError(
            f"HTTP {response.status_code}\n"
            f"{response.text}\n"
            f"x-request-id: {response.headers.get('x-request-id')}"
        )
    return response.json()


def output_items(response):
    """Responses API extractor for just text events for user display"""
    for item in response.get("output", []):
        if item.get("type") == "reasoning":
            for part in item.get("summary", []):
                if part.get("type") == "summary_text":
                    yield "reasoning summary", part["text"]

        elif item.get("type") == "message" and item.get("role") == "assistant":
            for part in item.get("content", []):
                if part.get("type") == "output_text":
                    yield "assistant message", part["text"]
                elif part.get("type") == "refusal":
                    yield "assistant refusal", part["refusal"]

def chat_output_items(response):
    """Chat Completions API extractor for just text for user display"""
    for choice in response.get("choices", []):
        message = choice.get("message", {})

        if message.get("role") == "assistant":
            if message.get("refusal") is not None:
                yield "assistant refusal", message["refusal"]
            elif message.get("content") is not None:
                yield "assistant message", message["content"]


MODEL = "gpt-5.6-luna"
DEVELOPER_TEXT = (
    "Fulfill user needs in a friendly multi-turn chat setting."
)
USER_TEXT = "Say only hello"

responses_payload = {
    "model": MODEL,
    "input": [
        {
            "type": "message",
            "role": "developer",
            "content": [
                {
                    "type": "input_text",
                    "text": DEVELOPER_TEXT,
                },
            ],
        },
        {
            "type": "message",
            "role": "user",
            "content": [
                {
                    "type": "input_text",
                    "text": USER_TEXT,
                },
            ],
        },
    ],
    "store": False,
    "tools": [],
}

chat_completions_payload = {
    "model": MODEL,
    "messages": [
        {
            "role": "developer",
            "content": [
                {
                    "type": "text",
                    "text": DEVELOPER_TEXT,
                },
            ],
        },
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": USER_TEXT,
                },
            ],
        },
    ],
    "store": False,
    "tools": [],
}

print("## - Responses - ##")
response = post_json(
    "https://api.openai.com/v1/responses",
    responses_payload,
)
for label, text in output_items(response):
    print(f"\n{label.upper()}\n{text}")
print("\nUSAGE")
print("```\n" + json.dumps(response.get("usage", {}), indent=2) + "\n```")

print("## - Chat Completions - ##")
chat_response = post_json(
    "https://api.openai.com/v1/chat/completions",
    chat_completions_payload,
)

for label, text in chat_output_items(chat_response):
    print(f"\n{label.upper()}\n{text}")
print("\nUSAGE")
print("```\n" + json.dumps(chat_response.get("usage", {}), indent=2) + "\n```")

Then and additionally: there is no need to block developer functions on gpt-5.4+ on Chat Completions. Functions can only be used with reasoning_effort:“none”, which degrades the output further. Because OpenAI couldn’t adapt “phase”?

I can confirm the observation that an empty tools array is not treated as inert when using chat.completions, unlike with the Responses API.

Regarding the limitation that 5.4+ models do not support tool calling unless reasoning effort is set to none: The API returns an error explaining this, but the limitation is not clearly mentioned in the docs. There is room for improvement here.

I have pinged the team to take a look.