ruby_llm 1.14.1 → 1.16.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 +6 -7
- data/lib/generators/ruby_llm/generator_helpers.rb +8 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
- data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +1 -1
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
- data/lib/ruby_llm/active_record/acts_as.rb +4 -26
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +123 -29
- data/lib/ruby_llm/active_record/chat_methods.rb +41 -24
- data/lib/ruby_llm/active_record/message_methods.rb +87 -4
- data/lib/ruby_llm/active_record/model_methods.rb +7 -9
- data/lib/ruby_llm/active_record/payload_helpers.rb +3 -0
- data/lib/ruby_llm/active_record/tool_call_methods.rb +3 -0
- data/lib/ruby_llm/agent.rb +4 -2
- data/lib/ruby_llm/aliases.json +108 -75
- data/lib/ruby_llm/aliases.rb +3 -0
- data/lib/ruby_llm/attachment.rb +41 -40
- data/lib/ruby_llm/chat.rb +229 -59
- data/lib/ruby_llm/configuration.rb +14 -1
- data/lib/ruby_llm/connection.rb +36 -7
- data/lib/ruby_llm/content.rb +15 -1
- data/lib/ruby_llm/cost.rb +224 -0
- data/lib/ruby_llm/deprecator.rb +24 -0
- data/lib/ruby_llm/embedding.rb +31 -1
- data/lib/ruby_llm/error.rb +11 -75
- data/lib/ruby_llm/error_middleware.rb +81 -0
- data/lib/ruby_llm/image.rb +39 -4
- data/lib/ruby_llm/instrumentation.rb +36 -0
- data/lib/ruby_llm/message.rb +20 -0
- data/lib/ruby_llm/mime_type.rb +25 -0
- data/lib/ruby_llm/model/info.rb +53 -2
- data/lib/ruby_llm/model/pricing.rb +19 -9
- data/lib/ruby_llm/model/pricing_category.rb +13 -2
- data/lib/ruby_llm/model/pricing_tier.rb +20 -9
- data/lib/ruby_llm/model_registry.rb +39 -0
- data/lib/ruby_llm/models.json +17817 -13942
- data/lib/ruby_llm/models.rb +97 -31
- data/lib/ruby_llm/models_schema.json +3 -0
- data/lib/ruby_llm/provider.rb +20 -4
- data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
- data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
- data/lib/ruby_llm/providers/azure/media.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
- data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
- data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
- data/lib/ruby_llm/providers/bedrock.rb +2 -2
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
- data/lib/ruby_llm/providers/gemini/images.rb +2 -2
- data/lib/ruby_llm/providers/gemini/media.rb +16 -9
- data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
- data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
- data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
- data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
- data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
- data/lib/ruby_llm/providers/mistral/media.rb +55 -0
- data/lib/ruby_llm/providers/mistral/models.rb +2 -0
- data/lib/ruby_llm/providers/mistral.rb +2 -2
- data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
- data/lib/ruby_llm/providers/openai/capabilities.rb +82 -12
- data/lib/ruby_llm/providers/openai/chat.rb +61 -7
- data/lib/ruby_llm/providers/openai/images.rb +58 -6
- data/lib/ruby_llm/providers/openai/media.rb +40 -16
- data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
- data/lib/ruby_llm/providers/openai/tools.rb +2 -0
- data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
- data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
- data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
- data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
- data/lib/ruby_llm/providers/perplexity.rb +2 -2
- data/lib/ruby_llm/providers/vertexai.rb +5 -1
- data/lib/ruby_llm/providers/xai/chat.rb +9 -0
- data/lib/ruby_llm/providers/xai/models.rb +15 -27
- data/lib/ruby_llm/providers/xai.rb +2 -2
- data/lib/ruby_llm/railtie.rb +11 -1
- data/lib/ruby_llm/stream_accumulator.rb +45 -30
- data/lib/ruby_llm/streaming.rb +4 -0
- data/lib/ruby_llm/tokens.rb +8 -0
- data/lib/ruby_llm/tool.rb +24 -7
- data/lib/ruby_llm/tool_concurrency.rb +105 -0
- data/lib/ruby_llm/transcription.rb +2 -1
- data/lib/ruby_llm/utils.rb +39 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +11 -6
- data/lib/tasks/models.rake +45 -16
- data/lib/tasks/release.rake +50 -23
- metadata +35 -13
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class Perplexity
|
|
6
|
+
# Handles Perplexity Sonar media content.
|
|
7
|
+
module Media
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
SUPPORTED_DOCUMENT_EXTENSIONS = %w[pdf doc docx txt rtf].freeze
|
|
11
|
+
|
|
12
|
+
def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
|
|
13
|
+
if content.is_a?(RubyLLM::Content::Raw)
|
|
14
|
+
value = content.value
|
|
15
|
+
return value.is_a?(Hash) ? value.to_json : value
|
|
16
|
+
end
|
|
17
|
+
return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
|
|
18
|
+
return content unless content.is_a?(Content)
|
|
19
|
+
|
|
20
|
+
parts = []
|
|
21
|
+
parts << OpenAI::Media.format_text(content.text) if content.text
|
|
22
|
+
|
|
23
|
+
content.attachments.each do |attachment|
|
|
24
|
+
case attachment.type
|
|
25
|
+
when :image
|
|
26
|
+
parts << OpenAI::Media.format_image(attachment)
|
|
27
|
+
when :pdf, :document
|
|
28
|
+
parts << format_document(attachment)
|
|
29
|
+
when :text
|
|
30
|
+
parts << format_text_attachment(attachment)
|
|
31
|
+
else
|
|
32
|
+
raise UnsupportedAttachmentError, attachment.mime_type
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
parts
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_document(attachment)
|
|
40
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless supported_file?(attachment)
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
type: 'file_url',
|
|
44
|
+
file_url: {
|
|
45
|
+
url: attachment.url? ? attachment.source.to_s : attachment.encoded
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_text_attachment(attachment)
|
|
51
|
+
OpenAI::Media.format_text_file(attachment)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def supported_file?(attachment)
|
|
55
|
+
return true if attachment.pdf?
|
|
56
|
+
|
|
57
|
+
SUPPORTED_DOCUMENT_EXTENSIONS.include?(attachment.extension)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -8,7 +8,7 @@ module RubyLLM
|
|
|
8
8
|
include Perplexity::Models
|
|
9
9
|
|
|
10
10
|
def api_base
|
|
11
|
-
'https://api.perplexity.ai'
|
|
11
|
+
@config.perplexity_api_base || 'https://api.perplexity.ai'
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def headers
|
|
@@ -24,7 +24,7 @@ module RubyLLM
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def configuration_options
|
|
27
|
-
%i[perplexity_api_key]
|
|
27
|
+
%i[perplexity_api_key perplexity_api_base]
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def configuration_requirements
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
3
5
|
module RubyLLM
|
|
4
6
|
module Providers
|
|
5
7
|
# Google Vertex AI implementation
|
|
@@ -21,6 +23,8 @@ module RubyLLM
|
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def api_base
|
|
26
|
+
return @config.vertexai_api_base if @config.vertexai_api_base
|
|
27
|
+
|
|
24
28
|
if @config.vertexai_location.to_s == 'global'
|
|
25
29
|
'https://aiplatform.googleapis.com/v1beta1'
|
|
26
30
|
else
|
|
@@ -41,7 +45,7 @@ module RubyLLM
|
|
|
41
45
|
|
|
42
46
|
class << self
|
|
43
47
|
def configuration_options
|
|
44
|
-
%i[vertexai_project_id vertexai_location vertexai_service_account_key]
|
|
48
|
+
%i[vertexai_project_id vertexai_location vertexai_service_account_key vertexai_api_base]
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
def configuration_requirements
|
|
@@ -9,6 +9,15 @@ module RubyLLM
|
|
|
9
9
|
def format_role(role)
|
|
10
10
|
role.to_s
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
def format_content(content)
|
|
14
|
+
OpenAI::Media.format_content(
|
|
15
|
+
content,
|
|
16
|
+
document_attachments: :none,
|
|
17
|
+
image_attachments: true,
|
|
18
|
+
audio_attachments: false
|
|
19
|
+
)
|
|
20
|
+
end
|
|
12
21
|
end
|
|
13
22
|
end
|
|
14
23
|
end
|
|
@@ -7,23 +7,6 @@ module RubyLLM
|
|
|
7
7
|
module Models
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
IMAGE_MODELS = %w[grok-2-image-1212].freeze
|
|
11
|
-
VISION_MODELS = %w[
|
|
12
|
-
grok-2-vision-1212
|
|
13
|
-
grok-4-0709
|
|
14
|
-
grok-4-fast-non-reasoning
|
|
15
|
-
grok-4-fast-reasoning
|
|
16
|
-
grok-4-1-fast-non-reasoning
|
|
17
|
-
grok-4-1-fast-reasoning
|
|
18
|
-
].freeze
|
|
19
|
-
REASONING_MODELS = %w[
|
|
20
|
-
grok-3-mini
|
|
21
|
-
grok-4-0709
|
|
22
|
-
grok-4-fast-reasoning
|
|
23
|
-
grok-4-1-fast-reasoning
|
|
24
|
-
grok-code-fast-1
|
|
25
|
-
].freeze
|
|
26
|
-
|
|
27
10
|
def parse_list_models_response(response, slug, _capabilities)
|
|
28
11
|
Array(response.body['data']).map do |model_data|
|
|
29
12
|
model_id = model_data['id']
|
|
@@ -48,27 +31,32 @@ module RubyLLM
|
|
|
48
31
|
end
|
|
49
32
|
|
|
50
33
|
def modalities_for(model_id)
|
|
51
|
-
if
|
|
52
|
-
{ input: [
|
|
34
|
+
if image_model?(model_id)
|
|
35
|
+
{ input: %w[text image], output: ['image'] }
|
|
36
|
+
elsif video_model?(model_id)
|
|
37
|
+
{ input: %w[text image video], output: ['video'] }
|
|
53
38
|
else
|
|
54
|
-
input
|
|
55
|
-
input << 'image' if VISION_MODELS.include?(model_id)
|
|
56
|
-
{ input: input, output: ['text'] }
|
|
39
|
+
{ input: ['text'], output: ['text'] }
|
|
57
40
|
end
|
|
58
41
|
end
|
|
59
42
|
|
|
60
43
|
def capabilities_for(model_id)
|
|
61
|
-
return [] if
|
|
44
|
+
return ['vision'] if image_model?(model_id) || video_model?(model_id)
|
|
62
45
|
|
|
63
|
-
|
|
64
|
-
capabilities << 'reasoning' if REASONING_MODELS.include?(model_id)
|
|
65
|
-
capabilities << 'vision' if VISION_MODELS.include?(model_id)
|
|
66
|
-
capabilities
|
|
46
|
+
['streaming']
|
|
67
47
|
end
|
|
68
48
|
|
|
69
49
|
def format_display_name(model_id)
|
|
70
50
|
model_id.tr('-', ' ').split.map(&:capitalize).join(' ')
|
|
71
51
|
end
|
|
52
|
+
|
|
53
|
+
def image_model?(model_id)
|
|
54
|
+
model_id.match?(/\Agrok-(?:2-)?imagine-image/) || model_id == 'grok-2-image-1212'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def video_model?(model_id)
|
|
58
|
+
model_id.match?(/\Agrok-(?:2-)?imagine-video/)
|
|
59
|
+
end
|
|
72
60
|
end
|
|
73
61
|
end
|
|
74
62
|
end
|
|
@@ -8,7 +8,7 @@ module RubyLLM
|
|
|
8
8
|
include XAI::Models
|
|
9
9
|
|
|
10
10
|
def api_base
|
|
11
|
-
'https://api.x.ai/v1'
|
|
11
|
+
@config.xai_api_base || 'https://api.x.ai/v1'
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def headers
|
|
@@ -20,7 +20,7 @@ module RubyLLM
|
|
|
20
20
|
|
|
21
21
|
class << self
|
|
22
22
|
def configuration_options
|
|
23
|
-
%i[xai_api_key]
|
|
23
|
+
%i[xai_api_key xai_api_base]
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def configuration_requirements
|
data/lib/ruby_llm/railtie.rb
CHANGED
|
@@ -10,8 +10,18 @@ if defined?(Rails::Railtie)
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
initializer 'ruby_llm.instrumentation' do
|
|
14
|
+
RubyLLM.config.instrumenter ||= ActiveSupport::Notifications
|
|
15
|
+
end
|
|
16
|
+
|
|
13
17
|
initializer 'ruby_llm.active_record' do
|
|
14
18
|
ActiveSupport.on_load :active_record do
|
|
19
|
+
require 'ruby_llm/active_record/payload_helpers'
|
|
20
|
+
require 'ruby_llm/active_record/chat_methods'
|
|
21
|
+
require 'ruby_llm/active_record/message_methods'
|
|
22
|
+
require 'ruby_llm/active_record/model_methods'
|
|
23
|
+
require 'ruby_llm/active_record/tool_call_methods'
|
|
24
|
+
|
|
15
25
|
if RubyLLM.config.use_new_acts_as
|
|
16
26
|
require 'ruby_llm/active_record/acts_as'
|
|
17
27
|
::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs
|
|
@@ -19,7 +29,7 @@ if defined?(Rails::Railtie)
|
|
|
19
29
|
require 'ruby_llm/active_record/acts_as_legacy'
|
|
20
30
|
::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAsLegacy
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
RubyLLM.deprecator.warn(
|
|
23
33
|
"\n!!! RubyLLM's legacy acts_as API is deprecated and will be removed in RubyLLM 2.0.0. " \
|
|
24
34
|
"Please consult the migration guide at https://rubyllm.com/upgrading-to-1-7/\n"
|
|
25
35
|
)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
3
6
|
module RubyLLM
|
|
4
7
|
# Assembles streaming responses from LLMs into complete messages.
|
|
5
8
|
class StreamAccumulator
|
|
@@ -18,6 +21,7 @@ module RubyLLM
|
|
|
18
21
|
@inside_think_tag = false
|
|
19
22
|
@pending_think_tag = +''
|
|
20
23
|
@latest_tool_call_id = nil
|
|
24
|
+
@tool_call_ids_by_index = {}
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def add(chunk)
|
|
@@ -72,43 +76,54 @@ module RubyLLM
|
|
|
72
76
|
end
|
|
73
77
|
end
|
|
74
78
|
|
|
75
|
-
def accumulate_tool_calls(new_tool_calls)
|
|
79
|
+
def accumulate_tool_calls(new_tool_calls)
|
|
76
80
|
RubyLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if RubyLLM.config.log_stream_debug
|
|
77
|
-
new_tool_calls.
|
|
81
|
+
new_tool_calls.each do |stream_key, tool_call|
|
|
78
82
|
if tool_call.id
|
|
79
|
-
|
|
80
|
-
tool_call_arguments = tool_call.arguments
|
|
81
|
-
if tool_call_arguments.nil? || (tool_call_arguments.respond_to?(:empty?) && tool_call_arguments.empty?)
|
|
82
|
-
tool_call_arguments = +''
|
|
83
|
-
end
|
|
84
|
-
@tool_calls[tool_call.id] = ToolCall.new(
|
|
85
|
-
id: tool_call_id,
|
|
86
|
-
name: tool_call.name,
|
|
87
|
-
arguments: tool_call_arguments,
|
|
88
|
-
thought_signature: tool_call.thought_signature
|
|
89
|
-
)
|
|
90
|
-
@latest_tool_call_id = tool_call.id
|
|
83
|
+
start_tool_call(stream_key, tool_call)
|
|
91
84
|
else
|
|
92
|
-
|
|
93
|
-
if existing
|
|
94
|
-
fragment = tool_call.arguments
|
|
95
|
-
fragment = '' if fragment.nil?
|
|
96
|
-
existing.arguments << fragment
|
|
97
|
-
if tool_call.thought_signature && existing.thought_signature.nil?
|
|
98
|
-
existing.thought_signature = tool_call.thought_signature
|
|
99
|
-
end
|
|
100
|
-
end
|
|
85
|
+
append_tool_call_fragment(stream_key, tool_call)
|
|
101
86
|
end
|
|
102
87
|
end
|
|
103
88
|
end
|
|
104
89
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
90
|
+
def start_tool_call(stream_key, tool_call)
|
|
91
|
+
tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
|
|
92
|
+
tool_call_key = tool_call.id
|
|
93
|
+
|
|
94
|
+
@tool_calls[tool_call_key] = ToolCall.new(
|
|
95
|
+
id: tool_call_id,
|
|
96
|
+
name: tool_call.name,
|
|
97
|
+
arguments: initial_tool_call_arguments(tool_call),
|
|
98
|
+
thought_signature: tool_call.thought_signature
|
|
99
|
+
)
|
|
100
|
+
@tool_call_ids_by_index[stream_key] = tool_call_key unless stream_key.nil?
|
|
101
|
+
@latest_tool_call_id = tool_call_key
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def initial_tool_call_arguments(tool_call)
|
|
105
|
+
arguments = tool_call.arguments
|
|
106
|
+
return +'' if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
|
|
107
|
+
|
|
108
|
+
arguments
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def append_tool_call_fragment(stream_key, tool_call)
|
|
112
|
+
existing = find_tool_call(stream_key)
|
|
113
|
+
return unless existing
|
|
114
|
+
|
|
115
|
+
fragment = tool_call.arguments
|
|
116
|
+
fragment = '' if fragment.nil?
|
|
117
|
+
existing.arguments << fragment
|
|
118
|
+
return unless tool_call.thought_signature && existing.thought_signature.nil?
|
|
119
|
+
|
|
120
|
+
existing.thought_signature = tool_call.thought_signature
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def find_tool_call(stream_key)
|
|
124
|
+
return @tool_calls[@latest_tool_call_id] if stream_key.nil?
|
|
125
|
+
|
|
126
|
+
@tool_calls[@tool_call_ids_by_index[stream_key]] || @tool_calls[stream_key]
|
|
112
127
|
end
|
|
113
128
|
|
|
114
129
|
def count_tokens(chunk)
|
data/lib/ruby_llm/streaming.rb
CHANGED
data/lib/ruby_llm/tokens.rb
CHANGED
data/lib/ruby_llm/tool.rb
CHANGED
|
@@ -7,10 +7,10 @@ module RubyLLM
|
|
|
7
7
|
class Parameter
|
|
8
8
|
attr_reader :name, :type, :description, :required
|
|
9
9
|
|
|
10
|
-
def initialize(name, type: 'string', desc: nil, required: true)
|
|
10
|
+
def initialize(name, type: 'string', desc: nil, description: nil, required: true)
|
|
11
11
|
@name = name
|
|
12
12
|
@type = type
|
|
13
|
-
@description = desc
|
|
13
|
+
@description = desc || description
|
|
14
14
|
@required = required
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -30,6 +30,8 @@ module RubyLLM
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
POSITIONAL_PARAMETER_KINDS = %i[req opt rest].freeze
|
|
34
|
+
|
|
33
35
|
class << self
|
|
34
36
|
attr_reader :params_schema_definition
|
|
35
37
|
|
|
@@ -38,6 +40,7 @@ module RubyLLM
|
|
|
38
40
|
|
|
39
41
|
@description = text
|
|
40
42
|
end
|
|
43
|
+
alias desc description
|
|
41
44
|
|
|
42
45
|
def param(name, **options)
|
|
43
46
|
parameters[name] = Parameter.new(name, **options)
|
|
@@ -94,6 +97,8 @@ module RubyLLM
|
|
|
94
97
|
definition.json_schema
|
|
95
98
|
elsif parameters.any?
|
|
96
99
|
SchemaDefinition.from_parameters(parameters)&.json_schema
|
|
100
|
+
else
|
|
101
|
+
SchemaDefinition.from_parameters(inferred_parameters, allow_empty: true)&.json_schema
|
|
97
102
|
end
|
|
98
103
|
end
|
|
99
104
|
end
|
|
@@ -127,9 +132,10 @@ module RubyLLM
|
|
|
127
132
|
end
|
|
128
133
|
|
|
129
134
|
def validate_keyword_arguments(arguments)
|
|
130
|
-
required_keywords, optional_keywords, accepts_extra_keywords =
|
|
135
|
+
required_keywords, optional_keywords, accepts_extra_keywords, accepts_positional_arguments =
|
|
136
|
+
execute_keyword_signature
|
|
131
137
|
|
|
132
|
-
return nil if required_keywords.empty? && optional_keywords.empty?
|
|
138
|
+
return nil if required_keywords.empty? && optional_keywords.empty? && accepts_positional_arguments
|
|
133
139
|
|
|
134
140
|
argument_keys = arguments.keys
|
|
135
141
|
missing_keyword = first_missing_keyword(required_keywords, argument_keys)
|
|
@@ -148,8 +154,11 @@ module RubyLLM
|
|
|
148
154
|
required_keywords = keyword_signature.filter_map { |kind, name| name if kind == :keyreq }
|
|
149
155
|
optional_keywords = keyword_signature.filter_map { |kind, name| name if kind == :key }
|
|
150
156
|
accepts_extra_keywords = keyword_signature.any? { |kind, _| kind == :keyrest }
|
|
157
|
+
accepts_positional_arguments = keyword_signature.any? do |kind, _|
|
|
158
|
+
POSITIONAL_PARAMETER_KINDS.include?(kind)
|
|
159
|
+
end
|
|
151
160
|
|
|
152
|
-
[required_keywords, optional_keywords, accepts_extra_keywords]
|
|
161
|
+
[required_keywords, optional_keywords, accepts_extra_keywords, accepts_positional_arguments]
|
|
153
162
|
end
|
|
154
163
|
|
|
155
164
|
def first_missing_keyword(required_keywords, argument_keys)
|
|
@@ -160,11 +169,19 @@ module RubyLLM
|
|
|
160
169
|
(argument_keys - allowed_keywords).first
|
|
161
170
|
end
|
|
162
171
|
|
|
172
|
+
def inferred_parameters
|
|
173
|
+
required_keywords, optional_keywords, = execute_keyword_signature
|
|
174
|
+
|
|
175
|
+
(required_keywords + optional_keywords).to_h do |name|
|
|
176
|
+
[name, Parameter.new(name, required: required_keywords.include?(name))]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
163
180
|
# Wraps schema handling for tool parameters, supporting JSON Schema hashes,
|
|
164
181
|
# RubyLLM::Schema instances/classes, and DSL blocks.
|
|
165
182
|
class SchemaDefinition
|
|
166
|
-
def self.from_parameters(parameters)
|
|
167
|
-
return nil if parameters.nil? || parameters.empty?
|
|
183
|
+
def self.from_parameters(parameters, allow_empty: false)
|
|
184
|
+
return nil if parameters.nil? || (parameters.empty? && !allow_empty)
|
|
168
185
|
|
|
169
186
|
properties = parameters.to_h do |name, param|
|
|
170
187
|
schema = {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
# Runs multiple tool calls concurrently with Ruby's built-in threads or optional fibers.
|
|
5
|
+
module ToolConcurrency
|
|
6
|
+
MODES = %i[threads fibers].freeze
|
|
7
|
+
Result = Struct.new(:index, :tool_call, :value, :error, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def modes
|
|
12
|
+
MODES
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def supported?(name)
|
|
16
|
+
MODES.include?(name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(mode, tool_calls, on_result: nil, &)
|
|
20
|
+
case mode
|
|
21
|
+
when :threads
|
|
22
|
+
run_with_threads(tool_calls, on_result:, &)
|
|
23
|
+
when :fibers
|
|
24
|
+
run_with_fibers(tool_calls, on_result:, &)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run_with_threads(tool_calls, on_result:, &execute)
|
|
29
|
+
executor = rails_executor
|
|
30
|
+
queue = Queue.new
|
|
31
|
+
threads = tool_calls.each_value.with_index.map do |tool_call, index|
|
|
32
|
+
thread = Thread.new { queue << capture_result(index, tool_call, executor, execute) }
|
|
33
|
+
thread.report_on_exception = false
|
|
34
|
+
thread
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
collect_results(queue, threads.size, on_result:)
|
|
38
|
+
ensure
|
|
39
|
+
threads&.each(&:join)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_with_fibers(tool_calls, on_result:, &execute)
|
|
43
|
+
begin
|
|
44
|
+
require 'async'
|
|
45
|
+
require 'async/queue'
|
|
46
|
+
rescue LoadError
|
|
47
|
+
raise LoadError, "The 'async' gem is required for concurrent tool execution with fibers. " \
|
|
48
|
+
"Add `gem 'async'` to your Gemfile or use `concurrency: :threads`."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
executor = rails_executor
|
|
52
|
+
Async do |task|
|
|
53
|
+
queue = Async::Queue.new
|
|
54
|
+
tasks = tool_calls.each_value.with_index.map do |tool_call, index|
|
|
55
|
+
task.async { queue << capture_result(index, tool_call, executor, execute) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
collect_results(queue, tasks.size, on_result:)
|
|
59
|
+
ensure
|
|
60
|
+
tasks&.each(&:wait)
|
|
61
|
+
end.wait
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def collect_results(queue, count, on_result:)
|
|
65
|
+
results = Array.new(count)
|
|
66
|
+
errors = []
|
|
67
|
+
|
|
68
|
+
count.times do
|
|
69
|
+
result = queue.pop
|
|
70
|
+
if result.error
|
|
71
|
+
errors << result.error
|
|
72
|
+
else
|
|
73
|
+
results[result.index] = [result.tool_call, result.value]
|
|
74
|
+
on_result&.call(result.tool_call, result.value)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise errors.first if errors.any?
|
|
79
|
+
|
|
80
|
+
results
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def capture_result(index, tool_call, rails_executor, execute)
|
|
84
|
+
tool_call, value = run_tool_call(tool_call, rails_executor, execute)
|
|
85
|
+
Result.new(index:, tool_call:, value:)
|
|
86
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
87
|
+
Result.new(index:, tool_call:, error: e)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run_tool_call(tool_call, rails_executor, execute)
|
|
91
|
+
if rails_executor
|
|
92
|
+
rails_executor.wrap { [tool_call, execute.call(tool_call)] }
|
|
93
|
+
else
|
|
94
|
+
[tool_call, execute.call(tool_call)]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rails_executor
|
|
99
|
+
defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.executor
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private_class_method :run_with_threads, :run_with_fibers, :collect_results, :capture_result, :run_tool_call,
|
|
103
|
+
:rails_executor
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
# Represents a transcription of audio content.
|
|
5
5
|
class Transcription
|
|
6
|
-
attr_reader :text, :model, :language, :duration, :segments, :input_tokens, :output_tokens
|
|
6
|
+
attr_reader :text, :model, :language, :duration, :segments, :words, :input_tokens, :output_tokens
|
|
7
7
|
|
|
8
8
|
def initialize(text:, model:, **attributes)
|
|
9
9
|
@text = text
|
|
@@ -11,6 +11,7 @@ module RubyLLM
|
|
|
11
11
|
@language = attributes[:language]
|
|
12
12
|
@duration = attributes[:duration]
|
|
13
13
|
@segments = attributes[:segments]
|
|
14
|
+
@words = attributes[:words]
|
|
14
15
|
@input_tokens = attributes[:input_tokens]
|
|
15
16
|
@output_tokens = attributes[:output_tokens]
|
|
16
17
|
end
|
data/lib/ruby_llm/utils.rb
CHANGED
|
@@ -32,6 +32,45 @@ module RubyLLM
|
|
|
32
32
|
value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def safe_constantize(name)
|
|
36
|
+
parts = name.to_s.split('::').reject(&:empty?)
|
|
37
|
+
return if parts.empty?
|
|
38
|
+
|
|
39
|
+
namespace = Object
|
|
40
|
+
until parts.empty?
|
|
41
|
+
const_name = parts.shift
|
|
42
|
+
return unless namespace.const_defined?(const_name, false)
|
|
43
|
+
|
|
44
|
+
namespace = namespace.const_get(const_name, false)
|
|
45
|
+
end
|
|
46
|
+
namespace
|
|
47
|
+
rescue NameError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_iso_date_prefix(value)
|
|
52
|
+
return value if value.is_a?(Date)
|
|
53
|
+
|
|
54
|
+
date = value.to_s.strip
|
|
55
|
+
return if date.empty?
|
|
56
|
+
|
|
57
|
+
case date
|
|
58
|
+
when /\A\d{4}-\d{2}-\d{2}\z/
|
|
59
|
+
Date.iso8601(date)
|
|
60
|
+
when /\A\d{4}-\d{2}\z/
|
|
61
|
+
Date.iso8601("#{date}-01")
|
|
62
|
+
when /\A\d{4}\z/
|
|
63
|
+
Date.iso8601("#{date}-01-01")
|
|
64
|
+
end
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def iso_date_prefix_to_utc_midnight_string(value)
|
|
70
|
+
date = parse_iso_date_prefix(value)
|
|
71
|
+
"#{date.strftime('%Y-%m-%d')} 00:00:00 UTC" if date
|
|
72
|
+
end
|
|
73
|
+
|
|
35
74
|
def deep_merge(original, overrides)
|
|
36
75
|
original.merge(overrides) do |_key, original_value, overrides_value|
|
|
37
76
|
if original_value.is_a?(Hash) && overrides_value.is_a?(Hash)
|
data/lib/ruby_llm/version.rb
CHANGED
data/lib/ruby_llm.rb
CHANGED
|
@@ -12,6 +12,7 @@ require 'securerandom'
|
|
|
12
12
|
require 'date'
|
|
13
13
|
require 'time'
|
|
14
14
|
require 'zeitwerk'
|
|
15
|
+
require 'ruby_llm/error'
|
|
15
16
|
|
|
16
17
|
loader = Zeitwerk::Loader.for_gem
|
|
17
18
|
loader.inflector.inflect(
|
|
@@ -33,14 +34,21 @@ loader.inflector.inflect(
|
|
|
33
34
|
)
|
|
34
35
|
loader.ignore("#{__dir__}/tasks")
|
|
35
36
|
loader.ignore("#{__dir__}/generators")
|
|
37
|
+
loader.ignore("#{__dir__}/ruby_llm/active_record")
|
|
36
38
|
loader.ignore("#{__dir__}/ruby_llm/railtie.rb")
|
|
37
39
|
loader.setup
|
|
38
40
|
|
|
39
41
|
# A delightful Ruby interface to modern AI language models.
|
|
40
42
|
module RubyLLM
|
|
41
|
-
class Error < StandardError; end
|
|
42
|
-
|
|
43
43
|
class << self
|
|
44
|
+
def deprecator
|
|
45
|
+
@deprecator ||= Deprecator.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def instrument(...)
|
|
49
|
+
Instrumentation.instrument(...)
|
|
50
|
+
end
|
|
51
|
+
|
|
44
52
|
def context
|
|
45
53
|
context_config = config.dup
|
|
46
54
|
yield context_config if block_given?
|
|
@@ -107,7 +115,4 @@ RubyLLM::Provider.register :perplexity, RubyLLM::Providers::Perplexity
|
|
|
107
115
|
RubyLLM::Provider.register :vertexai, RubyLLM::Providers::VertexAI
|
|
108
116
|
RubyLLM::Provider.register :xai, RubyLLM::Providers::XAI
|
|
109
117
|
|
|
110
|
-
if defined?(Rails::Railtie)
|
|
111
|
-
require 'ruby_llm/railtie'
|
|
112
|
-
require 'ruby_llm/active_record/acts_as'
|
|
113
|
-
end
|
|
118
|
+
require 'ruby_llm/railtie' if defined?(Rails::Railtie)
|