ruby_llm 1.3.0rc1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +13 -9
- data/lib/ruby_llm/active_record/acts_as.rb +67 -148
- data/lib/ruby_llm/aliases.json +178 -42
- data/lib/ruby_llm/attachment.rb +164 -0
- data/lib/ruby_llm/chat.rb +12 -4
- data/lib/ruby_llm/configuration.rb +6 -1
- data/lib/ruby_llm/connection.rb +28 -2
- data/lib/ruby_llm/content.rb +9 -40
- data/lib/ruby_llm/error.rb +1 -0
- data/lib/ruby_llm/image.rb +2 -3
- data/lib/ruby_llm/message.rb +2 -2
- data/lib/ruby_llm/mime_type.rb +67 -0
- data/lib/ruby_llm/model/info.rb +101 -0
- data/lib/ruby_llm/model/modalities.rb +22 -0
- data/lib/ruby_llm/model/pricing.rb +51 -0
- data/lib/ruby_llm/model/pricing_category.rb +48 -0
- data/lib/ruby_llm/model/pricing_tier.rb +34 -0
- data/lib/ruby_llm/model.rb +7 -0
- data/lib/ruby_llm/models.json +2646 -2201
- data/lib/ruby_llm/models.rb +20 -20
- data/lib/ruby_llm/provider.rb +1 -1
- data/lib/ruby_llm/providers/anthropic/media.rb +14 -3
- data/lib/ruby_llm/providers/anthropic/models.rb +1 -1
- data/lib/ruby_llm/providers/anthropic/tools.rb +5 -4
- data/lib/ruby_llm/providers/bedrock/media.rb +7 -4
- data/lib/ruby_llm/providers/bedrock/models.rb +2 -2
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +3 -3
- data/lib/ruby_llm/providers/gemini/images.rb +3 -2
- data/lib/ruby_llm/providers/gemini/media.rb +12 -24
- data/lib/ruby_llm/providers/gemini/models.rb +1 -1
- data/lib/ruby_llm/providers/ollama/media.rb +8 -4
- data/lib/ruby_llm/providers/openai/capabilities.rb +5 -2
- data/lib/ruby_llm/providers/openai/chat.rb +12 -8
- data/lib/ruby_llm/providers/openai/images.rb +3 -2
- data/lib/ruby_llm/providers/openai/media.rb +18 -8
- data/lib/ruby_llm/providers/openai/models.rb +1 -1
- data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
- data/lib/ruby_llm/streaming.rb +46 -11
- data/lib/ruby_llm/tool.rb +8 -8
- data/lib/ruby_llm/utils.rb +14 -9
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +1 -1
- data/lib/tasks/aliases.rake +235 -0
- data/lib/tasks/models_docs.rake +13 -7
- data/lib/tasks/release.rake +32 -0
- metadata +40 -25
- data/lib/ruby_llm/attachments/audio.rb +0 -12
- data/lib/ruby_llm/attachments/image.rb +0 -9
- data/lib/ruby_llm/attachments/pdf.rb +0 -9
- data/lib/ruby_llm/attachments.rb +0 -78
- data/lib/ruby_llm/mime_types.rb +0 -713
- data/lib/ruby_llm/model_info.rb +0 -237
- data/lib/tasks/{models.rake → models_update.rake} +13 -13
@@ -0,0 +1,164 @@
|
|
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
|
+
elsif active_storage?
|
17
|
+
@filename = filename || extract_filename_from_active_storage
|
18
|
+
else
|
19
|
+
@filename = filename
|
20
|
+
end
|
21
|
+
|
22
|
+
determine_mime_type
|
23
|
+
end
|
24
|
+
|
25
|
+
def url?
|
26
|
+
@source.is_a?(URI) || (@source.is_a?(String) && @source.match?(%r{^https?://}))
|
27
|
+
end
|
28
|
+
|
29
|
+
def path?
|
30
|
+
@source.is_a?(Pathname) || (@source.is_a?(String) && !url?)
|
31
|
+
end
|
32
|
+
|
33
|
+
def io_like?
|
34
|
+
@source.respond_to?(:read) && !path? && !active_storage?
|
35
|
+
end
|
36
|
+
|
37
|
+
def active_storage?
|
38
|
+
return false unless defined?(ActiveStorage)
|
39
|
+
|
40
|
+
@source.is_a?(ActiveStorage::Blob) ||
|
41
|
+
@source.is_a?(ActiveStorage::Attached::One) ||
|
42
|
+
@source.is_a?(ActiveStorage::Attached::Many)
|
43
|
+
end
|
44
|
+
|
45
|
+
def content
|
46
|
+
return @content if defined?(@content) && !@content.nil?
|
47
|
+
|
48
|
+
if url?
|
49
|
+
fetch_content
|
50
|
+
elsif path?
|
51
|
+
load_content_from_path
|
52
|
+
elsif active_storage?
|
53
|
+
load_content_from_active_storage
|
54
|
+
elsif io_like?
|
55
|
+
load_content_from_io
|
56
|
+
else
|
57
|
+
RubyLLM.logger.warn "Source is neither a URL, path, ActiveStorage, nor IO-like: #{@source.class}"
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
|
61
|
+
@content
|
62
|
+
end
|
63
|
+
|
64
|
+
def encoded
|
65
|
+
Base64.strict_encode64(content)
|
66
|
+
end
|
67
|
+
|
68
|
+
def type
|
69
|
+
return :image if image?
|
70
|
+
return :audio if audio?
|
71
|
+
return :pdf if pdf?
|
72
|
+
return :text if text?
|
73
|
+
|
74
|
+
:unknown
|
75
|
+
end
|
76
|
+
|
77
|
+
def image?
|
78
|
+
RubyLLM::MimeType.image? mime_type
|
79
|
+
end
|
80
|
+
|
81
|
+
def audio?
|
82
|
+
RubyLLM::MimeType.audio? mime_type
|
83
|
+
end
|
84
|
+
|
85
|
+
def pdf?
|
86
|
+
RubyLLM::MimeType.pdf? mime_type
|
87
|
+
end
|
88
|
+
|
89
|
+
def text?
|
90
|
+
RubyLLM::MimeType.text? mime_type
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_h
|
94
|
+
{ type: type, source: @source }
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def determine_mime_type
|
100
|
+
return @mime_type = active_storage_content_type if active_storage? && active_storage_content_type.present?
|
101
|
+
|
102
|
+
@mime_type = RubyLLM::MimeType.for(@source, name: @filename)
|
103
|
+
@mime_type = RubyLLM::MimeType.for(content) if @mime_type == 'application/octet-stream'
|
104
|
+
@mime_type = 'audio/wav' if @mime_type == 'audio/x-wav' # Normalize WAV type
|
105
|
+
end
|
106
|
+
|
107
|
+
def fetch_content
|
108
|
+
response = Connection.basic.get @source.to_s
|
109
|
+
@content = response.body
|
110
|
+
end
|
111
|
+
|
112
|
+
def load_content_from_path
|
113
|
+
@content = File.read(@source)
|
114
|
+
end
|
115
|
+
|
116
|
+
def load_content_from_io
|
117
|
+
@source.rewind if @source.respond_to? :rewind
|
118
|
+
@content = @source.read
|
119
|
+
end
|
120
|
+
|
121
|
+
def load_content_from_active_storage
|
122
|
+
return unless defined?(ActiveStorage)
|
123
|
+
|
124
|
+
@content = case @source
|
125
|
+
when ActiveStorage::Blob
|
126
|
+
@source.download
|
127
|
+
when ActiveStorage::Attached::One
|
128
|
+
@source.blob&.download
|
129
|
+
when ActiveStorage::Attached::Many
|
130
|
+
# For multiple attachments, just take the first one
|
131
|
+
# This maintains the single-attachment interface
|
132
|
+
@source.blobs.first&.download
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def extract_filename_from_active_storage # rubocop:disable Metrics/PerceivedComplexity
|
137
|
+
return 'attachment' unless defined?(ActiveStorage)
|
138
|
+
|
139
|
+
case @source
|
140
|
+
when ActiveStorage::Blob
|
141
|
+
@source.filename.to_s
|
142
|
+
when ActiveStorage::Attached::One
|
143
|
+
@source.blob&.filename&.to_s || 'attachment'
|
144
|
+
when ActiveStorage::Attached::Many
|
145
|
+
@source.blobs.first&.filename&.to_s || 'attachment'
|
146
|
+
else
|
147
|
+
'attachment'
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def active_storage_content_type
|
152
|
+
return unless defined?(ActiveStorage)
|
153
|
+
|
154
|
+
case @source
|
155
|
+
when ActiveStorage::Blob
|
156
|
+
@source.content_type
|
157
|
+
when ActiveStorage::Attached::One
|
158
|
+
@source.blob&.content_type
|
159
|
+
when ActiveStorage::Attached::Many
|
160
|
+
@source.blobs.first&.content_type
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
data/lib/ruby_llm/chat.rb
CHANGED
@@ -18,10 +18,10 @@ module RubyLLM
|
|
18
18
|
raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
|
19
19
|
end
|
20
20
|
|
21
|
-
|
22
|
-
|
21
|
+
@context = context
|
22
|
+
@config = context&.config || RubyLLM.config
|
23
|
+
model_id = model || @config.default_model
|
23
24
|
with_model(model_id, provider: provider, assume_exists: assume_model_exists)
|
24
|
-
@connection = context ? context.connection_for(@provider) : @provider.connection(config)
|
25
25
|
@temperature = 0.7
|
26
26
|
@messages = []
|
27
27
|
@tools = {}
|
@@ -39,7 +39,7 @@ module RubyLLM
|
|
39
39
|
alias say ask
|
40
40
|
|
41
41
|
def with_instructions(instructions, replace: false)
|
42
|
-
@messages = @messages.reject
|
42
|
+
@messages = @messages.reject { |msg| msg.role == :system } if replace
|
43
43
|
|
44
44
|
add_message role: :system, content: instructions
|
45
45
|
self
|
@@ -62,6 +62,7 @@ module RubyLLM
|
|
62
62
|
|
63
63
|
def with_model(model_id, provider: nil, assume_exists: false)
|
64
64
|
@model, @provider = Models.resolve(model_id, provider:, assume_exists:)
|
65
|
+
@connection = @context ? @context.connection_for(@provider) : @provider.connection(@config)
|
65
66
|
self
|
66
67
|
end
|
67
68
|
|
@@ -70,6 +71,13 @@ module RubyLLM
|
|
70
71
|
self
|
71
72
|
end
|
72
73
|
|
74
|
+
def with_context(context)
|
75
|
+
@context = context
|
76
|
+
@config = context.config
|
77
|
+
with_model(@model.id, provider: @provider.slug, assume_exists: true)
|
78
|
+
self
|
79
|
+
end
|
80
|
+
|
73
81
|
def on_new_message(&block)
|
74
82
|
@on[:new_message] = block
|
75
83
|
self
|
@@ -34,9 +34,12 @@ module RubyLLM
|
|
34
34
|
:retry_interval,
|
35
35
|
:retry_backoff_factor,
|
36
36
|
:retry_interval_randomness,
|
37
|
+
:http_proxy,
|
37
38
|
# Logging configuration
|
39
|
+
:logger,
|
38
40
|
:log_file,
|
39
|
-
:log_level
|
41
|
+
:log_level,
|
42
|
+
:log_assume_model_exists
|
40
43
|
|
41
44
|
def initialize
|
42
45
|
# Connection configuration
|
@@ -45,6 +48,7 @@ module RubyLLM
|
|
45
48
|
@retry_interval = 0.1
|
46
49
|
@retry_backoff_factor = 2
|
47
50
|
@retry_interval_randomness = 0.5
|
51
|
+
@http_proxy = nil
|
48
52
|
|
49
53
|
# Default models
|
50
54
|
@default_model = 'gpt-4.1-nano'
|
@@ -54,6 +58,7 @@ module RubyLLM
|
|
54
58
|
# Logging configuration
|
55
59
|
@log_file = $stdout
|
56
60
|
@log_level = ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO
|
61
|
+
@log_assume_model_exists = true
|
57
62
|
end
|
58
63
|
|
59
64
|
def inspect
|
data/lib/ruby_llm/connection.rb
CHANGED
@@ -5,6 +5,20 @@ module RubyLLM
|
|
5
5
|
class Connection
|
6
6
|
attr_reader :provider, :connection, :config
|
7
7
|
|
8
|
+
def self.basic(&)
|
9
|
+
Faraday.new do |f|
|
10
|
+
f.response :logger,
|
11
|
+
RubyLLM.logger,
|
12
|
+
bodies: false,
|
13
|
+
response: false,
|
14
|
+
errors: true,
|
15
|
+
headers: false,
|
16
|
+
log_level: :debug
|
17
|
+
f.response :raise_error
|
18
|
+
yield f if block_given?
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
8
22
|
def initialize(provider, config)
|
9
23
|
@provider = provider
|
10
24
|
@config = config
|
@@ -15,6 +29,7 @@ module RubyLLM
|
|
15
29
|
setup_logging(faraday)
|
16
30
|
setup_retry(faraday)
|
17
31
|
setup_middleware(faraday)
|
32
|
+
setup_http_proxy(faraday)
|
18
33
|
end
|
19
34
|
end
|
20
35
|
|
@@ -40,8 +55,13 @@ module RubyLLM
|
|
40
55
|
end
|
41
56
|
|
42
57
|
def setup_logging(faraday)
|
43
|
-
faraday.response :logger,
|
44
|
-
|
58
|
+
faraday.response :logger,
|
59
|
+
RubyLLM.logger,
|
60
|
+
bodies: true,
|
61
|
+
response: true,
|
62
|
+
errors: true,
|
63
|
+
headers: false,
|
64
|
+
log_level: :debug do |logger|
|
45
65
|
logger.filter(%r{[A-Za-z0-9+/=]{100,}}, 'data":"[BASE64 DATA]"')
|
46
66
|
logger.filter(/[-\d.e,\s]{100,}/, '[EMBEDDINGS ARRAY]')
|
47
67
|
end
|
@@ -65,6 +85,12 @@ module RubyLLM
|
|
65
85
|
faraday.use :llm_errors, provider: @provider
|
66
86
|
end
|
67
87
|
|
88
|
+
def setup_http_proxy(faraday)
|
89
|
+
return unless @config.http_proxy
|
90
|
+
|
91
|
+
faraday.proxy = @config.http_proxy
|
92
|
+
end
|
93
|
+
|
68
94
|
def retry_exceptions
|
69
95
|
[
|
70
96
|
Errno::ETIMEDOUT,
|
data/lib/ruby_llm/content.rb
CHANGED
@@ -2,8 +2,7 @@
|
|
2
2
|
|
3
3
|
module RubyLLM
|
4
4
|
# Represents the content sent to or received from an LLM.
|
5
|
-
#
|
6
|
-
# handle their own formatting needs.
|
5
|
+
# Selects the appropriate attachment class based on the content type.
|
7
6
|
class Content
|
8
7
|
attr_reader :text, :attachments
|
9
8
|
|
@@ -15,18 +14,8 @@ module RubyLLM
|
|
15
14
|
raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
|
16
15
|
end
|
17
16
|
|
18
|
-
def
|
19
|
-
@attachments <<
|
20
|
-
self
|
21
|
-
end
|
22
|
-
|
23
|
-
def add_audio(source)
|
24
|
-
@attachments << Attachments::Audio.new(source)
|
25
|
-
self
|
26
|
-
end
|
27
|
-
|
28
|
-
def add_pdf(source)
|
29
|
-
@attachments << Attachments::PDF.new(source)
|
17
|
+
def add_attachment(source, filename: nil)
|
18
|
+
@attachments << Attachment.new(source, filename:)
|
30
19
|
self
|
31
20
|
end
|
32
21
|
|
@@ -39,42 +28,22 @@ module RubyLLM
|
|
39
28
|
end
|
40
29
|
|
41
30
|
# For Rails serialization
|
42
|
-
def
|
43
|
-
|
44
|
-
unless @attachments.empty?
|
45
|
-
hash[:attachments] = @attachments.map do |a|
|
46
|
-
{ type: a.type, source: a.source }
|
47
|
-
end
|
48
|
-
end
|
49
|
-
hash
|
31
|
+
def to_h
|
32
|
+
{ text: @text, attachments: @attachments.map(&:to_h) }
|
50
33
|
end
|
51
34
|
|
52
35
|
private
|
53
36
|
|
54
|
-
def process_attachments_hash(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) }
|
60
|
-
end
|
61
|
-
|
62
37
|
def process_attachments_array_or_string(attachments)
|
63
|
-
|
64
|
-
|
65
|
-
if RubyLLM::MimeTypes.image?(mime_type)
|
66
|
-
add_image file
|
67
|
-
elsif RubyLLM::MimeTypes.audio?(mime_type)
|
68
|
-
add_audio file
|
69
|
-
else
|
70
|
-
add_pdf file # Default to PDF for unknown types for now
|
71
|
-
end
|
38
|
+
Utils.to_safe_array(attachments).each do |file|
|
39
|
+
add_attachment(file)
|
72
40
|
end
|
73
41
|
end
|
74
42
|
|
75
43
|
def process_attachments(attachments)
|
76
44
|
if attachments.is_a?(Hash)
|
77
|
-
|
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))
|
78
47
|
else
|
79
48
|
process_attachments_array_or_string attachments
|
80
49
|
end
|
data/lib/ruby_llm/error.rb
CHANGED
@@ -24,6 +24,7 @@ module RubyLLM
|
|
24
24
|
class InvalidRoleError < StandardError; end
|
25
25
|
class ModelNotFoundError < StandardError; end
|
26
26
|
class UnsupportedFunctionsError < StandardError; end
|
27
|
+
class UnsupportedAttachmentError < StandardError; end
|
27
28
|
|
28
29
|
# Error classes for different HTTP status codes
|
29
30
|
class BadRequestError < Error; end
|
data/lib/ruby_llm/image.rb
CHANGED
@@ -22,10 +22,9 @@ module RubyLLM
|
|
22
22
|
# Returns the raw binary image data regardless of source
|
23
23
|
def to_blob
|
24
24
|
if base64?
|
25
|
-
Base64.decode64
|
25
|
+
Base64.decode64 @data
|
26
26
|
else
|
27
|
-
|
28
|
-
response = Faraday.get(@url)
|
27
|
+
response = Connection.basic.get @url
|
29
28
|
response.body
|
30
29
|
end
|
31
30
|
end
|
data/lib/ruby_llm/message.rb
CHANGED
@@ -10,8 +10,8 @@ module RubyLLM
|
|
10
10
|
attr_reader :role, :tool_calls, :tool_call_id, :input_tokens, :output_tokens, :model_id
|
11
11
|
|
12
12
|
def initialize(options = {})
|
13
|
-
@role = options
|
14
|
-
@content = normalize_content(options
|
13
|
+
@role = options.fetch(:role).to_sym
|
14
|
+
@content = normalize_content(options.fetch(:content))
|
15
15
|
@tool_calls = options[:tool_calls]
|
16
16
|
@input_tokens = options[:input_tokens]
|
17
17
|
@output_tokens = options[:output_tokens]
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'marcel'
|
4
|
+
|
5
|
+
module RubyLLM
|
6
|
+
# MimeTypes module provides methods to handle MIME types using Marcel gem
|
7
|
+
module MimeType
|
8
|
+
module_function
|
9
|
+
|
10
|
+
def for(...)
|
11
|
+
Marcel::MimeType.for(...)
|
12
|
+
end
|
13
|
+
|
14
|
+
def image?(type)
|
15
|
+
type.start_with?('image/')
|
16
|
+
end
|
17
|
+
|
18
|
+
def audio?(type)
|
19
|
+
type.start_with?('audio/')
|
20
|
+
end
|
21
|
+
|
22
|
+
def pdf?(type)
|
23
|
+
type == 'application/pdf'
|
24
|
+
end
|
25
|
+
|
26
|
+
def text?(type)
|
27
|
+
type.start_with?('text/') ||
|
28
|
+
TEXT_SUFFIXES.any? { |suffix| type.end_with?(suffix) } ||
|
29
|
+
NON_TEXT_PREFIX_TEXT_MIME_TYPES.include?(type)
|
30
|
+
end
|
31
|
+
|
32
|
+
# MIME types that have a text/ prefix but need to be handled differently
|
33
|
+
TEXT_SUFFIXES = ['+json', '+xml', '+html', '+yaml', '+csv', '+plain', '+javascript', '+svg'].freeze
|
34
|
+
|
35
|
+
# MIME types that don't have a text/ prefix but should be treated as text
|
36
|
+
NON_TEXT_PREFIX_TEXT_MIME_TYPES = [
|
37
|
+
'application/json', # Base type, even if specific ones end with +json
|
38
|
+
'application/xml', # Base type, even if specific ones end with +xml
|
39
|
+
'application/javascript',
|
40
|
+
'application/ecmascript',
|
41
|
+
'application/rtf',
|
42
|
+
'application/sql',
|
43
|
+
'application/x-sh',
|
44
|
+
'application/x-csh',
|
45
|
+
'application/x-httpd-php',
|
46
|
+
'application/sdp',
|
47
|
+
'application/sparql-query',
|
48
|
+
'application/graphql',
|
49
|
+
'application/yang', # Data modeling language, often serialized as XML/JSON but the type itself is distinct
|
50
|
+
'application/mbox', # Mailbox format
|
51
|
+
'application/x-tex',
|
52
|
+
'application/x-latex',
|
53
|
+
'application/x-perl',
|
54
|
+
'application/x-python',
|
55
|
+
'application/x-tcl',
|
56
|
+
'application/pgp-signature', # Often ASCII armored
|
57
|
+
'application/pgp-keys', # Often ASCII armored
|
58
|
+
'application/vnd.coffeescript',
|
59
|
+
'application/vnd.dart',
|
60
|
+
'application/vnd.oai.openapi', # Base for OpenAPI, often with +json or +yaml suffix
|
61
|
+
'application/vnd.zul', # ZK User Interface Language (can be XML-like)
|
62
|
+
'application/x-yaml', # Common non-standard for YAML
|
63
|
+
'application/yaml', # Standard for YAML
|
64
|
+
'application/toml' # TOML configuration files
|
65
|
+
].freeze
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module Model
|
5
|
+
# Information about an AI model's capabilities, pricing, and metadata.
|
6
|
+
# Used by the Models registry to help developers choose the right model
|
7
|
+
# for their needs.
|
8
|
+
#
|
9
|
+
# Example:
|
10
|
+
# model = RubyLLM.models.find('gpt-4')
|
11
|
+
# model.supports_vision? # => true
|
12
|
+
# model.supports_functions? # => true
|
13
|
+
# model.input_price_per_million # => 30.0
|
14
|
+
class Info
|
15
|
+
attr_reader :id, :name, :provider, :family, :created_at, :context_window, :max_output_tokens, :knowledge_cutoff,
|
16
|
+
:modalities, :capabilities, :pricing, :metadata
|
17
|
+
|
18
|
+
def initialize(data)
|
19
|
+
@id = data[:id]
|
20
|
+
@name = data[:name]
|
21
|
+
@provider = data[:provider]
|
22
|
+
@family = data[:family]
|
23
|
+
@created_at = data[:created_at]
|
24
|
+
@context_window = data[:context_window]
|
25
|
+
@max_output_tokens = data[:max_output_tokens]
|
26
|
+
@knowledge_cutoff = data[:knowledge_cutoff]
|
27
|
+
@modalities = Modalities.new(data[:modalities] || {})
|
28
|
+
@capabilities = data[:capabilities] || []
|
29
|
+
@pricing = Pricing.new(data[:pricing] || {})
|
30
|
+
@metadata = data[:metadata] || {}
|
31
|
+
end
|
32
|
+
|
33
|
+
# Capability methods
|
34
|
+
def supports?(capability)
|
35
|
+
capabilities.include?(capability.to_s)
|
36
|
+
end
|
37
|
+
|
38
|
+
%w[function_calling structured_output batch reasoning citations streaming].each do |cap|
|
39
|
+
define_method "#{cap}?" do
|
40
|
+
supports?(cap)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Backward compatibility methods
|
45
|
+
def display_name
|
46
|
+
name
|
47
|
+
end
|
48
|
+
|
49
|
+
def max_tokens
|
50
|
+
max_output_tokens
|
51
|
+
end
|
52
|
+
|
53
|
+
def supports_vision?
|
54
|
+
modalities.input.include?('image')
|
55
|
+
end
|
56
|
+
|
57
|
+
def supports_functions?
|
58
|
+
function_calling?
|
59
|
+
end
|
60
|
+
|
61
|
+
def input_price_per_million
|
62
|
+
pricing.text_tokens.input
|
63
|
+
end
|
64
|
+
|
65
|
+
def output_price_per_million
|
66
|
+
pricing.text_tokens.output
|
67
|
+
end
|
68
|
+
|
69
|
+
def type # rubocop:disable Metrics/PerceivedComplexity
|
70
|
+
if modalities.output.include?('embeddings') && !modalities.output.include?('text')
|
71
|
+
'embedding'
|
72
|
+
elsif modalities.output.include?('image') && !modalities.output.include?('text')
|
73
|
+
'image'
|
74
|
+
elsif modalities.output.include?('audio') && !modalities.output.include?('text')
|
75
|
+
'audio'
|
76
|
+
elsif modalities.output.include?('moderation')
|
77
|
+
'moderation'
|
78
|
+
else
|
79
|
+
'chat'
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_h
|
84
|
+
{
|
85
|
+
id: id,
|
86
|
+
name: name,
|
87
|
+
provider: provider,
|
88
|
+
family: family,
|
89
|
+
created_at: created_at,
|
90
|
+
context_window: context_window,
|
91
|
+
max_output_tokens: max_output_tokens,
|
92
|
+
knowledge_cutoff: knowledge_cutoff,
|
93
|
+
modalities: modalities.to_h,
|
94
|
+
capabilities: capabilities,
|
95
|
+
pricing: pricing.to_h,
|
96
|
+
metadata: metadata
|
97
|
+
}
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module Model
|
5
|
+
# Holds and manages input and output modalities for a language model
|
6
|
+
class Modalities
|
7
|
+
attr_reader :input, :output
|
8
|
+
|
9
|
+
def initialize(data)
|
10
|
+
@input = Array(data[:input]).map(&:to_s)
|
11
|
+
@output = Array(data[:output]).map(&:to_s)
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_h
|
15
|
+
{
|
16
|
+
input: input,
|
17
|
+
output: output
|
18
|
+
}
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module Model
|
5
|
+
# A collection that manages and provides access to different categories of pricing information
|
6
|
+
# (text tokens, images, audio tokens, embeddings)
|
7
|
+
class Pricing
|
8
|
+
def initialize(data)
|
9
|
+
@data = {}
|
10
|
+
|
11
|
+
# Initialize pricing categories
|
12
|
+
%i[text_tokens images audio_tokens embeddings].each do |category|
|
13
|
+
@data[category] = PricingCategory.new(data[category]) if data[category] && !empty_pricing?(data[category])
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def method_missing(method, *args)
|
18
|
+
if respond_to_missing?(method)
|
19
|
+
@data[method.to_sym] || PricingCategory.new
|
20
|
+
else
|
21
|
+
super
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def respond_to_missing?(method, include_private = false)
|
26
|
+
%i[text_tokens images audio_tokens embeddings].include?(method.to_sym) || super
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_h
|
30
|
+
@data.transform_values(&:to_h)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def empty_pricing?(data)
|
36
|
+
# Check if all pricing values in this category are zero or nil
|
37
|
+
return true unless data
|
38
|
+
|
39
|
+
%i[standard batch].each do |tier|
|
40
|
+
next unless data[tier]
|
41
|
+
|
42
|
+
data[tier].each_value do |value|
|
43
|
+
return false if value && value != 0.0
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
true
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|