ruby_llm 1.13.2 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -13
  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 +1 -1
  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 +41 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +21 -18
  51. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +3 -4
  52. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
  53. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +3 -3
  54. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  55. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  56. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  57. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  59. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  60. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
  61. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +5 -7
  64. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  65. data/lib/ruby_llm/active_record/acts_as.rb +6 -26
  66. data/lib/ruby_llm/active_record/acts_as_legacy.rb +125 -29
  67. data/lib/ruby_llm/active_record/chat_methods.rb +50 -26
  68. data/lib/ruby_llm/active_record/message_methods.rb +104 -4
  69. data/lib/ruby_llm/active_record/model_methods.rb +8 -10
  70. data/lib/ruby_llm/active_record/payload_helpers.rb +29 -0
  71. data/lib/ruby_llm/active_record/tool_call_methods.rb +18 -0
  72. data/lib/ruby_llm/agent.rb +15 -2
  73. data/lib/ruby_llm/aliases.json +141 -89
  74. data/lib/ruby_llm/aliases.rb +3 -0
  75. data/lib/ruby_llm/attachment.rb +44 -40
  76. data/lib/ruby_llm/chat.rb +229 -59
  77. data/lib/ruby_llm/configuration.rb +69 -75
  78. data/lib/ruby_llm/connection.rb +37 -10
  79. data/lib/ruby_llm/content.rb +15 -1
  80. data/lib/ruby_llm/cost.rb +224 -0
  81. data/lib/ruby_llm/deprecator.rb +24 -0
  82. data/lib/ruby_llm/embedding.rb +31 -1
  83. data/lib/ruby_llm/error.rb +16 -75
  84. data/lib/ruby_llm/error_middleware.rb +81 -0
  85. data/lib/ruby_llm/image.rb +39 -4
  86. data/lib/ruby_llm/instrumentation.rb +36 -0
  87. data/lib/ruby_llm/message.rb +20 -0
  88. data/lib/ruby_llm/mime_type.rb +25 -0
  89. data/lib/ruby_llm/model/info.rb +67 -14
  90. data/lib/ruby_llm/model/pricing.rb +19 -9
  91. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  92. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  93. data/lib/ruby_llm/model_registry.rb +39 -0
  94. data/lib/ruby_llm/models.json +21796 -20601
  95. data/lib/ruby_llm/models.rb +107 -34
  96. data/lib/ruby_llm/models_schema.json +3 -0
  97. data/lib/ruby_llm/provider.rb +25 -4
  98. data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -133
  99. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  100. data/lib/ruby_llm/providers/anthropic/models.rb +4 -8
  101. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  102. data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
  103. data/lib/ruby_llm/providers/anthropic.rb +4 -0
  104. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  105. data/lib/ruby_llm/providers/azure.rb +4 -0
  106. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  107. data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
  108. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  109. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  110. data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
  111. data/lib/ruby_llm/providers/bedrock.rb +5 -1
  112. data/lib/ruby_llm/providers/deepseek/capabilities.rb +28 -103
  113. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  114. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  115. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -215
  116. data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
  117. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  118. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  119. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  120. data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
  121. data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
  122. data/lib/ruby_llm/providers/gemini.rb +4 -0
  123. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  124. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  125. data/lib/ruby_llm/providers/gpustack.rb +4 -0
  126. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
  127. data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
  128. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  129. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  130. data/lib/ruby_llm/providers/mistral.rb +5 -1
  131. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  132. data/lib/ruby_llm/providers/ollama.rb +4 -0
  133. data/lib/ruby_llm/providers/openai/capabilities.rb +157 -195
  134. data/lib/ruby_llm/providers/openai/chat.rb +61 -7
  135. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  136. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  137. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  138. data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
  139. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  140. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  141. data/lib/ruby_llm/providers/openai.rb +10 -0
  142. data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
  143. data/lib/ruby_llm/providers/openrouter/images.rb +3 -3
  144. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  145. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  146. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  147. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  148. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  149. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  150. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  151. data/lib/ruby_llm/providers/perplexity.rb +5 -1
  152. data/lib/ruby_llm/providers/vertexai.rb +8 -0
  153. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  154. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  155. data/lib/ruby_llm/providers/xai.rb +5 -1
  156. data/lib/ruby_llm/railtie.rb +11 -1
  157. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  158. data/lib/ruby_llm/streaming.rb +4 -0
  159. data/lib/ruby_llm/tokens.rb +8 -0
  160. data/lib/ruby_llm/tool.rb +24 -7
  161. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  162. data/lib/ruby_llm/transcription.rb +2 -1
  163. data/lib/ruby_llm/utils.rb +39 -0
  164. data/lib/ruby_llm/version.rb +1 -1
  165. data/lib/ruby_llm.rb +11 -6
  166. data/lib/tasks/models.rake +45 -16
  167. data/lib/tasks/release.rake +51 -24
  168. data/lib/tasks/ruby_llm.rake +6 -5
  169. data/lib/tasks/vcr.rake +1 -1
  170. metadata +81 -21
  171. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -3,13 +3,35 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Gemini
