Skip to content

Commit fb5e4e4

Browse files
committed
Refactor media attachment handling across providers
- Updated media.rb files for Anthropic, Bedrock, Gemini, Ollama, and OpenAI to use attachment types instead of classes for better clarity and maintainability. - Enhanced error handling to raise UnsupportedAttachmentError with attachment type. - Modified format_text_file_for_llm to include mime_type in the output. - Added 'marcel' gem dependency for improved file type detection. - Updated chat_content_spec to verify attachment filename and mime_type for various content types, ensuring accurate handling of remote and local files. - Removed redundant tests and improved error handling for missing extensions and bad URLs.
1 parent cc8b0f0 commit fb5e4e4

File tree

39 files changed

+1922
-1074
lines changed

39 files changed

+1922
-1074
lines changed

lib/ruby_llm/attachment.rb

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
# A class representing a file attachment.
5+
class Attachment
6+
attr_reader :source, :filename, :mime_type
7+
8+
def initialize(source, filename: nil)
9+
@source = source
10+
if url?
11+
@source = URI source
12+
@filename = filename || File.basename(@source.path).to_s
13+
elsif path?
14+
@source = Pathname.new source
15+
@filename = filename || @source.basename.to_s
16+
else
17+
@filename = filename
18+
end
19+
20+
@mime_type = RubyLLM::MimeType.for @source, name: @filename
21+
@mime_type = RubyLLM::MimeType.for content if @mime_type == 'application/octet-stream'
22+
end
23+
24+
def url?
25+
@source.is_a?(URI) || (@source.is_a?(String) && @source.match?(%r{^https?://}))
26+
end
27+
28+
def path?
29+
@source.is_a?(Pathname) || (@source.is_a?(String) && !url?)
30+
end
31+
32+
def io_like?
33+
@source.respond_to?(:read) && !path?
34+
end
35+
36+
def content
37+
return @content if defined?(@content) && !@content.nil?
38+
39+
if url?
40+
fetch_content
41+
elsif path?
42+
load_content_from_path
43+
elsif io_like?
44+
load_content_from_io
45+
else
46+
RubyLLM.logger.warn "Source is neither a URL, path, nor IO-like: #{@source.class}"
47+
nil
48+
end
49+
50+
@content
51+
end
52+
53+
def encoded
54+
Base64.strict_encode64(content)
55+
end
56+
57+
def type
58+
return :image if image?
59+
return :audio if audio?
60+
return :pdf if pdf?
61+
62+
:text
63+
end
64+
65+
def image?
66+
RubyLLM::MimeType.image? mime_type
67+
end
68+
69+
def audio?
70+
RubyLLM::MimeType.audio? mime_type
71+
end
72+
73+
def pdf?
74+
RubyLLM::MimeType.pdf? mime_type
75+
end
76+
77+
def text?
78+
RubyLLM::MimeType.text? mime_type
79+
end
80+
81+
def as_json
82+
{ type: a.type, source: a.source }
83+
end
84+
85+
private
86+
87+
def fetch_content
88+
response = Connection.basic.get @source.to_s
89+
@content = response.body
90+
end
91+
92+
def load_content_from_path
93+
@content = File.read(@source)
94+
end
95+
96+
def load_content_from_io
97+
@source.rewind if source.respond_to? :rewind
98+
@content = @source.read
99+
end
100+
end
101+
end

lib/ruby_llm/attachments.rb

Lines changed: 0 additions & 79 deletions
This file was deleted.

lib/ruby_llm/attachments/audio.rb

Lines changed: 0 additions & 12 deletions
This file was deleted.

lib/ruby_llm/attachments/image.rb

Lines changed: 0 additions & 9 deletions
This file was deleted.

lib/ruby_llm/attachments/pdf.rb

Lines changed: 0 additions & 9 deletions
This file was deleted.

lib/ruby_llm/attachments/text.rb

Lines changed: 0 additions & 9 deletions
This file was deleted.

lib/ruby_llm/connection.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ module RubyLLM
55
class Connection
66
attr_reader :provider, :connection, :config
77

8+
def self.basic
9+
Faraday.new do |faraday|
10+
faraday.response :logger,
11+
RubyLLM.logger,
12+
bodies: false,
13+
response: false,
14+
errors: true,
15+
headers: false,
16+
log_level: :debug
17+
faraday.use Faraday::Response::RaiseError
18+
end
19+
end
20+
821
def initialize(provider, config)
922
@provider = provider
1023
@config = config
@@ -40,8 +53,13 @@ def setup_timeout(faraday)
4053
end
4154

4255
def setup_logging(faraday)
43-
faraday.response :logger, RubyLLM.logger, bodies: true, response: true,
44-
errors: true, headers: false, log_level: :debug do |logger|
56+
faraday.response :logger,
57+
RubyLLM.logger,
58+
bodies: true,
59+
response: true,
60+
errors: true,
61+
headers: false,
62+
log_level: :debug do |logger|
4563
logger.filter(%r{[A-Za-z0-9+/=]{100,}}, 'data":"[BASE64 DATA]"')
4664
logger.filter(/[-\d.e,\s]{100,}/, '[EMBEDDINGS ARRAY]')
4765
end

lib/ruby_llm/content.rb

Lines changed: 7 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
module RubyLLM
44
# Represents the content sent to or received from an LLM.
5-
# Stores data in a standard internal format, letting providers
6-
# handle their own formatting needs.
5+
# Selects the appropriate attachment class based on the content type.
76
class Content
87
attr_reader :text, :attachments
98

@@ -15,29 +14,11 @@ def initialize(text = nil, attachments = nil)
1514
raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
1615
end
1716

18-
def add_attachment(klass, source)
19-
props = extract_source_properties(source)
20-
@attachments << klass.new(props[:location], filename: props[:filename], mime_type: props[:mime_type])
17+
def add_attachment(source)
18+
@attachments << Attachment.new(source)
2119
self
2220
end
2321

24-
def add_image(source)
25-
add_attachment(Attachments::Image, source)
26-
end
27-
28-
def add_audio(source)
29-
add_attachment(Attachments::Audio, source)
30-
end
31-
32-
def add_pdf(source)
33-
add_attachment(Attachments::PDF, source)
34-
end
35-
36-
def add_text(source)
37-
puts source
38-
add_attachment(Attachments::Text, source)
39-
end
40-
4122
def format
4223
if @text && @attachments.empty?
4324
@text
@@ -48,72 +29,21 @@ def format
4829

4930
# For Rails serialization
5031
def as_json
51-
hash = { text: @text }
52-
unless @attachments.empty?
53-
hash[:attachments] = @attachments.map do |a|
54-
{ type: a.type, source: a.source }
55-
end
56-
end
57-
hash
32+
{ text: @text, attachments: @attachments.map(&:as_json) }
5833
end
5934

6035
private
6136

62-
def extract_source_properties(source)
63-
if source.is_a?(Hash) && Utils.hash_get(source, :location)
64-
{
65-
location: Utils.hash_get(source, :location),
66-
mime_type: Utils.hash_get(source, :mime_type),
67-
filename: Utils.hash_get(source, :filename)
68-
}
69-
else
70-
{ location: source }
71-
end
72-
end
73-
74-
def process_attachments_hash(attachments)
75-
return unless attachments.is_a?(Hash)
76-
77-
Utils.to_safe_array(attachments[:image]).each { |source| add_image(source) }
78-
Utils.to_safe_array(attachments[:audio]).each { |source| add_audio(source) }
79-
Utils.to_safe_array(attachments[:pdf]).each { |source| add_pdf(source) }
80-
Utils.to_safe_array(attachments[:text]).each { |source| add_text(source) }
81-
end
82-
8337
def process_attachments_array_or_string(attachments)
8438
Utils.to_safe_array(attachments).each do |file|
85-
props = extract_source_properties(file)
86-
87-
if props[:mime_type]
88-
# Use explicitly provided MIME type
89-
add_attachment_by_mime_type(props[:location], props[:mime_type], props[:filename])
90-
else
91-
# Fall back to detection from path
92-
detect_and_add_attachment(props[:location])
93-
end
39+
add_attachment(file)
9440
end
9541
end
9642

97-
def add_attachment_by_mime_type(source, mime_type, filename = nil)
98-
if RubyLLM::MimeTypes.image?(mime_type)
99-
add_image({ location: source, mime_type: mime_type, filename: filename })
100-
elsif RubyLLM::MimeTypes.audio?(mime_type)
101-
add_audio({ location: source, mime_type: mime_type, filename: filename })
102-
elsif RubyLLM::MimeTypes.pdf?(mime_type)
103-
add_pdf({ location: source, mime_type: mime_type, filename: filename })
104-
else
105-
add_text({ location: source, mime_type: mime_type, filename: filename })
106-
end
107-
end
108-
109-
def detect_and_add_attachment(file)
110-
mime_type = RubyLLM::MimeTypes.detect_from_path(file.to_s)
111-
add_attachment_by_mime_type(file, mime_type)
112-
end
113-
11443
def process_attachments(attachments)
11544
if attachments.is_a?(Hash)
116-
process_attachments_hash attachments
45+
# Ignores types (like :image, :audio, :text, :pdf) since we have robust MIME type detection
46+
attachments.each_value(&method(:process_attachments_array_or_string))
11747
else
11848
process_attachments_array_or_string attachments
11949
end

lib/ruby_llm/error.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ class InvalidRoleError < StandardError; end
2525
class ModelNotFoundError < StandardError; end
2626
class UnsupportedFunctionsError < StandardError; end
2727
class UnsupportedAttachmentError < StandardError; end
28-
class MissingExtensionError < StandardError; end
2928

3029
# Error classes for different HTTP status codes
3130
class BadRequestError < Error; end

0 commit comments

Comments
 (0)