ruby_llm 1.12.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -5
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +2 -2
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +37 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
  51. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  52. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -1
  53. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
  54. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -2
  55. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
  56. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  57. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  59. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  60. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  61. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
  63. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  66. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  67. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  68. data/lib/ruby_llm/active_record/acts_as_legacy.rb +87 -20
  69. data/lib/ruby_llm/active_record/chat_methods.rb +80 -22
  70. data/lib/ruby_llm/active_record/message_methods.rb +17 -0
  71. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  72. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  73. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  74. data/lib/ruby_llm/agent.rb +50 -8
  75. data/lib/ruby_llm/aliases.json +60 -21
  76. data/lib/ruby_llm/attachment.rb +4 -1
  77. data/lib/ruby_llm/chat.rb +113 -12
  78. data/lib/ruby_llm/configuration.rb +65 -66
  79. data/lib/ruby_llm/connection.rb +11 -7
  80. data/lib/ruby_llm/content.rb +6 -2
  81. data/lib/ruby_llm/error.rb +37 -1
  82. data/lib/ruby_llm/message.rb +5 -3
  83. data/lib/ruby_llm/model/info.rb +15 -13
  84. data/lib/ruby_llm/models.json +12279 -13517
  85. data/lib/ruby_llm/models.rb +16 -6
  86. data/lib/ruby_llm/provider.rb +10 -1
  87. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  88. data/lib/ruby_llm/providers/anthropic/chat.rb +22 -5
  89. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  90. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  91. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  92. data/lib/ruby_llm/providers/azure/chat.rb +1 -1
  93. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -1
  94. data/lib/ruby_llm/providers/azure/models.rb +1 -1
  95. data/lib/ruby_llm/providers/azure.rb +92 -0
  96. data/lib/ruby_llm/providers/bedrock/chat.rb +50 -5
  97. data/lib/ruby_llm/providers/bedrock/models.rb +17 -1
  98. data/lib/ruby_llm/providers/bedrock/streaming.rb +8 -4
  99. data/lib/ruby_llm/providers/bedrock.rb +9 -1
  100. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  101. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  102. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  103. data/lib/ruby_llm/providers/gemini/chat.rb +20 -4
  104. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  105. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  106. data/lib/ruby_llm/providers/gemini/streaming.rb +2 -1
  107. data/lib/ruby_llm/providers/gemini/tools.rb +19 -0
  108. data/lib/ruby_llm/providers/gemini.rb +4 -0
  109. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  110. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  111. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  112. data/lib/ruby_llm/providers/mistral/chat.rb +2 -1
  113. data/lib/ruby_llm/providers/mistral.rb +4 -0
  114. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  115. data/lib/ruby_llm/providers/ollama.rb +11 -1
  116. data/lib/ruby_llm/providers/openai/capabilities.rb +95 -195
  117. data/lib/ruby_llm/providers/openai/chat.rb +15 -5
  118. data/lib/ruby_llm/providers/openai/media.rb +4 -1
  119. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  120. data/lib/ruby_llm/providers/openai/temperature.rb +2 -2
  121. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  122. data/lib/ruby_llm/providers/openai.rb +10 -0
  123. data/lib/ruby_llm/providers/openrouter/chat.rb +19 -5
  124. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  125. data/lib/ruby_llm/providers/openrouter.rb +35 -1
  126. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  127. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  128. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  129. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  130. data/lib/ruby_llm/providers/vertexai.rb +18 -6
  131. data/lib/ruby_llm/providers/xai.rb +4 -0
  132. data/lib/ruby_llm/stream_accumulator.rb +10 -5
  133. data/lib/ruby_llm/streaming.rb +7 -7
  134. data/lib/ruby_llm/tool.rb +48 -3
  135. data/lib/ruby_llm/version.rb +1 -1
  136. data/lib/tasks/models.rake +33 -7
  137. data/lib/tasks/release.rake +1 -1
  138. data/lib/tasks/ruby_llm.rake +9 -1
  139. data/lib/tasks/vcr.rake +1 -1
  140. metadata +56 -15
  141. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -18,12 +18,15 @@ module RubyLLM