6
- # Determines capabilities and pricing for Google Gemini models
6
+ # Provider-level capability checks and narrow registry fallbacks.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
+ PRICES = {
11
+ flash_2: { input: 0.10, output: 0.40 }, # rubocop:disable Naming/VariableNumber
12
+ flash_lite_2: { input: 0.075, output: 0.30 }, # rubocop:disable Naming/VariableNumber
13
+ flash: { input: 0.075, output: 0.30 },
14
+ flash_8b: { input: 0.0375, output: 0.15 },
15
+ pro: { input: 1.25, output: 5.0 },
16
+ pro_2_5: { input: 0.12, output: 0.50 }, # rubocop:disable Naming/VariableNumber
17
+ gemini_embedding: { input: 0.002, output: 0.004 },
18
+ embedding: { input: 0.00, output: 0.00 },
19
+ imagen: { price: 0.03 },
20
+ aqa: { input: 0.00, output: 0.00 }
21
+ }.freeze
22
+
23
+ def supports_tool_choice?(_model_id)
24
+ true
25
+ end
26
+
27
+ def supports_tool_parallel_control?(_model_id)
28
+ false
29
+ end
30
+
10
31
  def context_window_for(model_id)
11
32
  case model_id
12
- when /gemini-2\.5-pro-exp-03-25/, /gemini-2\.0-flash/, /gemini-2\.0-flash-lite/, /gemini-1\.5-flash/, /gemini-1\.5-flash-8b/ # rubocop:disable Layout/LineLength
33
+ when /gemini-2\.5-pro-exp-03-25/, /gemini-2\.0-flash/, /gemini-2\.0-flash-lite/, /gemini-1\.5-flash/,
34
+ /gemini-1\.5-flash-8b/
13
35
  1_048_576
14
36
  when /gemini-1\.5-pro/ then 2_097_152
15
37
  when /gemini-embedding-exp/ then 8_192
@@ -23,7 +45,8 @@ module RubyLLM
23
45
  def max_tokens_for(model_id)
24
46
  case model_id
25
47
  when /gemini-2\.5-pro-exp-03-25/ then 64_000
26
- when /gemini-2\.0-flash/, /gemini-2\.0-flash-lite/, /gemini-1\.5-flash/, /gemini-1\.5-flash-8b/, /gemini-1\.5-pro/ # rubocop:disable Layout/LineLength
48
+ when /gemini-2\.0-flash/, /gemini-2\.0-flash-lite/, /gemini-1\.5-flash/, /gemini-1\.5-flash-8b/,
49
+ /gemini-1\.5-pro/
27
50
  8_192
28
51
  when /gemini-embedding-exp/ then nil
29
52
  when /text-embedding-004/, /embedding-001/ then 768
@@ -32,18 +55,24 @@ module RubyLLM
32
55
  end
33
56
  end
34
57
 
