ruby_llm 1.10.0 → 1.12.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -2
  3. data/lib/ruby_llm/active_record/acts_as_legacy.rb +41 -7
  4. data/lib/ruby_llm/active_record/chat_methods.rb +41 -7
  5. data/lib/ruby_llm/agent.rb +323 -0
  6. data/lib/ruby_llm/aliases.json +50 -32
  7. data/lib/ruby_llm/chat.rb +27 -3
  8. data/lib/ruby_llm/configuration.rb +4 -0
  9. data/lib/ruby_llm/models.json +19806 -5991
  10. data/lib/ruby_llm/models.rb +35 -6
  11. data/lib/ruby_llm/provider.rb +13 -1
  12. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  13. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  14. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  15. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  16. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  17. data/lib/ruby_llm/providers/azure.rb +56 -0
  18. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  19. data/lib/ruby_llm/providers/bedrock/chat.rb +297 -56
  20. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  21. data/lib/ruby_llm/providers/bedrock/models.rb +88 -65
  22. data/lib/ruby_llm/providers/bedrock/streaming.rb +305 -8
  23. data/lib/ruby_llm/providers/bedrock.rb +61 -52
  24. data/lib/ruby_llm/providers/openai/media.rb +1 -1
  25. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  26. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  27. data/lib/ruby_llm/providers/xai.rb +28 -0
  28. data/lib/ruby_llm/version.rb +1 -1
  29. data/lib/ruby_llm.rb +14 -8
  30. data/lib/tasks/models.rake +10 -4
  31. data/lib/tasks/vcr.rake +32 -0
  32. metadata +16 -13
  33. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  34. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  35. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  36. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -128
  37. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  38. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -85
  39. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -16,6 +16,21 @@ module RubyLLM
16
16
  'openrouter' => 'openrouter',
17
17
  'perplexity' => 'perplexity'
18
18
  }.freeze
19
+ PROVIDER_PREFERENCE = %w[
20
+ openai
21
+ anthropic
22
+ gemini
23
+ vertexai
24
+ bedrock
25
+ openrouter
26
+ deepseek
27
+ mistral
28
+ perplexity
29
+ xai
30
+ azure
31
+ ollama
32
+ gpustack
33
+ ].freeze
19
34
 
20
35
  class << self
21
36
  def instance
@@ -93,7 +108,7 @@ module RubyLLM
93
108
 
94
109
  if provider_class
95
110
  temp_instance = provider_class.new(config)
96
- assume_exists = true if temp_instance.local?
111
+ assume_exists = true if temp_instance.local? || temp_instance.assume_models_exist?
97
112
  end
98
113
 
99
114
  if assume_exists
@@ -447,8 +462,8 @@ module RubyLLM
447
462
  def find_with_provider(model_id, provider)
448
463
  resolved_id = Aliases.resolve(model_id, provider)
449
464
  resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
450
- all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
451
- all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
465
+ all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
466
+ all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
452
467
  raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
453
468
  end
454
469
 
@@ -467,9 +482,23 @@ module RubyLLM
467
482
  end
468
483
 
469
484
  def find_without_provider(model_id)
470
- all.find { |m| m.id == model_id } ||
471
- all.find { |m| m.id == Aliases.resolve(model_id) } ||
472
- raise(ModelNotFoundError, "Unknown model: #{model_id}")
485
+ exact_matches = all.select { |m| m.id == model_id }
486
+ return preferred_match(exact_matches) if exact_matches.any?
487
+
488
+ resolved_id = Aliases.resolve(model_id)
489
+ alias_matches = all.select { |m| m.id == resolved_id }
490
+ return preferred_match(alias_matches) if alias_matches.any?
491
+
492
+ raise(ModelNotFoundError, "Unknown model: #{model_id}")
493
+ end
494
+
495
+ def preferred_match(candidates)
496
+ return candidates.first if candidates.size == 1
497
+
498
+ candidates.min_by do |model|
499
+ index = PROVIDER_PREFERENCE.index(model.provider)
500
+ index || PROVIDER_PREFERENCE.length
501
+ end
473
502
  end
474
503
  end
475
504
  end
@@ -102,16 +102,24 @@ module RubyLLM
102
102
  self.class.remote?
103
103
  end
104
104
 
105
+ def assume_models_exist?
106
+ self.class.assume_models_exist?
107
+ end
108
+
105
109
  def parse_error(response)
106
110
  return if response.body.empty?
107
111
 
108
112
  body = try_parse_json(response.body)
109
113
  case body
110
114
  when Hash
115
+ error = body['error']
116
+ return error if error.is_a?(String)
117
+
111
118
  body.dig('error', 'message')
112
119
  when Array
113
120
  body.map do |part|
114
- part.dig('error', 'message')
121
+ error = part['error']
122
+ error.is_a?(String) ? error : part.dig('error', 'message')
115
123
  end.join('. ')
