Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/req_llm/providers/openai.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 41 additions & 5 deletions lib/req_llm/providers/openai/responses_api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"] || %{}

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
100 changes: 100 additions & 0 deletions test/provider/openai/responses_api_unit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: %{
Expand Down Expand Up @@ -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"),
Expand Down
16 changes: 16 additions & 0 deletions test/providers/openai_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions test/req_llm/provider/options_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down