35
- def input_price_for(model_id)
36
- base_price = PRICES.dig(pricing_family(model_id), :input) || default_input_price
37
- return base_price unless long_context_model?(model_id)
38
-
39
- context_window_for(model_id) > 128_000 ? base_price * 2 : base_price
58
+ def critical_capabilities_for(model_id)
59
+ capabilities = []
60
+ capabilities << 'function_calling' if supports_functions?(model_id)
61
+ capabilities << 'structured_output' if supports_structured_output?(model_id)
62
+ capabilities << 'vision' if supports_vision?(model_id)
63
+ capabilities
40
64
  end
41
65
 
42
- def output_price_for(model_id)
43
- base_price = PRICES.dig(pricing_family(model_id), :output) || default_output_price
44
- return base_price unless long_context_model?(model_id)
45
-
46
- context_window_for(model_id) > 128_000 ? base_price * 2 : base_price
66
+ def pricing_for(model_id)
67
+ prices = PRICES.fetch(pricing_family(model_id), { input: 0.075, output: 0.30 })
68
+ {
69
+ text_tokens: {
70
+ standard: {
71
+ input_per_million: prices[:input] || prices[:price] || 0.075,
72
+ output_per_million: prices[:output] || prices[:price] || 0.30
73
+ }
74
+ }
75
+ }
47
76
  end
48
77
 
49
78
  def supports_vision?(model_id)
@@ -52,25 +81,13 @@ module RubyLLM
52
81
  model_id.match?(/gemini|flash|pro|imagen/)
53
82
  end
54
83
 
55
- def supports_video?(model_id)
56
- model_id.match?(/gemini/)
57
- end
58
-
59
84
  def supports_functions?(model_id)
60
85
  return false if model_id.match?(/text-embedding|embedding-001|aqa|flash-lite|imagen|gemini-2\.0-flash-lite/)
61
86
 
62
87
  model_id.match?(/gemini|pro|flash/)
63
88
  end
64
89
 
65
- def supports_tool_choice?(_model_id)
66
- true
67
- end
68
-
69
- def supports_tool_parallel_control?(_model_id)
70
- false
71
- end
72
-
73
- def supports_json_mode?(model_id)
90
+ def supports_structured_output?(model_id)
74
91
  if model_id.match?(/text-embedding|embedding-001|aqa|imagen|gemini-2\.0-flash-lite|gemini-2\.5-pro-exp-03-25/)
75
92
  return false
76
93
  end
@@ -78,59 +95,6 @@ module RubyLLM
78
95
  model_id.match?(/gemini|pro|flash/)
79
96
  end
80
97
 
81
- def format_display_name(model_id)
82
- model_id
83
- .delete_prefix('models/')
84
- .split('-')
85
- .map(&:capitalize)
86
- .join(' ')
87
- .gsub(/(\d+\.\d+)/, ' \1')
88
- .gsub(/\s+/, ' ')
89
- .gsub('Aqa', 'AQA')
90
- .strip
91
- end
92
-
93
- def supports_caching?(model_id)
94
- if model_id.match?(/flash-lite|gemini-2\.5-pro-exp-03-25|aqa|imagen|text-embedding|embedding-001/)
95
- return false
96
- end
97
-
98
- model_id.match?(/gemini|pro|flash/)
99
- end
100
-
101
- def supports_tuning?(model_id)
102
- model_id.match?(/gemini-1\.5-flash|gemini-1\.5-flash-8b/)
103
- end
104
-
105
- def supports_audio?(model_id)
106
- model_id.match?(/gemini|pro|flash/)
107
- end
108
-
109
- def model_type(model_id)
110
- case model_id
111
- when /text-embedding|embedding|gemini-embedding/ then 'embedding'
112
- when /imagen/ then 'image'
113
- else 'chat'
114
- end
115
- end
116
-
117
- def model_family(model_id)
118
- case model_id
119
- when /gemini-2\.5-pro-exp-03-25/ then 'gemini25_pro_exp'
120
- when /gemini-2\.0-flash-lite/ then 'gemini20_flash_lite'
121
- when /gemini-2\.0-flash/ then 'gemini20_flash'
122
- when /gemini-1\.5-flash-8b/ then 'gemini15_flash_8b'
123
- when /gemini-1\.5-flash/ then 'gemini15_flash'
124
- when /gemini-1\.5-pro/ then 'gemini15_pro'
125
- when /gemini-embedding-exp/ then 'gemini_embedding_exp'
126
- when /text-embedding-004/ then 'embedding4'
127
- when /embedding-001/ then 'embedding1'
128
- when /aqa/ then 'aqa'
129
- when /imagen-3/ then 'imagen3'
130
- else 'other'
131
- end
132
- end
133
-
134
98
  def pricing_family(model_id)
