Skip to content

Commit 65fdfe4

Browse files
committed
Completely refactored Content implementation.
Now uses POROs and simplifies implementation of providers. Fixes #143
1 parent 347e630 commit 65fdfe4

File tree

160 files changed

+8850
-8130
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

160 files changed

+8850
-8130
lines changed

lib/ruby_llm.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
'api' => 'API',
1818
'deepseek' => 'DeepSeek',
1919
'bedrock' => 'Bedrock',
20-
'openrouter' => 'OpenRouter'
20+
'openrouter' => 'OpenRouter',
21+
'pdf' => 'PDF'
2122
)
2223
loader.ignore("#{__dir__}/tasks")
2324
loader.ignore("#{__dir__}/ruby_llm/railtie")

lib/ruby_llm/attachments.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
# A module for handling attachments and file operations in RubyLLM.
5+
module Attachments
6+
# Base class for attachments
7+
class Base
8+
attr_reader :source
9+
10+
def initialize(source)
11+
@source = source
12+
end
13+
14+
def url?
15+
@source.is_a?(String) && @source.match?(%r{^https?://})
16+
end
17+
18+
def file?
19+
@source.is_a?(String) && !url?
20+
end
21+
22+
def content
23+
@content ||= load_content if file?
24+
@content ||= fetch_content if url?
25+
@content
26+
end
27+
28+
def type
29+
self.class.name.demodulize.downcase
30+
end
31+
32+
def encoded
33+
Base64.strict_encode64(content)
34+
end
35+
36+
private
37+
38+
def fetch_content
39+
RubyLLM.logger.debug("Fetching content from URL: #{@source}")
40+
Faraday.get(@source).body if url?
41+
end
42+
43+
def load_content
44+
File.read(File.expand_path(@source)) if file?
45+
end
46+
end
47+
end
48+
end

lib/ruby_llm/attachments/audio.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
module Attachments
5+
# Represents an audio attachment
6+
class Audio < Base
7+
def format
8+
File.extname(@source).downcase.delete('.') || 'wav'
9+
end
10+
end
11+
end
12+
end

lib/ruby_llm/attachments/image.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
module Attachments
5+
# Represents an audio attachment
6+
class Image < Base
7+
def mime_type
8+
ext = File.extname(@source).downcase.delete('.')
9+
"image/#{ext}"
10+
end
11+
end
12+
end
13+
end

lib/ruby_llm/attachments/pdf.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module RubyLLM
4+
module Attachments
5+
# Represents a PDF attachment
6+
class PDF < Base
7+
def mime_type
8+
'application/pdf'
9+
end
10+
end
11+
end
12+
end

lib/ruby_llm/content.rb

Lines changed: 36 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -5,100 +5,58 @@ module RubyLLM
55
# Stores data in a standard internal format, letting providers
66
# handle their own formatting needs.
77
class Content
8-
def initialize(text = nil, attachments = {}) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
9-
@parts = []
10-
@parts << { type: 'text', text: text } unless text.nil? || text.empty?
8+
attr_reader :text, :attachments
119

12-
Array(attachments[:image]).each do |source|
13-
@parts << attach_image(source)
14-
end
15-
16-
Array(attachments[:audio]).each do |source|
17-
@parts << attach_audio(source)
18-
end
10+
def initialize(text = nil, attachments = {})
11+
@text = text
12+
@attachments = []
1913

20-
Array(attachments[:pdf]).each do |source|
21-
@parts << attach_pdf(source)
22-
end
14+
process_attachments(attachments)
15+
raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
2316
end
2417

25-
def to_a
26-
return if @parts.empty?
27-
28-
@parts
18+
def add_image(source)
19+
@attachments << Attachments::Image.new(source)
20+
self
2921
end
3022

31-
def format
32-
return @parts.first[:text] if @parts.size == 1 && @parts.first[:type] == 'text'
33-
34-
to_a
23+
def add_audio(source)
24+
@attachments << Attachments::Audio.new(source)
25+
self
3526
end
3627

37-
private
38-
39-
def attach_image(source) # rubocop:disable Metrics/MethodLength
40-
source = File.expand_path(source) unless source.start_with?('http')
41-
42-
return { type: 'image', source: { url: source } } if source.start_with?('http')
43-
44-
data = Base64.strict_encode64(File.read(source))
45-
mime_type = mime_type_for(source)
46-
47-
{
48-
type: 'image',
49-
source: {
50-
type: 'base64',
51-
media_type: mime_type,
52-
data: data
53-
}
54-
}
28+
def add_pdf(source)
29+
@attachments << Attachments::PDF.new(source)
30+
self
5531
end
5632

57-
def attach_audio(source)
58-
source = File.expand_path(source) unless source.start_with?('http')
59-
data = encode_file(source)
60-
format = File.extname(source).delete('.') || 'wav'
61-
62-
{
63-
type: 'input_audio',
64-
input_audio: {
65-
data: data,
66-
format: format
67-
}
68-
}
69-
end
70-
71-
def attach_pdf(source)
72-
source = File.expand_path(source) unless source.start_with?('http')
73-
74-
pdf_data = {
75-
type: 'pdf',
76-
source: source
77-
}
78-
79-
# For local files, validate they exist
80-
unless source.start_with?('http')
81-
raise Error, "PDF file not found: #{source}" unless File.exist?(source)
82-
83-
# Preload file content for providers that need it
84-
pdf_data[:content] = File.read(source)
33+
def format
34+
if @text && @attachments.empty?
35+
@text
36+
else
37+
self
8538
end
86-
87-
pdf_data
8839
end
8940

90-
def encode_file(source)
91-
if source.start_with?('http')
92-
response = Faraday.get(source)
93-
Base64.strict_encode64(response.body)
94-
else
95-
Base64.strict_encode64(File.read(source))
41+
# For Rails serialization
42+
def as_json
43+
hash = { text: @text }
44+
unless @attachments.empty?
45+
hash[:attachments] = @attachments.map do |a|
46+
{ type: a.type, source: a.source }
47+
end
9648
end
49+
hash
9750
end
9851

99-
def mime_type_for(path)
100-
ext = File.extname(path).delete('.')
101-
"image/#{ext}"
52+
private
53+
54+
def process_attachments(attachments)
55+
return unless attachments.is_a?(Hash)
56+
57+
Array(attachments[:image]).each { |source| add_image(source) }
58+
Array(attachments[:audio]).each { |source| add_audio(source) }
59+
Array(attachments[:pdf]).each { |source| add_pdf(source) }
10260
end
10361
end
10462
end

lib/ruby_llm/message.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module RubyLLM
77
class Message
88
ROLES = %i[system user assistant tool].freeze
99

10-
attr_reader :role, :content, :tool_calls, :tool_call_id, :input_tokens, :output_tokens, :model_id
10+
attr_reader :role, :tool_calls, :tool_call_id, :input_tokens, :output_tokens, :model_id
1111

1212
def initialize(options = {})
1313
@role = options[:role].to_sym
@@ -21,6 +21,14 @@ def initialize(options = {})
2121
ensure_valid_role
2222
end
2323

24+
def content
25+
if @content.is_a?(Content) && @content.text && @content.attachments.empty?
26+
@content.text
27+
else
28+
@content
29+
end
30+
end
31+
2432
def tool_call?
2533
!tool_calls.nil? && !tool_calls.empty?
2634
end
@@ -49,9 +57,9 @@ def to_h
4957

5058
def normalize_content(content)
5159
case content
52-
when Content then content.format
53-
when String then Content.new(content).format
54-
else content
60+
when String then Content.new(content)
61+
when Hash then Content.new(content[:text], content)
62+
else content # Pass through nil, Content, or other types
5563
end
5664
end
5765

0 commit comments

Comments
 (0)