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
11 changes: 10 additions & 1 deletion lib/ruby_llm/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions lib/ruby_llm/providers/anthropic/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/providers/bedrock/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/bedrock/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'] || {}
Expand Down
2 changes: 2 additions & 0 deletions lib/ruby_llm/providers/gemini/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/openai/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/openrouter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/openrouter/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/perplexity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?('<html>') && body.include?('<title>')
Expand Down
66 changes: 66 additions & 0 deletions spec/ruby_llm/provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions spec/ruby_llm/providers/anthropic/chat_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions spec/ruby_llm/providers/bedrock/chat_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down
15 changes: 15 additions & 0 deletions spec/ruby_llm/providers/gemini/chat_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
10 changes: 10 additions & 0 deletions spec/ruby_llm/providers/open_ai/chat_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions spec/ruby_llm/providers/open_router/chat_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')] }
Expand Down
6 changes: 6 additions & 0 deletions spec/ruby_llm/providers/open_router/parse_error_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions spec/ruby_llm/providers/perplexity_spec.rb
Original file line number Diff line number Diff line change
@@ -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