18
18
  {}
19
19
  end
20
20
 
21
- def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil, &) # rubocop:disable Metrics/ParameterLists
21
+ # rubocop:disable Metrics/ParameterLists
22
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
23
+ tool_prefs: nil, &)
22
24
  normalized_params = normalize_params(params, model:)
23
25
 
24
26
  super(
25
27
  messages,
26
28
  tools: tools,
29
+ tool_prefs: tool_prefs,
27
30
  temperature: temperature,
28
31
  model: model,
29
32
  params: normalized_params,
@@ -33,6 +36,7 @@ module RubyLLM
33
36
  &
34
37
  )
35
38
  end
39
+ # rubocop:enable Metrics/ParameterLists
36
40
 
37
41
  def parse_error(response)
38
42
  return if response.body.nil? || response.body.empty?
@@ -49,6 +53,10 @@ module RubyLLM
49
53
  end
50
54
 
51
55
  class << self
56
+ def configuration_options
57
+ %i[bedrock_api_key bedrock_secret_key bedrock_region bedrock_session_token]
58
+ end
59
+
52
60
  def configuration_requirements
53
61
  %i[bedrock_api_key bedrock_secret_key bedrock_region]
54
62
  end
@@ -3,127 +3,17 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class DeepSeek
6
- # Determines capabilities and pricing for DeepSeek models
6
+ # Provider-level capability checks used outside the model registry.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
- def context_window_for(model_id)
11
- case model_id
12
- when /deepseek-(?:chat|reasoner)/ then 64_000
13
- else 32_768
14
- end
10
+ def supports_tool_choice?(_model_id)
11
+ true
15
12
  end
16
13
 
17
- def max_tokens_for(model_id)
18
- case model_id
19
- when /deepseek-(?:chat|reasoner)/ then 8_192
20
- else 4_096
21
- end
22
- end
23
-
24
- def input_price_for(model_id)
25
- PRICES.dig(model_family(model_id), :input_miss) || default_input_price
26
- end
27
-
28
- def output_price_for(model_id)
29
- PRICES.dig(model_family(model_id), :output) || default_output_price
30
- end
31
-
32
- def cache_hit_price_for(model_id)
33
- PRICES.dig(model_family(model_id), :input_hit) || default_cache_hit_price
34
- end
35
-
36
- def supports_vision?(_model_id)
14
+ def supports_tool_parallel_control?(_model_id)
37
15
  false
38
16
  end
39
-
40
- def supports_functions?(model_id)
41
- model_id.match?(/deepseek-chat/)
42
- end
43
-
44
- def supports_json_mode?(_model_id)
45
- false
46
- end
47
-
48
- def format_display_name(model_id)
49
- case model_id
50
- when 'deepseek-chat' then 'DeepSeek V3'
51
- when 'deepseek-reasoner' then 'DeepSeek R1'
52
- else
53
- model_id.split('-')
54
- .map(&:capitalize)
55
- .join(' ')
56
- end
57
- end
58
-
59
- def model_type(_model_id)
60
- 'chat'
61
- end
62
-
63
- def model_family(model_id)
64
- case model_id
65
- when /deepseek-reasoner/ then :reasoner
66
- else :chat
67
- end
68
- end
69
-
70
- PRICES = {
71
- chat: {
72
- input_hit: 0.07,
73
- input_miss: 0.27,
74
- output: 1.10
75
- },
76
- reasoner: {
77
- input_hit: 0.14,
78
- input_miss: 0.55,
79
- output: 2.19
80
- }
81
- }.freeze
82
-
83
- def default_input_price
84
- 0.27
85
- end
86
-
87
- def default_output_price
88
- 1.10
89
- end
90
-
91
- def default_cache_hit_price
92
- 0.07
93
- end
94
-
95
- def modalities_for(_model_id)
96
- {
97
- input: ['text'],
98
- output: ['text']
99
- }
100
- end
101
-
102
- def capabilities_for(model_id)
103
- capabilities = ['streaming']
104
-
105
- capabilities << 'function_calling' if model_id.match?(/deepseek-chat/)
106
-
107
- capabilities
108
- end
109
-
110
- def pricing_for(model_id)
111
- family = model_family(model_id)
112
- prices = PRICES.fetch(family, { input_miss: default_input_price, output: default_output_price })
113
-
114
- standard_pricing = {
115
- input_per_million: prices[:input_miss],
116
- output_per_million: prices[:output]
117
- }
118
-
119
- standard_pricing[:cached_input_per_million] = prices[:input_hit] if prices[:input_hit]
120
-
121
- {
122
- text_tokens: {
123
- standard: standard_pricing
124
- }
125
- }
126
- end
127
17
  end
