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
6 changes: 5 additions & 1 deletion lib/ruby_llm/active_record/message_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ def extract_tool_call_id
end

def extract_content
return RubyLLM::Content::Raw.new(content_raw) if has_attribute?(:content_raw) && content_raw.present?
if has_attribute?(:content_raw) && content_raw.present?
return content_raw if content_raw.is_a?(Hash)

return RubyLLM::Content::Raw.new(content_raw)
end

content_value = self[:content]

Expand Down
15 changes: 15 additions & 0 deletions lib/ruby_llm/content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ def format
def to_h
@value
end

def to_s
case @value
when String
@value
when Hash, Array
@value.to_json
else
@value.to_s
end
end

def to_json(*args)
@value.to_json(*args)
end
end
end
end
12 changes: 11 additions & 1 deletion lib/ruby_llm/message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,21 @@ def normalize_content(content, role:, tool_calls:)

case content
when String then Content.new(content)
when Hash then Content.new(content[:text], content)
when Hash then normalize_hash_content(content)
else content
end
end

def normalize_hash_content(content)
return content if content.keys.any?(String)

text = content[:text]
attachments = content.except(:text)
return Content.new(text) if attachments.empty?

Content.new(text, attachments)
end

def ensure_valid_role
raise InvalidRoleError, "Expected role to be one of: #{ROLES.join(', ')}" unless ROLES.include?(role)
end
Expand Down
7 changes: 6 additions & 1 deletion lib/ruby_llm/providers/bedrock/streaming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ def event_stream_decoder

def handle_failed_stream(chunk, env)
data = JSON.parse(chunk)
error_response = env.merge(body: data)
error_status = env&.status || data.dig('error', 'code') || data['code'] || 500
error_response = if env.respond_to?(:merge)
env.merge(body: data, status: error_status)
else
Struct.new(:body, :status).new(data, error_status)
end
ErrorMiddleware.parse_error(provider: self, response: error_response)
rescue JSON::ParserError
RubyLLM.logger.debug { "Failed Bedrock stream error chunk: #{chunk}" }
Expand Down
18 changes: 11 additions & 7 deletions lib/ruby_llm/streaming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ def handle_error_event(data, env)

def parse_streaming_error(data)
error_data = JSON.parse(data)
[500, error_data['message'] || 'Unknown streaming error']
error_payload = error_data['error'].is_a?(Hash) ? error_data['error'] : error_data
status = error_payload['code'] || error_data['code'] || 500
message = error_payload['message'] || error_data['message'] || 'Unknown streaming error'

[status, message]
rescue JSON::ParserError => e
RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
[500, "Failed to parse error: #{data}"]
Expand All @@ -136,10 +140,10 @@ def parse_error_from_json(data, env, error_message)
def build_stream_error_response(parsed_data, env, status)
error_status = status || env&.status || 500

if faraday_1?
Struct.new(:body, :status).new(parsed_data, error_status)
else
if env.respond_to?(:merge)
env.merge(body: parsed_data, status: error_status)
else
Struct.new(:body, :status).new(parsed_data, error_status)
end
end

Expand All @@ -163,10 +167,10 @@ def v1_on_data(on_chunk)

def v2_on_data(on_chunk, on_failed_response)
proc do |chunk, _bytes, env|
if env&.status == 200
on_chunk.call(chunk, env)
else
if env && env.status != 200
on_failed_response.call(chunk, env)
else
on_chunk.call(chunk, env)
end
end
end
Expand Down
1 change: 1 addition & 0 deletions spec/ruby_llm/active_record/acts_as_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def execute(expression:)
saved_message = chat.messages.last
expect(saved_message.role).to eq('assistant')
expect(saved_message.content_raw).to eq({ 'name' => 'Alice', 'age' => 25 })
expect(saved_message.to_llm.content).to eq({ 'name' => 'Alice', 'age' => 25 })
end

it 'supports multi-turn conversations with structured responses' do
Expand Down
31 changes: 31 additions & 0 deletions spec/ruby_llm/message_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,36 @@

expect(message.content).to be_nil
end

it 'preserves string-keyed hashes as structured content' do
payload = { 'name' => 'Alice', 'age' => 25 }
message = described_class.new(role: :assistant, content: payload)

expect(message.content).to eq(payload)
end

it 'keeps symbol-keyed attachment hashes as content objects' do
image_path = File.expand_path('../fixtures/ruby.png', __dir__)
message = described_class.new(role: :user, content: { image: image_path })

expect(message.content).to be_a(RubyLLM::Content)
expect(message.content.attachments.first.filename).to eq('ruby.png')
end
end

describe RubyLLM::Content::Raw do
describe '#to_s' do
it 'serializes hashes to JSON strings' do
raw = described_class.new({ 'name' => 'Alice', 'age' => 25 })

expect(raw.to_s).to eq('{"name":"Alice","age":25}')
end

it 'returns string payloads unchanged' do
raw = described_class.new('hello')

expect(raw.to_s).to eq('hello')
end
end
end
end
32 changes: 32 additions & 0 deletions spec/ruby_llm/streaming_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,36 @@

expect(yielded_chunks).to eq(['chunk:ok'])
end

it 'parses nested provider error codes from streaming payloads' do
status, message = test_obj.send(
:parse_streaming_error,
{ error: { code: 529, message: 'Overloaded' } }.to_json
)

expect(status).to eq(529)
expect(message).to eq('Overloaded')
end

it 'builds a minimal error response when Faraday v2 env is missing' do
response = test_obj.send(:build_stream_error_response, { 'error' => { 'message' => 'oops' } }, nil, 500)

expect(response.body).to eq({ 'error' => { 'message' => 'oops' } })
expect(response.status).to eq(500)
end

it 'treats nil env as a normal chunk in the v2 on_data handler' do
yielded_chunks = []
failed_chunks = []
handler = described_class::FaradayHandlers.send(
:v2_on_data,
->(chunk, env) { yielded_chunks << [chunk, env] },
->(chunk, env) { failed_chunks << [chunk, env] }
)

handler.call("data: {\"x\":\"ok\"}\n\n", 0, nil)

expect(yielded_chunks).to eq([["data: {\"x\":\"ok\"}\n\n", nil]])
expect(failed_chunks).to be_empty
end
end
4 changes: 3 additions & 1 deletion spec/ruby_llm_gemspec_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ def runtime_dependency(name)
end

it 'keeps faraday compatible with Ruby < 4.0' do
expect(runtime_dependency('faraday').requirement.to_s).to eq('>= 1.10.0')
expected_requirement = ENV['FARADAY_VERSION'] ? "= #{ENV['FARADAY_VERSION']}" : '>= 1.10.0'

expect(runtime_dependency('faraday').requirement.to_s).to eq(expected_requirement)
end

it 'keeps faraday-retry compatible with Faraday v1 and v2' do
Expand Down
10 changes: 10 additions & 0 deletions spec/support/streaming_error_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,16 @@ def stub_error_response(provider, type)
config = ERROR_HANDLING_CONFIGS[provider]
return unless config

if provider == :vertexai
require 'googleauth'
allow(Google::Auth).to receive(:get_application_default).and_return(
instance_double(
Google::Auth::GCECredentials,
apply: { 'Authorization' => 'Bearer test-token' }
)
)
end

url = config[:url].respond_to?(:call) ? config[:url].call : config[:url]

body = case type
Expand Down