ruby_llm 1.12.0 → 1.13.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 +2 -0
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +1 -1
- data/lib/generators/ruby_llm/generator_helpers.rb +4 -0
- data/lib/generators/ruby_llm/install/install_generator.rb +5 -4
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -1
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +1 -6
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
- data/lib/ruby_llm/active_record/acts_as.rb +8 -4
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +85 -20
- data/lib/ruby_llm/active_record/chat_methods.rb +67 -16
- data/lib/ruby_llm/agent.rb +39 -8
- data/lib/ruby_llm/aliases.json +19 -9
- data/lib/ruby_llm/chat.rb +107 -11
- data/lib/ruby_llm/configuration.rb +18 -0
- data/lib/ruby_llm/connection.rb +10 -4
- data/lib/ruby_llm/content.rb +6 -2
- data/lib/ruby_llm/error.rb +32 -1
- data/lib/ruby_llm/message.rb +5 -3
- data/lib/ruby_llm/model/info.rb +1 -1
- data/lib/ruby_llm/models.json +3535 -2894
- data/lib/ruby_llm/models.rb +5 -3
- data/lib/ruby_llm/provider.rb +5 -1
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +22 -4
- data/lib/ruby_llm/providers/anthropic/chat.rb +22 -5
- data/lib/ruby_llm/providers/anthropic/models.rb +1 -1
- data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
- data/lib/ruby_llm/providers/anthropic.rb +1 -1
- data/lib/ruby_llm/providers/azure/chat.rb +1 -1
- data/lib/ruby_llm/providers/azure/embeddings.rb +1 -1
- data/lib/ruby_llm/providers/azure/models.rb +1 -1
- data/lib/ruby_llm/providers/azure.rb +88 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +50 -5
- data/lib/ruby_llm/providers/bedrock/models.rb +17 -1
- data/lib/ruby_llm/providers/bedrock/streaming.rb +8 -4
- data/lib/ruby_llm/providers/bedrock.rb +5 -1
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +8 -0
- data/lib/ruby_llm/providers/deepseek.rb +1 -1
- data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +19 -4
- data/lib/ruby_llm/providers/gemini/images.rb +1 -1
- data/lib/ruby_llm/providers/gemini/streaming.rb +1 -1
- data/lib/ruby_llm/providers/gemini/tools.rb +19 -0
- data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
- data/lib/ruby_llm/providers/gpustack.rb +4 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +2 -1
- data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
- data/lib/ruby_llm/providers/ollama.rb +7 -1
- data/lib/ruby_llm/providers/openai/capabilities.rb +10 -2
- data/lib/ruby_llm/providers/openai/chat.rb +15 -5
- data/lib/ruby_llm/providers/openai/media.rb +4 -1
- data/lib/ruby_llm/providers/openai/temperature.rb +2 -2
- data/lib/ruby_llm/providers/openai/tools.rb +27 -2
- data/lib/ruby_llm/providers/openrouter/chat.rb +19 -5
- data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
- data/lib/ruby_llm/providers/openrouter.rb +31 -1
- data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
- data/lib/ruby_llm/providers/vertexai.rb +14 -6
- data/lib/ruby_llm/stream_accumulator.rb +10 -5
- data/lib/ruby_llm/streaming.rb +6 -6
- data/lib/ruby_llm/tool.rb +48 -3
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/tasks/models.rake +33 -7
- data/lib/tasks/release.rake +1 -1
- data/lib/tasks/ruby_llm.rake +7 -0
- data/lib/tasks/vcr.rake +1 -1
- metadata +8 -5
|
@@ -205,6 +205,25 @@ module RubyLLM
|
|
|
205
205
|
else 'STRING'
|
|
206
206
|
end
|
|
207
207
|
end
|
|
208
|
+
|
|
209
|
+
def build_tool_config(tool_choice)
|
|
210
|
+
{
|
|
211
|
+
functionCallingConfig: {
|
|
212
|
+
mode: forced_tool_choice?(tool_choice) ? 'any' : tool_choice
|
|
213
|
+
}.tap do |config|
|
|
214
|
+
# Use allowedFunctionNames to simulate specific tool choice
|
|
215
|
+
config[:allowedFunctionNames] = [tool_choice] if specific_tool_choice?(tool_choice)
|
|
216
|
+
end
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def forced_tool_choice?(tool_choice)
|
|
221
|
+
tool_choice == :required || specific_tool_choice?(tool_choice)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def specific_tool_choice?(tool_choice)
|
|
225
|
+
!%i[auto none required].include?(tool_choice)
|
|
226
|
+
end
|
|
208
227
|
end
|
|
209
228
|
end
|
|
210
229
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class GPUStack
|
|
6
|
+
# Determines capabilities for GPUStack models
|
|
7
|
+
module Capabilities
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def supports_tool_choice?(_model_id)
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def supports_tool_parallel_control?(_model_id)
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -15,6 +15,14 @@ module RubyLLM
|
|
|
15
15
|
!model_id.match?(/embed|moderation|ocr|voxtral|transcriptions|mistral-(tiny|small)-(2312|2402)/)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
def supports_tool_choice?(_model_id)
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def supports_tool_parallel_control?(_model_id)
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
18
26
|
def supports_vision?(model_id)
|
|
19
27
|
model_id.match?(/pixtral|mistral-small-(2503|2506)|mistral-medium/)
|
|
20
28
|
end
|
|
@@ -23,7 +23,8 @@ module RubyLLM
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# rubocop:disable Metrics/ParameterLists
|
|
26
|
-
def render_payload(messages, tools:, temperature:, model:, stream: false,
|
|
26
|
+
def render_payload(messages, tools:, temperature:, model:, stream: false,
|
|
27
|
+
schema: nil, thinking: nil, tool_prefs: nil)
|
|
27
28
|
payload = super
|
|
28
29
|
payload.delete(:stream_options)
|
|
29
30
|
payload.delete(:reasoning_effort)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class Ollama
|
|
6
|
+
# Determines capabilities for Ollama models
|
|
7
|
+
module Capabilities
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def supports_tool_choice?(_model_id)
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def supports_tool_parallel_control?(_model_id)
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -13,7 +13,9 @@ module RubyLLM
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def headers
|
|
16
|
-
{}
|
|
16
|
+
return {} unless @config.ollama_api_key
|
|
17
|
+
|
|
18
|
+
{ 'Authorization' => "Bearer #{@config.ollama_api_key}" }
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
class << self
|
|
@@ -24,6 +26,10 @@ module RubyLLM
|
|
|
24
26
|
def local?
|
|
25
27
|
true
|
|
26
28
|
end
|
|
29
|
+
|
|
30
|
+
def capabilities
|
|
31
|
+
Ollama::Capabilities
|
|
32
|
+
end
|
|
27
33
|
end
|
|
28
34
|
end
|
|
29
35
|
end
|
|
@@ -97,6 +97,14 @@ module RubyLLM
|
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
def supports_tool_choice?(_model_id)
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def supports_tool_parallel_control?(_model_id)
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
100
108
|
def supports_structured_output?(model_id)
|
|
101
109
|
case model_family(model_id)
|
|
102
110
|
when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'chatgpt4o', 'gpt4o',
|
|
@@ -225,10 +233,10 @@ module RubyLLM
|
|
|
225
233
|
|
|
226
234
|
def self.normalize_temperature(temperature, model_id)
|
|
227
235
|
if model_id.match?(/^(o\d|gpt-5)/) && !temperature.nil? && !temperature_close_to_one?(temperature)
|
|
228
|
-
RubyLLM.logger.debug "Model #{model_id} requires temperature=1.0, setting that instead."
|
|
236
|
+
RubyLLM.logger.debug { "Model #{model_id} requires temperature=1.0, setting that instead." }
|
|
229
237
|
1.0
|
|
230
238
|
elsif model_id.match?(/-search/)
|
|
231
|
-
RubyLLM.logger.debug "Model #{model_id} does not accept temperature parameter, removing"
|
|
239
|
+
RubyLLM.logger.debug { "Model #{model_id} does not accept temperature parameter, removing" }
|
|
232
240
|
nil
|
|
233
241
|
else
|
|
234
242
|
temperature
|
|
@@ -11,7 +11,10 @@ module RubyLLM
|
|
|
11
11
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity
|
|
15
|
+
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
|
|
16
|
+
thinking: nil, tool_prefs: nil)
|
|
17
|
+
tool_prefs ||= {}
|
|
15
18
|
payload = {
|
|
16
19
|
model: model.id,
|
|
17
20
|
messages: format_messages(messages),
|
|
@@ -19,16 +22,22 @@ module RubyLLM
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
payload[:temperature] = temperature unless temperature.nil?
|
|
22
|
-
|
|
25
|
+
if tools.any?
|
|
26
|
+
payload[:tools] = tools.map { |_, tool| tool_for(tool) }
|
|
27
|
+
payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
|
|
28
|
+
payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
|
|
29
|
+
end
|
|
23
30
|
|
|
24
31
|
if schema
|
|
25
|
-
|
|
32
|
+
schema_name = schema[:name]
|
|
33
|
+
schema_def = schema[:schema]
|
|
34
|
+
strict = schema[:strict]
|
|
26
35
|
|
|
27
36
|
payload[:response_format] = {
|
|
28
37
|
type: 'json_schema',
|
|
29
38
|
json_schema: {
|
|
30
|
-
name:
|
|
31
|
-
schema:
|
|
39
|
+
name: schema_name,
|
|
40
|
+
schema: schema_def,
|
|
32
41
|
strict: strict
|
|
33
42
|
}
|
|
34
43
|
}
|
|
@@ -40,6 +49,7 @@ module RubyLLM
|
|
|
40
49
|
payload[:stream_options] = { include_usage: true } if stream
|
|
41
50
|
payload
|
|
42
51
|
end
|
|
52
|
+
# rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity
|
|
43
53
|
|
|
44
54
|
def parse_completion_response(response)
|
|
45
55
|
data = response.body
|
|
@@ -8,7 +8,10 @@ module RubyLLM
|
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
|
|
11
|
-
|
|
11
|
+
if content.is_a?(RubyLLM::Content::Raw)
|
|
12
|
+
value = content.value
|
|
13
|
+
return value.is_a?(Hash) ? value.to_json : value
|
|
14
|
+
end
|
|
12
15
|
return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
|
|
13
16
|
return content unless content.is_a?(Content)
|
|
14
17
|
|
|
@@ -9,10 +9,10 @@ module RubyLLM
|
|
|
9
9
|
|
|
10
10
|
def normalize(temperature, model_id)
|
|
11
11
|
if model_id.match?(/^(o\d|gpt-5)/) && !temperature.nil? && !temperature_close_to_one?(temperature)
|
|
12
|
-
RubyLLM.logger.debug "Model #{model_id} requires temperature=1.0, setting that instead."
|
|
12
|
+
RubyLLM.logger.debug { "Model #{model_id} requires temperature=1.0, setting that instead." }
|
|
13
13
|
1.0
|
|
14
14
|
elsif model_id.include?('-search')
|
|
15
|
-
RubyLLM.logger.debug "Model #{model_id} does not accept temperature parameter, removing"
|
|
15
|
+
RubyLLM.logger.debug { "Model #{model_id} does not accept temperature parameter, removing" }
|
|
16
16
|
nil
|
|
17
17
|
else
|
|
18
18
|
temperature
|
|
@@ -53,7 +53,7 @@ module RubyLLM
|
|
|
53
53
|
return nil unless tool_calls&.any?
|
|
54
54
|
|
|
55
55
|
tool_calls.map do |_, tc|
|
|
56
|
-
{
|
|
56
|
+
call = {
|
|
57
57
|
id: tc.id,
|
|
58
58
|
type: 'function',
|
|
59
59
|
function: {
|
|
@@ -61,6 +61,12 @@ module RubyLLM
|
|
|
61
61
|
arguments: JSON.generate(tc.arguments)
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
if tc.thought_signature
|
|
65
|
+
call[:extra_content] = {
|
|
66
|
+
google: { thought_signature: tc.thought_signature }
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
call
|
|
64
70
|
end
|
|
65
71
|
end
|
|
66
72
|
|
|
@@ -87,11 +93,30 @@ module RubyLLM
|
|
|
87
93
|
parse_tool_call_arguments(tc)
|
|
88
94
|
else
|
|
89
95
|
tc.dig('function', 'arguments')
|
|
90
|
-
end
|
|
96
|
+
end,
|
|
97
|
+
thought_signature: extract_tool_call_thought_signature(tc)
|
|
91
98
|
)
|
|
92
99
|
]
|
|
93
100
|
end
|
|
94
101
|
end
|
|
102
|
+
|
|
103
|
+
def build_tool_choice(tool_choice)
|
|
104
|
+
case tool_choice
|
|
105
|
+
when :auto, :none, :required
|
|
106
|
+
tool_choice
|
|
107
|
+
else
|
|
108
|
+
{
|
|
109
|
+
type: 'function',
|
|
110
|
+
function: {
|
|
111
|
+
name: tool_choice
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def extract_tool_call_thought_signature(tool_call)
|
|
118
|
+
tool_call.dig('extra_content', 'google', 'thought_signature')
|
|
119
|
+
end
|
|
95
120
|
end
|
|
96
121
|
end
|
|
97
122
|
end
|
|
@@ -7,7 +7,10 @@ module RubyLLM
|
|
|
7
7
|
module Chat
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity
|
|
11
|
+
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
|
|
12
|
+
thinking: nil, tool_prefs: nil)
|
|
13
|
+
tool_prefs ||= {}
|
|
11
14
|
payload = {
|
|
12
15
|
model: model.id,
|
|
13
16
|
messages: format_messages(messages),
|
|
@@ -15,15 +18,25 @@ module RubyLLM
|
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
payload[:temperature] = temperature unless temperature.nil?
|
|
18
|
-
|
|
21
|
+
if tools.any?
|
|
22
|
+
payload[:tools] = tools.map { |_, tool| OpenAI::Tools.tool_for(tool) }
|
|
23
|
+
payload[:tool_choice] = OpenAI::Tools.build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
|
|
24
|
+
payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
|
|
25
|
+
end
|
|
19
26
|
|
|
20
27
|
if schema
|
|
21
|
-
|
|
28
|
+
schema_name = schema[:name]
|
|
29
|
+
schema_def = RubyLLM::Utils.deep_dup(schema[:schema])
|
|
30
|
+
if schema_def.is_a?(Hash)
|
|
31
|
+
schema_def.delete(:strict)
|
|
32
|
+
schema_def.delete('strict')
|
|
33
|
+
end
|
|
34
|
+
strict = schema[:strict]
|
|
22
35
|
payload[:response_format] = {
|
|
23
36
|
type: 'json_schema',
|
|
24
37
|
json_schema: {
|
|
25
|
-
name:
|
|
26
|
-
schema:
|
|
38
|
+
name: schema_name,
|
|
39
|
+
schema: schema_def,
|
|
27
40
|
strict: strict
|
|
28
41
|
}
|
|
29
42
|
}
|
|
@@ -35,6 +48,7 @@ module RubyLLM
|
|
|
35
48
|
payload[:stream_options] = { include_usage: true } if stream
|
|
36
49
|
payload
|
|
37
50
|
end
|
|
51
|
+
# rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity
|
|
38
52
|
|
|
39
53
|
def parse_completion_response(response)
|
|
40
54
|
data = response.body
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class OpenRouter
|
|
6
|
+
# Image generation methods for the OpenRouter API integration.
|
|
7
|
+
# OpenRouter uses the chat completions endpoint for image generation
|
|
8
|
+
# instead of a dedicated images endpoint.
|
|
9
|
+
module Images
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def images_url
|
|
13
|
+
'chat/completions'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render_image_payload(prompt, model:, size:)
|
|
17
|
+
RubyLLM.logger.debug "Ignoring size #{size}. OpenRouter image generation does not support size parameter."
|
|
18
|
+
{
|
|
19
|
+
model: model,
|
|
20
|
+
messages: [
|
|
21
|
+
{
|
|
22
|
+
role: 'user',
|
|
23
|
+
content: prompt
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
modalities: %w[image text]
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse_image_response(response, model:)
|
|
31
|
+
data = response.body
|
|
32
|
+
message = data.dig('choices', 0, 'message')
|
|
33
|
+
|
|
34
|
+
unless message&.key?('images') && message['images']&.any?
|
|
35
|
+
raise Error.new(nil, 'Unexpected response format from OpenRouter image generation API')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
image_data = message['images'].first
|
|
39
|
+
image_url = image_data.dig('image_url', 'url') || image_data['url']
|
|
40
|
+
|
|
41
|
+
raise Error.new(nil, 'No image URL found in OpenRouter response') unless image_url
|
|
42
|
+
|
|
43
|
+
build_image_from_url(image_url, model)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_image_from_url(image_url, model)
|
|
47
|
+
if image_url.start_with?('data:')
|
|
48
|
+
# Parse data URL format: data:image/png;base64,<data>
|
|
49
|
+
match = image_url.match(/^data:([^;]+);base64,(.+)$/)
|
|
50
|
+
raise Error.new(nil, 'Invalid data URL format from OpenRouter') unless match
|
|
51
|
+
|
|
52
|
+
Image.new(
|
|
53
|
+
data: match[2],
|
|
54
|
+
mime_type: match[1],
|
|
55
|
+
model_id: model
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
# Regular URL
|
|
59
|
+
Image.new(
|
|
60
|
+
url: image_url,
|
|
61
|
+
mime_type: 'image/png',
|
|
62
|
+
model_id: model
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -7,9 +7,10 @@ module RubyLLM
|
|
|
7
7
|
include OpenRouter::Chat
|
|
8
8
|
include OpenRouter::Models
|
|
9
9
|
include OpenRouter::Streaming
|
|
10
|
+
include OpenRouter::Images
|
|
10
11
|
|
|
11
12
|
def api_base
|
|
12
|
-
'https://openrouter.ai/api/v1'
|
|
13
|
+
@config.openrouter_api_base || 'https://openrouter.ai/api/v1'
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def headers
|
|
@@ -18,6 +19,35 @@ module RubyLLM
|
|
|
18
19
|
}
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
def parse_error(response)
|
|
23
|
+
return if response.body.empty?
|
|
24
|
+
|
|
25
|
+
body = try_parse_json(response.body)
|
|
26
|
+
case body
|
|
27
|
+
when Hash
|
|
28
|
+
parse_error_part_message body
|
|
29
|
+
when Array
|
|
30
|
+
body.map do |part|
|
|
31
|
+
parse_error_part_message part
|
|
32
|
+
end.join('. ')
|
|
33
|
+
else
|
|
34
|
+
body
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse_error_part_message(part)
|
|
41
|
+
message = part.dig('error', 'message')
|
|
42
|
+
raw = try_parse_json(part.dig('error', 'metadata', 'raw'))
|
|
43
|
+
return message unless raw.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
raw_message = raw.dig('error', 'message')
|
|
46
|
+
return [message, raw_message].compact.join(' - ') if raw_message
|
|
47
|
+
|
|
48
|
+
message
|
|
49
|
+
end
|
|
50
|
+
|
|
21
51
|
class << self
|
|
22
52
|
def configuration_requirements
|
|
23
53
|
%i[openrouter_api_key]
|
|
@@ -10,6 +10,11 @@ module RubyLLM
|
|
|
10
10
|
include VertexAI::Models
|
|
11
11
|
include VertexAI::Transcription
|
|
12
12
|
|
|
13
|
+
SCOPES = [
|
|
14
|
+
'https://www.googleapis.com/auth/cloud-platform',
|
|
15
|
+
'https://www.googleapis.com/auth/generative-language.retriever'
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
13
18
|
def initialize(config)
|
|
14
19
|
super
|
|
15
20
|
@authorizer = nil
|
|
@@ -44,12 +49,15 @@ module RubyLLM
|
|
|
44
49
|
|
|
45
50
|
def initialize_authorizer
|
|
46
51
|
require 'googleauth'
|
|
47
|
-
@authorizer =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
@authorizer =
|
|
53
|
+
if @config.vertexai_service_account_key
|
|
54
|
+
::Google::Auth::ServiceAccountCredentials.make_creds(
|
|
55
|
+
json_key_io: StringIO.new(@config.vertexai_service_account_key),
|
|
56
|
+
scope: SCOPES
|
|
57
|
+
)
|
|
58
|
+
else
|
|
59
|
+
::Google::Auth.get_application_default(SCOPES)
|
|
60
|
+
end
|
|
53
61
|
rescue LoadError
|
|
54
62
|
raise Error,
|
|
55
63
|
'The googleauth gem ~> 1.15 is required for Vertex AI. Please add it to your Gemfile: gem "googleauth"'
|
|
@@ -21,13 +21,13 @@ module RubyLLM
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def add(chunk)
|
|
24
|
-
RubyLLM.logger.debug chunk.inspect if RubyLLM.config.log_stream_debug
|
|
24
|
+
RubyLLM.logger.debug { chunk.inspect } if RubyLLM.config.log_stream_debug
|
|
25
25
|
@model_id ||= chunk.model_id
|
|
26
26
|
|
|
27
27
|
handle_chunk_content(chunk)
|
|
28
28
|
append_thinking_from_chunk(chunk)
|
|
29
29
|
count_tokens chunk
|
|
30
|
-
RubyLLM.logger.debug inspect if RubyLLM.config.log_stream_debug
|
|
30
|
+
RubyLLM.logger.debug { inspect } if RubyLLM.config.log_stream_debug
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def to_message(response)
|
|
@@ -73,11 +73,14 @@ module RubyLLM
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def accumulate_tool_calls(new_tool_calls) # rubocop:disable Metrics/PerceivedComplexity
|
|
76
|
-
RubyLLM.logger.debug "Accumulating tool calls: #{new_tool_calls}" if RubyLLM.config.log_stream_debug
|
|
76
|
+
RubyLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if RubyLLM.config.log_stream_debug
|
|
77
77
|
new_tool_calls.each_value do |tool_call|
|
|
78
78
|
if tool_call.id
|
|
79
79
|
tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
|
|
80
|
-
tool_call_arguments = tool_call.arguments
|
|
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
|
|
81
84
|
@tool_calls[tool_call.id] = ToolCall.new(
|
|
82
85
|
id: tool_call_id,
|
|
83
86
|
name: tool_call.name,
|
|
@@ -88,7 +91,9 @@ module RubyLLM
|
|
|
88
91
|
else
|
|
89
92
|
existing = @tool_calls[@latest_tool_call_id]
|
|
90
93
|
if existing
|
|
91
|
-
|
|
94
|
+
fragment = tool_call.arguments
|
|
95
|
+
fragment = '' if fragment.nil?
|
|
96
|
+
existing.arguments << fragment
|
|
92
97
|
if tool_call.thought_signature && existing.thought_signature.nil?
|
|
93
98
|
existing.thought_signature = tool_call.thought_signature
|
|
94
99
|
end
|
data/lib/ruby_llm/streaming.rb
CHANGED
|
@@ -24,7 +24,7 @@ module RubyLLM
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
message = accumulator.to_message(response)
|
|
27
|
-
RubyLLM.logger.debug "Stream completed: #{message.content}"
|
|
27
|
+
RubyLLM.logger.debug { "Stream completed: #{message.content}" }
|
|
28
28
|
message
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -52,7 +52,7 @@ module RubyLLM
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def process_stream_chunk(chunk, parser, env, &)
|
|
55
|
-
RubyLLM.logger.debug "Received chunk: #{chunk}" if RubyLLM.config.log_stream_debug
|
|
55
|
+
RubyLLM.logger.debug { "Received chunk: #{chunk}" } if RubyLLM.config.log_stream_debug
|
|
56
56
|
|
|
57
57
|
if error_chunk?(chunk)
|
|
58
58
|
handle_error_chunk(chunk, env)
|
|
@@ -85,7 +85,7 @@ module RubyLLM
|
|
|
85
85
|
error_data = JSON.parse(buffer)
|
|
86
86
|
handle_parsed_error(error_data, env)
|
|
87
87
|
rescue JSON::ParserError
|
|
88
|
-
RubyLLM.logger.debug "Accumulating error chunk: #{chunk}"
|
|
88
|
+
RubyLLM.logger.debug { "Accumulating error chunk: #{chunk}" }
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def handle_sse(chunk, parser, env, &block)
|
|
@@ -105,7 +105,7 @@ module RubyLLM
|
|
|
105
105
|
|
|
106
106
|
handle_parsed_error(parsed, env)
|
|
107
107
|
rescue JSON::ParserError => e
|
|
108
|
-
RubyLLM.logger.debug "Failed to parse data chunk: #{e.message}"
|
|
108
|
+
RubyLLM.logger.debug { "Failed to parse data chunk: #{e.message}" }
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
def handle_error_event(data, env)
|
|
@@ -116,7 +116,7 @@ module RubyLLM
|
|
|
116
116
|
error_data = JSON.parse(data)
|
|
117
117
|
[500, error_data['message'] || 'Unknown streaming error']
|
|
118
118
|
rescue JSON::ParserError => e
|
|
119
|
-
RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
|
|
119
|
+
RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
|
|
120
120
|
[500, "Failed to parse error: #{data}"]
|
|
121
121
|
end
|
|
122
122
|
|
|
@@ -130,7 +130,7 @@ module RubyLLM
|
|
|
130
130
|
parsed_data = JSON.parse(data)
|
|
131
131
|
handle_parsed_error(parsed_data, env)
|
|
132
132
|
rescue JSON::ParserError => e
|
|
133
|
-
RubyLLM.logger.debug "#{error_message}: #{e.message}"
|
|
133
|
+
RubyLLM.logger.debug { "#{error_message}: #{e.message}" }
|
|
134
134
|
end
|
|
135
135
|
|
|
136
136
|
def build_stream_error_response(parsed_data, env, status)
|
data/lib/ruby_llm/tool.rb
CHANGED
|
@@ -99,9 +99,13 @@ module RubyLLM
|
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def call(args)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
normalized_args = normalize_args(args)
|
|
103
|
+
validation_error = validate_keyword_arguments(normalized_args)
|
|
104
|
+
return { error: "Invalid tool arguments: #{validation_error}" } if validation_error
|
|
105
|
+
|
|
106
|
+
RubyLLM.logger.debug { "Tool #{name} called with: #{normalized_args.inspect}" }
|
|
107
|
+
result = execute(**normalized_args)
|
|
108
|
+
RubyLLM.logger.debug { "Tool #{name} returned: #{result.inspect}" }
|
|
105
109
|
result
|
|
106
110
|
end
|
|
107
111
|
|
|
@@ -115,6 +119,47 @@ module RubyLLM
|
|
|
115
119
|
Halt.new(message)
|
|
116
120
|
end
|
|
117
121
|
|
|
122
|
+
def normalize_args(args)
|
|
123
|
+
return {} if args.nil?
|
|
124
|
+
return args.transform_keys(&:to_sym) if args.respond_to?(:transform_keys)
|
|
125
|
+
|
|
126
|
+
{}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_keyword_arguments(arguments)
|
|
130
|
+
required_keywords, optional_keywords, accepts_extra_keywords = execute_keyword_signature
|
|
131
|
+
|
|
132
|
+
return nil if required_keywords.empty? && optional_keywords.empty?
|
|
133
|
+
|
|
134
|
+
argument_keys = arguments.keys
|
|
135
|
+
missing_keyword = first_missing_keyword(required_keywords, argument_keys)
|
|
136
|
+
return "missing keyword: #{missing_keyword}" if missing_keyword
|
|
137
|
+
return nil if accepts_extra_keywords
|
|
138
|
+
|
|
139
|
+
allowed_keywords = required_keywords + optional_keywords
|
|
140
|
+
unknown_keyword = first_unknown_keyword(argument_keys, allowed_keywords)
|
|
141
|
+
return "unknown keyword: #{unknown_keyword}" if unknown_keyword
|
|
142
|
+
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def execute_keyword_signature
|
|
147
|
+
keyword_signature = method(:execute).parameters
|
|
148
|
+
required_keywords = keyword_signature.filter_map { |kind, name| name if kind == :keyreq }
|
|
149
|
+
optional_keywords = keyword_signature.filter_map { |kind, name| name if kind == :key }
|
|
150
|
+
accepts_extra_keywords = keyword_signature.any? { |kind, _| kind == :keyrest }
|
|
151
|
+
|
|
152
|
+
[required_keywords, optional_keywords, accepts_extra_keywords]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def first_missing_keyword(required_keywords, argument_keys)
|
|
156
|
+
(required_keywords - argument_keys).first
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def first_unknown_keyword(argument_keys, allowed_keywords)
|
|
160
|
+
(argument_keys - allowed_keywords).first
|
|
161
|
+
end
|
|
162
|
+
|
|
118
163
|
# Wraps schema handling for tool parameters, supporting JSON Schema hashes,
|
|
119
164
|
# RubyLLM::Schema instances/classes, and DSL blocks.
|
|
120
165
|
class SchemaDefinition
|
data/lib/ruby_llm/version.rb
CHANGED