From 6c0671a4078d555324bad7924cd53a4c0801e201 Mon Sep 17 00:00:00 2001 From: Matt Sutton Date: Tue, 14 Apr 2026 01:25:25 -0400 Subject: [PATCH 1/3] fix(openai): include summary field in reasoning block encoding The OpenAI Responses API requires a `summary` field on reasoning items in the input. `encode_single_reasoning_detail` was omitting it, causing 400 errors on multi-turn conversations with reasoning models. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/req_llm/providers/openai/responses_api.ex | 7 ++++ .../openai/responses_api_unit_test.exs | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/req_llm/providers/openai/responses_api.ex b/lib/req_llm/providers/openai/responses_api.ex index 66fa31c4..2571cda5 100644 --- a/lib/req_llm/providers/openai/responses_api.ex +++ b/lib/req_llm/providers/openai/responses_api.ex @@ -705,6 +705,13 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do item end + item = + if detail.text do + Map.put(item, "summary", [%{"type" => "summary_text", "text" => detail.text}]) + else + Map.put(item, "summary", []) + end + [item] end diff --git a/test/provider/openai/responses_api_unit_test.exs b/test/provider/openai/responses_api_unit_test.exs index 021e2f47..d6072e9e 100644 --- a/test/provider/openai/responses_api_unit_test.exs +++ b/test/provider/openai/responses_api_unit_test.exs @@ -1415,6 +1415,42 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do assert log =~ "Skipping non-OpenAI reasoning detail from provider: :anthropic" end + test "encodes summary from reasoning detail text" do + reasoning_detail = %ReqLLM.Message.ReasoningDetails{ + text: "I need to think about this carefully", + signature: "encrypted_sig_abc", + encrypted?: true, + provider: :openai, + format: "openai-responses-v1", + index: 0, + provider_data: %{"id" => "rs_prev123", "type" => "reasoning"} + } + + assistant_msg = %ReqLLM.Message{ + role: :assistant, + content: [%ReqLLM.Message.ContentPart{type: :text, text: "Answer"}], + reasoning_details: [reasoning_detail] + } + + user_msg = %ReqLLM.Message{ + role: :user, + content: [%ReqLLM.Message.ContentPart{type: :text, text: "Follow up"}] + } + + context = %ReqLLM.Context{messages: [assistant_msg, user_msg]} + request = build_request(context: context) + + encoded = ResponsesAPI.encode_body(request) + body = Jason.decode!(encoded.body) + + [reasoning_input | _] = body["input"] + assert reasoning_input["type"] == "reasoning" + + assert reasoning_input["summary"] == [ + %{"type" => "summary_text", "text" => "I need to think about this carefully"} + ] + end + test "encodes reasoning detail without id when provider_data has no id" do reasoning_detail = %ReqLLM.Message.ReasoningDetails{ text: "Reasoning text", From 2f97ec4306fac9af5a38f0ddb12ac879209ab3de Mon Sep 17 00:00:00 2001 From: Matt Sutton Date: Tue, 14 Apr 2026 01:37:45 -0400 Subject: [PATCH 2/3] fix(openai): only keep latest reasoning details in multi-turn context Previously, reasoning blocks from every assistant message accumulated in the input, bloating the context and slowing requests. Now only the most recent assistant's reasoning details are preserved. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/req_llm/providers/openai/responses_api.ex | 7 ++- .../openai/responses_api_unit_test.exs | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/lib/req_llm/providers/openai/responses_api.ex b/lib/req_llm/providers/openai/responses_api.ex index 2571cda5..84f16abc 100644 --- a/lib/req_llm/providers/openai/responses_api.ex +++ b/lib/req_llm/providers/openai/responses_api.ex @@ -545,6 +545,7 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do :assistant -> new_reasoning = encode_reasoning_details_from_message(msg) + latest_reasoning = if new_reasoning != [], do: new_reasoning, else: reasoning_acc content_type = "output_text" content = @@ -553,14 +554,14 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do end) if content == [] and msg.tool_calls == nil do - {input_acc, tool_acc, reasoning_acc ++ new_reasoning} + {input_acc, tool_acc, latest_reasoning} else if msg.tool_calls != nil and msg.tool_calls != [] do function_calls = encode_tool_calls_as_function_calls(msg.tool_calls) - {input_acc ++ function_calls, tool_acc, reasoning_acc ++ new_reasoning} + {input_acc ++ function_calls, tool_acc, latest_reasoning} else {input_acc ++ [%{"role" => "assistant", "content" => content}], tool_acc, - reasoning_acc ++ new_reasoning} + latest_reasoning} end end diff --git a/test/provider/openai/responses_api_unit_test.exs b/test/provider/openai/responses_api_unit_test.exs index d6072e9e..142362da 100644 --- a/test/provider/openai/responses_api_unit_test.exs +++ b/test/provider/openai/responses_api_unit_test.exs @@ -1451,6 +1451,62 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do ] end + test "only keeps reasoning details from last assistant message" do + old_reasoning = %ReqLLM.Message.ReasoningDetails{ + text: nil, + signature: "old_sig", + encrypted?: true, + provider: :openai, + format: "openai-responses-v1", + index: 0, + provider_data: %{"id" => "rs_old", "type" => "reasoning"} + } + + old_assistant = %ReqLLM.Message{ + role: :assistant, + content: [%ReqLLM.Message.ContentPart{type: :text, text: "First answer"}], + reasoning_details: [old_reasoning] + } + + new_reasoning = %ReqLLM.Message.ReasoningDetails{ + text: nil, + signature: "new_sig", + encrypted?: true, + provider: :openai, + format: "openai-responses-v1", + index: 0, + provider_data: %{"id" => "rs_new", "type" => "reasoning"} + } + + new_assistant = %ReqLLM.Message{ + role: :assistant, + content: [%ReqLLM.Message.ContentPart{type: :text, text: "Second answer"}], + reasoning_details: [new_reasoning] + } + + user1 = %ReqLLM.Message{ + role: :user, + content: [%ReqLLM.Message.ContentPart{type: :text, text: "First question"}] + } + + user2 = %ReqLLM.Message{ + role: :user, + content: [%ReqLLM.Message.ContentPart{type: :text, text: "Second question"}] + } + + context = %ReqLLM.Context{messages: [old_assistant, user1, new_assistant, user2]} + request = build_request(context: context) + + encoded = ResponsesAPI.encode_body(request) + body = Jason.decode!(encoded.body) + + reasoning_items = + Enum.filter(body["input"], fn item -> item["type"] == "reasoning" end) + + assert length(reasoning_items) == 1 + assert hd(reasoning_items)["id"] == "rs_new" + end + test "encodes reasoning detail without id when provider_data has no id" do reasoning_detail = %ReqLLM.Message.ReasoningDetails{ text: "Reasoning text", From afaffc3730ae9252b29a7c1cd9ffc09600fa28ee Mon Sep 17 00:00:00 2001 From: Matt Sutton Date: Tue, 14 Apr 2026 01:39:52 -0400 Subject: [PATCH 3/3] Revert "fix(openai): only keep latest reasoning details in multi-turn context" This reverts commit 2f97ec4306fac9af5a38f0ddb12ac879209ab3de. --- lib/req_llm/providers/openai/responses_api.ex | 7 +-- .../openai/responses_api_unit_test.exs | 56 ------------------- 2 files changed, 3 insertions(+), 60 deletions(-) diff --git a/lib/req_llm/providers/openai/responses_api.ex b/lib/req_llm/providers/openai/responses_api.ex index 84f16abc..2571cda5 100644 --- a/lib/req_llm/providers/openai/responses_api.ex +++ b/lib/req_llm/providers/openai/responses_api.ex @@ -545,7 +545,6 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do :assistant -> new_reasoning = encode_reasoning_details_from_message(msg) - latest_reasoning = if new_reasoning != [], do: new_reasoning, else: reasoning_acc content_type = "output_text" content = @@ -554,14 +553,14 @@ defmodule ReqLLM.Providers.OpenAI.ResponsesAPI do end) if content == [] and msg.tool_calls == nil do - {input_acc, tool_acc, latest_reasoning} + {input_acc, tool_acc, reasoning_acc ++ new_reasoning} else if msg.tool_calls != nil and msg.tool_calls != [] do function_calls = encode_tool_calls_as_function_calls(msg.tool_calls) - {input_acc ++ function_calls, tool_acc, latest_reasoning} + {input_acc ++ function_calls, tool_acc, reasoning_acc ++ new_reasoning} else {input_acc ++ [%{"role" => "assistant", "content" => content}], tool_acc, - latest_reasoning} + reasoning_acc ++ new_reasoning} end end diff --git a/test/provider/openai/responses_api_unit_test.exs b/test/provider/openai/responses_api_unit_test.exs index 142362da..d6072e9e 100644 --- a/test/provider/openai/responses_api_unit_test.exs +++ b/test/provider/openai/responses_api_unit_test.exs @@ -1451,62 +1451,6 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do ] end - test "only keeps reasoning details from last assistant message" do - old_reasoning = %ReqLLM.Message.ReasoningDetails{ - text: nil, - signature: "old_sig", - encrypted?: true, - provider: :openai, - format: "openai-responses-v1", - index: 0, - provider_data: %{"id" => "rs_old", "type" => "reasoning"} - } - - old_assistant = %ReqLLM.Message{ - role: :assistant, - content: [%ReqLLM.Message.ContentPart{type: :text, text: "First answer"}], - reasoning_details: [old_reasoning] - } - - new_reasoning = %ReqLLM.Message.ReasoningDetails{ - text: nil, - signature: "new_sig", - encrypted?: true, - provider: :openai, - format: "openai-responses-v1", - index: 0, - provider_data: %{"id" => "rs_new", "type" => "reasoning"} - } - - new_assistant = %ReqLLM.Message{ - role: :assistant, - content: [%ReqLLM.Message.ContentPart{type: :text, text: "Second answer"}], - reasoning_details: [new_reasoning] - } - - user1 = %ReqLLM.Message{ - role: :user, - content: [%ReqLLM.Message.ContentPart{type: :text, text: "First question"}] - } - - user2 = %ReqLLM.Message{ - role: :user, - content: [%ReqLLM.Message.ContentPart{type: :text, text: "Second question"}] - } - - context = %ReqLLM.Context{messages: [old_assistant, user1, new_assistant, user2]} - request = build_request(context: context) - - encoded = ResponsesAPI.encode_body(request) - body = Jason.decode!(encoded.body) - - reasoning_items = - Enum.filter(body["input"], fn item -> item["type"] == "reasoning" end) - - assert length(reasoning_items) == 1 - assert hd(reasoning_items)["id"] == "rs_new" - end - test "encodes reasoning detail without id when provider_data has no id" do reasoning_detail = %ReqLLM.Message.ReasoningDetails{ text: "Reasoning text",