ruby_llm 1.3.0rc1 → 1.3.0

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ruby_llm/active_record/acts_as.rb +66 -148
  3. data/lib/ruby_llm/aliases.json +170 -42
  4. data/lib/ruby_llm/attachment.rb +164 -0
  5. data/lib/ruby_llm/chat.rb +12 -4
  6. data/lib/ruby_llm/configuration.rb +5 -1
  7. data/lib/ruby_llm/connection.rb +28 -2
  8. data/lib/ruby_llm/content.rb +9 -40
  9. data/lib/ruby_llm/error.rb +1 -0
  10. data/lib/ruby_llm/image.rb +2 -3
  11. data/lib/ruby_llm/message.rb +2 -2
  12. data/lib/ruby_llm/mime_type.rb +67 -0
  13. data/lib/ruby_llm/model/info.rb +101 -0
  14. data/lib/ruby_llm/model/modalities.rb +22 -0
  15. data/lib/ruby_llm/model/pricing.rb +51 -0
  16. data/lib/ruby_llm/model/pricing_category.rb +48 -0
  17. data/lib/ruby_llm/model/pricing_tier.rb +34 -0
  18. data/lib/ruby_llm/model.rb +7 -0
  19. data/lib/ruby_llm/models.json +2220 -1915
  20. data/lib/ruby_llm/models.rb +20 -20
  21. data/lib/ruby_llm/provider.rb +1 -1
  22. data/lib/ruby_llm/providers/anthropic/media.rb +14 -3
  23. data/lib/ruby_llm/providers/anthropic/models.rb +1 -1
  24. data/lib/ruby_llm/providers/bedrock/media.rb +7 -4
  25. data/lib/ruby_llm/providers/bedrock/models.rb +2 -2
  26. data/lib/ruby_llm/providers/gemini/images.rb +3 -2
  27. data/lib/ruby_llm/providers/gemini/media.rb +12 -24
  28. data/lib/ruby_llm/providers/gemini/models.rb +1 -1
  29. data/lib/ruby_llm/providers/ollama/media.rb +8 -4
  30. data/lib/ruby_llm/providers/openai/capabilities.rb +1 -1
  31. data/lib/ruby_llm/providers/openai/images.rb +3 -2
  32. data/lib/ruby_llm/providers/openai/media.rb +18 -8
  33. data/lib/ruby_llm/providers/openai/models.rb +1 -1
  34. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  35. data/lib/ruby_llm/streaming.rb +46 -11
  36. data/lib/ruby_llm/utils.rb +14 -9
  37. data/lib/ruby_llm/version.rb +1 -1
  38. data/lib/tasks/aliases.rake +235 -0
  39. data/lib/tasks/release.rake +32 -0
  40. metadata +40 -25
  41. data/lib/ruby_llm/attachments/audio.rb +0 -12
  42. data/lib/ruby_llm/attachments/image.rb +0 -9
  43. data/lib/ruby_llm/attachments/pdf.rb +0 -9
  44. data/lib/ruby_llm/attachments.rb +0 -78
  45. data/lib/ruby_llm/mime_types.rb +0 -713
  46. data/lib/ruby_llm/model_info.rb +0 -237
  47. data/lib/tasks/{models.rake → models_update.rake} +13 -13
@@ -46,22 +46,25 @@ module RubyLLM
46
46
  end
47
47
  end
48
48
 
49
- def resolve(model_id, provider: nil, assume_exists: false)
49
+ def resolve(model_id, provider: nil, assume_exists: false) # rubocop:disable Metrics/PerceivedComplexity
50
50
  assume_exists = true if provider && Provider.providers[provider.to_sym].local?
51
51
 
52
52
  if assume_exists
53
53
  raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
54
54
 
55
55
  provider = Provider.providers[provider.to_sym] || raise(Error, "Unknown provider: #{provider.to_sym}")