135
99
  case model_id
136
100
  when /gemini-2\.5-pro-exp-03-25/ then :pro_2_5 # rubocop:disable Naming/VariableNumber
@@ -147,142 +111,8 @@ module RubyLLM
147
111
  end
148
112
  end
149
113
 
150
- def long_context_model?(model_id)
151
- model_id.match?(/gemini-1\.5-(?:pro|flash)|gemini-1\.5-flash-8b/)
152
- end
153
-
154
- def context_length(model_id)
155
- context_window_for(model_id)
156
- end
157
-
158
- PRICES = {
159
- flash_2: { # rubocop:disable Naming/VariableNumber
160
- input: 0.10,
161
- output: 0.40,
162
- audio_input: 0.70,
163
- cache: 0.025,
164
- cache_storage: 1.00,
165
- grounding_search: 35.00
166
- },
167
- flash_lite_2: { # rubocop:disable Naming/VariableNumber
168
- input: 0.075,
169
- output: 0.30
170
- },
171
- flash: {
172
- input: 0.075,
173
- output: 0.30,
174
- cache: 0.01875,
175
- cache_storage: 1.00,
176
- grounding_search: 35.00
177
- },
178
- flash_8b: {
179
- input: 0.0375,
180
- output: 0.15,
181
- cache: 0.01,
182
- cache_storage: 0.25,
183
- grounding_search: 35.00
184
- },
185
- pro: {
186
- input: 1.25,
187
- output: 5.0,
188
- cache: 0.3125,
189
- cache_storage: 4.50,
190
- grounding_search: 35.00
191
- },
192
- pro_2_5: { # rubocop:disable Naming/VariableNumber
193
- input: 0.12,
194
- output: 0.50
195
- },
196
- gemini_embedding: {
197
- input: 0.002,
198
- output: 0.004
199
- },
200
- embedding: {
201
- input: 0.00,
202
- output: 0.00
203
- },
204
- imagen: {
205
- price: 0.03
206
- },
207
- aqa: {
208
- input: 0.00,
209
- output: 0.00
210
- }
211
- }.freeze
212
-
213
- def default_input_price
214
- 0.075
215
- end
216
-
217
- def default_output_price
218
- 0.30
219
- end
220
-
221
- def modalities_for(model_id)
222
- modalities = {
223
- input: ['text'],
224
- output: ['text']
225
- }
226
-
227
- if supports_vision?(model_id)
228
- modalities[:input] << 'image'
229
- modalities[:input] << 'pdf'
230
- end
231
-
232
- modalities[:input] << 'video' if supports_video?(model_id)
233
- modalities[:input] << 'audio' if model_id.match?(/audio/)
234
- modalities[:output] << 'embeddings' if model_id.match?(/embedding|gemini-embedding/)
235
- modalities[:output] = ['image'] if model_id.match?(/imagen/)
236
-
237
- modalities
238
- end
239
-
240
- def capabilities_for(model_id)
241
- capabilities = ['streaming']
242
-
243
- capabilities << 'function_calling' if supports_functions?(model_id)
244
- capabilities << 'structured_output' if supports_json_mode?(model_id)
245
- capabilities << 'batch' if model_id.match?(/embedding|flash/)
246
- capabilities << 'caching' if supports_caching?(model_id)
247
- capabilities << 'fine_tuning' if supports_tuning?(model_id)
248
- capabilities
249
- end
250
-
251
- def pricing_for(model_id)
252
- family = pricing_family(model_id)
253
- prices = PRICES.fetch(family, { input: default_input_price, output: default_output_price })
254
-
255
- standard_pricing = {
256
- input_per_million: prices[:input],
257
- output_per_million: prices[:output]
258
- }
259
-
260
- standard_pricing[:cached_input_per_million] = prices[:input_hit] if prices[:input_hit]
261
-
262
- batch_pricing = {
263
- input_per_million: (standard_pricing[:input_per_million] || 0) * 0.5,
264
- output_per_million: (standard_pricing[:output_per_million] || 0) * 0.5
265
- }
266
-
267
- if standard_pricing[:cached_input_per_million]
268
- batch_pricing[:cached_input_per_million] = standard_pricing[:cached_input_per_million] * 0.5
269
- end
270
-
271
- pricing = {
272
- text_tokens: {
273
- standard: standard_pricing,
274
- batch: batch_pricing
275
- }
276
- }
277
-
278
- if model_id.match?(/embedding|gemini-embedding/)
279
- pricing[:embeddings] = {
280
- standard: { input_per_million: prices[:price] || 0.002 }
281
- }
282
- end
283
-
284
- pricing
285
- end
114
+ module_function :context_window_for, :max_tokens_for, :critical_capabilities_for, :pricing_for,
115
+ :supports_vision?, :supports_functions?, :supports_structured_output?, :pricing_family
286
116
  end
