dify_llm 1.9.1 → 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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -8
  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 +4 -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 -1
  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/templates/add_v1_10_message_columns.rb.tt +19 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  66. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +1 -1
  67. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  68. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  69. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  70. data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
  71. data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
  72. data/lib/ruby_llm/active_record/message_methods.rb +58 -8
  73. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  74. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  75. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  76. data/lib/ruby_llm/agent.rb +365 -0
  77. data/lib/ruby_llm/aliases.json +199 -62
  78. data/lib/ruby_llm/attachment.rb +15 -4
  79. data/lib/ruby_llm/chat.rb +150 -22
  80. data/lib/ruby_llm/configuration.rb +65 -65
  81. data/lib/ruby_llm/connection.rb +11 -7
  82. data/lib/ruby_llm/content.rb +6 -2
  83. data/lib/ruby_llm/error.rb +37 -1
  84. data/lib/ruby_llm/message.rb +43 -15
  85. data/lib/ruby_llm/model/info.rb +15 -13
  86. data/lib/ruby_llm/models.json +37560 -14094
  87. data/lib/ruby_llm/models.rb +321 -38
  88. data/lib/ruby_llm/models_schema.json +2 -2
  89. data/lib/ruby_llm/provider.rb +26 -4
  90. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  91. data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
  92. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  93. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  94. data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
  95. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  96. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  97. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  98. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  99. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  100. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  101. data/lib/ruby_llm/providers/azure.rb +148 -0
  102. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  103. data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
  104. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  105. data/lib/ruby_llm/providers/bedrock/models.rb +107 -62
  106. data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
  107. data/lib/ruby_llm/providers/bedrock.rb +69 -52
  108. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  109. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  110. data/lib/ruby_llm/providers/dify/chat.rb +82 -7
  111. data/lib/ruby_llm/providers/dify/media.rb +2 -2
  112. data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
  113. data/lib/ruby_llm/providers/dify.rb +4 -0
  114. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  115. data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
  116. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  117. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  118. data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
  119. data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
  120. data/lib/ruby_llm/providers/gemini.rb +4 -0
  121. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  122. data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
  123. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  124. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  125. data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
  126. data/lib/ruby_llm/providers/mistral.rb +4 -0
  127. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  128. data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
  129. data/lib/ruby_llm/providers/ollama.rb +11 -1
  130. data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
  131. data/lib/ruby_llm/providers/openai/chat.rb +101 -7
  132. data/lib/ruby_llm/providers/openai/media.rb +5 -2
  133. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  134. data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
  135. data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
  136. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  137. data/lib/ruby_llm/providers/openai.rb +11 -1
  138. data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
  139. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  140. data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
  141. data/lib/ruby_llm/providers/openrouter.rb +37 -1
  142. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  143. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  144. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  145. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  146. data/lib/ruby_llm/providers/vertexai.rb +23 -7
  147. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  148. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  149. data/lib/ruby_llm/providers/xai.rb +32 -0
  150. data/lib/ruby_llm/stream_accumulator.rb +120 -18
  151. data/lib/ruby_llm/streaming.rb +82 -60
  152. data/lib/ruby_llm/thinking.rb +49 -0
  153. data/lib/ruby_llm/tokens.rb +47 -0
  154. data/lib/ruby_llm/tool.rb +49 -4
  155. data/lib/ruby_llm/tool_call.rb +6 -3
  156. data/lib/ruby_llm/version.rb +1 -1
  157. data/lib/ruby_llm.rb +14 -8
  158. data/lib/tasks/models.rake +62 -23
  159. data/lib/tasks/release.rake +1 -1
  160. data/lib/tasks/ruby_llm.rake +9 -1
  161. data/lib/tasks/vcr.rake +33 -1
  162. metadata +67 -16
  163. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
  164. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  165. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  166. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  167. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
  168. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  169. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
  170. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
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, :provider
8
+ attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema, :provider
9
9
 
10
10
  def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
11
11
  if assume_model_exists && !provider