128
18
  end
129
19
  end
@@ -7,7 +7,7 @@ module RubyLLM
7
7
  include DeepSeek::Chat
8
8
 
9
9
  def api_base
10
- 'https://api.deepseek.com'
10
+ @config.deepseek_api_base || 'https://api.deepseek.com'
11
11
  end
12
12
 
13
13
  def headers
@@ -21,6 +21,10 @@ module RubyLLM
21
21
  DeepSeek::Capabilities
22
22
  end
23
23
 
24
+ def configuration_options
25
+ %i[deepseek_api_key deepseek_api_base]
26
+ end
27
+
24
28
  def configuration_requirements
25
29
  %i[deepseek_api_key]
26
30
  end
@@ -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,17 +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_json_mode?(model_id)
90
+ def supports_structured_output?(model_id)
66
91
  if model_id.match?(/text-embedding|embedding-001|aqa|imagen|gemini-2\.0-flash-lite|gemini-2\.5-pro-exp-03-25/)
67
92
  return false
68
93
  end
@@ -70,59 +95,6 @@ module RubyLLM
70
95
  model_id.match?(/gemini|pro|flash/)
71
96
  end
72
97
 
73
- def format_display_name(model_id)
74
- model_id
75
- .delete_prefix('models/')
76
- .split('-')
77
- .map(&:capitalize)
78
- .join(' ')
79
- .gsub(/(\d+\.\d+)/, ' \1')
80
- .gsub(/\s+/, ' ')
81
- .gsub('Aqa', 'AQA')
82
- .strip
83
- end
84
-
85
- def supports_caching?(model_id)
86
- if model_id.match?(/flash-lite|gemini-2\.5-pro-exp-03-25|aqa|imagen|text-embedding|embedding-001/)
87
- return false
88
- end
89
-
90
- model_id.match?(/gemini|pro|flash/)
91
- end
92
-
93
- def supports_tuning?(model_id)
94
- model_id.match?(/gemini-1\.5-flash|gemini-1\.5-flash-8b/)
95
- end
96
-
97
- def supports_audio?(model_id)
98
- model_id.match?(/gemini|pro|flash/)
99
- end
100
-
101
- def model_type(model_id)
102
- case model_id
103
- when /text-embedding|embedding|gemini-embedding/ then 'embedding'
104
- when /imagen/ then 'image'
105
- else 'chat'
106
- end
107
- end
108
-
109
- def model_family(model_id)
110
- case model_id
111
- when /gemini-2\.5-pro-exp-03-25/ then 'gemini25_pro_exp'
112
- when /gemini-2\.0-flash-lite/ then 'gemini20_flash_lite'
113
- when /gemini-2\.0-flash/ then 'gemini20_flash'
114
- when /gemini-1\.5-flash-8b/ then 'gemini15_flash_8b'
115
- when /gemini-1\.5-flash/ then 'gemini15_flash'
116
- when /gemini-1\.5-pro/ then 'gemini15_pro'
117
- when /gemini-embedding-exp/ then 'gemini_embedding_exp'
118
- when /text-embedding-004/ then 'embedding4'
119
- when /embedding-001/ then 'embedding1'
120
- when /aqa/ then 'aqa'
121
- when /imagen-3/ then 'imagen3'
122
- else 'other'
123
- end
124
- end
125
-
126
98
  def pricing_family(model_id)
127
99
  case model_id
128
100
  when /gemini-2\.5-pro-exp-03-25/ then :pro_2_5 # rubocop:disable Naming/VariableNumber
@@ -139,142 +111,8 @@ module RubyLLM
139
111
  end
