dify_llm 1.9.2 → 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 (168) 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/upgrade_to_v1_7_generator.rb +2 -4
  67. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  68. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  69. data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
  70. data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
  71. data/lib/ruby_llm/active_record/message_methods.rb +58 -8
  72. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  73. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  74. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  75. data/lib/ruby_llm/agent.rb +365 -0
  76. data/lib/ruby_llm/aliases.json +106 -61
  77. data/lib/ruby_llm/attachment.rb +8 -3
  78. data/lib/ruby_llm/chat.rb +150 -22
  79. data/lib/ruby_llm/configuration.rb +65 -65
  80. data/lib/ruby_llm/connection.rb +11 -7
  81. data/lib/ruby_llm/content.rb +6 -2
  82. data/lib/ruby_llm/error.rb +37 -1
  83. data/lib/ruby_llm/message.rb +43 -15
  84. data/lib/ruby_llm/model/info.rb +15 -13
  85. data/lib/ruby_llm/models.json +25039 -12260
  86. data/lib/ruby_llm/models.rb +185 -24
  87. data/lib/ruby_llm/provider.rb +26 -4
  88. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  89. data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
  90. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  91. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  92. data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
  93. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  94. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  95. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  96. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  97. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  98. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  99. data/lib/ruby_llm/providers/azure.rb +148 -0
  100. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  101. data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
  102. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  103. data/lib/ruby_llm/providers/bedrock/models.rb +104 -65
  104. data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
  105. data/lib/ruby_llm/providers/bedrock.rb +69 -52
  106. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  107. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  108. data/lib/ruby_llm/providers/dify/chat.rb +82 -7
  109. data/lib/ruby_llm/providers/dify/media.rb +2 -2
  110. data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
  111. data/lib/ruby_llm/providers/dify.rb +4 -0
  112. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  113. data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
  114. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  115. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  116. data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
  117. data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
  118. data/lib/ruby_llm/providers/gemini.rb +4 -0
  119. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  120. data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
  121. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  122. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  123. data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
  124. data/lib/ruby_llm/providers/mistral.rb +4 -0
  125. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  126. data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
  127. data/lib/ruby_llm/providers/ollama.rb +11 -1
  128. data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
  129. data/lib/ruby_llm/providers/openai/chat.rb +101 -7
  130. data/lib/ruby_llm/providers/openai/media.rb +5 -2
  131. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  132. data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
  133. data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
  134. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  135. data/lib/ruby_llm/providers/openai.rb +11 -1
  136. data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
  137. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  138. data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
  139. data/lib/ruby_llm/providers/openrouter.rb +37 -1
  140. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  141. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  142. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  143. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  144. data/lib/ruby_llm/providers/vertexai.rb +23 -7
  145. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  146. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  147. data/lib/ruby_llm/providers/xai.rb +32 -0
  148. data/lib/ruby_llm/stream_accumulator.rb +120 -18
  149. data/lib/ruby_llm/streaming.rb +60 -57
  150. data/lib/ruby_llm/thinking.rb +49 -0
  151. data/lib/ruby_llm/tokens.rb +47 -0
  152. data/lib/ruby_llm/tool.rb +48 -3
  153. data/lib/ruby_llm/tool_call.rb +6 -3
  154. data/lib/ruby_llm/version.rb +1 -1
  155. data/lib/ruby_llm.rb +14 -8
  156. data/lib/tasks/models.rake +61 -22
  157. data/lib/tasks/release.rake +1 -1
  158. data/lib/tasks/ruby_llm.rake +9 -1
  159. data/lib/tasks/vcr.rake +33 -1
  160. metadata +67 -16
  161. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
  162. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  163. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  164. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  165. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
  166. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  167. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
  168. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -14,7 +14,10 @@ module RubyLLM
14
14
  "models/#{@model}:generateContent"
15
15
  end
16
16
 
17
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
17
+ # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
18
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
19
+ thinking: nil, tool_prefs: nil)
20
+ tool_prefs ||= {}
18
21
  @model = model.id
