diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 6937518b5d..bd14e73d16 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -29,6 +29,7 @@ logger = structlog.get_logger(__name__) # still accept it. Match the 4-7 line specifically so we keep the knob # live on every other Claude generation. _ANTHROPIC_TOP_K_DEPRECATED = re.compile(r"^claude-(?:opus|sonnet|haiku)-4-7(?:[-.]|$)") +_OPENAI_REASONING_SUMMARY_UNSUPPORTED = re.compile(r"^o3(?:[-.]|$)") class _AnthropicThinkingSpec(NamedTuple): @@ -1659,6 +1660,9 @@ class ExternalProviderClient: # to wrap, and the chat reasoning panel stays blank. Always pair # an explicit effort with summary except for the explicit "off" # case (effort: "none"), where summaries are pointless. + summary_unsupported = bool( + _OPENAI_REASONING_SUMMARY_UNSUPPORTED.match(model.strip().lower()) + ) if reasoning_effort in ( "minimal", "low", @@ -1667,11 +1671,15 @@ class ExternalProviderClient: "max", "xhigh", ): - body["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} + body["reasoning"] = {"effort": reasoning_effort} + if not summary_unsupported: + body["reasoning"]["summary"] = "auto" elif reasoning_effort == "none" or enable_thinking is False: body["reasoning"] = {"effort": "none"} elif enable_thinking is True: - body["reasoning"] = {"effort": "medium", "summary": "auto"} + body["reasoning"] = {"effort": "medium"} + if not summary_unsupported: + body["reasoning"]["summary"] = "auto" if instructions_parts: body["instructions"] = "\n\n".join(instructions_parts) if max_tokens is not None: diff --git a/studio/backend/tests/test_openai_responses_translation.py b/studio/backend/tests/test_openai_responses_translation.py index 4ad6a19ea9..22ccba7058 100644 --- a/studio/backend/tests/test_openai_responses_translation.py +++ b/studio/backend/tests/test_openai_responses_translation.py @@ -286,6 +286,68 @@ def test_responses_reasoning_effort_included_when_requested(monkeypatch): assert captured["body"]["reasoning"] == {"effort": "high", "summary": "auto"} +def test_responses_reasoning_summary_omitted_for_o3(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "o3", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = None, + reasoning_effort = "high", + ): + pass + await client.close() + + _drive(run()) + assert captured["body"]["reasoning"] == {"effort": "high"} + + +def test_responses_reasoning_summary_omitted_for_o3_with_enable_thinking(monkeypatch): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + content = _responses_sse([{"type": "response.completed", "response": {}}]), + headers = {"content-type": "text/event-stream"}, + ) + + _mock_http_client(monkeypatch, handler) + + async def run(): + client = _make_client() + async for _ in client._stream_openai_responses( + messages = [{"role": "user", "content": "hi"}], + model = "o3", + temperature = 0.7, + top_p = 0.95, + max_tokens = None, + enable_thinking = True, + reasoning_effort = None, + ): + pass + await client.close() + + _drive(run()) + assert captured["body"]["reasoning"] == {"effort": "medium"} + + def test_responses_reasoning_effort_none_omits_summary(monkeypatch): captured: dict = {}