140
112
  end
141
113
 
142
- def long_context_model?(model_id)
143
- model_id.match?(/gemini-1\.5-(?:pro|flash)|gemini-1\.5-flash-8b/)
144
- end
145
-
146
- def context_length(model_id)
147
- context_window_for(model_id)
148
- end
149
-
150
- PRICES = {
151
- flash_2: { # rubocop:disable Naming/VariableNumber
152
- input: 0.10,
153
- output: 0.40,
154
- audio_input: 0.70,
155
- cache: 0.025,
156
- cache_storage: 1.00,
157
- grounding_search: 35.00
158
- },
159
- flash_lite_2: { # rubocop:disable Naming/VariableNumber
160
- input: 0.075,
161
- output: 0.30
162
- },
163
- flash: {
164
- input: 0.075,
165
- output: 0.30,
166
- cache: 0.01875,
167
- cache_storage: 1.00,
168
- grounding_search: 35.00
169
- },
170
- flash_8b: {
171
- input: 0.0375,
172
- output: 0.15,
173
- cache: 0.01,
174
- cache_storage: 0.25,
175
- grounding_search: 35.00
176
- },
177
- pro: {
178
- input: 1.25,
179
- output: 5.0,
180
- cache: 0.3125,
181
- cache_storage: 4.50,
182
- grounding_search: 35.00
183
- },
184
- pro_2_5: { # rubocop:disable Naming/VariableNumber
185
- input: 0.12,
186
- output: 0.50
187
- },
188
- gemini_embedding: {
189
- input: 0.002,
190
- output: 0.004
191
- },
192
- embedding: {
193
- input: 0.00,
194
- output: 0.00
195
- },
196
- imagen: {
197
- price: 0.03
198
- },
199
- aqa: {
200
- input: 0.00,
201
- output: 0.00
202
- }
203
- }.freeze
204
-
205
- def default_input_price
206
- 0.075
207
- end
208
-
209
- def default_output_price
210
- 0.30
211
- end
212
-
213
- def modalities_for(model_id)
214
- modalities = {
215
- input: ['text'],
216
- output: ['text']
217
- }
218
-
219
- if supports_vision?(model_id)
220
- modalities[:input] << 'image'
221
- modalities[:input] << 'pdf'
222
- end
223
-
224
- modalities[:input] << 'video' if supports_video?(model_id)
225
- modalities[:input] << 'audio' if model_id.match?(/audio/)
226
- modalities[:output] << 'embeddings' if model_id.match?(/embedding|gemini-embedding/)
227
- modalities[:output] = ['image'] if model_id.match?(/imagen/)
228
-
229
- modalities
230
- end
231
-
232
- def capabilities_for(model_id)
233
- capabilities = ['streaming']
234
-
235
- capabilities << 'function_calling' if supports_functions?(model_id)
236
- capabilities << 'structured_output' if supports_json_mode?(model_id)
237
- capabilities << 'batch' if model_id.match?(/embedding|flash/)
238
- capabilities << 'caching' if supports_caching?(model_id)
239
- capabilities << 'fine_tuning' if supports_tuning?(model_id)
240
- capabilities
241
- end
242
-
243
- def pricing_for(model_id)
244
- family = pricing_family(model_id)
245
- prices = PRICES.fetch(family, { input: default_input_price, output: default_output_price })
246
-
247
- standard_pricing = {
248
- input_per_million: prices[:input],
249
- output_per_million: prices[:output]
250
- }
251
-
252
- standard_pricing[:cached_input_per_million] = prices[:input_hit] if prices[:input_hit]
253
-
254
- batch_pricing = {
255
- input_per_million: (standard_pricing[:input_per_million] || 0) * 0.5,
256
- output_per_million: (standard_pricing[:output_per_million] || 0) * 0.5
257
- }
258
-
259
- if standard_pricing[:cached_input_per_million]
260
- batch_pricing[:cached_input_per_million] = standard_pricing[:cached_input_per_million] * 0.5
261
- end
262
-
263
- pricing = {
264
- text_tokens: {
265
- standard: standard_pricing,
266
- batch: batch_pricing
267
- }
268
- }
269
-
270
- if model_id.match?(/embedding|gemini-embedding/)
271
- pricing[:embeddings] = {
272
- standard: { input_per_million: prices[:price] || 0.002 }
273
- }
274
- end
275
-
276
- pricing
277
- 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
278
116
  end