116
124
  else
117
125
  body
@@ -160,6 +168,10 @@ module RubyLLM
160
168
  !local?
161
169
  end
162
170
 
171
+ def assume_models_exist?
172
+ false
173
+ end
174
+
163
175
  def configured?(config)
164
176
  configuration_requirements.all? { |req| config.send(req) }
165
177
  end
@@ -44,7 +44,7 @@ module RubyLLM
44
44
  type: 'image',
45
45
  source: {
46
46
  type: 'url',
47
- url: image.source
47
+ url: image.source.to_s
48
48
  }
49
49
  }
50
50
  else
@@ -65,7 +65,7 @@ module RubyLLM
65
65
  type: 'document',
66
66
  source: {
67
67
  type: 'url',
68
- url: pdf.source
68
+ url: pdf.source.to_s
69
69
  }
70
70
  }
71
71
  else
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Azure
6
+ # Chat methods of the Azure AI Foundry API integration
7
+ module Chat
8
+ def completion_url
9
+ 'models/chat/completions?api-version=2024-05-01-preview'
10
+ end
11
+
12
+ def format_messages(messages)
13
+ messages.map do |msg|
14
+ {
15
+ role: format_role(msg.role),
16
+ content: Media.format_content(msg.content),
17
+ tool_calls: format_tool_calls(msg.tool_calls),
18
+ tool_call_id: msg.tool_call_id
19
+ }.compact.merge(format_thinking(msg))
20
+ end
21
+ end
22
+
23
+ def format_role(role)
24
+ role.to_s
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Azure
6
+ # Embeddings methods of the Azure AI Foundry API integration
7
+ module Embeddings
8
+ module_function
9
+
10
+ def embedding_url(...)
11
+ 'openai/v1/embeddings'
12
+ end
13
+
14
+ def render_embedding_payload(text, model:, dimensions:)
15
+ {
16
+ model: model,
17
+ input: [text].flatten,
18
+ dimensions: dimensions
19
+ }.compact
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Azure
6
+ # Handles formatting of media content (images, audio) for Azure OpenAI-compatible APIs.
7
+ module Media
8
+ module_function
9
+
10
+ def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
11
+ return content.value if content.is_a?(RubyLLM::Content::Raw)
12
+ return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
13
+ return content unless content.is_a?(Content)
14
+
15
+ parts = []
16
+ parts << OpenAI::Media.format_text(content.text) if content.text
17
+
18
+ content.attachments.each do |attachment|
19
+ case attachment.type
20
+ when :image
21
+ parts << format_image(attachment)
22
+ when :audio
23
+ parts << OpenAI::Media.format_audio(attachment)
24
+ when :text
25
+ parts << OpenAI::Media.format_text_file(attachment)
26
+ else
27
+ raise UnsupportedAttachmentError, attachment.type
28
+ end
29
+ end
30
+
31
+ parts
32
+ end
33
+
34
+ def format_image(image)
35
+ {
36
+ type: 'image_url',
37
+ image_url: {
38
+ url: image.for_llm
39
+ }
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Azure
6
+ # Models methods of the Azure AI Foundry API integration
7
+ module Models
8
+ def models_url
9
+ 'openai/v1/models?api-version=preview'
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Azure AI Foundry / OpenAI-compatible API integration.
6
+ class Azure < OpenAI
7
+ include Azure::Chat
8
+ include Azure::Embeddings
9
+ include Azure::Media
10
+ include Azure::Models
11
+
12
+ def api_base
13
+ @config.azure_api_base
14
+ end
15
+
16
+ def headers
17
+ if @config.azure_api_key
18
+ { 'api-key' => @config.azure_api_key }
19
+ else
20
+ { 'Authorization' => "Bearer #{@config.azure_ai_auth_token}" }
21
+ end
22
+ end
23
+
24
+ def configured?
25
+ self.class.configured?(@config)
26
+ end
27
+
28
+ class << self
29
+ def configuration_requirements
30
+ %i[azure_api_base]
31
+ end
32
+
33
+ def configured?(config)
34
+ config.azure_api_base && (config.azure_api_key || config.azure_ai_auth_token)
35
+ end
36
+
37
+ # Azure works with deployment names, instead of model names
38
+ def assume_models_exist?
39
+ true
40
+ end
41
+ end
42
+
43
+ def ensure_configured!
44
+ missing = []
45
+ missing << :azure_api_base unless @config.azure_api_base
46
+ if @config.azure_api_key.nil? && @config.azure_ai_auth_token.nil?
47
+ missing << 'azure_api_key or azure_ai_auth_token'
48
+ end
49
+ return if missing.empty?
50
+
51
+ raise ConfigurationError,
52
+ "Missing configuration for Azure: #{missing.join(', ')}"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'openssl'
5
+
6
+ module RubyLLM
7
+ module Providers
8
+ class Bedrock
9
+ # SigV4 authentication helpers for Bedrock.
10
+ module Auth
11
+ private
12
+
13
+ def signed_post(connection, url, payload, additional_headers = {})
14
+ request_payload = api_payload(payload)
15
+ body = JSON.generate(request_payload)
16
+ signed_headers = sign_headers('POST', url, body)
17
+
18
+ response = connection.post(url, request_payload) do |req|
19
+ req.headers.merge!(signed_headers)
20
+ req.headers.merge!(additional_headers) unless additional_headers.empty?
21
+ yield req if block_given?
22
+ end
23
+
24
+ parse_completion_response(response)
25
+ end
26
+
27
+ def signed_get(base_url, url)
28
+ conn = Connection.basic do |f|
29
+ f.request :json
30
+ f.response :json
31
+ f.adapter :net_http
32
+ f.use :llm_errors, provider: self
33
+ end
34
+
35
+ conn.url_prefix = base_url
36
+
37
+ conn.get(url) do |req|
38
+ req.headers.merge!(sign_headers('GET', url, '', base_url: base_url))
39
+ end
40
+ end
41
+
42
+ def sign_headers(method, path, body, base_url: api_base)
43
+ now = Time.now.utc
44
+ amz_date = now.strftime('%Y%m%dT%H%M%SZ')
45
+ date_stamp = now.strftime('%Y%m%d')
46
+
47
+ uri = URI.parse(path)
48
+ canonical_uri = canonical_uri(uri.path)
49
+ canonical_query = canonical_query_string(uri.query)
50
+ payload_hash = Digest::SHA256.hexdigest(body)
51
+
52
+ headers = {
53
+ 'host' => URI.parse(base_url).host,
54
+ 'x-amz-content-sha256' => payload_hash,
55
+ 'x-amz-date' => amz_date
56
+ }
57
+ headers['x-amz-security-token'] = @config.bedrock_session_token if @config.bedrock_session_token
58
+
59
+ signed_headers = headers.keys.sort.join(';')
60
+ canonical_headers = headers.keys.sort.map { |key| "#{key}:#{headers[key].to_s.strip}\n" }.join
61
+
62
+ canonical_request = [
63
+ method,
64
+ canonical_uri,
65
+ canonical_query,
66
+ canonical_headers,
67
+ signed_headers,
68
+ payload_hash
69
+ ].join("\n")
70
+
71
+ credential_scope = "#{date_stamp}/#{bedrock_region}/bedrock/aws4_request"
72
+ string_to_sign = [
73
+ 'AWS4-HMAC-SHA256',
74
+ amz_date,
75
+ credential_scope,
76
+ Digest::SHA256.hexdigest(canonical_request)
77
+ ].join("\n")
78
+
79
+ signing_key = signing_key(date_stamp)
80
+ signature = OpenSSL::HMAC.hexdigest('sha256', signing_key, string_to_sign)
81
+
82
+ {
83
+ 'X-Amz-Date' => amz_date,
84
+ 'X-Amz-Content-Sha256' => payload_hash,
85
+ 'X-Amz-Security-Token' => @config.bedrock_session_token,
86
+ 'Authorization' => "AWS4-HMAC-SHA256 Credential=#{@config.bedrock_api_key}/#{credential_scope}, " \
87
+ "SignedHeaders=#{signed_headers}, Signature=#{signature}",
88
+ 'Content-Type' => 'application/json'
89
+ }.compact
90
+ end
91
+
92
+ def canonical_query_string(raw_query)
93
+ return '' if raw_query.nil? || raw_query.empty?
94
+
95
+ URI.decode_www_form(raw_query)
96
+ .sort_by(&:first)
97
+ .map { |k, v| "#{uri_encode(k)}=#{uri_encode(v)}" }
98
+ .join('&')
99
+ end
100
+
101
+ def canonical_uri(path)
102
+ return '/' if path.nil? || path.empty?
103
+
104
+ segments = path.split('/', -1).map { |segment| uri_encode(segment) }
105
+ canonical = segments.join('/')
106
+ canonical.start_with?('/') ? canonical : "/#{canonical}"
107
+ end
108
+
109
+ def uri_encode(text)
110
+ URI.encode_www_form_component(text.to_s).gsub('+', '%20').gsub('%7E', '~')
111
+ end
112
+
113
+ def signing_key(date_stamp)
114
+ k_date = OpenSSL::HMAC.digest('sha256', "AWS4#{@config.bedrock_secret_key}", date_stamp)
115
+ k_region = OpenSSL::HMAC.digest('sha256', k_date, bedrock_region)
116
+ k_service = OpenSSL::HMAC.digest('sha256', k_region, 'bedrock')
117
+ OpenSSL::HMAC.digest('sha256', k_service, 'aws4_request')
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end