@@ -19,9 +19,11 @@ 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
26
+ @thinking = nil
25
27
  @on = {
26
28
  new_message: nil,
27
29
  end_message: nil,
@@ -37,22 +39,31 @@ module RubyLLM
37
39
 
38
40
  alias say ask
39
41
 
40
- def with_instructions(instructions, replace: false)
41
- @messages = @messages.reject { |msg| msg.role == :system } if replace
42
+ def with_instructions(instructions, append: false, replace: nil)
43
+ append ||= (replace == false) unless replace.nil?
44
+
45
+ if append
46
+ append_system_instruction(instructions)
47
+ else
48
+ replace_system_instruction(instructions)
49
+ end
42
50
 
43
- add_message role: :system, content: instructions
44
51
  self
45
52
  end
46
53
 
47
- def with_tool(tool)
48
- tool_instance = tool.is_a?(Class) ? tool.new : tool
49
- @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:)
50
60
  self
51
61
  end
52
62
 
53
- def with_tools(*tools, replace: false)
63
+ def with_tools(*tools, replace: false, choice: nil, calls: nil)
54
64
  @tools.clear if replace
55
65
  tools.compact.each { |tool| with_tool tool }
66
+ update_tool_options(choice:, calls:)
56
67
  self
57
68
  end
58
69
 
@@ -67,6 +78,13 @@ module RubyLLM
67
78
  self
68
79
  end
69
80
 
81
+ def with_thinking(effort: nil, budget: nil)
82
+ raise ArgumentError, 'with_thinking requires :effort or :budget' if effort.nil? && budget.nil?
83
+
84
+ @thinking = Thinking::Config.new(effort: effort, budget: budget)
85
+ self
86
+ end
87
+
70
88
  def with_context(context)
71
89
  @context = context
72
90
  @config = context.config
@@ -87,12 +105,9 @@ module RubyLLM
87
105
  def with_schema(schema)
88
106
  schema_instance = schema.is_a?(Class) ? schema.new : schema
89
107
 
90
- # Accept both RubyLLM::Schema instances and plain JSON schemas
91
- @schema = if schema_instance.respond_to?(:to_json_schema)
92
- schema_instance.to_json_schema[:schema]
93
- else
94
- schema_instance
95
- end
108
+ @schema = normalize_schema_payload(
109
+ schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
110
+ )
96
111
 
97
112
  self
98
113
  end
@@ -125,17 +140,19 @@ module RubyLLM
125
140
  response = @provider.complete(
126
141
  messages,
127
142
  tools: @tools,
143
+ tool_prefs: @tool_prefs,
128
144
  temperature: @temperature,
129
145
  model: @model,
130
146
  params: @params,
131
147
  headers: @headers,
132
148
  schema: @schema,
149
+ thinking: @thinking,
133
150
  &wrap_streaming_block(&)
134
151
  )
135
152
 
136
153
  @on[:new_message]&.call unless block_given?
137
154
 
138
- if @schema && response.content.is_a?(String)
155
+ if @schema && response.content.is_a?(String) && !response.tool_call?
139
156
  begin
140
157
  response.content = JSON.parse(response.content)
141
158
  rescue JSON::ParserError
@@ -169,18 +186,47 @@ module RubyLLM
169
186
 
170
187
  private
171
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
+
172
224
  def wrap_streaming_block(&block)
173
225
  return nil unless block_given?
174
226
 
175
- first_chunk_received = false
227
+ @on[:new_message]&.call
176
228
 
177
229
  proc do |chunk|
178
- # Create message on first content chunk
179
- unless first_chunk_received
180
- first_chunk_received = true
181
- @on[:new_message]&.call
182
- end
183
-
184
230
  block.call chunk
185
231
  end
186
232
  end
@@ -201,15 +247,78 @@ module RubyLLM
201
247
  halt_result = result if result.is_a?(Tool::Halt)
202
248
  end
203
249
 
250
+ reset_tool_choice if forced_tool_choice?
204
251
  halt_result || complete(&)
205
252
  end
206
253
 
207
254
  def execute_tool(tool_call)
208
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
+
209
263
  args = tool_call.arguments
210
264
  tool.call(args)
211
265
  end
212
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
+
213
322
  def build_content(message, attachments)
214
323
  return message if content_like?(message)
