diff --git a/CHANGELOG.md b/CHANGELOG.md index d43a0b9..c3e5f8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [Unreleased] + +### Fixed +- `NoMethodError: undefined method 'strip' for nil` in `Raix::ChatCompletion` when an LLM (notably Gemini under certain stop conditions) returns a final assistant message with `"content": null`. Three call sites in `lib/raix/chat_completion.rb` now use `content.to_s.strip` so a nil response coerces to `""` instead of raising. + ## [2.0.2] - 2026-03-27 ### Fixed diff --git a/Gemfile.lock b/Gemfile.lock index 635dac9..7760797 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - raix (2.0.1) + raix (2.0.2) activesupport (>= 6.0) faraday-retry (~> 2.0) ostruct @@ -49,8 +49,8 @@ GEM net-http faraday-retry (2.4.0) faraday (~> 2.0) - ffi (1.17.2) ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-linux-gnu) formatador (1.1.0) guard (2.18.1) formatador (>= 0.2.4) @@ -83,18 +83,16 @@ GEM lumberjack (1.2.10) marcel (1.1.0) method_source (1.1.0) - mini_portile2 (2.8.9) minitest (5.27.0) multipart-post (2.4.1) nenv (0.3.0) net-http (0.4.1) uri netrc (0.11.0) - nokogiri (1.18.8) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.18.8-arm64-darwin) racc (~> 1.4) + nokogiri (1.18.8-x86_64-linux-gnu) + racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) diff --git a/lib/raix/chat_completion.rb b/lib/raix/chat_completion.rb index 1609bf9..c2e36cc 100644 --- a/lib/raix/chat_completion.rb +++ b/lib/raix/chat_completion.rb @@ -174,7 +174,7 @@ def chat_completion(params: {}, loop: false, json: false, raw: false, openai: ni # Process the final response content = response.dig("choices", 0, "message", "content") transcript << { assistant: content } if save_response - return raw ? response : content.strip + return raw ? response : content.to_s.strip end # Dispatch tool calls @@ -215,7 +215,7 @@ def chat_completion(params: {}, loop: false, json: false, raw: false, openai: ni content = response.dig("choices", 0, "message", "content") transcript << { assistant: content } if save_response - return raw ? response : content.strip + return raw ? response : content.to_s.strip end end @@ -223,7 +223,7 @@ def chat_completion(params: {}, loop: false, json: false, raw: false, openai: ni content = res.dig("choices", 0, "message", "content") transcript << { assistant: content } if save_response - content = content.strip + content = content.to_s.strip if json # Make automatic JSON parsing available to non-OpenAI providers that don't support the response_format parameter diff --git a/spec/raix/nil_content_spec.rb b/spec/raix/nil_content_spec.rb new file mode 100644 index 0000000..6cb235b --- /dev/null +++ b/spec/raix/nil_content_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +RSpec.describe "nil content in final assistant response" do + # Some providers (notably Gemini under certain stop conditions) return a final + # assistant message with `content: nil`. The three call sites in chat_completion + # that turn the response into a string previously crashed with NoMethodError on + # `nil.strip`. They now use `content.to_s.strip` and should return "". + + def nil_content_response(tool_calls: nil) + { + "choices" => [ + { + "message" => { + "role" => "assistant", + "content" => nil, + "tool_calls" => tool_calls + }, + "finish_reason" => tool_calls ? "tool_calls" : "stop" + } + ], + "usage" => { + "prompt_tokens" => 1, + "completion_tokens" => 0, + "total_tokens" => 1 + } + } + end + + def tool_call_response + { + "choices" => [ + { + "message" => { + "role" => "assistant", + "content" => nil, + "tool_calls" => [ + { + "id" => "call_1", + "type" => "function", + "function" => { + "name" => "do_thing", + "arguments" => "{}" + } + } + ] + }, + "finish_reason" => "tool_calls" + } + ], + "usage" => { + "prompt_tokens" => 1, + "completion_tokens" => 0, + "total_tokens" => 1 + } + } + end + + describe "plain final response with nil content" do + let(:chat_class) do + Class.new do + include Raix::ChatCompletion + + def initialize + self.model = "test-model" + transcript << { user: "Hello" } + end + end + end + + it "returns an empty string instead of raising NoMethodError" do + instance = chat_class.new + allow(instance).to receive(:ruby_llm_request).and_return(nil_content_response) + + expect { instance.chat_completion }.not_to raise_error + end + + it "returns an empty string when content is nil" do + instance = chat_class.new + allow(instance).to receive(:ruby_llm_request).and_return(nil_content_response) + + expect(instance.chat_completion).to eq("") + end + end + + describe "max_tool_calls exceeded with nil content on forced final response" do + let(:chat_class) do + Class.new do + include Raix::ChatCompletion + include Raix::FunctionDispatch + + function :do_thing, "Does a thing" do |_arguments| + "done" + end + + def initialize + self.model = "test-model" + transcript << { user: "Call do_thing repeatedly" } + end + end + end + + it "returns an empty string instead of raising NoMethodError" do + instance = chat_class.new + + # First call returns a tool call (which exceeds max_tool_calls=0), + # forcing chat_completion into the max-tool-calls-exceeded branch. + # The forced final response then returns nil content. + call_count = 0 + allow(instance).to receive(:ruby_llm_request) do + call_count += 1 + call_count == 1 ? tool_call_response : nil_content_response + end + + expect { instance.chat_completion(max_tool_calls: 0) }.not_to raise_error + end + + it "returns an empty string when forced final content is nil" do + instance = chat_class.new + + call_count = 0 + allow(instance).to receive(:ruby_llm_request) do + call_count += 1 + call_count == 1 ? tool_call_response : nil_content_response + end + + expect(instance.chat_completion(max_tool_calls: 0)).to eq("") + end + end + + describe "stop_tool_calls_and_respond! with nil content on forced final response" do + let(:chat_class) do + Class.new do + include Raix::ChatCompletion + include Raix::FunctionDispatch + + function :stop_now, "Halts and forces a final response" do |_arguments| + stop_tool_calls_and_respond! + "stopping" + end + + def initialize + self.model = "test-model" + transcript << { user: "Call stop_now" } + end + end + end + + it "returns an empty string instead of raising NoMethodError" do + instance = chat_class.new + + stop_tool_call = { + "choices" => [ + { + "message" => { + "role" => "assistant", + "content" => nil, + "tool_calls" => [ + { + "id" => "call_stop", + "type" => "function", + "function" => { + "name" => "stop_now", + "arguments" => "{}" + } + } + ] + }, + "finish_reason" => "tool_calls" + } + ], + "usage" => { "prompt_tokens" => 1, "completion_tokens" => 0, "total_tokens" => 1 } + } + + call_count = 0 + allow(instance).to receive(:ruby_llm_request) do + call_count += 1 + call_count == 1 ? stop_tool_call : nil_content_response + end + + expect { instance.chat_completion }.not_to raise_error + end + end +end