19
22
  payload = {
20
23
  contents: format_messages(messages),
@@ -24,10 +27,35 @@ module RubyLLM
24
27
  payload[:generationConfig][:temperature] = temperature unless temperature.nil?
25
28
 
26
29
  payload[:generationConfig].merge!(structured_output_config(schema, model)) if schema
30
+ payload[:generationConfig][:thinkingConfig] = build_thinking_config(model, thinking) if thinking&.enabled?
31
+
32
+ if tools.any?
33
+ payload[:tools] = format_tools(tools)
34
+ # Gemini doesn't support controlling parallel tool calls
35
+ payload[:toolConfig] = build_tool_config(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
36
+ end
27
37
 
28
- payload[:tools] = format_tools(tools) if tools.any?
29
38
  payload
30
39
  end
40
+ # rubocop:enable Metrics/ParameterLists,Lint/UnusedMethodArgument
41
+
42
+ def build_thinking_config(_model, thinking)
43
+ config = { includeThoughts: true }
44
+
45
+ config[:thinkingLevel] = resolve_effort_level(thinking) if thinking&.effort
46
+ config[:thinkingBudget] = resolve_budget(thinking) if thinking&.budget
47
+
48
+ config
49
+ end
50
+
51
+ def resolve_effort_level(thinking)
52
+ thinking.respond_to?(:effort) ? thinking.effort : thinking
53
+ end
54
+
55
+ def resolve_budget(thinking)
56
+ budget = thinking.respond_to?(:budget) ? thinking.budget : thinking
57
+ budget.is_a?(Integer) ? budget : nil
58
+ end
31
59
 
32
60
  private
33
61
 
@@ -56,20 +84,44 @@ module RubyLLM
56
84
  elsif msg.tool_result?
57
85
  format_tool_result(msg)
58
86
  else
59
- Media.format_content(msg.content)
87
+ format_message_parts(msg)
60
88
  end
61
89
  end
62
90
 
91
+ def format_message_parts(msg)
92
+ parts = []
93
+
94
+ parts << build_thought_part(msg.thinking) if msg.role == :assistant && msg.thinking
95
+
96
+ content_parts = Media.format_content(msg.content)
97
+ parts.concat(content_parts.is_a?(Array) ? content_parts : [content_parts])
98
+ parts
99
+ end
100
+
101
+ def build_thought_part(thinking)
102
+ part = { thought: true }
103
+ part[:text] = thinking.text if thinking.text
104
+ part[:thoughtSignature] = thinking.signature if thinking.signature
105
+ part
106
+ end
107
+
63
108
  def parse_completion_response(response)
64
109
  data = response.body
110
+ parts = data.dig('candidates', 0, 'content', 'parts') || []
65
111
  tool_calls = extract_tool_calls(data)
66
112
 
67
113
  Message.new(
68
114
  role: :assistant,
69
- content: parse_content(data),
115
+ content: extract_text_parts(parts) || parse_content(data),
116
+ thinking: Thinking.build(
117
+ text: extract_thought_parts(parts),
118
+ signature: extract_thought_signature(parts)
119
+ ),
70
120
  tool_calls: tool_calls,
71
121
  input_tokens: data.dig('usageMetadata', 'promptTokenCount'),
72
122
  output_tokens: calculate_output_tokens(data),
123
+ cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
124
+ thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
73
125
  model_id: data['modelVersion'] || response.env.url.path.split('/')[3].split(':')[0],
74
126
  raw: response
75
127
  )
@@ -78,6 +130,9 @@ module RubyLLM
78
130
  def convert_schema_to_gemini(schema)
79
131
  return nil unless schema
80
132
 
133
+ # Extract inner schema if wrapper format (e.g., from RubyLLM::Schema.to_json_schema)
134
+ schema = schema[:schema] || schema
135
+
81
136
  GeminiSchema.new(schema).to_h
82
137
  end
83
138
 
@@ -90,7 +145,34 @@ module RubyLLM
90
145
  parts = candidate.dig('content', 'parts')
91
146
  return '' unless parts&.any?
92
147
 
93
- build_response_content(parts)
148
+ non_thought_parts = parts.reject { |part| part['thought'] }
149
+ return '' unless non_thought_parts.any?
150
+
151
+ build_response_content(non_thought_parts)
152
+ end
153
+
154
+ def extract_text_parts(parts)
155
+ text_parts = parts.reject { |p| p['thought'] }
156
+ content = text_parts.filter_map { |p| p['text'] }.join
157
+ content.empty? ? nil : content
158
+ end
159
+
160
+ def extract_thought_parts(parts)
161
+ thought_parts = parts.select { |p| p['thought'] }
162
+ thoughts = thought_parts.filter_map { |p| p['text'] }.join
163
+ thoughts.empty? ? nil : thoughts
164
+ end
165
+
166
+ def extract_thought_signature(parts)
167
+ parts.each do |part|
168
+ signature = part['thoughtSignature'] ||
169
+ part['thought_signature'] ||
170
+ part.dig('functionCall', 'thoughtSignature') ||
171
+ part.dig('functionCall', 'thought_signature')
172
+ return signature if signature
173
+ end
174
+
175
+ nil
94
176
  end
95
177
 
96
178
  def function_call?(candidate)
@@ -110,7 +192,7 @@ module RubyLLM
110
192
  end
111
193
 
112
194
  def build_json_schema(schema)
113
- normalized = RubyLLM::Utils.deep_dup(schema)
195
+ normalized = RubyLLM::Utils.deep_dup(schema[:schema])
114
196
  normalized.delete(:strict)
115
197
  normalized.delete('strict')
116
198
  RubyLLM::Utils.deep_stringify_keys(normalized)
@@ -10,7 +10,7 @@ module RubyLLM
10
10
  end
11
11
 
12
12
  def render_image_payload(prompt, model:, size:)
13
- RubyLLM.logger.debug "Ignoring size #{size}. Gemini does not support image size customization."
13
+ RubyLLM.logger.debug { "Ignoring size #{size}. Gemini does not support image size customization." }
14
14
  @model = model
15
15
  {
16
16
  instances: [
@@ -17,14 +17,12 @@ module RubyLLM
17
17
 
18
18
  Model::Info.new(
19
19
  id: model_id,
20
- name: model_data['displayName'],
20
+ name: model_data['displayName'] || model_id,
21
21
  provider: slug,
22
- family: capabilities.model_family(model_id),
23
22
  created_at: nil,
24
23
  context_window: model_data['inputTokenLimit'] || capabilities.context_window_for(model_id),
25
24
  max_output_tokens: model_data['outputTokenLimit'] || capabilities.max_tokens_for(model_id),
26
- modalities: capabilities.modalities_for(model_id),
27
- capabilities: capabilities.capabilities_for(model_id),
25
+ capabilities: capabilities.critical_capabilities_for(model_id),
28
26
  pricing: capabilities.pricing_for(model_id),
29
27
  metadata: {
30
28
  version: model_data['version'],
@@ -10,12 +10,20 @@ module RubyLLM
10
10
  end
11
11
 
12
12
  def build_chunk(data)
13
+ parts = data.dig('candidates', 0, 'content', 'parts') || []
14
+
13
15
  Chunk.new(
14
16
  role: :assistant,
15
17
  model_id: extract_model_id(data),
16
- content: extract_content(data),
18
+ content: extract_text_content(parts),
19
+ thinking: Thinking.build(
20
+ text: extract_thought_content(parts),
21
+ signature: extract_thought_signature(parts)
22
+ ),
17
23
  input_tokens: extract_input_tokens(data),
18
24
  output_tokens: extract_output_tokens(data),
25
+ cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
26
+ thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
19
27
  tool_calls: extract_tool_calls(data)
20
28
  )
21
29
  end
@@ -26,6 +34,30 @@ module RubyLLM
26
34
  data['modelVersion']
27
35
  end
28
36
 
37
+ def extract_text_content(parts)
38
+ text_parts = parts.reject { |p| p['thought'] }
39
+ text = text_parts.filter_map { |p| p['text'] }.join
40
+ text.empty? ? nil : text
41
+ end
42
+
43
+ def extract_thought_content(parts)
44
+ thought_parts = parts.select { |p| p['thought'] }
45
+ thoughts = thought_parts.filter_map { |p| p['text'] }.join
46
+ thoughts.empty? ? nil : thoughts
47
+ end
48
+
49
+ def extract_thought_signature(parts)
50
+ parts.each do |part|
51
+ signature = part['thoughtSignature'] ||
52
+ part['thought_signature'] ||
53
+ part.dig('functionCall', 'thoughtSignature') ||
54
+ part.dig('functionCall', 'thought_signature')
55
+ return signature if signature
56
+ end
57
+
58
+ nil
59
+ end
60
+
29
61
  def extract_content(data)
30
62
  return nil unless data['candidates']&.any?
31
63
 
@@ -52,7 +84,7 @@ module RubyLLM
52
84
  error_data = JSON.parse(data)
53
85
  [error_data['error']['code'], error_data['error']['message']]
54
86
  rescue JSON::ParserError => e
55
- RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
87
+ RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
56
88
  [500, "Failed to parse error: #{data}"]
57
89
  end
58
90
  end
@@ -13,7 +13,7 @@ module RubyLLM
13
13
  }]
14
14
  end
15
15
 
16
- def format_tool_call(msg)
16
+ def format_tool_call(msg) # rubocop:disable Metrics/PerceivedComplexity
17
17
  parts = []
18
18
 
19
19
  if msg.content && !(msg.content.respond_to?(:empty?) && msg.content.empty?)
@@ -21,13 +21,24 @@ module RubyLLM
21
21
  parts.concat(formatted_content.is_a?(Array) ? formatted_content : [formatted_content])
22
22
  end
23
23
 
24
+ fallback_signature = msg.thinking&.signature
25
+ used_fallback = false
26
+
24
27
  msg.tool_calls.each_value do |tool_call|
25
- parts << {
28
+ part = {
26
29
  functionCall: {
27
30
  name: tool_call.name,
28
31
  args: tool_call.arguments
29
32
  }
30
33
  }
34
+
35
+ signature = tool_call.thought_signature
36
+ if signature.nil? && fallback_signature && !used_fallback
37
+ signature = fallback_signature
38
+ used_fallback = true
39
+ end
40
+ part[:thoughtSignature] = signature if signature
41
+ parts << part
31
42
  end
32
43
 
33
44
  parts
@@ -61,11 +72,13 @@ module RubyLLM
61
72
  next unless function_data
62
73
 
63
74
  id = SecureRandom.uuid
75
+ thought_signature = part['thoughtSignature'] || part['thought_signature']
64
76
 
65
77
  result[id] = ToolCall.new(
66
78
  id:,
67
79
  name: function_data['name'],
68
- arguments: function_data['args'] || {}
80
+ arguments: function_data['args'] || {},
81
+ thought_signature: thought_signature
69
82
  )
70
83
  end
71
84
 
@@ -192,6 +205,25 @@ module RubyLLM
192
205
  else 'STRING'
193
206
  end
194
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
195
227
  end
196
228
  end
197
229
  end
@@ -28,6 +28,10 @@ module RubyLLM
28
28
  Gemini::Capabilities
29
29
  end
30
30
 
31
+ def configuration_options
32
+ %i[gemini_api_key gemini_api_base]
33
+ end
34
+
31
35
  def configuration_requirements
32
36
  %i[gemini_api_key]
33
37
  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
@@ -14,7 +14,7 @@ module RubyLLM
14
14
  content: GPUStack::Media.format_content(msg.content),
15
15
  tool_calls: format_tool_calls(msg.tool_calls),
16
16
  tool_call_id: msg.tool_call_id
17
- }.compact
17
+ }.compact.merge(OpenAI::Chat.format_thinking(msg))
18
18
  end
19
19
  end
20
20
 
@@ -21,6 +21,10 @@ module RubyLLM
21
21
  end
22
22
 
23
23
  class << self
24
+ def configuration_options
25
+ %i[gpustack_api_base gpustack_api_key]
26
+ end
27
+
24
28
  def local?
25
29
  true
26
30
  end
@@ -28,6 +32,10 @@ module RubyLLM
28
32
  def configuration_requirements
29
33
  %i[gpustack_api_base]
30
34
  end
35
+
36
+ def capabilities
37
+ GPUStack::Capabilities
38
+ end
31
39
  end
32
40
  end
33
41
  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
@@ -11,13 +11,71 @@ module RubyLLM
11
11
  role.to_s
12
12
  end
13
13
 
14
+ def format_messages(messages)
15
+ messages.map do |msg|
16
+ {
17
+ role: format_role(msg.role),
18
+ content: format_content_with_thinking(msg),
19
+ tool_calls: OpenAI::Tools.format_tool_calls(msg.tool_calls),
20
+ tool_call_id: msg.tool_call_id
21
+ }.compact
22
+ end
23
+ end
24
+
14
25
  # rubocop:disable Metrics/ParameterLists
15
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil)
26
+ def render_payload(messages, tools:, temperature:, model:, stream: false,
27
+ schema: nil, thinking: nil, tool_prefs: nil)
16
28
  payload = super
17
29
  payload.delete(:stream_options)
30
+ payload.delete(:reasoning_effort)
31
+ warn_on_unsupported_thinking(model, thinking)
18
32
  payload
19
33
  end
20
34
  # rubocop:enable Metrics/ParameterLists
35
+
36
+ def format_content_with_thinking(msg)
37
+ formatted_content = OpenAI::Media.format_content(msg.content)
38
+ return formatted_content unless msg.role == :assistant && msg.thinking
39
+
40
+ content_blocks = build_thinking_blocks(msg.thinking)
41
+ append_formatted_content(content_blocks, formatted_content)
42
+
43
+ content_blocks
44
+ end
45
+
46
+ def warn_on_unsupported_thinking(model, thinking)
47
+ return unless thinking&.enabled?
48
+ return if model.id.to_s.include?('magistral')
49
+
50
+ RubyLLM.logger.warn(
51
+ 'Mistral thinking is only supported on Magistral models. ' \
52
+ "Ignoring thinking settings for #{model.id}."
53
+ )
54
+ end
55
+
56
+ def build_thinking_blocks(thinking)
57
+ return [] unless thinking
58
+
59
+ if thinking.text
60
+ [{
61
+ type: 'thinking',
62
+ thinking: [{ type: 'text', text: thinking.text }],
63
+ signature: thinking.signature
64
+ }.compact]
65
+ elsif thinking.signature
66
+ [{ type: 'thinking', signature: thinking.signature }]
67
+ else
68
+ []
69
+ end
70
+ end
71
+
72
+ def append_formatted_content(content_blocks, formatted_content)
73
+ if formatted_content.is_a?(Array)
74
+ content_blocks.concat(formatted_content)
75
+ elsif formatted_content
76
+ content_blocks << { type: 'text', text: formatted_content }
77
+ end
78
+ end
21
79
  end
22
80
  end
23
81
  end
@@ -23,6 +23,10 @@ module RubyLLM
23
23
  Mistral::Capabilities
24
24
  end
25
25
 
26
+ def configuration_options
27
+ %i[mistral_api_key]
28
+ end
29
+
26
30
  def configuration_requirements
27
31
  %i[mistral_api_key]
28
32
  end
@@ -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
@@ -14,7 +14,7 @@ module RubyLLM
14
14
  content: Ollama::Media.format_content(msg.content),
15
15
  tool_calls: format_tool_calls(msg.tool_calls),
16
16
  tool_call_id: msg.tool_call_id
17
- }.compact
17
+ }.compact.merge(OpenAI::Chat.format_thinking(msg))
18
18
  end
19
19
  end
20
20
 
@@ -13,10 +13,16 @@ 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
22
+ def configuration_options
23
+ %i[ollama_api_base ollama_api_key]
24
+ end
25
+
20
26
  def configuration_requirements
21
27
  %i[ollama_api_base]
22
28
  end
@@ -24,6 +30,10 @@ module RubyLLM
24
30
  def local?
25
31
  true
26
32
  end
33
+
34
+ def capabilities
35
+ Ollama::Capabilities
36
+ end
27
37
  end
28
38
  end
29
39
  end