ruby_llm 1.12.0 → 1.14.1

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 (141) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -5
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +2 -2
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +37 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
  51. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  52. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -1
  53. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
  54. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -2
  55. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
  56. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  57. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  59. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  60. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  61. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
  63. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  66. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  67. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  68. data/lib/ruby_llm/active_record/acts_as_legacy.rb +87 -20
  69. data/lib/ruby_llm/active_record/chat_methods.rb +80 -22
  70. data/lib/ruby_llm/active_record/message_methods.rb +17 -0
  71. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  72. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  73. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  74. data/lib/ruby_llm/agent.rb +50 -8
  75. data/lib/ruby_llm/aliases.json +60 -21
  76. data/lib/ruby_llm/attachment.rb +4 -1
  77. data/lib/ruby_llm/chat.rb +113 -12
  78. data/lib/ruby_llm/configuration.rb +65 -66
  79. data/lib/ruby_llm/connection.rb +11 -7
  80. data/lib/ruby_llm/content.rb +6 -2
  81. data/lib/ruby_llm/error.rb +37 -1
  82. data/lib/ruby_llm/message.rb +5 -3
  83. data/lib/ruby_llm/model/info.rb +15 -13
  84. data/lib/ruby_llm/models.json +12279 -13517
  85. data/lib/ruby_llm/models.rb +16 -6
  86. data/lib/ruby_llm/provider.rb +10 -1
  87. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  88. data/lib/ruby_llm/providers/anthropic/chat.rb +22 -5
  89. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  90. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  91. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  92. data/lib/ruby_llm/providers/azure/chat.rb +1 -1
  93. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -1
  94. data/lib/ruby_llm/providers/azure/models.rb +1 -1
  95. data/lib/ruby_llm/providers/azure.rb +92 -0
  96. data/lib/ruby_llm/providers/bedrock/chat.rb +50 -5
  97. data/lib/ruby_llm/providers/bedrock/models.rb +17 -1
  98. data/lib/ruby_llm/providers/bedrock/streaming.rb +8 -4
  99. data/lib/ruby_llm/providers/bedrock.rb +9 -1
  100. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  101. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  102. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  103. data/lib/ruby_llm/providers/gemini/chat.rb +20 -4
  104. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  105. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  106. data/lib/ruby_llm/providers/gemini/streaming.rb +2 -1
  107. data/lib/ruby_llm/providers/gemini/tools.rb +19 -0
  108. data/lib/ruby_llm/providers/gemini.rb +4 -0
  109. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  110. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  111. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  112. data/lib/ruby_llm/providers/mistral/chat.rb +2 -1
  113. data/lib/ruby_llm/providers/mistral.rb +4 -0
  114. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  115. data/lib/ruby_llm/providers/ollama.rb +11 -1
  116. data/lib/ruby_llm/providers/openai/capabilities.rb +95 -195
  117. data/lib/ruby_llm/providers/openai/chat.rb +15 -5
  118. data/lib/ruby_llm/providers/openai/media.rb +4 -1
  119. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  120. data/lib/ruby_llm/providers/openai/temperature.rb +2 -2
  121. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  122. data/lib/ruby_llm/providers/openai.rb +10 -0
  123. data/lib/ruby_llm/providers/openrouter/chat.rb +19 -5
  124. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  125. data/lib/ruby_llm/providers/openrouter.rb +35 -1
  126. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  127. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  128. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  129. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  130. data/lib/ruby_llm/providers/vertexai.rb +18 -6
  131. data/lib/ruby_llm/providers/xai.rb +4 -0
  132. data/lib/ruby_llm/stream_accumulator.rb +10 -5
  133. data/lib/ruby_llm/streaming.rb +7 -7
  134. data/lib/ruby_llm/tool.rb +48 -3
  135. data/lib/ruby_llm/version.rb +1 -1
  136. data/lib/tasks/models.rake +33 -7
  137. data/lib/tasks/release.rake +1 -1
  138. data/lib/tasks/ruby_llm.rake +9 -1
  139. data/lib/tasks/vcr.rake +1 -1
  140. metadata +56 -15
  141. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -1,8 +1,4 @@