215
324
 
@@ -219,5 +328,24 @@ module RubyLLM
219
328
  def content_like?(object)
220
329
  object.is_a?(Content) || object.is_a?(Content::Raw)
221
330
  end
331
+
332
+ def append_system_instruction(instructions)
333
+ system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
334
+ system_messages << Message.new(role: :system, content: instructions)
335
+ @messages = system_messages + non_system_messages
336
+ end
337
+
338
+ def replace_system_instruction(instructions)
339
+ system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
340
+
341
+ if system_messages.empty?
342
+ system_messages = [Message.new(role: :system, content: instructions)]
343
+ else
344
+ system_messages.first.content = instructions
345
+ system_messages = [system_messages.first]
346
+ end
347
+
348
+ @messages = system_messages + non_system_messages
349
+ end
222
350
  end
223
351
  end
@@ -3,79 +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
- :anthropic_api_key,
12
- :gemini_api_key,
13
- :gemini_api_base,
14
- :vertexai_project_id,
15
- :vertexai_location,
16
- :deepseek_api_key,
17
- :dify_api_base,
18
- :dify_api_key,
19
- :dify_user,
20
- :perplexity_api_key,
21
- :bedrock_api_key,
22
- :bedrock_secret_key,
23
- :bedrock_region,
24
- :bedrock_session_token,
25
- :openrouter_api_key,
26
- :ollama_api_base,
27
- :gpustack_api_base,
28
- :gpustack_api_key,
29
- :mistral_api_key,
30
- # Default models
31
- :default_model,
32
- :default_embedding_model,
33
- :default_moderation_model,
34
- :default_image_model,
35
- :default_transcription_model,
36
- # Model registry
37
- :model_registry_file,
38
- :model_registry_class,
39
- # Rails integration
40
- :use_new_acts_as,
41
- # Connection configuration
42
- :request_timeout,
43
- :max_retries,
44
- :retry_interval,
45
- :retry_backoff_factor,
46
- :retry_interval_randomness,
47
- :http_proxy,
48
- # Logging configuration
49
- :logger,
50
- :log_file,
51
- :log_level,
52
- :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)
53
11
 
54
- def initialize
55
- @request_timeout = 300
56
- @max_retries = 3
57
- @retry_interval = 0.1
58
- @retry_backoff_factor = 2
59
- @retry_interval_randomness = 0.5
60
- @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
61
31
 
62
- @default_model = 'gpt-4.1-nano'
63
- @default_embedding_model = 'text-embedding-3-small'
64
- @default_moderation_model = 'omni-moderation-latest'
65
- @default_image_model = 'gpt-image-1'
66
- @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'
67
40
 
68
- @model_registry_file = File.expand_path('models.json', __dir__)
69
- @model_registry_class = 'Model'
70
- @use_new_acts_as = false
41
+ option :model_registry_file, -> { File.expand_path('models.json', __dir__) }
42
+ option :model_registry_class, 'Model'
71
43
 
72
- @log_file = $stdout
73
- @log_level = ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO
74
- @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
75
64
  end
76
65
 
77
66
  def instance_variables
78
67
  super.reject { |ivar| ivar.to_s.match?(/_id|_key|_secret|_token$/) }
79
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
80
80
  end
81
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
@@ -71,24 +70,29 @@ module RubyLLM
71
70
  def setup_logging(faraday)
72
71
  faraday.response :logger,
73
72
  RubyLLM.logger,
74
- bodies: true,
75
- response: true,
73
+ bodies: RubyLLM.logger.debug?,
76
74
  errors: true,
77
75
  headers: false,
78
76
  log_level: :debug do |logger|
79
- logger.filter(%r{[A-Za-z0-9+/=]{100,}}, '[BASE64 DATA]')
80
- logger.filter(/[-\d.e,\s]{100,}/, '[EMBEDDINGS ARRAY]')
77
+ logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}'), '[BASE64 DATA]')
78
+ logger.filter(logging_regexp('[-\\d.e,\\s]{100,}'), '[EMBEDDINGS ARRAY]')
81
79
  end
82
80
  end
83
81
 
