ruby_llm 1.2.0 → 1.3.0rc1

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -133
  3. data/lib/ruby_llm/active_record/acts_as.rb +212 -33
  4. data/lib/ruby_llm/aliases.json +48 -6
  5. data/lib/ruby_llm/attachments/audio.rb +12 -0
  6. data/lib/ruby_llm/attachments/image.rb +9 -0
  7. data/lib/ruby_llm/attachments/pdf.rb +9 -0
  8. data/lib/ruby_llm/attachments.rb +78 -0
  9. data/lib/ruby_llm/chat.rb +22 -19
  10. data/lib/ruby_llm/configuration.rb +30 -1
  11. data/lib/ruby_llm/connection.rb +95 -0
  12. data/lib/ruby_llm/content.rb +51 -72
  13. data/lib/ruby_llm/context.rb +30 -0
  14. data/lib/ruby_llm/embedding.rb +13 -5
  15. data/lib/ruby_llm/error.rb +1 -1
  16. data/lib/ruby_llm/image.rb +13 -5
  17. data/lib/ruby_llm/message.rb +12 -4
  18. data/lib/ruby_llm/mime_types.rb +713 -0
  19. data/lib/ruby_llm/model_info.rb +208 -27
  20. data/lib/ruby_llm/models.json +25766 -2154
  21. data/lib/ruby_llm/models.rb +95 -14
  22. data/lib/ruby_llm/provider.rb +48 -90
  23. data/lib/ruby_llm/providers/anthropic/capabilities.rb +76 -13
  24. data/lib/ruby_llm/providers/anthropic/chat.rb +7 -14
  25. data/lib/ruby_llm/providers/anthropic/media.rb +44 -34
  26. data/lib/ruby_llm/providers/anthropic/models.rb +15 -15
  27. data/lib/ruby_llm/providers/anthropic/tools.rb +2 -2
  28. data/lib/ruby_llm/providers/anthropic.rb +3 -3
  29. data/lib/ruby_llm/providers/bedrock/capabilities.rb +61 -2
  30. data/lib/ruby_llm/providers/bedrock/chat.rb +30 -73
  31. data/lib/ruby_llm/providers/bedrock/media.rb +56 -0
  32. data/lib/ruby_llm/providers/bedrock/models.rb +50 -58
  33. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +16 -0
  34. data/lib/ruby_llm/providers/bedrock.rb +14 -25
  35. data/lib/ruby_llm/providers/deepseek/capabilities.rb +35 -2
  36. data/lib/ruby_llm/providers/deepseek.rb +3 -3
  37. data/lib/ruby_llm/providers/gemini/capabilities.rb +84 -3
  38. data/lib/ruby_llm/providers/gemini/chat.rb +8 -37
  39. data/lib/ruby_llm/providers/gemini/embeddings.rb +18 -34
  40. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  41. data/lib/ruby_llm/providers/gemini/media.rb +39 -110
  42. data/lib/ruby_llm/providers/gemini/models.rb +16 -22
  43. data/lib/ruby_llm/providers/gemini/tools.rb +1 -1
  44. data/lib/ruby_llm/providers/gemini.rb +3 -3
  45. data/lib/ruby_llm/providers/ollama/chat.rb +28 -0
  46. data/lib/ruby_llm/providers/ollama/media.rb +44 -0
  47. data/lib/ruby_llm/providers/ollama.rb +34 -0
  48. data/lib/ruby_llm/providers/openai/capabilities.rb +78 -3
  49. data/lib/ruby_llm/providers/openai/chat.rb +6 -4
  50. data/lib/ruby_llm/providers/openai/embeddings.rb +8 -12
  51. data/lib/ruby_llm/providers/openai/media.rb +38 -21
  52. data/lib/ruby_llm/providers/openai/models.rb +16 -17
  53. data/lib/ruby_llm/providers/openai/tools.rb +9 -5
  54. data/lib/ruby_llm/providers/openai.rb +7 -5
  55. data/lib/ruby_llm/providers/openrouter/models.rb +88 -0
  56. data/lib/ruby_llm/providers/openrouter.rb +31 -0
  57. data/lib/ruby_llm/stream_accumulator.rb +4 -4
  58. data/lib/ruby_llm/streaming.rb +3 -3
  59. data/lib/ruby_llm/utils.rb +22 -0
  60. data/lib/ruby_llm/version.rb +1 -1
  61. data/lib/ruby_llm.rb +15 -5
  62. data/lib/tasks/models.rake +69 -33
  63. data/lib/tasks/models_docs.rake +164 -121
  64. data/lib/tasks/vcr.rake +4 -2
  65. metadata +23 -14
  66. data/lib/tasks/browser_helper.rb +0 -97
  67. data/lib/tasks/capability_generator.rb +0 -123
  68. data/lib/tasks/capability_scraper.rb +0 -224
  69. data/lib/tasks/cli_helper.rb +0 -22
  70. data/lib/tasks/code_validator.rb +0 -29
  71. data/lib/tasks/model_updater.rb +0 -66
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "claude-3-5-sonnet": {
3
3
  "anthropic": "claude-3-5-sonnet-20241022",
4
- "bedrock": "anthropic.claude-3-5-sonnet-20241022-v2:0"
4
+ "bedrock": "anthropic.claude-3-5-sonnet-20241022-v2:0",
5
+ "openrouter": "anthropic/claude-3.5-sonnet"
5
6
  },
