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.
- checksums.yaml +4 -4
- data/README.md +14 -2
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +41 -7
- data/lib/ruby_llm/active_record/chat_methods.rb +41 -7
- data/lib/ruby_llm/agent.rb +323 -0
- data/lib/ruby_llm/aliases.json +50 -32
- data/lib/ruby_llm/chat.rb +27 -3
- data/lib/ruby_llm/configuration.rb +4 -0
- data/lib/ruby_llm/models.json +19806 -5991
- data/lib/ruby_llm/models.rb +35 -6
- data/lib/ruby_llm/provider.rb +13 -1
- data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
- data/lib/ruby_llm/providers/azure/chat.rb +29 -0
- data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
- data/lib/ruby_llm/providers/azure/media.rb +45 -0
- data/lib/ruby_llm/providers/azure/models.rb +14 -0
- data/lib/ruby_llm/providers/azure.rb +56 -0
- data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +297 -56
- data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
- data/lib/ruby_llm/providers/bedrock/models.rb +88 -65
- data/lib/ruby_llm/providers/bedrock/streaming.rb +305 -8
- data/lib/ruby_llm/providers/bedrock.rb +61 -52
- data/lib/ruby_llm/providers/openai/media.rb +1 -1
- data/lib/ruby_llm/providers/xai/chat.rb +15 -0
- data/lib/ruby_llm/providers/xai/models.rb +75 -0
- data/lib/ruby_llm/providers/xai.rb +28 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +14 -8
- data/lib/tasks/models.rake +10 -4
- data/lib/tasks/vcr.rake +32 -0
- metadata +16 -13
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
- data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -128
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -85
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
data/lib/ruby_llm/models.rb
CHANGED
|
@@ -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 ==
|
|
451
|
-
all.find { |m| m.id ==
|
|
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.
|
|
471
|
-
|
|
472
|
-
|
|
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
|
data/lib/ruby_llm/provider.rb
CHANGED
|
@@ -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
|
|
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,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
|