82
+ def logging_regexp(pattern)
83
+ return Regexp.new(pattern) if @config.log_regexp_timeout.nil? || !Regexp.respond_to?(:timeout)
84
+
85
+ Regexp.new(pattern, timeout: @config.log_regexp_timeout)
86
+ end
87
+
84
88
  def setup_retry(faraday)
85
89
  faraday.request :retry, {
86
90
  max: @config.max_retries,
87
91
  interval: @config.retry_interval,
88
92
  interval_randomness: @config.retry_interval_randomness,
89
93
  backoff_factor: @config.retry_backoff_factor,
90
- exceptions: retry_exceptions,
91
- retry_statuses: [429, 500, 502, 503, 504, 529]
94
+ methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS + [:post],
95
+ exceptions: retry_exceptions
92
96
  }
93
97
  end
94
98
 
@@ -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
@@ -5,22 +5,26 @@ module RubyLLM
5
5
  class Message
6
6
  ROLES = %i[system user assistant tool].freeze
7
7
 
8
- attr_reader :role, :model_id, :tool_calls, :tool_call_id, :input_tokens, :output_tokens,
9
- :cached_tokens, :cache_creation_tokens, :raw, :conversation_id
8
+ attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :conversation_id, :thinking, :tokens
10
9
  attr_writer :content
11
10
 
12
11
  def initialize(options = {})
13
12
  @role = options.fetch(:role).to_sym
14
- @content = normalize_content(options.fetch(:content))
15
- @model_id = options[:model_id]
16
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]
17
16
  @tool_call_id = options[:tool_call_id]
18
- @conversation_id = options[:conversation_id]
19
- @input_tokens = options[:input_tokens]
20
- @output_tokens = options[:output_tokens]
21
- @cached_tokens = options[:cached_tokens]
22
- @cache_creation_tokens = options[:cache_creation_tokens]
17
+ @tokens = options[:tokens] || Tokens.build(
18
+ input: options[:input_tokens],
19
+ output: options[:output_tokens],
20
+ cached: options[:cached_tokens],
21
+ cache_creation: options[:cache_creation_tokens],
22
+ thinking: options[:thinking_tokens],
23
+ reasoning: options[:reasoning_tokens]
24
+ )
23
25
  @raw = options[:raw]
26
+ @conversation_id = options[:conversation_id]
27
+ @thinking = options[:thinking]
24
28
 
25
29
  ensure_valid_role
26
30
  end
@@ -45,6 +49,30 @@ module RubyLLM
45
49
  content if tool_result?
46
50
  end
47
51
 
52
+ def input_tokens
53
+ tokens&.input
54
+ end
55
+
56
+ def output_tokens
57
+ tokens&.output
58
+ end
59
+
60
+ def cached_tokens
61
+ tokens&.cached
62
+ end
63
+
64
+ def cache_creation_tokens
65
+ tokens&.cache_creation
66
+ end
67
+
68
+ def thinking_tokens
69
+ tokens&.thinking
70
+ end
71
+
72
+ def reasoning_tokens
73
+ tokens&.thinking
74
+ end
75
+
48
76
  def to_h
49
77
  {
50
78
  role: role,
@@ -53,11 +81,9 @@ module RubyLLM
53
81
  tool_calls: tool_calls,
54
82
  tool_call_id: tool_call_id,
55
83
  conversation_id: conversation_id,
56
- input_tokens: input_tokens,
57
- output_tokens: output_tokens,
58
- cached_tokens: cached_tokens,
59
- cache_creation_tokens: cache_creation_tokens
60
- }.compact
84
+ thinking: thinking&.text,
85
+ thinking_signature: thinking&.signature
86
+ }.merge(tokens ? tokens.to_h : {}).compact
61
87
  end
62
88
 
63
89
  def instance_variables
@@ -66,7 +92,9 @@ module RubyLLM
66
92
 
67
93
  private
68
94
 
69
- def normalize_content(content)
95
+ def normalize_content(content, role:, tool_calls:)
96
+ return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
97
+
70
98
  case content
71
99
  when String then Content.new(content)
72
100
  when Hash then Content.new(content[:text], content)