279
117
  end
280
118
  end
@@ -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, thinking: 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),
@@ -26,9 +29,15 @@ module RubyLLM
26
29
  payload[:generationConfig].merge!(structured_output_config(schema, model)) if schema
27
30
  payload[:generationConfig][:thinkingConfig] = build_thinking_config(model, thinking) if thinking&.enabled?
28
31
 
29
- payload[:tools] = format_tools(tools) if tools.any?
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
37
+
30
38
  payload
31
39
  end
40
+ # rubocop:enable Metrics/ParameterLists,Lint/UnusedMethodArgument
32
41
 
33
42
  def build_thinking_config(_model, thinking)
34
43
  config = { includeThoughts: true }
@@ -111,6 +120,7 @@ module RubyLLM
111
120
  tool_calls: tool_calls,
112
121
  input_tokens: data.dig('usageMetadata', 'promptTokenCount'),
113
122
  output_tokens: calculate_output_tokens(data),
123
+ cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
114
124
  thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
115
125
  model_id: data['modelVersion'] || response.env.url.path.split('/')[3].split(':')[0],
116
126
  raw: response
@@ -120,6 +130,9 @@ module RubyLLM
120
130
  def convert_schema_to_gemini(schema)
121
131
  return nil unless schema
122
132
 
133
+ # Extract inner schema if wrapper format (e.g., from RubyLLM::Schema.to_json_schema)
134
+ schema = schema[:schema] || schema
135
+
123
136
  GeminiSchema.new(schema).to_h
124
137
  end
125
138
 
@@ -132,7 +145,10 @@ module RubyLLM
132
145
  parts = candidate.dig('content', 'parts')
133
146
  return '' unless parts&.any?
134
147
 
135
- 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)
136
152
  end
137
153
 
138
154
  def extract_text_parts(parts)
@@ -176,7 +192,7 @@ module RubyLLM
176
192
  end
177
193
 
178
194
  def build_json_schema(schema)
179
- normalized = RubyLLM::Utils.deep_dup(schema)
195
+ normalized = RubyLLM::Utils.deep_dup(schema[:schema])
180
196
  normalized.delete(:strict)
181
197
  normalized.delete('strict')
182
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'],
@@ -22,6 +22,7 @@ module RubyLLM
22
22
  ),
23
23
  input_tokens: extract_input_tokens(data),
24
24
  output_tokens: extract_output_tokens(data),
25
+ cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
25
26
  thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
26
27
  tool_calls: extract_tool_calls(data)
27
28
  )
@@ -83,7 +84,7 @@ module RubyLLM
83
84
  error_data = JSON.parse(data)
84
85
  [error_data['error']['code'], error_data['error']['message']]
85
86
  rescue JSON::ParserError => e
86
- RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
87
+ RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
87
88
  [500, "Failed to parse error: #{data}"]
88
89
  end
89
90
  end
@@ -205,6 +205,25 @@ module RubyLLM
205
205
  else 'STRING'
206
206
  end
207
207
  end
208
+
209
+ def build_tool_config(tool_choice)
210
+ {
211
+ functionCallingConfig: {
212
+ mode: forced_tool_choice?(tool_choice) ? 'any' : tool_choice
213
+ }.tap do |config|
214
+ # Use allowedFunctionNames to simulate specific tool choice
215
+ config[:allowedFunctionNames] = [tool_choice] if specific_tool_choice?(tool_choice)
216
+ end
217
+ }
218
+ end
219
+
220
+ def forced_tool_choice?(tool_choice)
221
+ tool_choice == :required || specific_tool_choice?(tool_choice)
222
+ end
223
+
224
+ def specific_tool_choice?(tool_choice)
225
+ !%i[auto none required].include?(tool_choice)
226
+ end
208
227
  end
209
228
  end
210
229
  end
@@ -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
@@ -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