Fixing OpenAI’s 400 “Function tools with reasoning_effort are not supported”
If you call /v1/chat/completions with gpt-5.6-sol and attach function tools, you can get this back even though your request never mentions reasoning:
400 Function tools with reasoning_effort are not supported for gpt-5.6-sol in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.
The model reasons at medium effort when you send nothing, and Chat Completions won’t combine that with function tools. Send "reasoning_effort": "none" when you want tools without reasoning, and move the request to /v1/responses when you want both.
Why a request without reasoning_effort fails
The message reads as if you asked for reasoning. You didn’t; the model’s default did. OpenAI’s reasoning guide says gpt-5.5 defaults to medium effort, and so does GPT-5.6 when the effort is omitted. With gpt-5.4, leaving the field out meant no reasoning: OpenRouter’s model list has gpt-5.4 at default_enabled: false and gpt-5.5 and every gpt-5.6 model at default_enabled: true. Code that worked on 5.4 can start failing on 5.5 and 5.6 the first time a tool is attached.
The error offers two ways out:
- Send
reasoning_effort: "none". gpt-5.5 and the gpt-5.6 models accept it. The request stays on Chat Completions and the tools work, without reasoning. - Use
/v1/responses. It takes reasoning and function tools together. The body is shaped differently:inputinstead ofmessages,reasoning: {"effort": ...}instead ofreasoning_effort, and tools are flat ({"type": "function", "name": ...}) instead of nested underfunction.
Don’t send "none" to every model, though. gpt-5 doesn’t accept it, and some models can’t stop reasoning at all (OpenRouter marks those mandatory, grok-4.6 for example). Sending a value a model doesn’t support trades one error for another. So you need two facts per model: does it reason by default, and does it accept "none".
The fix
For a single model you control, add the line:
import httpx
body = {
"model": "gpt-5.6-sol",
"messages": [{"role": "user", "content": "What's the weather in Boston?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}],
"reasoning_effort": "none", # without this line: the 400 above
}
r = httpx.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json=body,
timeout=60,
)
If you serve many models, decide per request. This is simplified from the function that does it in my backend:
def outgoing_reasoning_effort(user_effort, profile):
"""user_effort: what the user picked; None or "none" means thinking off.
profile: {"default_enabled": bool, "supports_none": bool}, or {} if unknown."""
if user_effort and user_effort != "none":
return user_effort # thinking on: send it, and use /v1/responses
if profile.get("default_enabled") is True and profile.get("supports_none") is True:
return "none" # reasons by default and can be switched off
return None # unknown or mandatory: send nothing, as before
Requests that do carry an effort go to the Responses API, but only for api.openai.com; other OpenAI-compatible endpoints stay on Chat Completions:
def use_responses_api(endpoint, effort):
return "api.openai.com" in endpoint and bool(effort) and effort != "none"
The Responses body then carries "reasoning": {"effort": effort} and the flattened tools.
Where the two facts come from
A hardcoded “gpt-5.5 and newer” check works until the next model name. I read the facts from two public catalogs my backend already syncs daily for context lengths and prices.
LiteLLM’s model_prices_and_context_window.json has per-effort support flags. For gpt-5.5 and the gpt-5.6 models it has "supports_reasoning": true and "supports_none_reasoning_effort": true, but no default effort.
OpenRouter’s https://openrouter.ai/api/v1/models publishes the default. This is gpt-5.6-sol’s block:
"reasoning": {
"mandatory": false,
"default_enabled": true,
"default_effort": "medium",
"supported_efforts": ["max", "xhigh", "high", "medium", "low", "none"]
}
Both normalize into one small profile:
def profile_from_litellm(entry):
if entry.get("supports_reasoning") is not True:
return None
flag = entry.get("supports_none_reasoning_effort")
return {"supports_none": flag} if isinstance(flag, bool) else None
def profile_from_openrouter(block):
if not isinstance(block, dict):
return None
profile = {}
if isinstance(block.get("default_enabled"), bool):
profile["default_enabled"] = block["default_enabled"]
if block.get("default_effort"):
profile["default_effort"] = block["default_effort"]
efforts = block.get("supported_efforts")
if isinstance(efforts, list) and efforts:
profile["supports_none"] = "none" in efforts
elif block.get("mandatory") is True:
profile["supports_none"] = False
return profile or None
The merge rule matters more than the parsing. supports_none has to describe the API the request actually goes to. OpenRouter’s effort list describes OpenRouter’s gateway, so for a model called directly on api.openai.com I take supports_none only from LiteLLM. From the OpenRouter entry with the same bare model name I borrow only default_enabled and default_effort, and only when LiteLLM already says the model reasons. For gpt-5.6-sol on api.openai.com that gives:
{"supports_none": True, "default_enabled": True, "default_effort": "medium"}
When neither catalog knows a model, the profile stays empty and the request goes out exactly as before. The profile is copied onto each model’s configuration by the nightly sync; a model added since then is looked up by name at request time (cached for five minutes), and a failed lookup is logged and ignored rather than failing the chat.
How to verify
Send the request above without reasoning_effort and you get the 400. Add "none" and the same request goes through.
Then pin the decision table down with tests. These mirror the ones in my repository:
GPT56 = {"supports_none": True, "default_enabled": True, "default_effort": "medium"}
GROK = {"supports_none": False, "default_enabled": True, "default_effort": "high"}
def test_thinking_off_on_default_on_model_sends_none():
assert outgoing_reasoning_effort(None, GPT56) == "none"
assert outgoing_reasoning_effort("none", GPT56) == "none"
def test_mandatory_model_gets_nothing():
assert outgoing_reasoning_effort(None, GROK) is None
def test_unknown_none_support_gets_nothing():
assert outgoing_reasoning_effort(None, {"default_enabled": True}) is None
def test_user_effort_wins():
assert outgoing_reasoning_effort("high", GROK) == "high"
Last, run the profile over your own model list and look at which models change. When I ran it against the live catalogs on September 2, exactly five direct OpenAI models started getting "none" with thinking off: gpt-5.1, gpt-5.5, gpt-5.6-sol, gpt-5.6-luna and gpt-5.6-terra. gpt-5.4, gpt-5, the o-series, gpt-4.1, Claude, Gemini, xAI and local models were left alone.
How I ran into it
On September 2 a chat in Catalyst, my AI workspace, failed with this 400 on gpt-5.6-sol as soon as it had tools attached. The thinking toggle was off, so the request carried no reasoning_effort at all. Since May, requests with an effort had been routed to /v1/responses, so thinking on worked. Thinking off, which used to be the safe case, was the one that broke. The backend only sent an explicit "none" when I had marked a model by hand as thinking by default, and nobody had marked gpt-5.6. Our iPhone client never sends an effort, so on gpt-5.6 it hit the same 400 whenever tools were attached.
With the profile in place, thinking off on gpt-5.6 sends "none" and the tools work. The composer’s toggle now also starts on, at the model’s default effort, for models that reason by default, so turning it off means what it says.