56
- model = Struct.new(:id, :provider, :capabilities, :modalities, :supports_vision?, :supports_functions?)
57
- .new(model_id,
58
- provider,
59
- %w[function_calling streaming],
60
- RubyLLM::Modalities.new({ input: %w[text image], output: %w[text] }),
61
- true,
62
- true)
63
- RubyLLM.logger.warn "Assuming model '#{model_id}' exists for provider '#{provider}'. " \
64
- 'Capabilities may not be accurately reflected.'
56
+ model = Model::Info.new(
57
+ id: model_id,
58
+ name: model_id.gsub('-', ' ').capitalize,
59
+ provider: provider.slug,
60
+ capabilities: %w[function_calling streaming],
61
+ modalities: { input: %w[text image], output: %w[text] },
62
+ metadata: { warning: 'Assuming model exists, capabilities may not be accurate' }
63
+ )
64
+ if RubyLLM.config.log_assume_model_exists
65
+ RubyLLM.logger.warn "Assuming model '#{model_id}' exists for provider '#{provider}'. " \
66
+ 'Capabilities may not be accurately reflected.'
67
+ end
65
68
  else
66
69
  model = Models.find model_id, provider
67
70
  provider = Provider.providers[model.provider.to_sym] || raise(Error, "Unknown provider: #{model.provider}")
@@ -84,15 +87,12 @@ module RubyLLM
84
87
  def fetch_from_parsera
85
88
  RubyLLM.logger.info 'Fetching models from Parsera API...'
86
89
 
87
- connection = Faraday.new('https://api.parsera.org') do |f|
90
+ connection = Connection.basic do |f|
88
91
  f.request :json
89
- f.response :json
90
- f.response :raise_error
91
- f.adapter Faraday.default_adapter
92
+ f.response :json, parser_options: { symbolize_names: true }
92
93
  end
93
-
94
- response = connection.get('/v1/llm-specs')
95
- response.body.map { |data| ModelInfo.new(Utils.deep_symbolize_keys(data)) }
94
+ response = connection.get 'https://api.parsera.org/v1/llm-specs'
95
+ response.body.map { |data| Model::Info.new(data) }
96
96
  end
97
97
 
98
98
  def merge_models(provider_models, parsera_models)
@@ -130,10 +130,10 @@ module RubyLLM
130
130
  end
131
131
 
132
132
  def add_provider_metadata(parsera_model, provider_model)
133
- # Create a new ModelInfo with parsera data but include provider metadata
133
+ # Create a new Model::Info with parsera data but include provider metadata
134
134
  data = parsera_model.to_h
135
135
  data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
136
- ModelInfo.new(data)
136
+ Model::Info.new(data)
137
137
  end
138
138
  end
139
139
 
@@ -145,7 +145,7 @@ module RubyLLM
145
145
  # Load models from the JSON file
146
146
  def load_models
147
147
  data = File.exist?(self.class.models_file) ? File.read(self.class.models_file) : '[]'
148
- JSON.parse(data).map { |model| ModelInfo.new(Utils.deep_symbolize_keys(model)) }
148
+ JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
149
149
  rescue JSON::ParserError
150
150
  []
151
151
  end
@@ -40,7 +40,7 @@ module RubyLLM
40
40
  def paint(prompt, model:, size:, connection:)
41
41
  payload = render_image_payload(prompt, model:, size:)
42
42
  response = connection.post images_url, payload
43
- parse_image_response response
43
+ parse_image_response(response, model:)
44
44
  end
45
45
 
46
46
  def configured?(config = nil)
@@ -14,11 +14,15 @@ module RubyLLM
14
14
  parts << format_text(content.text) if content.text
15
15
 
16
16
  content.attachments.each do |attachment|
17
- case attachment
18
- when Attachments::Image
17
+ case attachment.type
18
+ when :image
19
19
  parts << format_image(attachment)
20
- when Attachments::PDF
20
+ when :pdf
21
21
  parts << format_pdf(attachment)
22
+ when :text
23
+ parts << format_text_file(attachment)
24
+ else
25
+ raise UnsupportedAttachmentError, attachment.mime_type
22
26
  end
23
27
  end
24
28
 
@@ -73,6 +77,13 @@ module RubyLLM
73
77
  }
74
78
  end
75
79
  end
80
+
81
+ def format_text_file(text_file)
82
+ {
83
+ type: 'text',
84
+ text: Utils.format_text_file_for_llm(text_file)
85
+ }
86
+ end
76
87
  end
77
88
  end
78
89
  end
@@ -15,7 +15,7 @@ module RubyLLM
15
15
  Array(response.body['data']).map do |model_data|
16
16
  model_id = model_data['id']
17
17
 
