diff --git a/lib/ruby_llm/provider.rb b/lib/ruby_llm/provider.rb index 2b588c547..8e354b0ef 100644 --- a/lib/ruby_llm/provider.rb +++ b/lib/ruby_llm/provider.rb @@ -112,7 +112,7 @@ def assume_models_exist? end def parse_error(response) - return if response.body.empty? + return if response.body.nil? || response.body.empty? body = try_parse_json(response.body) case body @@ -266,7 +266,16 @@ def sync_response(connection, payload, additional_headers = {}) response = connection.post completion_url, payload do |req| req.headers = additional_headers.merge(req.headers) unless additional_headers.empty? end + ensure_response_body!(response) + parse_completion_response response end + + def ensure_response_body!(response) + body = response.body + return unless body.nil? || (body.respond_to?(:empty?) && body.empty?) + + raise Error.new(response, 'Provider returned an empty response body') + end end end diff --git a/lib/ruby_llm/providers/anthropic/chat.rb b/lib/ruby_llm/providers/anthropic/chat.rb index 9926fe98b..545be8314 100644 --- a/lib/ruby_llm/providers/anthropic/chat.rb +++ b/lib/ruby_llm/providers/anthropic/chat.rb @@ -84,6 +84,8 @@ def build_output_config(schema) def parse_completion_response(response) data = response.body + raise Error.new(response, 'Provider returned an empty response body') if data.nil? || data.empty? + content_blocks = data['content'] || [] text_content = extract_text_content(content_blocks) diff --git a/lib/ruby_llm/providers/bedrock/auth.rb b/lib/ruby_llm/providers/bedrock/auth.rb index cc33a2217..cbf380aac 100644 --- a/lib/ruby_llm/providers/bedrock/auth.rb +++ b/lib/ruby_llm/providers/bedrock/auth.rb @@ -21,6 +21,7 @@ def signed_post(connection, url, payload, additional_headers = {}) yield req if block_given? end + ensure_response_body!(response) parse_completion_response(response) end diff --git a/lib/ruby_llm/providers/bedrock/chat.rb b/lib/ruby_llm/providers/bedrock/chat.rb index db7785ea7..aff598d28 100644 --- a/lib/ruby_llm/providers/bedrock/chat.rb +++ b/lib/ruby_llm/providers/bedrock/chat.rb @@ -45,7 +45,7 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, def parse_completion_response(response) data = response.body - return if data.nil? || data.empty? + raise Error.new(response, 'Provider returned an empty response body') if data.nil? || data.empty? content_blocks = data.dig('output', 'message', 'content') || [] usage = data['usage'] || {} diff --git a/lib/ruby_llm/providers/gemini/chat.rb b/lib/ruby_llm/providers/gemini/chat.rb index 54fc51f72..4b948594b 100644 --- a/lib/ruby_llm/providers/gemini/chat.rb +++ b/lib/ruby_llm/providers/gemini/chat.rb @@ -107,6 +107,8 @@ def build_thought_part(thinking) def parse_completion_response(response) data = response.body + raise Error.new(response, 'Provider returned an empty response body') if data.nil? || data.empty? + parts = data.dig('candidates', 0, 'content', 'parts') || [] tool_calls = extract_tool_calls(data) diff --git a/lib/ruby_llm/providers/openai/chat.rb b/lib/ruby_llm/providers/openai/chat.rb index ab9552edd..595feabca 100644 --- a/lib/ruby_llm/providers/openai/chat.rb +++ b/lib/ruby_llm/providers/openai/chat.rb @@ -53,7 +53,7 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, schema def parse_completion_response(response) data = response.body - return if data.empty? + raise Error.new(response, 'Provider returned an empty response body') if data.nil? || data.empty? raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message') diff --git a/lib/ruby_llm/providers/openrouter.rb b/lib/ruby_llm/providers/openrouter.rb index af2f444ad..62b6c6e01 100644 --- a/lib/ruby_llm/providers/openrouter.rb +++ b/lib/ruby_llm/providers/openrouter.rb @@ -20,7 +20,7 @@ def headers end def parse_error(response) - return if response.body.empty? + return if response.body.nil? || response.body.empty? body = try_parse_json(response.body) case body diff --git a/lib/ruby_llm/providers/openrouter/chat.rb b/lib/ruby_llm/providers/openrouter/chat.rb index 0c3622bdf..0cf841582 100644 --- a/lib/ruby_llm/providers/openrouter/chat.rb +++ b/lib/ruby_llm/providers/openrouter/chat.rb @@ -52,7 +52,7 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, schema def parse_completion_response(response) data = response.body - return if data.empty? + raise Error.new(response, 'Provider returned an empty response body') if data.nil? || data.empty? raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message') diff --git a/lib/ruby_llm/providers/perplexity.rb b/lib/ruby_llm/providers/perplexity.rb index 3c4fa91cc..46da62fd2 100644 --- a/lib/ruby_llm/providers/perplexity.rb +++ b/lib/ruby_llm/providers/perplexity.rb @@ -34,7 +34,7 @@ def configuration_requirements def parse_error(response) body = response.body - return if body.empty? + return if body.nil? || body.empty? # If response is HTML (Perplexity returns HTML for auth errors) if body.include?('') && body.include?('') diff --git a/spec/ruby_llm/provider_spec.rb b/spec/ruby_llm/provider_spec.rb index 4e14b5dfa..1ea77f0ee 100644 --- a/spec/ruby_llm/provider_spec.rb +++ b/spec/ruby_llm/provider_spec.rb @@ -3,6 +3,72 @@ require 'spec_helper' RSpec.describe RubyLLM::Provider do + describe '#sync_response' do + let(:provider_class) do + Class.new(described_class) do + def api_base + 'https://example.com' + end + + def completion_url + 'chat/completions' + end + + def parse_completion_response(_response) + :parsed + end + end + end + let(:provider) { provider_class.new(RubyLLM::Configuration.new) } + + it 'raises RubyLLM::Error for nil completion bodies' do + request = instance_double(Faraday::Request, headers: {}) + response = instance_double(Faraday::Response, body: nil) + connection = instance_double(RubyLLM::Connection) + + allow(connection).to receive(:post) + .with('chat/completions', { prompt: 'hello' }) + .and_yield(request) + .and_return(response) + + expect do + provider.send(:sync_response, connection, { prompt: 'hello' }) + end.to raise_error(RubyLLM::Error, 'Provider returned an empty response body') + end + + it 'raises RubyLLM::Error for empty completion bodies' do + request = instance_double(Faraday::Request, headers: {}) + response = instance_double(Faraday::Response, body: {}) + connection = instance_double(RubyLLM::Connection) + + allow(connection).to receive(:post) + .with('chat/completions', { prompt: 'hello' }) + .and_yield(request) + .and_return(response) + + expect do + provider.send(:sync_response, connection, { prompt: 'hello' }) + end.to raise_error(RubyLLM::Error, 'Provider returned an empty response body') + end + end + + describe '#parse_error' do + let(:provider_class) do + Class.new(described_class) do + def api_base + 'https://example.com' + end + end + end + let(:provider) { provider_class.new(RubyLLM::Configuration.new) } + + it 'returns nil when the response body is nil' do + response = Struct.new(:body).new(nil) + + expect(provider.parse_error(response)).to be_nil + end + end + describe '.register' do it 'registers provider configuration options on Configuration' do provider_key = :test_provider_spec diff --git a/spec/ruby_llm/providers/anthropic/chat_spec.rb b/spec/ruby_llm/providers/anthropic/chat_spec.rb index 004864050..1312ea39c 100644 --- a/spec/ruby_llm/providers/anthropic/chat_spec.rb +++ b/spec/ruby_llm/providers/anthropic/chat_spec.rb @@ -134,6 +134,16 @@ end describe '.parse_completion_response' do + it 'raises RubyLLM::Error for nil or empty completion bodies' do + [nil, {}].each do |body| + response = instance_double(Faraday::Response, body: body) + + expect do + described_class.parse_completion_response(response) + end.to raise_error(RubyLLM::Error, 'Provider returned an empty response body') + end + end + it 'captures cache usage metrics on the message' do response_body = { 'model' => 'claude-sonnet-4-5-20250929', diff --git a/spec/ruby_llm/providers/bedrock/chat_spec.rb b/spec/ruby_llm/providers/bedrock/chat_spec.rb index e672af2ac..5528e7028 100644 --- a/spec/ruby_llm/providers/bedrock/chat_spec.rb +++ b/spec/ruby_llm/providers/bedrock/chat_spec.rb @@ -3,6 +3,18 @@ require 'spec_helper' RSpec.describe RubyLLM::Providers::Bedrock::Chat do + describe '.parse_completion_response' do + it 'raises RubyLLM::Error for nil or empty completion bodies' do + [nil, {}].each do |body| + response = instance_double(Faraday::Response, body: body) + + expect do + described_class.parse_completion_response(response) + end.to raise_error(RubyLLM::Error, 'Provider returned an empty response body') + end + end + end + describe '.render_tool_result_content' do it 'uses a placeholder when the tool returns no content' do result = described_class.render_tool_result_content('') diff --git a/spec/ruby_llm/providers/gemini/chat_spec.rb b/spec/ruby_llm/providers/gemini/chat_spec.rb index ea4675262..e9fdb9740 100644 --- a/spec/ruby_llm/providers/gemini/chat_spec.rb +++ b/spec/ruby_llm/providers/gemini/chat_spec.rb @@ -525,6 +525,21 @@ end describe '#parse_completion_response' do + it 'raises RubyLLM::Error for nil or empty completion bodies' do + provider = RubyLLM::Providers::Gemini.new(RubyLLM.config) + + [nil, {}].each do |body| + response = Struct.new(:body, :env).new( + body, + Struct.new(:url).new(Struct.new(:path).new('/v1/models/gemini-2.5-flash:generateContent')) + ) + + expect do + provider.send(:parse_completion_response, response) + end.to raise_error(RubyLLM::Error, 'Provider returned an empty response body') + end + end + it 'keeps thought-only parts out of assistant content' do response = Struct.new(:body, :env).new( { diff --git a/spec/ruby_llm/providers/open_ai/chat_spec.rb b/spec/ruby_llm/providers/open_ai/chat_spec.rb index e6f681299..9b7a88bfa 100644 --- a/spec/ruby_llm/providers/open_ai/chat_spec.rb +++ b/spec/ruby_llm/providers/open_ai/chat_spec.rb @@ -4,6 +4,16 @@ RSpec.describe RubyLLM::Providers::OpenAI::Chat do describe '.parse_completion_response' do + it 'raises RubyLLM::Error for nil or empty completion bodies' do + [nil, {}].each do |body| + response = instance_double(Faraday::Response, body: body) + + expect do + described_class.parse_completion_response(response) + end.to raise_error(RubyLLM::Error, 'Provider returned an empty response body') + end + end + it 'captures cached token information when present' do response_body = { 'model' => 'gpt-4.1-nano', diff --git a/spec/ruby_llm/providers/open_router/chat_spec.rb b/spec/ruby_llm/providers/open_router/chat_spec.rb index be77c5f83..a205d3932 100644 --- a/spec/ruby_llm/providers/open_router/chat_spec.rb +++ b/spec/ruby_llm/providers/open_router/chat_spec.rb @@ -3,6 +3,18 @@ require 'spec_helper' RSpec.describe RubyLLM::Providers::OpenRouter::Chat do + describe '.parse_completion_response' do + it 'raises RubyLLM::Error for nil or empty completion bodies' do + [nil, {}].each do |body| + response = instance_double(Faraday::Response, body: body) + + expect do + described_class.parse_completion_response(response) + end.to raise_error(RubyLLM::Error, 'Provider returned an empty response body') + end + end + end + describe '.render_payload' do let(:model) { instance_double(RubyLLM::Model::Info, id: 'anthropic/claude-haiku-4.5') } let(:messages) { [RubyLLM::Message.new(role: :user, content: 'Hello')] } diff --git a/spec/ruby_llm/providers/open_router/parse_error_spec.rb b/spec/ruby_llm/providers/open_router/parse_error_spec.rb index 93e36d338..625e96da7 100644 --- a/spec/ruby_llm/providers/open_router/parse_error_spec.rb +++ b/spec/ruby_llm/providers/open_router/parse_error_spec.rb @@ -10,6 +10,12 @@ end describe '#parse_error' do + it 'returns nil when the response body is nil' do + response = instance_double(Faraday::Response, body: nil) + + expect(provider.parse_error(response)).to be_nil + end + it 'appends nested provider message from metadata.raw when present' do response = instance_double( Faraday::Response, diff --git a/spec/ruby_llm/providers/perplexity_spec.rb b/spec/ruby_llm/providers/perplexity_spec.rb new file mode 100644 index 000000000..7a44a16bb --- /dev/null +++ b/spec/ruby_llm/providers/perplexity_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::Perplexity do + let(:provider) do + config = RubyLLM::Configuration.new + config.perplexity_api_key = 'test' + described_class.new(config) + end + + describe '#parse_error' do + it 'returns nil when the response body is nil' do + response = instance_double(Faraday::Response, body: nil) + + expect(provider.parse_error(response)).to be_nil + end + end +end