From 04497e26b4451af7175811d61d1635009b70d224 Mon Sep 17 00:00:00 2001 From: Omar Goubail Date: Tue, 23 Jun 2026 17:49:39 +0300 Subject: [PATCH] feat(openai): support reasoning.summary parameter in Responses API Add reasoning_summary option alongside reasoning_effort for OpenAI Responses API requests. Wire the option through global option validation, Req option registration, and OpenAI pre-validation normalization. Forward the summary value into the reasoning body object and decode response.reasoning_summary_text.* SSE events as thinking chunks. --- lib/req_llm/providers/openai.ex | 6 ++ lib/req_llm/providers/openai/responses_api.ex | 46 +++++++- .../openai/responses_api_unit_test.exs | 100 ++++++++++++++++++ test/providers/openai_test.exs | 16 +++ test/req_llm/provider/options_test.exs | 24 +++++ 5 files changed, 187 insertions(+), 5 deletions(-) diff --git a/lib/req_llm/providers/openai.ex b/lib/req_llm/providers/openai.ex index b50f35c46..d9fed85d8 100644 --- a/lib/req_llm/providers/openai.ex +++ b/lib/req_llm/providers/openai.ex @@ -34,6 +34,7 @@ defmodule ReqLLM.Providers.OpenAI do ### Responses API (ResponsesAPI) - Extended reasoning for o-series and GPT-5 models - Reasoning effort control (minimal, low, medium, high) + - Reasoning summary control (`:auto`, `:concise`, `:detailed`) - Streaming with reasoning token tracking - Tool calling with responses-specific format - Enhanced usage metrics including `:reasoning_tokens` @@ -155,6 +156,11 @@ defmodule ReqLLM.Providers.OpenAI do doc: "Responses API include values, such as reasoning.encrypted_content for reasoning signatures" ], + reasoning_summary: [ + type: {:or, [:string, {:in, [:auto, :concise, :detailed]}]}, + doc: + "Controls whether the model returns a human-readable summary of its reasoning (auto, concise, detailed)" + ], openai_structured_output_mode: [ type: {:in, [:auto, :json_schema, :tool_strict]}, default: :auto, diff --git a/lib/req_llm/providers/openai/responses_api.ex b/lib/req_llm/providers/openai/responses_api.ex index 771f8753b..52842dbec 100644 --- a/lib/req_llm/providers/openai/responses_api.ex +++ b/lib/req_llm/providers/openai/responses_api.ex @@ -139,6 +139,16 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do text = data["delta"] || "" if text == "", do: [], else: [ReqLLM.StreamChunk.thinking(text, thinking_metadata(data))] + "response.reasoning_summary_text.delta" -> + text = data["delta"] || "" + if text == "", do: [], else: [ReqLLM.StreamChunk.thinking(text, thinking_metadata(data))] + + "response.reasoning_summary_text.done" -> + [] + + "response.reasoning_summary_part.done" -> + [] + "response.usage" -> usage_data = data["usage"] || %{} @@ -690,7 +700,7 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do tools = encode_tools_if_any(temp_request) |> ensure_deep_research_tools(temp_request) tool_choice = encode_tool_choice(opts_map[:tool_choice]) - reasoning = encode_reasoning_effort(opts_map[:reasoning_effort]) + reasoning = encode_reasoning(reasoning_input(opts_map, provider_opts)) service_tier = opts_map[:service_tier] || provider_opts[:service_tier] include = response_include(provider_opts, model_name) @@ -1585,13 +1595,39 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do defp encode_tool_choice("required"), do: "required" defp encode_tool_choice(_), do: nil - defp encode_reasoning_effort(nil), do: nil + defp reasoning_input(opts_map, provider_opts) do + effort = opts_map[:reasoning_effort] + summary = provider_opts[:reasoning_summary] - defp encode_reasoning_effort(effort) when is_atom(effort), + cond do + is_nil(effort) and is_nil(summary) -> nil + is_nil(summary) -> effort + true -> %{effort: effort, summary: summary} + end + end + + defp encode_reasoning(nil), do: nil + + defp encode_reasoning(effort) when is_atom(effort), do: %{"effort" => Atom.to_string(effort)} - defp encode_reasoning_effort(effort) when is_binary(effort), do: %{"effort" => effort} - defp encode_reasoning_effort(_), do: nil + defp encode_reasoning(effort) when is_binary(effort), do: %{"effort" => effort} + + defp encode_reasoning(%{effort: effort, summary: summary}) do + %{} + |> maybe_put_reasoning_key("effort", encode_reasoning_value(effort)) + |> maybe_put_reasoning_key("summary", encode_reasoning_value(summary)) + end + + defp encode_reasoning(_), do: nil + + defp encode_reasoning_value(nil), do: nil + defp encode_reasoning_value(value) when is_atom(value), do: Atom.to_string(value) + defp encode_reasoning_value(value) when is_binary(value), do: value + defp encode_reasoning_value(_), do: nil + + defp maybe_put_reasoning_key(map, _key, nil), do: map + defp maybe_put_reasoning_key(map, key, value), do: Map.put(map, key, value) @doc false def encode_text_format(response_format, verbosity \\ nil) diff --git a/test/provider/openai/responses_api_unit_test.exs b/test/provider/openai/responses_api_unit_test.exs index 2626e61b3..8880a6773 100644 --- a/test/provider/openai/responses_api_unit_test.exs +++ b/test/provider/openai/responses_api_unit_test.exs @@ -467,6 +467,43 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do assert body["reasoning"] == %{"effort" => "xhigh"} end + test "encodes reasoning summary alongside effort" do + request = build_request(reasoning_effort: :high, reasoning_summary: "auto") + + encoded = ResponsesAPI.encode_body(request) + body = ReqLLM.Test.Helpers.json_body(encoded) + + assert body["reasoning"] == %{"effort" => "high", "summary" => "auto"} + end + + test "encodes atom reasoning summary value" do + request = build_request(reasoning_effort: :medium, reasoning_summary: :detailed) + + encoded = ResponsesAPI.encode_body(request) + body = ReqLLM.Test.Helpers.json_body(encoded) + + assert body["reasoning"] == %{"effort" => "medium", "summary" => "detailed"} + end + + test "encodes reasoning summary without effort" do + request = build_request(reasoning_summary: "auto") + + encoded = ResponsesAPI.encode_body(request) + body = ReqLLM.Test.Helpers.json_body(encoded) + + assert body["reasoning"] == %{"summary" => "auto"} + refute Map.has_key?(body["reasoning"], "effort") + end + + test "keeps backward-compatible reasoning shape without summary" do + request = build_request(reasoning_effort: :low) + + encoded = ResponsesAPI.encode_body(request) + body = ReqLLM.Test.Helpers.json_body(encoded) + + assert body["reasoning"] == %{"effort" => "low"} + end + test "omits reasoning effort when nil" do request = build_request(provider_options: []) @@ -903,6 +940,27 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do assert thinking_part.text == "Thinking about this..." end + test "extracts reasoning summary text via Response.thinking/1" do + response_body = %{ + "id" => "resp_123", + "model" => "gpt-5", + "output" => [ + %{ + "type" => "reasoning", + "id" => "rs_123", + "summary" => [ + %{"type" => "summary_text", "text" => "Determining if 143 is prime..."} + ] + } + ], + "usage" => %{"input_tokens" => 5, "output_tokens" => 10} + } + + {_req, resp} = ResponsesAPI.decode_response(build_response(200, response_body)) + + assert ReqLLM.Response.thinking(resp.body) == "Determining if 143 is prime..." + end + test "decodes reasoning content" do response_body = %{ "id" => "resp_123", @@ -1304,6 +1362,42 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do assert [] = ResponsesAPI.decode_stream_event(event, model) end + test "decodes reasoning summary text delta", %{model: model} do + event = %{ + data: %{ + "event" => "response.reasoning_summary_text.delta", + "delta" => "Checking primality..." + } + } + + assert [chunk] = ResponsesAPI.decode_stream_event(event, model) + assert chunk.type == :thinking + assert chunk.text == "Checking primality..." + end + + test "ignores empty reasoning summary text delta", %{model: model} do + event = %{ + data: %{ + "event" => "response.reasoning_summary_text.delta", + "delta" => "" + } + } + + assert [] = ResponsesAPI.decode_stream_event(event, model) + end + + test "ignores reasoning summary text done event", %{model: model} do + event = %{data: %{"event" => "response.reasoning_summary_text.done"}} + + assert [] = ResponsesAPI.decode_stream_event(event, model) + end + + test "ignores reasoning summary part done event", %{model: model} do + event = %{data: %{"event" => "response.reasoning_summary_part.done"}} + + assert [] = ResponsesAPI.decode_stream_event(event, model) + end + test "decodes usage event", %{model: model} do event = %{ data: %{ @@ -2298,6 +2392,12 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do context = Keyword.get(opts, :context, %ReqLLM.Context{messages: []}) provider_opts = Keyword.get(opts, :provider_options, []) + provider_opts = + case Keyword.get(opts, :reasoning_summary) do + nil -> provider_opts + summary -> Keyword.put(provider_opts, :reasoning_summary, summary) + end + req_opts = %{ id: Keyword.get(opts, :id, "gpt-5"), diff --git a/test/providers/openai_test.exs b/test/providers/openai_test.exs index f8cf567de..0fef405a1 100644 --- a/test/providers/openai_test.exs +++ b/test/providers/openai_test.exs @@ -171,6 +171,22 @@ defmodule ReqLLM.Providers.OpenAITest do assert body["reasoning_effort"] == "high" end + test "prepare_request passes through top-level reasoning_summary option to Responses API request body" do + {:ok, model} = ReqLLM.model("openai:gpt-5") + + {:ok, request} = + OpenAI.prepare_request(:chat, model, "Is 143 prime?", + api_key: "test-key", + reasoning_effort: :high, + reasoning_summary: "auto" + ) + + encoded_request = ReqLLM.Providers.OpenAI.ResponsesAPI.encode_body(request) + body = ReqLLM.Test.Helpers.json_body(encoded_request) + + assert body["reasoning"] == %{"effort" => "high", "summary" => "auto"} + end + test "attach_stream defaults chat max_tokens from model output limit" do model = %LLMDB.Model{ provider: :openai, diff --git a/test/req_llm/provider/options_test.exs b/test/req_llm/provider/options_test.exs index d3667a7c6..2aa6218ae 100644 --- a/test/req_llm/provider/options_test.exs +++ b/test/req_llm/provider/options_test.exs @@ -93,6 +93,30 @@ defmodule ReqLLM.Provider.OptionsTest do assert processed[:stream] == false assert processed[:n] == 1 end + + test "rejects reasoning_summary for providers that do not support it" do + model = %LLMDB.Model{provider: :mock, id: "test-model"} + opts = [reasoning_summary: "auto"] + + assert {:error, %ReqLLM.Error.Unknown.Unknown{}} = + Options.process(MockProvider, :chat, model, opts) + end + + test "validates and auto-hoists reasoning_summary for OpenAI" do + {:ok, model} = ReqLLM.model("openai:gpt-5") + opts = [reasoning_summary: "auto"] + + assert {:ok, processed} = Options.process(OpenAI, :chat, model, opts) + assert processed[:provider_options][:reasoning_summary] == "auto" + end + + test "rejects invalid reasoning_summary values for OpenAI" do + {:ok, model} = ReqLLM.model("openai:gpt-5") + opts = [reasoning_summary: :invalid] + + assert {:error, %ReqLLM.Error.Unknown.Unknown{}} = + Options.process(OpenAI, :chat, model, opts) + end end describe "Options.process/4 - provider-specific options" do