18
- ModelInfo.new(
18
+ Model::Info.new(
19
19
  id: model_id,
20
20
  name: model_data['display_name'],
21
21
  provider: slug,
@@ -4,6 +4,7 @@ module RubyLLM
4
4
  module Providers
5
5
  module Bedrock
6
6
  # Media handling methods for the Bedrock API integration
7
+ # NOTE: Bedrock does not support url attachments
7
8
  module Media
8
9
  extend Anthropic::Media
9
10
 
@@ -16,13 +17,15 @@ module RubyLLM
16
17
  parts << Anthropic::Media.format_text(content.text) if content.text
17
18
 
18
19
  content.attachments.each do |attachment|
19
- case attachment
20
- when Attachments::Image
20
+ case attachment.type
21
+ when :image
21
22
  parts << format_image(attachment)
22
- when Attachments::PDF
23
+ when :pdf
23
24
  parts << format_pdf(attachment)
25
+ when :text
26
+ parts << Anthropic::Media.format_text_file(attachment)
24
27
  else
25
- raise "Unsupported attachment type: #{attachment.class}"
28
+ raise UnsupportedAttachmentError, attachment.type
26
29
  end
27
30
  end
28
31
 
@@ -30,7 +30,7 @@ module RubyLLM
30
30
  models.select { |m| m['modelId'].include?('claude') }.map do |model_data|
31
31
  model_id = model_data['modelId']
32
32
 
33
- ModelInfo.new(
33
+ Model::Info.new(
34
34
  id: model_id_with_region(model_id, model_data),
35
35
  name: model_data['modelName'] || capabilities.format_display_name(model_id),
36
36
  provider: slug,
@@ -56,7 +56,7 @@ module RubyLLM
56
56
  def create_model_info(model_data, slug, _capabilities)
57
57
  model_id = model_data['modelId']
58
58
 
59
- ModelInfo.new(
59
+ Model::Info.new(
60
60
  id: model_id_with_region(model_id, model_data),
61
61
  name: model_data['modelName'] || model_id,
62
62
  provider: slug,
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  }
25
25
  end
26
26
 
27
- def parse_image_response(response)
27
+ def parse_image_response(response, model:)
28
28
  data = response.body
29
29
  image_data = data['predictions']&.first
30
30
 
@@ -38,7 +38,8 @@ module RubyLLM
38
38
 
39
39
  Image.new(
40
40
  data: base64_data,
41
- mime_type: mime_type
41
+ mime_type: mime_type,
42
+ model_id: model
42
43
  )
43
44
  end
44
45
  end
@@ -14,43 +14,31 @@ module RubyLLM
14
14
  parts << format_text(content.text) if content.text
15
15
 
16
16
  content.attachments.each do |attachment|
17
- case attachment
18
- when Attachments::Image
19
- parts << format_image(attachment)
20
- when Attachments::PDF
21
- parts << format_pdf(attachment)
22
- when Attachments::Audio
23
- parts << format_audio(attachment)
17
+ case attachment.type
18
+ when :text
19
+ parts << format_text_file(attachment)
20
+ when :unknown
21
+ raise UnsupportedAttachmentError, attachment.mime_type
22
+ else
23
+ parts << format_attachment(attachment)
24
24
  end
25
25
  end
26
26
 
27
27
  parts
28
28
  end
29
29
 
30
- def format_image(image)
30
+ def format_attachment(attachment)
31
31
  {
32
32
  inline_data: {
33
- mime_type: image.mime_type,
34
- data: image.encoded
33
+ mime_type: attachment.mime_type,
34
+ data: attachment.encoded
35
35
  }
36
36
  }
37
37
  end
38
38
 
39
- def format_pdf(pdf)
39
+ def format_text_file(text_file)
40
40
  {
41
- inline_data: {
42
- mime_type: pdf.mime_type,
43
- data: pdf.encoded
44
- }
45
- }
46
- end
47
-
48
- def format_audio(audio)
49
- {
50
- inline_data: {
51
- mime_type: audio.mime_type,
52
- data: audio.encoded
53
- }
41
+ text: Utils.format_text_file_for_llm(text_file)
54
42
  }
55
43
  end
56
44
 
@@ -16,7 +16,7 @@ module RubyLLM
16
16
  # Extract model ID without "models/" prefix
17
17
  model_id = model_data['name'].gsub('models/', '')
18
18
 
19
- ModelInfo.new(
19
+ Model::Info.new(
20
20
  id: model_id,
21
21
  name: model_data['displayName'],
22
22
  provider: slug,
@@ -16,13 +16,17 @@ module RubyLLM
16
16
  parts << format_text(content.text) if content.text
17
17
 
18
18
  content.attachments.each do |attachment|
19
- case attachment
20
- when Attachments::Image
19
+ case attachment.type
20
+ when :image
21
21
  parts << Ollama::Media.format_image(attachment)
22
- when Attachments::PDF
22
+ when :pdf
23
23
  parts << format_pdf(attachment)
24
- when Attachments::Audio
24
+ when :audio
25
25
  parts << format_audio(attachment)
26
+ when :text
27
+ parts << format_text_file(attachment)
28
+ else
29
+ raise UnsupportedAttachmentError, attachment.mime_type
26
30
  end
27
31
  end
28
32
 
@@ -263,7 +263,7 @@ module RubyLLM
263
263
  # Advanced capabilities
264
264
  capabilities << 'reasoning' if model_id.match?(/o1/)
265
265
 
266
- if model_id.match?(/gpt-4-turbo|gpt-4o|claude/)
266
+ if model_id.match?(/gpt-4-turbo|gpt-4o/)
267
267
  capabilities << 'image_generation' if model_id.match?(/vision/)
268
268
  capabilities << 'speech_generation' if model_id.match?(/audio/)
269
269
  capabilities << 'transcription' if model_id.match?(/audio/)
@@ -20,7 +20,7 @@ module RubyLLM
20
20
  }
21
21
  end
22
22
 
23
- def parse_image_response(response)
23
+ def parse_image_response(response, model:)
24
24
  data = response.body
25
25
  image_data = data['data'].first
26
26
 
@@ -28,7 +28,8 @@ module RubyLLM
28
28
  url: image_data['url'],
29
29
  mime_type: 'image/png', # DALL-E typically returns PNGs
30
30
  revised_prompt: image_data['revised_prompt'],
31
- model_id: data['model']
31
+ model_id: model,
32
+ data: image_data['b64_json']
32
33
  )
33
34
  end
34
35
  end
@@ -14,13 +14,17 @@ module RubyLLM
14
14
  parts << format_text(content.text) if content.text
15
15
 
16
16
  content.attachments.each do |attachment|
17
- case attachment
18
- when Attachments::Image
17
+ case attachment.type
18
+ when :image
19
19
  parts << format_image(attachment)
20
- when Attachments::PDF
20
+ when :pdf
21
21
  parts << format_pdf(attachment)
22
- when Attachments::Audio
22
+ when :audio
23
23
  parts << format_audio(attachment)
24
+ when :text
25
+ parts << format_text_file(attachment)
26
+ else
27
+ raise UnsupportedAttachmentError, attachment.type
24
28
  end
25
29
  end
26
30
 
@@ -31,8 +35,7 @@ module RubyLLM
31
35
  {
32
36
  type: 'image_url',
33
37
  image_url: {
34
- url: image.url? ? image.source : "data:#{image.mime_type};base64,#{image.encoded}",
35
- detail: 'auto'
38
+ url: image.url? ? image.source : "data:#{image.mime_type};base64,#{image.encoded}"
36
39
  }
37
40
  }
38
41
  end
@@ -41,18 +44,25 @@ module RubyLLM
41
44
  {
42
45
  type: 'file',
43
46
  file: {
44
- filename: File.basename(pdf.source),
47
+ filename: pdf.filename,
45
48
  file_data: "data:#{pdf.mime_type};base64,#{pdf.encoded}"
46
49
  }
47
50
  }
48
51
  end
49
52
 
53
+ def format_text_file(text_file)
54
+ {
55
+ type: 'text',
56
+ text: Utils.format_text_file_for_llm(text_file)
57
+ }
58
+ end
59
+
50
60
  def format_audio(audio)
51
61
  {
52
62
  type: 'input_audio',
53
63
  input_audio: {
54
64
  data: audio.encoded,
55
- format: audio.format
65
+ format: audio.mime_type.split('/').last
56
66
  }
57
67
  }
58
68
  end
@@ -15,7 +15,7 @@ module RubyLLM
15
15
  Array(response.body['data']).map do |model_data|
16
16
  model_id = model_data['id']
17
17
 
18
- ModelInfo.new(
18
+ Model::Info.new(
19
19
  id: model_id,
20
20
  name: capabilities.format_display_name(model_id),
21
21
  provider: slug,
@@ -37,7 +37,7 @@ module RubyLLM
37
37
  # Convert OpenRouter's supported parameters to our capability format
38
38
  capabilities = supported_parameters_to_capabilities(model_data['supported_parameters'])
39
39
 
40
- ModelInfo.new(
40
+ Model::Info.new(
41
41
  id: model_data['id'],
42
42
  name: model_data['name'],
43
43
  provider: slug,
@@ -12,9 +12,18 @@ module RubyLLM
12
12
  accumulator = StreamAccumulator.new
13
13
 
14
14
  connection.post stream_url, payload do |req|
15
- req.options.on_data = handle_stream do |chunk|
16
- accumulator.add chunk
17
- block.call chunk
15
+ if req.options.respond_to?(:on_data)
16
+ # Handle Faraday 2.x streaming with on_data method
17
+ req.options.on_data = handle_stream do |chunk|
18
+ accumulator.add chunk
19
+ block.call chunk
20
+ end
21
+ else
22
+ # Handle Faraday 1.x streaming with :on_data key
23
+ req.options[:on_data] = handle_stream do |chunk|
24
+ accumulator.add chunk
25
+ block.call chunk
26
+ end
18
27
  end
19
28
  end
20
29
 
@@ -29,19 +38,45 @@ module RubyLLM
29
38
 
30
39
  private
31
40
 
32
- def to_json_stream(&block)
41
+ def to_json_stream(&)
33
42
  buffer = String.new
34
43
  parser = EventStreamParser::Parser.new
35
44
 
36
- proc do |chunk, _bytes, env|
37
- RubyLLM.logger.debug "Received chunk: #{chunk}"
45
+ create_stream_processor(parser, buffer, &)
46
+ end
38
47
 
39
- if error_chunk?(chunk)
40
- handle_error_chunk(chunk, env)
41
- elsif env&.status != 200
42
- handle_failed_response(chunk, buffer, env)
48
+ def create_stream_processor(parser, buffer, &)
49
+ if Faraday::VERSION.start_with?('1')
50
+ # Faraday 1.x: on_data receives (chunk, size)
51
+ legacy_stream_processor(parser, &)
52
+ else
53
+ # Faraday 2.x: on_data receives (chunk, bytes, env)
54
+ stream_processor(parser, buffer, &)
55
+ end
56
+ end
57
+
58
+ def process_stream_chunk(chunk, parser, _env, &)
59
+ RubyLLM.logger.debug "Received chunk: #{chunk}"
60
+
61
+ if error_chunk?(chunk)
62
+ handle_error_chunk(chunk, nil)
63
+ else
64
+ yield handle_sse(chunk, parser, nil, &)
65
+ end
66
+ end
67
+
68
+ def legacy_stream_processor(parser, &block)
69
+ proc do |chunk, _size|
70
+ process_stream_chunk(chunk, parser, nil, &block)
71
+ end
72
+ end
73
+
74
+ def stream_processor(parser, buffer, &block)
75
+ proc do |chunk, _bytes, env|
76
+ if env&.status == 200
77
+ process_stream_chunk(chunk, parser, env, &block)
43
78
  else
44
- yield handle_sse(chunk, parser, env, &block)
79
+ handle_failed_response(chunk, buffer, env)
45
80
  end
46
81
  end
47
82
  end
@@ -5,17 +5,22 @@ module RubyLLM
5
5
  module Utils
6
6
  module_function
7
7
 
8
- def deep_symbolize_keys(value)
9
- case value
10
- when Hash
11
- value.each_with_object({}) do |(k, v), new_hash|
12
- new_key = k.is_a?(String) ? k.to_sym : k
13
- new_hash[new_key] = deep_symbolize_keys(v)
14
- end
8
+ def format_text_file_for_llm(text_file)
9
+ "<file name='#{text_file.filename}' mime_type='#{text_file.mime_type}'>#{text_file.content}</file>"
10
+ end
11
+
12
+ def hash_get(hash, key)
13
+ hash[key.to_sym] || hash[key.to_s]
14
+ end
15
+
16
+ def to_safe_array(item)
17
+ case item
15
18
  when Array
16
- value.map { |v| deep_symbolize_keys(v) }
19
+ item
20
+ when Hash
21
+ [item]
17
22
  else
18
- value
23
+ Array(item)
19
24
  end
20
25
  end
21
26
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- VERSION = '1.3.0rc1'
4
+ VERSION = '1.3.0'
5
5
  end