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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -9
  3. data/lib/ruby_llm/active_record/acts_as.rb +67 -148
  4. data/lib/ruby_llm/aliases.json +178 -42
  5. data/lib/ruby_llm/attachment.rb +164 -0
  6. data/lib/ruby_llm/chat.rb +12 -4
  7. data/lib/ruby_llm/configuration.rb +6 -1
  8. data/lib/ruby_llm/connection.rb +28 -2
  9. data/lib/ruby_llm/content.rb +9 -40
  10. data/lib/ruby_llm/error.rb +1 -0
  11. data/lib/ruby_llm/image.rb +2 -3
  12. data/lib/ruby_llm/message.rb +2 -2
  13. data/lib/ruby_llm/mime_type.rb +67 -0
  14. data/lib/ruby_llm/model/info.rb +101 -0
  15. data/lib/ruby_llm/model/modalities.rb +22 -0
  16. data/lib/ruby_llm/model/pricing.rb +51 -0
  17. data/lib/ruby_llm/model/pricing_category.rb +48 -0
  18. data/lib/ruby_llm/model/pricing_tier.rb +34 -0
  19. data/lib/ruby_llm/model.rb +7 -0
  20. data/lib/ruby_llm/models.json +2646 -2201
  21. data/lib/ruby_llm/models.rb +20 -20
  22. data/lib/ruby_llm/provider.rb +1 -1
  23. data/lib/ruby_llm/providers/anthropic/media.rb +14 -3
  24. data/lib/ruby_llm/providers/anthropic/models.rb +1 -1
  25. data/lib/ruby_llm/providers/anthropic/tools.rb +5 -4
  26. data/lib/ruby_llm/providers/bedrock/media.rb +7 -4
  27. data/lib/ruby_llm/providers/bedrock/models.rb +2 -2
  28. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +3 -3
  29. data/lib/ruby_llm/providers/gemini/images.rb +3 -2
  30. data/lib/ruby_llm/providers/gemini/media.rb +12 -24
  31. data/lib/ruby_llm/providers/gemini/models.rb +1 -1
  32. data/lib/ruby_llm/providers/ollama/media.rb +8 -4
  33. data/lib/ruby_llm/providers/openai/capabilities.rb +5 -2
  34. data/lib/ruby_llm/providers/openai/chat.rb +12 -8
  35. data/lib/ruby_llm/providers/openai/images.rb +3 -2
  36. data/lib/ruby_llm/providers/openai/media.rb +18 -8
  37. data/lib/ruby_llm/providers/openai/models.rb +1 -1
  38. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  39. data/lib/ruby_llm/streaming.rb +46 -11
  40. data/lib/ruby_llm/tool.rb +8 -8
  41. data/lib/ruby_llm/utils.rb +14 -9
  42. data/lib/ruby_llm/version.rb +1 -1
  43. data/lib/ruby_llm.rb +1 -1
  44. data/lib/tasks/aliases.rake +235 -0
  45. data/lib/tasks/models_docs.rake +13 -7
  46. data/lib/tasks/release.rake +32 -0
  47. metadata +40 -25
  48. data/lib/ruby_llm/attachments/audio.rb +0 -12
  49. data/lib/ruby_llm/attachments/image.rb +0 -9
  50. data/lib/ruby_llm/attachments/pdf.rb +0 -9
  51. data/lib/ruby_llm/attachments.rb +0 -78
  52. data/lib/ruby_llm/mime_types.rb +0 -713
  53. data/lib/ruby_llm/model_info.rb +0 -237
  54. 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
- config = context&.config || RubyLLM.config
22
- model_id = model || config.default_model
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! { |msg| msg.role == :system } if replace
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
@@ -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, RubyLLM.logger, bodies: true, response: true,
44
- errors: true, headers: false, log_level: :debug do |logger|
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,
@@ -2,8 +2,7 @@
2
2
 
3
3
  module RubyLLM
4
4
  # 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.
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 add_image(source)
19
- @attachments << Attachments::Image.new(source)
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 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
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
- Array(attachments).each do |file|
64
- mime_type = RubyLLM::MimeTypes.detect_from_path(file.to_s)
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
- 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))
78
47
  else
79
48
  process_attachments_array_or_string attachments
80
49
  end
@@ -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
@@ -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(@data)
25
+ Base64.decode64 @data
26
26
  else
27
- # Use Faraday instead of URI.open for better security
28
- response = Faraday.get(@url)
27
+ response = Connection.basic.get @url
29
28
  response.body
30
29
  end
31
30
  end
@@ -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[:role].to_sym
14
- @content = normalize_content(options[:content])
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