287
117
  end
288
118
  end
@@ -72,8 +72,7 @@ module RubyLLM
72
72
  def format_role(role)
73
73
  case role
74
74
  when :assistant then 'model'
75
- when :system then 'user'
76
- when :tool then 'function'
75
+ when :system, :tool then 'user'
77
76
  else role.to_s
78
77
  end
79
78
  end
@@ -118,7 +117,7 @@ module RubyLLM
118
117
  signature: extract_thought_signature(parts)
119
118
  ),
120
119
  tool_calls: tool_calls,
121
- input_tokens: data.dig('usageMetadata', 'promptTokenCount'),
120
+ input_tokens: input_tokens(data),
122
121
  output_tokens: calculate_output_tokens(data),
123
122
  cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
124
123
  thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
@@ -127,6 +126,13 @@ module RubyLLM
127
126
  )
128
127
  end
129
128
 
129
+ def input_tokens(data)
130
+ prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
131
+ return unless prompt_tokens
132
+
133
+ [prompt_tokens.to_i - data.dig('usageMetadata', 'cachedContentTokenCount').to_i, 0].max
134
+ end
135
+
130
136
  def convert_schema_to_gemini(schema)
131
137
  return nil unless schema
132
138
 
@@ -307,7 +313,7 @@ module RubyLLM
307
313
  end
308
314
 
309
315
  def build_tool_response(parts)
310
- { role: 'function', parts: parts }
316
+ { role: 'user', parts: parts }
311
317
  end
312
318
 
313
319
  def remember_tool_calls
@@ -5,11 +5,11 @@ module RubyLLM
5
5
  class Gemini
6
6
  # Image generation methods for the Gemini API implementation
7
7
  module Images
8
- def images_url
8
+ def images_url(with: nil, mask: nil) # rubocop:disable Lint/UnusedMethodArgument
9
9
  "models/#{@model}:predict"
10
10
  end
11
11
 
