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