← back to Exo
#Add reasoning parms to chat completion and responses APIs (#1654)
4de8f801c727bcf8c57927386c346f28799cd883 · 2026-03-04 14:27:49 +0000 · ciaranbor
## Motivation
Adds reasoning_effort parameter support to both the Chat Completions and
Responses APIs, aligning with the OpenAI spec and enabling thinking
control for gpt-oss models
## Changes
- Added ReasoningEffort literal type ("none" | "minimal" | "low" |
"medium" | "high" | "xhigh") and a resolve_reasoning_params() helper
that cross-derives reasoning_effort ↔ enable_thinking when only one is
provided
- Added reasoning_effort field to ChatCompletionRequest and reasoning
(with Reasoning model) + enable_thinking to ResponsesRequest
- Both adapters now call resolve_reasoning_params() before building
TextGenerationTaskParams
- reasoning_effort is passed through to the MLX chat template as a
template variable
## Why It Works
resolve_reasoning_params is a pure function that normalises the two
overlapping knobs (reasoning_effort and enable_thinking) into a
consistent pair, so downstream code always has both values regardless of
which the caller supplied.
## Test Plan
### Automated Testing
Added test_resolve_reasoning_params.py with 10 test cases covering:
both-None, both-set passthrough, enable_thinking → effort derivation,
and effort → enable_thinking derivation for every ReasoningEffort
variant.
Files touched
M src/exo/master/adapters/chat_completions.pyM src/exo/master/adapters/responses.pyA src/exo/shared/tests/test_resolve_reasoning_params.pyM src/exo/shared/types/api.pyM src/exo/shared/types/openai_responses.pyM src/exo/shared/types/text_generation.pyM src/exo/worker/engines/mlx/utils_mlx.py
Diff
commit 4de8f801c727bcf8c57927386c346f28799cd883
Author: ciaranbor <81697641+ciaranbor@users.noreply.github.com>
Date: Wed Mar 4 14:27:49 2026 +0000
#Add reasoning parms to chat completion and responses APIs (#1654)
## Motivation
Adds reasoning_effort parameter support to both the Chat Completions and
Responses APIs, aligning with the OpenAI spec and enabling thinking
control for gpt-oss models
## Changes
- Added ReasoningEffort literal type ("none" | "minimal" | "low" |
"medium" | "high" | "xhigh") and a resolve_reasoning_params() helper
that cross-derives reasoning_effort ↔ enable_thinking when only one is
provided
- Added reasoning_effort field to ChatCompletionRequest and reasoning
(with Reasoning model) + enable_thinking to ResponsesRequest
- Both adapters now call resolve_reasoning_params() before building
TextGenerationTaskParams
- reasoning_effort is passed through to the MLX chat template as a
template variable
## Why It Works
resolve_reasoning_params is a pure function that normalises the two
overlapping knobs (reasoning_effort and enable_thinking) into a
consistent pair, so downstream code always has both values regardless of
which the caller supplied.
## Test Plan
### Automated Testing
Added test_resolve_reasoning_params.py with 10 test cases covering:
both-None, both-set passthrough, enable_thinking → effort derivation,
and effort → enable_thinking derivation for every ReasoningEffort
variant.
---
src/exo/master/adapters/chat_completions.py | 13 ++++++++--
src/exo/master/adapters/responses.py | 13 +++++++++-
.../shared/tests/test_resolve_reasoning_params.py | 30 ++++++++++++++++++++++
src/exo/shared/types/api.py | 2 ++
src/exo/shared/types/openai_responses.py | 15 +++++++++++
src/exo/shared/types/text_generation.py | 24 +++++++++++++++++
src/exo/worker/engines/mlx/utils_mlx.py | 2 ++
7 files changed, 96 insertions(+), 3 deletions(-)
diff --git a/src/exo/master/adapters/chat_completions.py b/src/exo/master/adapters/chat_completions.py
index 9773806f..dd76e6b8 100644
--- a/src/exo/master/adapters/chat_completions.py
+++ b/src/exo/master/adapters/chat_completions.py
@@ -26,7 +26,11 @@ from exo.shared.types.chunks import (
ToolCallChunk,
)
from exo.shared.types.common import CommandId
-from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
+from exo.shared.types.text_generation import (
+ InputMessage,
+ TextGenerationTaskParams,
+ resolve_reasoning_params,
+)
def chat_request_to_text_generation(
@@ -75,6 +79,10 @@ def chat_request_to_text_generation(
dumped: dict[str, Any] = msg_copy.model_dump(exclude_none=True)
chat_template_messages.append(dumped)
+ resolved_effort, resolved_thinking = resolve_reasoning_params(
+ request.reasoning_effort, request.enable_thinking
+ )
+
return TextGenerationTaskParams(
model=request.model,
input=input_messages
@@ -89,7 +97,8 @@ def chat_request_to_text_generation(
seed=request.seed,
stream=request.stream,
tools=request.tools,
- enable_thinking=request.enable_thinking,
+ reasoning_effort=resolved_effort,
+ enable_thinking=resolved_thinking,
chat_template_messages=chat_template_messages
if chat_template_messages
else None,
diff --git a/src/exo/master/adapters/responses.py b/src/exo/master/adapters/responses.py
index dc726ba7..0a40c016 100644
--- a/src/exo/master/adapters/responses.py
+++ b/src/exo/master/adapters/responses.py
@@ -42,7 +42,11 @@ from exo.shared.types.openai_responses import (
ResponseTextDoneEvent,
ResponseUsage,
)
-from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
+from exo.shared.types.text_generation import (
+ InputMessage,
+ TextGenerationTaskParams,
+ resolve_reasoning_params,
+)
def _format_sse(event: ResponsesStreamEvent) -> str:
@@ -119,6 +123,11 @@ def responses_request_to_text_generation(
)
built_chat_template = chat_template_messages if chat_template_messages else None
+ effort_from_reasoning = request.reasoning.effort if request.reasoning else None
+ resolved_effort, resolved_thinking = resolve_reasoning_params(
+ effort_from_reasoning, request.enable_thinking
+ )
+
return TextGenerationTaskParams(
model=request.model,
input=input_value,
@@ -132,6 +141,8 @@ def responses_request_to_text_generation(
stop=request.stop,
seed=request.seed,
chat_template_messages=built_chat_template or request.chat_template_messages,
+ reasoning_effort=resolved_effort,
+ enable_thinking=resolved_thinking,
)
diff --git a/src/exo/shared/tests/test_resolve_reasoning_params.py b/src/exo/shared/tests/test_resolve_reasoning_params.py
new file mode 100644
index 00000000..912469c2
--- /dev/null
+++ b/src/exo/shared/tests/test_resolve_reasoning_params.py
@@ -0,0 +1,30 @@
+import pytest
+
+from exo.shared.types.text_generation import resolve_reasoning_params
+
+
+def test_both_none_returns_none_none() -> None:
+ assert resolve_reasoning_params(None, None) == (None, None)
+
+
+def test_both_set_passes_through_unchanged() -> None:
+ assert resolve_reasoning_params("high", True) == ("high", True)
+ assert resolve_reasoning_params("none", True) == ("none", True)
+ assert resolve_reasoning_params("low", False) == ("low", False)
+
+
+def test_enable_thinking_true_derives_medium() -> None:
+ assert resolve_reasoning_params(None, True) == ("medium", True)
+
+
+def test_enable_thinking_false_derives_none() -> None:
+ assert resolve_reasoning_params(None, False) == ("none", False)
+
+
+def test_reasoning_effort_none_derives_thinking_false() -> None:
+ assert resolve_reasoning_params("none", None) == ("none", False)
+
+
+@pytest.mark.parametrize("effort", ["minimal", "low", "medium", "high", "xhigh"])
+def test_non_none_effort_derives_thinking_true(effort: str) -> None:
+ assert resolve_reasoning_params(effort, None) == (effort, True) # pyright: ignore[reportArgumentType]
diff --git a/src/exo/shared/types/api.py b/src/exo/shared/types/api.py
index 511bc754..e6420a9b 100644
--- a/src/exo/shared/types/api.py
+++ b/src/exo/shared/types/api.py
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field, field_validator
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.common import CommandId, NodeId
from exo.shared.types.memory import Memory
+from exo.shared.types.text_generation import ReasoningEffort
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding, ShardMetadata
from exo.utils.pydantic_ext import CamelCaseModel
@@ -198,6 +199,7 @@ class ChatCompletionRequest(BaseModel):
top_p: float | None = None
top_k: int | None = None
tools: list[dict[str, Any]] | None = None
+ reasoning_effort: ReasoningEffort | None = None
enable_thinking: bool | None = None
tool_choice: str | dict[str, Any] | None = None
parallel_tool_calls: bool | None = None
diff --git a/src/exo/shared/types/openai_responses.py b/src/exo/shared/types/openai_responses.py
index 331f68e4..eb21c4ae 100644
--- a/src/exo/shared/types/openai_responses.py
+++ b/src/exo/shared/types/openai_responses.py
@@ -12,6 +12,7 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
from exo.shared.types.common import ModelId
+from exo.shared.types.text_generation import ReasoningEffort
# Type aliases
ResponseStatus = Literal["completed", "failed", "in_progress", "incomplete"]
@@ -71,6 +72,13 @@ ResponseInputItem = (
)
+class Reasoning(BaseModel, frozen=True):
+ """Reasoning configuration for OpenAI Responses API."""
+
+ effort: ReasoningEffort | None = None
+ summary: Literal["auto", "concise", "detailed"] | None = None
+
+
class ResponsesRequest(BaseModel, frozen=True):
"""Request body for OpenAI Responses API.
@@ -89,8 +97,15 @@ class ResponsesRequest(BaseModel, frozen=True):
stream: bool = False
tools: list[dict[str, Any]] | None = None
metadata: dict[str, str] | None = None
+ reasoning: Reasoning | None = None
# --- exo extensions (not in OpenAI Responses API spec) ---
+ enable_thinking: bool | None = Field(
+ default=None,
+ description="[exo extension] Boolean thinking toggle. Not part of the OpenAI Responses API.",
+ json_schema_extra={"x-exo-extension": True},
+ )
+
top_k: int | None = Field(
default=None,
description="[exo extension] Top-k sampling parameter. Not part of the OpenAI Responses API.",
diff --git a/src/exo/shared/types/text_generation.py b/src/exo/shared/types/text_generation.py
index 2f42b68f..51510b5c 100644
--- a/src/exo/shared/types/text_generation.py
+++ b/src/exo/shared/types/text_generation.py
@@ -11,6 +11,29 @@ from pydantic import BaseModel
from exo.shared.types.common import ModelId
MessageRole = Literal["user", "assistant", "system", "developer"]
+ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"]
+
+
+def resolve_reasoning_params(
+ reasoning_effort: ReasoningEffort | None,
+ enable_thinking: bool | None,
+) -> tuple[ReasoningEffort | None, bool | None]:
+ """
+ enable_thinking=True -> reasoning_effort="medium"
+ enable_thinking=False -> reasoning_effort="none"
+ reasoning_effort="none" -> enable_thinking=False
+ reasoning_effort=<anything else> -> enable_thinking=True
+ """
+ resolved_effort: ReasoningEffort | None = reasoning_effort
+ resolved_thinking: bool | None = enable_thinking
+
+ if reasoning_effort is None and enable_thinking is not None:
+ resolved_effort = "medium" if enable_thinking else "none"
+
+ if enable_thinking is None and reasoning_effort is not None:
+ resolved_thinking = reasoning_effort != "none"
+
+ return resolved_effort, resolved_thinking
class InputMessage(BaseModel, frozen=True):
@@ -40,6 +63,7 @@ class TextGenerationTaskParams(BaseModel, frozen=True):
stop: str | list[str] | None = None
seed: int | None = None
chat_template_messages: list[dict[str, Any]] | None = None
+ reasoning_effort: ReasoningEffort | None = None
enable_thinking: bool | None = None
logprobs: bool = False
top_logprobs: int | None = None
diff --git a/src/exo/worker/engines/mlx/utils_mlx.py b/src/exo/worker/engines/mlx/utils_mlx.py
index 596db0b1..4d50e8f3 100644
--- a/src/exo/worker/engines/mlx/utils_mlx.py
+++ b/src/exo/worker/engines/mlx/utils_mlx.py
@@ -554,6 +554,8 @@ def apply_chat_template(
# Jinja ignores unknown variables, so passing both is safe.
extra_kwargs["enable_thinking"] = task_params.enable_thinking
extra_kwargs["thinking"] = task_params.enable_thinking
+ if task_params.reasoning_effort is not None:
+ extra_kwargs["reasoning_effort"] = task_params.reasoning_effort
patched_template: str | None = None
if task_params.tools:
← 5777bf3c fix: coerce tool-call argument types from tool schema (#1651
·
back to Exo
·
fix(worker): emit error chunks when a runner dies mid-comman 84858050 →