12
- def render_image_payload(prompt, model:, size:)
12
+ def render_image_payload(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
13
13
  RubyLLM.logger.debug { "Ignoring size #{size}. Gemini does not support image size customization." }
14
14
  @model = model
15
15
  {
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
4
+ require 'stringio'
5
+
3
6
  module RubyLLM
4
7
  module Providers
5
8
  class Gemini # rubocop:disable Style/Documentation
@@ -16,19 +19,23 @@ module RubyLLM
16
19
  parts << format_text(content.text) if content.text
17
20
 
18
21
  content.attachments.each do |attachment|
19
- case attachment.type
20
- when :text
21
- parts << format_text_file(attachment)
22
- when :unknown
23
- raise UnsupportedAttachmentError, attachment.mime_type
24
- else
25
- parts << format_attachment(attachment)
26
- end
22
+ parts << format_content_attachment(attachment)
27
23
  end
28
24
 
29
25
  parts
30
26
  end
31
27
 
28
+ def format_content_attachment(attachment)
29
+ case attachment.type
30
+ when :text
31
+ format_text_file(attachment)
32
+ when :document, :unknown
33
+ raise UnsupportedAttachmentError, attachment.mime_type
34
+ else
35
+ format_attachment(attachment)
36
+ end
37
+ end
38
+
32
39
  def format_attachment(attachment)
33
40
  {
34
41
  inline_data: {
@@ -71,7 +78,7 @@ module RubyLLM
71
78
  text = nil if text.empty?
72
79
  return text if attachments.empty?
73
80
 
74
- Content.new(text:, attachments:)
81
+ Content.new(text, attachments)
75
82
  end
76
83
 
77
84
  def build_inline_attachment(inline_data, index)
@@ -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'],
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Gemini
@@ -70,7 +72,10 @@ module RubyLLM
70
72
  end
71
73
 
72
74
  def extract_input_tokens(data)
73
- data.dig('usageMetadata', 'promptTokenCount')
75
+ prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
76
+ return unless prompt_tokens
77
+
78
+ [prompt_tokens.to_i - data.dig('usageMetadata', 'cachedContentTokenCount').to_i, 0].max
74
79
  end
75
80
 
76
81
  def extract_output_tokens(data)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Gemini
@@ -46,13 +48,15 @@ module RubyLLM
46
48
 
47
49
  def format_tool_result(msg, function_name = nil)
48
50
  function_name ||= msg.tool_call_id
51
+ content = msg.content
52
+ content = '(no output)' if content.nil? || (content.respond_to?(:empty?) && content.empty?)
49
53
 
50
54
  [{
51
55
  functionResponse: {
52
56
  name: function_name,
53
57
  response: {
54
58
  name: function_name,
55
- content: Media.format_content(msg.content)
59
+ content: Media.format_content(content)
56
60
  }
57
61
  }
58
62
  }]
@@ -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
@@ -11,13 +11,20 @@ module RubyLLM
11
11
  messages.map do |msg|
12
12
  {
13
13
  role: format_role(msg.role),
14
- content: GPUStack::Media.format_content(msg.content),
14
+ content: format_message_content(msg),
15
15
  tool_calls: format_tool_calls(msg.tool_calls),
16
16
  tool_call_id: msg.tool_call_id
17
17
  }.compact.merge(OpenAI::Chat.format_thinking(msg))
18
18
  end
19
19
  end
20
20
 
21
+ def format_message_content(msg)
22
+ content = GPUStack::Media.format_content(msg.content)
23
+ return '' if content.nil? && OpenAI::Chat.thinking_only_assistant_message?(msg)
24
+
25
+ content
26
+ end
27
+
21
28
  def format_role(role)
22
29
  role.to_s
23
30
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class GPUStack
@@ -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
@@ -31,6 +31,11 @@ module RubyLLM
31
31
  !model_id.match?(/embed|moderation|ocr|voxtral|transcriptions/) && supports_tools?(model_id)
32
32
  end
33
33
 
34
+ def supports_reasoning?(model_id)
35
+ model_id.match?(/magistral/) ||
36
+ model_id.match?(/\Amistral-(?:small-latest|medium-(?:3(?:[.-]5)?|latest))\z/)
37
+ end
38
+
34
39
  def format_display_name(model_id)
35
40
  case model_id
36
41
  when /mistral-large/ then 'Mistral Large'
@@ -101,7 +106,7 @@ module RubyLLM
101
106
  capabilities << 'structured_output' if supports_json_mode?(model_id)
102
107
  capabilities << 'vision' if supports_vision?(model_id)
103
108
 
104
- capabilities << 'reasoning' if model_id.match?(/magistral/)
109
+ capabilities << 'reasoning' if supports_reasoning?(model_id)
105
110
  capabilities << 'batch' unless model_id.match?(/voxtral|ocr|embed|moderation/)
106
111
  capabilities << 'fine_tuning' if model_id.match?(/mistral-(small|medium|large)|devstral/)
107
112
  capabilities << 'distillation' if model_id.match?(/ministral/)
@@ -118,7 +123,7 @@ module RubyLLM
118
123
  }
119
124
  end
120
125
 
121
- def release_date_for(model_id)
126
+ def release_date_for(model_id) # rubocop:disable Metrics/CyclomaticComplexity
122
127
  case model_id
123
128
  when 'open-mistral-7b', 'mistral-tiny' then '2023-09-27'
124
129
  when 'mistral-medium-2312', 'mistral-small-2312', 'mistral-small',
@@ -27,14 +27,32 @@ module RubyLLM
27
27
  schema: nil, thinking: nil, tool_prefs: nil)
28
28
  payload = super
29
29
  payload.delete(:stream_options)
30
- payload.delete(:reasoning_effort)
31
- warn_on_unsupported_thinking(model, thinking)
30
+ configure_thinking_payload(payload, model, thinking)
31
+ normalize_required_tool_choice(payload)
32
32
  payload
33
33
  end
34
34
  # rubocop:enable Metrics/ParameterLists
35
35
 
36
+ def build_tool_choice(tool_choice)
37
+ return 'any' if tool_choice == :required
38
+
39
+ OpenAI::Tools.build_tool_choice(tool_choice)
40
+ end
41
+
42
+ def normalize_required_tool_choice(payload)
43
+ return unless payload[:tool_choice] == 'any' && Array(payload[:tools]).one?
44
+
45
+ function_name = payload.dig(:tools, 0, :function, :name)
46
+ return unless function_name
47
+
48
+ payload[:tool_choice] = {
49
+ type: 'function',
50
+ function: { name: function_name }
51
+ }
52
+ end
53
+
36
54
  def format_content_with_thinking(msg)
37
- formatted_content = OpenAI::Media.format_content(msg.content)
55
+ formatted_content = Mistral::Media.format_content(msg.content)
38
56
  return formatted_content unless msg.role == :assistant && msg.thinking
39
57
 
40
58
  content_blocks = build_thinking_blocks(msg.thinking)
@@ -45,14 +63,47 @@ module RubyLLM
45
63
 
46
64
  def warn_on_unsupported_thinking(model, thinking)
47
65
  return unless thinking&.enabled?
48
- return if model.id.to_s.include?('magistral')
66
+ return if native_reasoning_model?(model.id) || adjustable_reasoning_model?(model.id)
49
67
 
50
68
  RubyLLM.logger.warn(
51
- 'Mistral thinking is only supported on Magistral models. ' \
69
+ 'Mistral thinking is only supported on Magistral and adjustable-reasoning models. ' \
52
70
  "Ignoring thinking settings for #{model.id}."
53
71
  )
54
72
  end
55
73
 
74
+ def configure_thinking_payload(payload, model, thinking)
75
+ return unless thinking&.enabled?
76
+
77
+ if native_reasoning_model?(model.id)
78
+ configure_native_reasoning_payload(payload, thinking)
79
+ elsif adjustable_reasoning_model?(model.id)
80
+ payload[:reasoning_effort] = reasoning_effort_for(thinking)
81
+ else
82
+ payload.delete(:reasoning_effort)
83
+ warn_on_unsupported_thinking(model, thinking)
84
+ end
85
+ end
86
+
87
+ def configure_native_reasoning_payload(payload, thinking)
88
+ payload.delete(:reasoning_effort)
89
+ payload[:prompt_mode] = thinking.effort == 'none' ? nil : 'reasoning'
90
+ end
91
+
92
+ def reasoning_effort_for(thinking)
93
+ effort = thinking.respond_to?(:effort) ? thinking.effort : nil
94
+ return effort if %w[high none].include?(effort)
95
+
96
+ 'high'
97
+ end
98
+
99
+ def native_reasoning_model?(model_id)
100
+ model_id.to_s.include?('magistral')
101
+ end
102
+
103
+ def adjustable_reasoning_model?(model_id)
104
+ model_id.to_s.match?(/\Amistral-(?:small-latest|medium-(?:3(?:[.-]5)?|latest))\z/)
105
+ end
106
+
56
107
  def build_thinking_blocks(thinking)
57
108
  return [] unless thinking
58
109