1
1
  {
2
- "chatgpt-4o": {
3
- "openai": "chatgpt-4o-latest",
4
- "openrouter": "openai/chatgpt-4o-latest"
5
- },
6
2
  "claude-3-5-haiku": {
7
3
  "anthropic": "claude-3-5-haiku-20241022",
8
4
  "openrouter": "anthropic/claude-3.5-haiku",
@@ -14,7 +10,7 @@
14
10
  "claude-3-5-sonnet": {
15
11
  "anthropic": "claude-3-5-sonnet-20241022",
16
12
  "openrouter": "anthropic/claude-3.5-sonnet",
17
- "bedrock": "anthropic.claude-3-5-sonnet-20240620-v1:0:200k"
13
+ "bedrock": "anthropic.claude-3-5-sonnet-20241022-v2:0"
18
14
  },
19
15
  "claude-3-7-sonnet": {
20
16
  "anthropic": "claude-3-7-sonnet-20250219",
@@ -30,8 +26,7 @@
30
26
  "bedrock": "anthropic.claude-3-haiku-20240307-v1:0:200k"
31
27
  },
32
28
  "claude-3-opus": {
33
- "anthropic": "claude-3-opus-20240229",
34
- "bedrock": "anthropic.claude-3-opus-20240229-v1:0:200k"
29
+ "anthropic": "claude-3-opus-20240229"
35
30
  },
36
31
  "claude-3-sonnet": {
37
32
  "anthropic": "claude-3-sonnet-20240229",
@@ -66,7 +61,8 @@
66
61
  "claude-opus-4-6": {
67
62
  "anthropic": "claude-opus-4-6",
68
63
  "openrouter": "anthropic/claude-opus-4.6",
69
- "bedrock": "anthropic.claude-opus-4-6-v1"
64
+ "bedrock": "anthropic.claude-opus-4-6-v1",
65
+ "azure": "claude-opus-4-6"
70
66
  },
71
67
  "claude-sonnet-4": {
72
68
  "anthropic": "claude-sonnet-4-20250514",
@@ -82,6 +78,12 @@
82
78
  "bedrock": "anthropic.claude-sonnet-4-5-20250929-v1:0",
83
79
  "azure": "claude-sonnet-4-5-20250929"
84
80
  },
81
+ "claude-sonnet-4-6": {
82
+ "anthropic": "claude-sonnet-4-6",
83
+ "openrouter": "anthropic/claude-sonnet-4.6",
84
+ "bedrock": "anthropic.claude-sonnet-4-6",
85
+ "azure": "claude-sonnet-4-6"
86
+ },
85
87
  "deepseek-chat": {
86
88
  "deepseek": "deepseek-chat",
87
89
  "openrouter": "deepseek/deepseek-chat"
@@ -181,14 +183,30 @@
181
183
  "openrouter": "google/gemini-3-pro-preview",
182
184
  "vertexai": "gemini-3-pro-preview"
183
185
  },
186
+ "gemini-3.1-flash-image-preview": {
187
+ "gemini": "gemini-3.1-flash-image-preview",
188
+ "openrouter": "google/gemini-3.1-flash-image-preview",
189
+ "vertexai": "gemini-3.1-flash-image-preview"
190
+ },
191
+ "gemini-3.1-flash-lite-preview": {
192
+ "gemini": "gemini-3.1-flash-lite-preview",
193
+ "openrouter": "google/gemini-3.1-flash-lite-preview",
194
+ "vertexai": "gemini-3.1-flash-lite-preview"
195
+ },
196
+ "gemini-3.1-pro-preview": {
197
+ "gemini": "gemini-3.1-pro-preview",
198
+ "openrouter": "google/gemini-3.1-pro-preview",
199
+ "vertexai": "gemini-3.1-pro-preview"
200
+ },
201
+ "gemini-3.1-pro-preview-customtools": {
202
+ "gemini": "gemini-3.1-pro-preview-customtools",
203
+ "openrouter": "google/gemini-3.1-pro-preview-customtools",
204
+ "vertexai": "gemini-3.1-pro-preview-customtools"
205
+ },
184
206
  "gemini-embedding-001": {
185
207
  "gemini": "gemini-embedding-001",
186
208
  "vertexai": "gemini-embedding-001"
187
209
  },
188
- "gemini-exp-1206": {
189
- "gemini": "gemini-exp-1206",
190
- "vertexai": "gemini-exp-1206"
191
- },
192
210
  "gemini-flash": {
193
211
  "gemini": "gemini-flash-latest",
194
212
  "vertexai": "gemini-flash-latest"
@@ -230,18 +248,10 @@
230
248
  "openrouter": "openai/gpt-4",
231
249
  "azure": "gpt-4"
232
250
  },
233
- "gpt-4-1106-preview": {
234
- "openai": "gpt-4-1106-preview",
235
- "openrouter": "openai/gpt-4-1106-preview"
236
- },
237
251
  "gpt-4-turbo": {
238
252
  "openai": "gpt-4-turbo",
239
253
  "openrouter": "openai/gpt-4-turbo"
240
254
  },
241
- "gpt-4-turbo-preview": {
242
- "openai": "gpt-4-turbo-preview",
243
- "openrouter": "openai/gpt-4-turbo-preview"
244
- },
245
255
  "gpt-4.1": {
246
256
  "openai": "gpt-4.1",
247
257
  "openrouter": "openai/gpt-4.1",
@@ -321,7 +331,8 @@
321
331
  },
322
332
  "gpt-5.1": {
323
333
  "openai": "gpt-5.1",
324
- "openrouter": "openai/gpt-5.1"
334
+ "openrouter": "openai/gpt-5.1",
335
+ "azure": "gpt-5.1"
325
336
  },
326
337
  "gpt-5.1-codex": {
327
338
  "openai": "gpt-5.1-codex",
@@ -347,6 +358,26 @@
347
358
  "openai": "gpt-5.2-pro",
348
359
  "openrouter": "openai/gpt-5.2-pro"
349
360
  },
361
+ "gpt-5.3-codex": {
362
+ "openai": "gpt-5.3-codex",
363
+ "openrouter": "openai/gpt-5.3-codex"
364
+ },
365
+ "gpt-5.4": {
366
+ "openai": "gpt-5.4",
367
+ "openrouter": "openai/gpt-5.4"
368
+ },
369
+ "gpt-5.4-mini": {
370
+ "openai": "gpt-5.4-mini",
371
+ "openrouter": "openai/gpt-5.4-mini"
372
+ },
373
+ "gpt-5.4-nano": {
374
+ "openai": "gpt-5.4-nano",
375
+ "openrouter": "openai/gpt-5.4-nano"
376
+ },
377
+ "gpt-5.4-pro": {
378
+ "openai": "gpt-5.4-pro",
379
+ "openrouter": "openai/gpt-5.4-pro"
380
+ },
350
381
  "gpt-audio": {
351
382
  "openai": "gpt-audio",
352
383
  "openrouter": "openai/gpt-audio"
@@ -355,6 +386,14 @@
355
386
  "openai": "gpt-audio-mini",
356
387
  "openrouter": "openai/gpt-audio-mini"
357
388
  },
389
+ "lyria-3-clip-preview": {
390
+ "gemini": "lyria-3-clip-preview",
391
+ "openrouter": "google/lyria-3-clip-preview"
392
+ },
393
+ "lyria-3-pro-preview": {
394
+ "gemini": "lyria-3-pro-preview",
395
+ "openrouter": "google/lyria-3-pro-preview"
396
+ },
358
397
  "o1": {
359
398
  "openai": "o1",
360
399
  "openrouter": "openai/o1"
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
4
+ require 'uri'
5
+
3
6
  module RubyLLM
4
7
  # A class representing a file attachment.
5
8
  class Attachment
@@ -134,7 +137,7 @@ module RubyLLM
134
137
  end
135
138
 
136
139
  def load_content_from_path
137
- @content = File.read(@source)
140
+ @content = File.binread(@source)
138
141
  end
139
142
 
140
143
  def load_content_from_io
data/lib/ruby_llm/chat.rb CHANGED
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  class Chat
6
6
  include Enumerable
7
7
 
8
- attr_reader :model, :messages, :tools, :params, :headers, :schema
8
+ attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
9
9
 
10
10
  def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
11
11
  if assume_model_exists && !provider
@@ -19,6 +19,7 @@ module RubyLLM
19
19
  @temperature = nil
20
20
  @messages = []
21
21
  @tools = {}
22
+ @tool_prefs = { choice: nil, calls: nil }
22
23
  @params = {}
23
24
  @headers = {}
24
25
  @schema = nil
@@ -50,15 +51,19 @@ module RubyLLM
50
51
  self
51
52
  end
52
53
 
53
- def with_tool(tool)
54
- tool_instance = tool.is_a?(Class) ? tool.new : tool
55
- @tools[tool_instance.name.to_sym] = tool_instance
54
+ def with_tool(tool, choice: nil, calls: nil)
55
+ unless tool.nil?
56
+ tool_instance = tool.is_a?(Class) ? tool.new : tool
57
+ @tools[tool_instance.name.to_sym] = tool_instance
58
+ end
59
+ update_tool_options(choice:, calls:)
56
60
  self
57
61
  end
58
62
 
59
- def with_tools(*tools, replace: false)
63
+ def with_tools(*tools, replace: false, choice: nil, calls: nil)
60
64
  @tools.clear if replace
61
65
  tools.compact.each { |tool| with_tool tool }
66
+ update_tool_options(choice:, calls:)
62
67
  self
63
68
  end
64
69
 
@@ -100,12 +105,9 @@ module RubyLLM
100
105
  def with_schema(schema)
101
106
  schema_instance = schema.is_a?(Class) ? schema.new : schema
102
107
 
103
- # Accept both RubyLLM::Schema instances and plain JSON schemas
104
- @schema = if schema_instance.respond_to?(:to_json_schema)
105
- schema_instance.to_json_schema[:schema]
106
- else
107
- schema_instance
108
- end
108
+ @schema = normalize_schema_payload(
109
+ schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
110
+ )
109
111
 
110
112
  self
111
113
  end
@@ -138,6 +140,7 @@ module RubyLLM
138
140
  response = @provider.complete(
139
141
  messages,
140
142
  tools: @tools,
143
+ tool_prefs: @tool_prefs,
141
144
  temperature: @temperature,
142
145
  model: @model,
143
146
  params: @params,
@@ -149,7 +152,7 @@ module RubyLLM
149
152
 
150
153
  @on[:new_message]&.call unless block_given?
151
154
 
152
- if @schema && response.content.is_a?(String)
155
+ if @schema && response.content.is_a?(String) && !response.tool_call?
153
156
  begin
154
157
  response.content = JSON.parse(response.content)
155
158
  rescue JSON::ParserError
@@ -183,6 +186,41 @@ module RubyLLM
183
186
 
184
187
  private
185
188
 
189
+ def normalize_schema_payload(raw_schema)
190
+ return nil if raw_schema.nil?
191
+ return raw_schema unless raw_schema.is_a?(Hash)
192
+
193
+ schema = RubyLLM::Utils.deep_symbolize_keys(raw_schema)
194
+ schema_def = extract_schema_definition(schema)
195
+ strict = extract_schema_strict(schema, schema_def)
196
+ build_schema_payload(schema, schema_def, strict)
197
+ end
198
+
199
+ def extract_schema_definition(schema)
200
+ RubyLLM::Utils.deep_dup(schema[:schema] || schema)
201
+ end
202
+
203
+ def extract_schema_strict(schema, schema_def)
204
+ return schema[:strict] if schema.key?(:strict)
205
+ return schema_def.delete(:strict) if schema_def.is_a?(Hash)
206
+
207
+ nil
208
+ end
209
+
210
+ def build_schema_payload(schema, schema_def, strict)
211
+ {
212
+ name: sanitize_schema_name(schema[:name] || 'response'),
213
+ schema: schema_def,
214
+ strict: strict.nil? || strict,
215
+ description: schema[:description]
216
+ }.compact
217
+ end
218
+
219
+ def sanitize_schema_name(name)
220
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
221
+ sanitized.empty? ? 'response' : sanitized
222
+ end
223
+
186
224
  def wrap_streaming_block(&block)
187
225
  return nil unless block_given?
188
226
 
@@ -209,15 +247,78 @@ module RubyLLM
209
247
  halt_result = result if result.is_a?(Tool::Halt)
210
248
  end
211
249
 
250
+ reset_tool_choice if forced_tool_choice?
212
251
  halt_result || complete(&)
213
252
  end
214
253
 
215
254
  def execute_tool(tool_call)
216
255
  tool = tools[tool_call.name.to_sym]
256
+ if tool.nil?
257
+ return {
258
+ error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
259
+ "Available tools: #{tools.keys.to_json}."
260
+ }
261
+ end
262
+
217
263
  args = tool_call.arguments
218
264
  tool.call(args)
219
265
  end
220
266
 
267
+ def update_tool_options(choice:, calls:)
268
+ unless choice.nil?
269
+ normalized_choice = normalize_tool_choice(choice)
270
+ valid_tool_choices = %i[auto none required] + tools.keys
271
+ unless valid_tool_choices.include?(normalized_choice)
272
+ raise InvalidToolChoiceError,
273
+ "Invalid tool choice: #{choice}. Valid choices are: #{valid_tool_choices.join(', ')}"
274
+ end
275
+
276
+ @tool_prefs[:choice] = normalized_choice
277
+ end
278
+
279
+ @tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
280
+ end
281
+
282
+ def normalize_calls(calls)
283
+ case calls
284
+ when :many, 'many'
285
+ :many
286
+ when :one, 'one', 1
287
+ :one
288
+ else
289
+ raise ArgumentError, "Invalid calls value: #{calls.inspect}. Valid values are: :many, :one, or 1"
290
+ end
291
+ end
292
+
293
+ def normalize_tool_choice(choice)
294
+ return choice.to_sym if choice.is_a?(String) || choice.is_a?(Symbol)
295
+ return tool_name_for_choice_class(choice) if choice.is_a?(Class)
296
+
297
+ choice.respond_to?(:name) ? choice.name.to_sym : choice.to_sym
298
+ end
299
+
300
+ def tool_name_for_choice_class(tool_class)
301
+ matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
302
+ return matched_tool_name if matched_tool_name
303
+
304
+ classify_tool_name(tool_class.name)
305
+ end
306
+
307
+ def classify_tool_name(class_name)
308
+ class_name.split('::').last
309
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
310
+ .downcase
311
+ .to_sym
312
+ end
313
+
314
+ def forced_tool_choice?
315
+ @tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
316
+ end
317
+
318
+ def reset_tool_choice
319
+ @tool_prefs[:choice] = nil
320
+ end
321
+
221
322
  def build_content(message, attachments)
222
323
  return message if content_like?(message)
223
324
 
@@ -3,80 +3,79 @@
3
3
  module RubyLLM
4
4
  # Global configuration for RubyLLM
5
5
  class Configuration
6
- attr_accessor :openai_api_key,
7
- :openai_api_base,
8
- :openai_organization_id,
9
- :openai_project_id,
10
- :openai_use_system_role,
11
- :azure_api_base,
12
- :azure_api_key,
13
- :azure_ai_auth_token,
14
- :anthropic_api_key,
15
- :gemini_api_key,
16
- :gemini_api_base,
17
- :vertexai_project_id,
18
- :vertexai_location,
19
- :deepseek_api_key,
20
- :perplexity_api_key,
21
- :bedrock_api_key,
22
- :bedrock_secret_key,
23
- :bedrock_region,
24
- :bedrock_session_token,
25
- :openrouter_api_key,
26
- :xai_api_key,
27
- :ollama_api_base,
28
- :gpustack_api_base,
29
- :gpustack_api_key,
30
- :mistral_api_key,
31
- # Default models
32
- :default_model,
33
- :default_embedding_model,
34
- :default_moderation_model,
35
- :default_image_model,
36
- :default_transcription_model,
37
- # Model registry
38
- :model_registry_file,
39
- :model_registry_class,
40
- # Rails integration
41
- :use_new_acts_as,
42
- # Connection configuration
43
- :request_timeout,
44
- :max_retries,
45
- :retry_interval,
46
- :retry_backoff_factor,
47
- :retry_interval_randomness,
48
- :http_proxy,
49
- # Logging configuration
50
- :logger,
51
- :log_file,
52
- :log_level,
53
- :log_stream_debug
6
+ class << self
7
+ # Declare a single configuration option.
8
+ def option(key, default = nil)
9
+ key = key.to_sym
10
+ return if options.include?(key)
54
11
 
55
- def initialize
56
- @request_timeout = 300
57
- @max_retries = 3
58
- @retry_interval = 0.1
59
- @retry_backoff_factor = 2
60
- @retry_interval_randomness = 0.5
61
- @http_proxy = nil
12
+ send(:attr_accessor, key)
13
+ option_keys << key
14
+ defaults[key] = default
15
+ end
16
+
17
+ def register_provider_options(options)
18
+ Array(options).each { |key| option(key, nil) }
19
+ end
20
+
21
+ def options
22
+ option_keys.dup
23
+ end
24
+
25
+ private
26
+
27
+ def option_keys = @option_keys ||= []
28
+ def defaults = @defaults ||= {}
29
+ private :option
30
+ end
62
31
 
63
- @default_model = 'gpt-5-nano'
64
- @default_embedding_model = 'text-embedding-3-small'
65
- @default_moderation_model = 'omni-moderation-latest'
66
- @default_image_model = 'gpt-image-1'
67
- @default_transcription_model = 'whisper-1'
32
+ # System-level options are declared here.
33
+ # Provider-specific options are declared in each provider class via
34
+ # `self.configuration_options` and registered through Provider.register.
35
+ option :default_model, 'gpt-5.4'
36
+ option :default_embedding_model, 'text-embedding-3-small'
37
+ option :default_moderation_model, 'omni-moderation-latest'
38
+ option :default_image_model, 'gpt-image-1.5'
39
+ option :default_transcription_model, 'whisper-1'
68
40
 
69
- @model_registry_file = File.expand_path('models.json', __dir__)
70
- @model_registry_class = 'Model'
71
- @use_new_acts_as = false
41
+ option :model_registry_file, -> { File.expand_path('models.json', __dir__) }
42
+ option :model_registry_class, 'Model'
72
43
 
73
- @log_file = $stdout
74
- @log_level = ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO
75
- @log_stream_debug = ENV['RUBYLLM_STREAM_DEBUG'] == 'true'
44
+ option :use_new_acts_as, false
45
+
46
+ option :request_timeout, 300
47
+ option :max_retries, 3
48
+ option :retry_interval, 0.1
49
+ option :retry_backoff_factor, 2
50
+ option :retry_interval_randomness, 0.5
51
+ option :http_proxy, nil
52
+
53
+ option :logger, nil
54
+ option :log_file, -> { $stdout }
55
+ option :log_level, -> { ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO }
56
+ option :log_stream_debug, -> { ENV['RUBYLLM_STREAM_DEBUG'] == 'true' }
57
+ option :log_regexp_timeout, -> { Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil }
58
+
59
+ def initialize
60
+ self.class.send(:defaults).each do |key, default|
61
+ value = default.respond_to?(:call) ? instance_exec(&default) : default
62
+ public_send("#{key}=", value)
63
+ end
76
64
  end
77
65
 
78
66
  def instance_variables
79
67
  super.reject { |ivar| ivar.to_s.match?(/_id|_key|_secret|_token$/) }
80
68
  end
69
+
70
+ def log_regexp_timeout=(value)
71
+ if value.nil?
72
+ @log_regexp_timeout = nil
73
+ elsif Regexp.respond_to?(:timeout)
74
+ @log_regexp_timeout = value
75
+ else
76
+ RubyLLM.logger.warn("log_regexp_timeout is not supported on Ruby #{RUBY_VERSION}")
77
+ @log_regexp_timeout = value
78
+ end
79
+ end
81
80
  end
82
81
  end
@@ -10,7 +10,6 @@ module RubyLLM
10
10
  f.response :logger,
11
11
  RubyLLM.logger,
12
12
  bodies: false,
13
- response: false,
14
13
  errors: true,
15
14
  headers: false,
16
15
  log_level: :debug
@@ -60,24 +59,29 @@ module RubyLLM
60
59
  def setup_logging(faraday)
61
60
  faraday.response :logger,
62
61
  RubyLLM.logger,
63
- bodies: true,
64
- response: true,
62
+ bodies: RubyLLM.logger.debug?,
65
63
  errors: true,
66
64
  headers: false,
67
65
  log_level: :debug do |logger|
68
- logger.filter(%r{[A-Za-z0-9+/=]{100,}}, '[BASE64 DATA]')
69
- logger.filter(/[-\d.e,\s]{100,}/, '[EMBEDDINGS ARRAY]')
66
+ logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}'), '[BASE64 DATA]')
67
+ logger.filter(logging_regexp('[-\\d.e,\\s]{100,}'), '[EMBEDDINGS ARRAY]')
70
68
  end
71
69
  end
72
70
 
71
+ def logging_regexp(pattern)
72
+ return Regexp.new(pattern) if @config.log_regexp_timeout.nil? || !Regexp.respond_to?(:timeout)
73
+
74
+ Regexp.new(pattern, timeout: @config.log_regexp_timeout)
75
+ end
76
+
73
77
  def setup_retry(faraday)
74
78
  faraday.request :retry, {
75
79
  max: @config.max_retries,
76
80
  interval: @config.retry_interval,
77
81
  interval_randomness: @config.retry_interval_randomness,
78
82
  backoff_factor: @config.retry_backoff_factor,
79
- exceptions: retry_exceptions,
80
- retry_statuses: [429, 500, 502, 503, 504, 529]
83
+ methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS + [:post],
84
+ exceptions: retry_exceptions
81
85
  }
82
86
  end
83
87
 
@@ -35,10 +35,16 @@ module RubyLLM
35
35
 
36
36
  def process_attachments_array_or_string(attachments)
37
37
  Utils.to_safe_array(attachments).each do |file|
38
+ next if blank_attachment_entry?(file)
39
+
38
40
  add_attachment(file)
39
41
  end
40
42
  end
41
43
 
44
+ def blank_attachment_entry?(file)
45
+ file.nil? || (file.is_a?(String) && file.strip.empty?)
46
+ end
47
+
42
48
  def process_attachments(attachments)
43
49
  if attachments.is_a?(Hash)
44
50
  attachments.each_value { |attachment| process_attachments_array_or_string(attachment) }
@@ -47,9 +53,7 @@ module RubyLLM
47
53
  end
48
54
  end
49
55
  end
50
- end
51
56
 
52
- module RubyLLM
53
57
  class Content
54
58
  # Represents provider-specific payloads that should bypass RubyLLM formatting.
55
59
  class Raw
@@ -7,6 +7,11 @@ module RubyLLM
7
7
  attr_reader :response
8
8
 
9
9
  def initialize(response = nil, message = nil)
10
+ if response.is_a?(String)
11
+ message = response
12
+ response = nil
13
+ end
14
+
10
15
  @response = response
11
16
  super(message || response&.body)
12
17
  end
@@ -14,13 +19,16 @@ module RubyLLM
14
19
 
15
20
  # Error classes for non-HTTP errors
16
21
  class ConfigurationError < StandardError; end
22
+ class PromptNotFoundError < StandardError; end
17
23
  class InvalidRoleError < StandardError; end
24
+ class InvalidToolChoiceError < StandardError; end
18
25
  class ModelNotFoundError < StandardError; end
19
26
  class UnsupportedAttachmentError < StandardError; end
20
27
 
21
28
  # Error classes for different HTTP status codes
22
29
  class BadRequestError < Error; end
23
30
  class ForbiddenError < Error; end
31
+ class ContextLengthExceededError < Error; end
24
32
  class OverloadedError < Error; end
25
33
  class PaymentRequiredError < Error; end
26
34
  class RateLimitError < Error; end
@@ -42,6 +50,18 @@ module RubyLLM
42
50
  end
43
51
 
44
52
  class << self
53
+ CONTEXT_LENGTH_PATTERNS = [
54
+ /context length/i,
55
+ /context window/i,
56
+ /maximum context/i,
57
+ /request too large/i,
58
+ /too many tokens/i,
59
+ /token count exceeds/i,
60
+ /input[_\s-]?token/i,
61
+ /input or output tokens? must be reduced/i,
62
+ /reduce the length of messages/i
63
+ ].freeze
64
+
45
65
  def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
46
66
  message = provider&.parse_error(response)
47
67
 
@@ -49,6 +69,10 @@ module RubyLLM
49
69
  when 200..399
50
70
  message
51
71
  when 400
72
+ if context_length_exceeded?(message)
73
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
74
+ end
75
+
52
76
  raise BadRequestError.new(response, message || 'Invalid request - please check your input')
53
77
  when 401
54
78
  raise UnauthorizedError.new(response, message || 'Invalid API key - check your credentials')
@@ -58,10 +82,14 @@ module RubyLLM
58
82
  raise ForbiddenError.new(response,
59
83
  message || 'Forbidden - you do not have permission to access this resource')
60
84
  when 429
85
+ if context_length_exceeded?(message)
86
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
87
+ end
88
+
61
89
  raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
62
90
  when 500
63
91
  raise ServerError.new(response, message || 'API server error - please try again')
64
- when 502..503
92
+ when 502..504
65
93
  raise ServiceUnavailableError.new(response, message || 'API server unavailable - please try again later')
66
94
  when 529
67
95
  raise OverloadedError.new(response, message || 'Service overloaded - please try again later')
@@ -69,6 +97,14 @@ module RubyLLM
69
97
  raise Error.new(response, message || 'An unknown error occurred')
70
98
  end
71
99
  end
100
+
101
+ private
102
+
103
+ def context_length_exceeded?(message)
104
+ return false if message.to_s.empty?
105
+
106
+ CONTEXT_LENGTH_PATTERNS.any? { |pattern| message.match?(pattern) }
107
+ end
72
108
  end
73
109
  end
74
110
  end
@@ -10,9 +10,9 @@ module RubyLLM
10
10
 
11
11
  def initialize(options = {})
12
12
  @role = options.fetch(:role).to_sym
13
- @content = normalize_content(options.fetch(:content))
14
- @model_id = options[:model_id]
15
13
  @tool_calls = options[:tool_calls]
14
+ @content = normalize_content(options.fetch(:content), role: @role, tool_calls: @tool_calls)
15
+ @model_id = options[:model_id]
16
16
  @tool_call_id = options[:tool_call_id]
17
17
  @tokens = options[:tokens] || Tokens.build(
18
18
  input: options[:input_tokens],
@@ -90,7 +90,9 @@ module RubyLLM
90
90
 
91
91
  private
92
92
 
93
- def normalize_content(content)
93
+ def normalize_content(content, role:, tool_calls:)
94
+ return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
95
+
94
96
  case content
95
97
  when String then Content.new(content)
96
98
  when Hash then Content.new(content[:text], content)