6
7
  "claude-3-5-haiku": {
7
8
  "anthropic": "claude-3-5-haiku-20241022",
8
- "bedrock": "anthropic.claude-3-5-haiku-20241022-v1:0"
9
+ "bedrock": "anthropic.claude-3-5-haiku-20241022-v1:0",
10
+ "openrouter": "anthropic/claude-3.5-haiku-20241022"
9
11
  },
10
12
  "claude-3-7-sonnet": {
11
13
  "anthropic": "claude-3-7-sonnet-20250219",
12
- "bedrock": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"
14
+ "bedrock": "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
15
+ "openrouter": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"
13
16
  },
14
17
  "claude-3-opus": {
15
18
  "anthropic": "claude-3-opus-20240229",
@@ -25,14 +28,53 @@
25
28
  },
26
29
  "claude-3": {
27
30
  "anthropic": "claude-3-sonnet-20240229",
28
- "bedrock": "anthropic.claude-3-sonnet-20240229-v1:0"
31
+ "bedrock": "anthropic.claude-3-sonnet-20240229-v1:0",
32
+ "openrouter": "anthropic/claude-3-sonnet"
29
33
  },
30
34
  "claude-2": {
31
35
  "anthropic": "claude-2.0",
32
- "bedrock": "anthropic.claude-2.0"
36
+ "bedrock": "anthropic.claude-2.0",
37
+ "openrouter": "anthropic/claude-2"
33
38
  },
34
39
  "claude-2-1": {
35
40
  "anthropic": "claude-2.1",
36
- "bedrock": "anthropic.claude-2.1"
41
+ "bedrock": "anthropic.claude-2.1",
42
+ "openrouter": "anthropic/claude-2.1"
43
+ },
44
+ "gpt-4o": {
45
+ "openai": "gpt-4o",
46
+ "openrouter": "openai/gpt-4o"
47
+ },
48
+ "gpt-4o-mini": {
49
+ "openai": "gpt-4o-mini",
50
+ "openrouter": "openai/gpt-4o-mini"
51
+ },
52
+ "gpt-4-turbo": {
53
+ "openai": "gpt-4-turbo",
54
+ "openrouter": "openai/gpt-4-turbo"
55
+ },
56
+ "gemini-1.5-flash": {
57
+ "gemini": "gemini-1.5-flash",
58
+ "openrouter": "google/gemini-flash-1.5"
59
+ },
60
+ "gemini-1.5-flash-8b": {
61
+ "gemini": "gemini-1.5-flash-8b",
62
+ "openrouter": "google/gemini-flash-1.5-8b"
63
+ },
64
+ "gemini-1.5-pro": {
65
+ "gemini": "gemini-1.5-pro",
66
+ "openrouter": "google/gemini-pro-1.5"
67
+ },
68
+ "gemini-2.0-flash": {
69
+ "gemini": "gemini-2.0-flash",
70
+ "openrouter": "google/gemini-2.0-flash-001"
71
+ },
72
+ "o1": {
73
+ "openai": "o1",
74
+ "openrouter": "openai/o1"
75
+ },
76
+ "o3-mini": {
77
+ "openai": "o3-mini",
78
+ "openrouter": "openai/o3-mini"
37
79
  }
