From fa5fedf2db9f6ae5e9548201c94f9990bbaa8adf Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Thu, 2 Apr 2026 20:51:01 +0300 Subject: [PATCH] Fix persisted content handling and streaming error paths --- lib/ruby_llm/active_record/message_methods.rb | 6 +++- lib/ruby_llm/content.rb | 15 +++++++++ lib/ruby_llm/message.rb | 12 ++++++- lib/ruby_llm/providers/bedrock/streaming.rb | 7 +++- lib/ruby_llm/streaming.rb | 18 +++++++---- spec/ruby_llm/active_record/acts_as_spec.rb | 1 + spec/ruby_llm/message_spec.rb | 31 ++++++++++++++++++ spec/ruby_llm/streaming_spec.rb | 32 +++++++++++++++++++ spec/ruby_llm_gemspec_spec.rb | 4 ++- spec/support/streaming_error_helpers.rb | 10 ++++++ 10 files changed, 125 insertions(+), 11 deletions(-) diff --git a/lib/ruby_llm/active_record/message_methods.rb b/lib/ruby_llm/active_record/message_methods.rb index 57a4ef8c9..78a7fe856 100644 --- a/lib/ruby_llm/active_record/message_methods.rb +++ b/lib/ruby_llm/active_record/message_methods.rb @@ -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] diff --git a/lib/ruby_llm/content.rb b/lib/ruby_llm/content.rb index d4010c4ba..f84f88679 100644 --- a/lib/ruby_llm/content.rb +++ b/lib/ruby_llm/content.rb @@ -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 diff --git a/lib/ruby_llm/message.rb b/lib/ruby_llm/message.rb index eefb93e55..d87dbf23d 100644 --- a/lib/ruby_llm/message.rb +++ b/lib/ruby_llm/message.rb @@ -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 diff --git a/lib/ruby_llm/providers/bedrock/streaming.rb b/lib/ruby_llm/providers/bedrock/streaming.rb index c51077b13..af41405c6 100644 --- a/lib/ruby_llm/providers/bedrock/streaming.rb +++ b/lib/ruby_llm/providers/bedrock/streaming.rb @@ -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}" } diff --git a/lib/ruby_llm/streaming.rb b/lib/ruby_llm/streaming.rb index a671f9cca..cd02351ab 100644 --- a/lib/ruby_llm/streaming.rb +++ b/lib/ruby_llm/streaming.rb @@ -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}"] @@ -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 @@ -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 diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 1bd648ec9..3a04924a8 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -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 diff --git a/spec/ruby_llm/message_spec.rb b/spec/ruby_llm/message_spec.rb index a787fd0e7..d303cb032 100644 --- a/spec/ruby_llm/message_spec.rb +++ b/spec/ruby_llm/message_spec.rb @@ -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 diff --git a/spec/ruby_llm/streaming_spec.rb b/spec/ruby_llm/streaming_spec.rb index 2252474e8..30072ffed 100644 --- a/spec/ruby_llm/streaming_spec.rb +++ b/spec/ruby_llm/streaming_spec.rb @@ -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 diff --git a/spec/ruby_llm_gemspec_spec.rb b/spec/ruby_llm_gemspec_spec.rb index 1a47b867d..cfa884c0b 100644 --- a/spec/ruby_llm_gemspec_spec.rb +++ b/spec/ruby_llm_gemspec_spec.rb @@ -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 diff --git a/spec/support/streaming_error_helpers.rb b/spec/support/streaming_error_helpers.rb index 4e34500d0..3befd3d60 100644 --- a/spec/support/streaming_error_helpers.rb +++ b/spec/support/streaming_error_helpers.rb @@ -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