38
80
  }
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Attachments
5
+ # Represents an audio attachment
6
+ class Image < Base
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Attachments
5
+ # Represents a PDF attachment
6
+ class PDF < Base
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,78 @@
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, :filename
9
+
10
+ def initialize(source, filename: nil)
11
+ @source = source
12
+ @filename = filename ||
13
+ (@source.respond_to?(:original_filename) && @source.original_filename) ||
14
+ (@source.respond_to?(:path) && File.basename(@source.path)) ||
15
+ (@source.is_a?(String) && File.basename(@source.split('?').first)) || # Basic URL basename
16
+ nil
17
+ end
18
+
19
+ def url?
20
+ @source.is_a?(String) && @source.match?(%r{^https?://})
21
+ end
22
+
23
+ def file_path?
24
+ @source.is_a?(String) && !url?
25
+ end
26
+
27
+ def io_like?
28
+ @source.respond_to?(:read) && !file_path?
29
+ end
30
+
31
+ def content
32
+ return @content if defined?(@content) && !@content.nil?
33
+
34
+ if url?
35
+ fetch_content
36
+ elsif file_path?
37
+ load_content_from_path
38
+ elsif io_like?
39
+ load_content_from_io
40
+ else
41
+ RubyLLM.logger.warn "Attachment source is neither a String nor an IO-like object: #{@source}"
42
+ nil
43
+ end
44
+
45
+ @content
46
+ end
47
+
48
+ def type
49
+ self.class.name.demodulize.downcase
50
+ end
51
+
52
+ def encoded
53
+ Base64.strict_encode64(content)
54
+ end
55
+
56
+ def mime_type
57
+ RubyLLM::MimeTypes.detect_from_path(@filename)
58
+ end
59
+
60
+ private
61
+
62
+ def fetch_content
63
+ RubyLLM.logger.debug("Fetching content from URL: #{@source}")
64
+ response = Faraday.get(@source)
65
+ @content = response.body if response.success?
66
+ end
67
+
68
+ def load_content_from_path
69
+ @content = File.read(File.expand_path(@source))
70
+ end
71
+
72
+ def load_content_from_io
73
+ @source.rewind if source.respond_to? :rewind
74
+ @content = @source.read
75
+ end
76
+ end
77
+ end
78
+ end
data/lib/ruby_llm/chat.rb CHANGED
@@ -8,18 +8,20 @@ module RubyLLM
8
8
  # chat = RubyLLM.chat
9
9
  # chat.ask "What's the best way to learn Ruby?"
10
10
  # chat.ask "Can you elaborate on that?"
11
- class Chat # rubocop:disable Metrics/ClassLength
11
+ class Chat
12
12
  include Enumerable
13
13
 
14
14
  attr_reader :model, :messages, :tools
15
15
 
16
- def initialize(model: nil, provider: nil, assume_model_exists: false) # rubocop:disable Metrics/MethodLength
16
+ def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
17
17
  if assume_model_exists && !provider
18
18
  raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
19
19
  end
20
20
 
21
- model_id = model || RubyLLM.config.default_model
21
+ config = context&.config || RubyLLM.config
22
+ model_id = model || config.default_model
22
23
  with_model(model_id, provider: provider, assume_exists: assume_model_exists)
24
+ @connection = context ? context.connection_for(@provider) : @provider.connection(config)
23
25
  @temperature = 0.7
24
26
  @messages = []
25
27
  @tools = {}
@@ -29,9 +31,9 @@ module RubyLLM
29
31
  }
30
32
  end
31
33
 
32
- def ask(message = nil, with: {}, &block)
34
+ def ask(message = nil, with: nil, &)
33
35
  add_message role: :user, content: Content.new(message, with)
34
- complete(&block)
36
+ complete(&)
35
37
  end
36
38
 
37
39
  alias say ask
@@ -44,7 +46,7 @@ module RubyLLM
44
46
  end
45
47
 
46
48
  def with_tool(tool)
47
- unless @model.supports_functions
49
+ unless @model.supports_functions?
48
50
  raise UnsupportedFunctionsError, "Model #{@model.id} doesn't support function calling"
49
51
  end
50
52
 
@@ -58,18 +60,8 @@ module RubyLLM
58
60
  self
59
61
  end
60
62
 
61
- def with_model(model_id, provider: nil, assume_exists: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
62
- if assume_exists
63
- raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
64
-
65
- @provider = Provider.providers[provider.to_sym] || raise(Error, "Unknown provider: #{provider.to_sym}")
66
- @model = Struct.new(:id, :provider, :supports_functions, :supports_vision).new(model_id, provider, true, true)
67
- RubyLLM.logger.warn "Assuming model '#{model_id}' exists for provider '#{provider}'. " \
68
- 'Capabilities may not be accurately reflected.'
69
- else
70
- @model = Models.find model_id, provider
71
- @provider = Provider.providers[@model.provider.to_sym] || raise(Error, "Unknown provider: #{@model.provider}")
72
- end
63
+ def with_model(model_id, provider: nil, assume_exists: false)
64
+ @model, @provider = Models.resolve(model_id, provider:, assume_exists:)
73
65
  self
74
66
  end
75
67
 
@@ -94,7 +86,14 @@ module RubyLLM
94
86
 
95
87
  def complete(&)
96
88
  @on[:new_message]&.call
97
- response = @provider.complete(messages, tools: @tools, temperature: @temperature, model: @model.id, &)
89
+ response = @provider.complete(
90
+ messages,
91
+ tools: @tools,
92
+ temperature: @temperature,
93
+ model: @model.id,
94
+ connection: @connection,
95
+ &
96
+ )
98
97
  @on[:end_message]&.call(response)
99
98
 
100
99
  add_message response
@@ -111,6 +110,10 @@ module RubyLLM
111
110
  message
112
111
  end
113
112
 
113
+ def reset_messages!
114
+ @messages.clear
115
+ end
116
+
114
117
  private
115
118
 
116
119
  def handle_tool_calls(response, &)
@@ -13,6 +13,8 @@ module RubyLLM
13
13
  # Provider-specific configuration
14
14
  attr_accessor :openai_api_key,
15
15
  :openai_api_base,
16
+ :openai_organization_id,
17
+ :openai_project_id,
16
18
  :anthropic_api_key,
17
19
  :gemini_api_key,
18
20
  :deepseek_api_key,
@@ -20,6 +22,8 @@ module RubyLLM
20
22
  :bedrock_secret_key,
21
23
  :bedrock_region,
22
24
  :bedrock_session_token,
25
+ :openrouter_api_key,
26
+ :ollama_api_base,
23
27
  # Default models
24
28
  :default_model,
25
29
  :default_embedding_model,
@@ -29,7 +33,10 @@ module RubyLLM
29
33
  :max_retries,
30
34
  :retry_interval,
31
35
  :retry_backoff_factor,
32
- :retry_interval_randomness
36
+ :retry_interval_randomness,
37
+ # Logging configuration
38
+ :log_file,
39
+ :log_level
33
40
 
34
41
  def initialize
35
42
  # Connection configuration
@@ -43,6 +50,28 @@ module RubyLLM
43
50
  @default_model = 'gpt-4.1-nano'
44
51
  @default_embedding_model = 'text-embedding-3-small'
45
52
  @default_image_model = 'dall-e-3'
53
+
54
+ # Logging configuration
55
+ @log_file = $stdout
56
+ @log_level = ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO
57
+ end
58
+
59
+ def inspect
60
+ redacted = lambda do |name, value|
61
+ if name.match?(/_id|_key|_secret|_token$/)
62
+ value.nil? ? 'nil' : '[FILTERED]'
63
+ else
64
+ value
65
+ end
66
+ end
67
+
68
+ inspection = instance_variables.map do |ivar|
69
+ name = ivar.to_s.delete_prefix('@')
70
+ value = redacted[name, instance_variable_get(ivar)]
71
+ "#{name}: #{value}"
72
+ end.join(', ')
73
+
74
+ "#<#{self.class}:0x#{object_id.to_s(16)} #{inspection}>"
46
75
  end
47
76
  end
48
77
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Connection class for managing API connections to various providers.
5
+ class Connection
6
+ attr_reader :provider, :connection, :config
7
+
8
+ def initialize(provider, config)
9
+ @provider = provider
10
+ @config = config
11
+
12
+ ensure_configured!
13
+ @connection ||= Faraday.new(provider.api_base(@config)) do |faraday|
14
+ setup_timeout(faraday)
15
+ setup_logging(faraday)
16
+ setup_retry(faraday)
17
+ setup_middleware(faraday)
18
+ end
19
+ end
20
+
21
+ def post(url, payload, &)
22
+ body = payload.is_a?(Hash) ? JSON.generate(payload, ascii_only: false) : payload
23
+ @connection.post url, body do |req|
24
+ req.headers.merge! @provider.headers(@config) if @provider.respond_to?(:headers)
25
+ yield req if block_given?
26
+ end
27
+ end
28
+
29
+ def get(url, &)
30
+ @connection.get url do |req|
31
+ req.headers.merge! @provider.headers(@config) if @provider.respond_to?(:headers)
32
+ yield req if block_given?
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def setup_timeout(faraday)
39
+ faraday.options.timeout = @config.request_timeout
40
+ end
41
+
42
+ def setup_logging(faraday)
43
+ faraday.response :logger, RubyLLM.logger, bodies: true, response: true,
44
+ errors: true, headers: false, log_level: :debug do |logger|
45
+ logger.filter(%r{[A-Za-z0-9+/=]{100,}}, 'data":"[BASE64 DATA]"')
46
+ logger.filter(/[-\d.e,\s]{100,}/, '[EMBEDDINGS ARRAY]')
47
+ end
48
+ end
49
+
50
+ def setup_retry(faraday)
51
+ faraday.request :retry, {
52
+ max: @config.max_retries,
53
+ interval: @config.retry_interval,
54
+ interval_randomness: @config.retry_interval_randomness,
55
+ backoff_factor: @config.retry_backoff_factor,
56
+ exceptions: retry_exceptions,
57
+ retry_statuses: [429, 500, 502, 503, 504, 529]
58
+ }
59
+ end
60
+
61
+ def setup_middleware(faraday)
62
+ faraday.request :json
63
+ faraday.response :json
64
+ faraday.adapter Faraday.default_adapter
65
+ faraday.use :llm_errors, provider: @provider
66
+ end
67
+
68
+ def retry_exceptions
69
+ [
70
+ Errno::ETIMEDOUT,
71
+ Timeout::Error,
72
+ Faraday::TimeoutError,
73
+ Faraday::ConnectionFailed,
74
+ Faraday::RetriableResponse,
75
+ RubyLLM::RateLimitError,
76
+ RubyLLM::ServerError,
77
+ RubyLLM::ServiceUnavailableError,
78
+ RubyLLM::OverloadedError
79
+ ]
80
+ end
81
+
82
+ def ensure_configured!
83
+ return if @provider.configured?(@config)
84
+
85
+ config_block = <<~RUBY
86
+ RubyLLM.configure do |config|
87
+ #{@provider.missing_configs(@config).map { |key| "config.#{key} = ENV['#{key.to_s.upcase}']" }.join("\n ")}
88
+ end
89
+ RUBY
90
+
91
+ raise ConfigurationError,
92
+ "#{@provider.slug} provider is not configured. Add this to your initialization:\n\n#{config_block}"
93
+ end
94
+ end
95
+ end
@@ -5,100 +5,79 @@ module RubyLLM
5
5
  # Stores data in a standard internal format, letting providers
6
6
  # handle their own formatting needs.
7
7
  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
11
9
 
12
- Array(attachments[:image]).each do |source|
13
- @parts << attach_image(source)
14
- end
10
+ def initialize(text = nil, attachments = nil)
11
+ @text = text
12
+ @attachments = []
15
13
 
16
- Array(attachments[:audio]).each do |source|
17
- @parts << attach_audio(source)
18
- end
14
+ process_attachments(attachments)
15
+ raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
16
+ end
19
17
 
20
- Array(attachments[:pdf]).each do |source|
21
- @parts << attach_pdf(source)
22
- end
18
+ def add_image(source)
19
+ @attachments << Attachments::Image.new(source)
20
+ self
23
21
  end
24
22
 
25
- def to_a
26
- return if @parts.empty?
23
+ def add_audio(source)
24
+ @attachments << Attachments::Audio.new(source)
25
+ self
26
+ end
27
27
 
28
- @parts
28
+ def add_pdf(source)
29
+ @attachments << Attachments::PDF.new(source)
30
+ self
29
31
  end
30
32
 
31
33
  def format
32
- return @parts.first[:text] if @parts.size == 1 && @parts.first[:type] == 'text'
34
+ if @text && @attachments.empty?
35
+ @text
36
+ else
37
+ self
38
+ end
39
+ end
33
40
 
34
- to_a
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
48
+ end
49
+ hash
35
50
  end
36
51
 
37
52
  private
38
53
 
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')
54
+ def process_attachments_hash(attachments)
55
+ return unless attachments.is_a?(Hash)
43
56
 
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
- }
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) }
55
60
  end
56
61
 
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)
62
+ 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
85
72
  end
86
-
87
- pdf_data
88
73
  end
89
74
 
90
- def encode_file(source)
91
- if source.start_with?('http')
92
- response = Faraday.get(source)
93
- Base64.strict_encode64(response.body)
75
+ def process_attachments(attachments)
76
+ if attachments.is_a?(Hash)
77
+ process_attachments_hash attachments
94
78
  else
95
- Base64.strict_encode64(File.read(source))
79
+ process_attachments_array_or_string attachments
96
80
  end
97
81
  end
98
-
99
- def mime_type_for(path)
100
- ext = File.extname(path).delete('.')
101
- "image/#{ext}"
102
- end
103
82
  end
104
83
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Holds per-call configs
5
+ class Context
6
+ attr_reader :config
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ @connections = {}
11
+ end
12
+
13
+ def chat(*args, **kwargs, &)
14
+ Chat.new(*args, **kwargs, context: self, &)
15
+ end
16
+
17
+ def embed(*args, **kwargs, &)
18
+ Embedding.embed(*args, **kwargs, context: self, &)
19
+ end
20
+
21
+ def paint(*args, **kwargs, &)
22
+ Image.paint(*args, **kwargs, context: self, &)
23
+ end
24
+
25
+ def connection_for(provider_module)
26
+ slug = provider_module.slug.to_sym
27
+ @connections[slug] ||= Connection.new(provider_module, @config)
28
+ end
29
+ end
30
+ end
@@ -12,12 +12,20 @@ module RubyLLM
12
12
  @input_tokens = input_tokens
13
13
  end
14
14
 
15
- def self.embed(text, model: nil)
16
- model_id = model || RubyLLM.config.default_embedding_model
17
- Models.find(model_id)
15
+ def self.embed(text, # rubocop:disable Metrics/ParameterLists
16
+ model: nil,
17
+ provider: nil,
18
+ assume_model_exists: false,
19
+ context: nil,
20
+ dimensions: nil)
21
+ config = context&.config || RubyLLM.config
22
+ model ||= config.default_embedding_model
23
+ model, provider = Models.resolve(model, provider: provider, assume_exists: assume_model_exists)
24
+ model_id = model.id
18
25
 
19
- provider = Provider.for(model_id)
20
- provider.embed(text, model: model_id)
26
+ provider = Provider.for(model_id) if provider.nil?
27
+ connection = context ? context.connection_for(provider) : provider.connection(config)
28
+ provider.embed(text, model: model_id, connection:, dimensions:)
21
29
  end
22
30
  end
23
31
  end
@@ -50,7 +50,7 @@ module RubyLLM
50
50
  end
51
51
 
52
52
  class << self
53
- def parse_error(provider:, response:) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
53
+ def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
54
54
  message = provider&.parse_error(response)
55
55
 
56
56
  case response.status
@@ -36,12 +36,20 @@ module RubyLLM
36
36
  path
37
37
  end
38
38
 
39
- def self.paint(prompt, model: nil, size: '1024x1024')
40
- model_id = model || RubyLLM.config.default_image_model
41
- Models.find(model_id) # Validate model exists
39
+ def self.paint(prompt, # rubocop:disable Metrics/ParameterLists
40
+ model: nil,
41
+ provider: nil,
42
+ assume_model_exists: false,
43
+ size: '1024x1024',
44
+ context: nil)
45
+ config = context&.config || RubyLLM.config
46
+ model ||= config.default_image_model
47
+ model, provider = Models.resolve(model, provider: provider, assume_exists: assume_model_exists)
48
+ model_id = model.id
42
49
 
43
- provider = Provider.for(model_id)
44
- provider.paint(prompt, model: model_id, size: size)
50
+ provider = Provider.for(model_id) if provider.nil?
51
+ connection = context ? context.connection_for(provider) : provider.connection(config)
52
+ provider.paint(prompt, model: model_id, size:, connection:)
45
53
  end
46
54
  end
47
55
  end