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
@@ -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
@@ -23,7 +23,8 @@ module RubyLLM
23
23
  end
24
24
 
25
25
  # rubocop:disable Metrics/ParameterLists
26
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil)
26
+ def render_payload(messages, tools:, temperature:, model:, stream: false,
27
+ schema: nil, thinking: nil, tool_prefs: nil)
27
28
  payload = super
28
29
  payload.delete(:stream_options)
29
30
  payload.delete(:reasoning_effort)
@@ -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
@@ -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
@@ -3,13 +3,11 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class OpenAI
6
- # Determines capabilities and pricing for OpenAI models
6
+ # Provider-level capability checks and narrow registry fallbacks.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
10
  MODEL_PATTERNS = {
11
- dall_e: /^dall-e/,
12
- chatgpt4o: /^chatgpt-4o/,
13
11
  gpt41: /^gpt-4\.1(?!-(?:mini|nano))/,
14
12
  gpt41_mini: /^gpt-4\.1-mini/,
15
13
  gpt41_nano: /^gpt-4\.1-nano/,
@@ -26,9 +24,9 @@ module RubyLLM
26
24
  gpt4o_realtime: /^gpt-4o-realtime/,
27
25
  gpt4o_search: /^gpt-4o-search/,
28
26
  gpt4o_transcribe: /^gpt-4o-transcribe/,
29
- gpt5: /^gpt-5/,
30
- gpt5_mini: /^gpt-5-mini/,
31
- gpt5_nano: /^gpt-5-nano/,
27
+ gpt5: /^gpt-5(?!.*(?:mini|nano))/,
28
+ gpt5_mini: /^gpt-5.*mini/,
29
+ gpt5_nano: /^gpt-5.*nano/,
32
30
  o1: /^o1(?!-(?:mini|pro))/,
33
31
  o1_mini: /^o1-mini/,
34
32
  o1_pro: /^o1-pro/,
@@ -44,71 +42,6 @@ module RubyLLM
44
42
  moderation: /^(?:omni|text)-moderation/
45
43
  }.freeze
46
44
 
47
- def context_window_for(model_id)
48
- case model_family(model_id)
49
- when 'gpt41', 'gpt41_mini', 'gpt41_nano' then 1_047_576
50
- when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'chatgpt4o', 'gpt4_turbo', 'gpt4o', 'gpt4o_audio', 'gpt4o_mini',
51
- 'gpt4o_mini_audio', 'gpt4o_mini_realtime', 'gpt4o_realtime',
52
- 'gpt4o_search', 'gpt4o_transcribe', 'gpt4o_mini_search', 'o1_mini' then 128_000
53
- when 'gpt4' then 8_192
54
- when 'gpt4o_mini_transcribe' then 16_000
55
- when 'o1', 'o1_pro', 'o3_mini' then 200_000
56
- when 'gpt35_turbo' then 16_385
57
- when 'gpt4o_mini_tts', 'tts1', 'tts1_hd', 'whisper', 'moderation',
58
- 'embedding3_large', 'embedding3_small', 'embedding_ada' then nil
59
- else 4_096
60
- end
61
- end
62
-
63
- def max_tokens_for(model_id)
64
- case model_family(model_id)
65
- when 'gpt5', 'gpt5_mini', 'gpt5_nano' then 400_000
66
- when 'gpt41', 'gpt41_mini', 'gpt41_nano' then 32_768
67
- when 'chatgpt4o', 'gpt4o', 'gpt4o_mini', 'gpt4o_mini_search' then 16_384
68
- when 'babbage', 'davinci' then 16_384 # rubocop:disable Lint/DuplicateBranch
69
- when 'gpt4' then 8_192
70
- when 'gpt35_turbo' then 4_096
71
- when 'gpt4_turbo', 'gpt4o_realtime', 'gpt4o_mini_realtime' then 4_096 # rubocop:disable Lint/DuplicateBranch
72
- when 'gpt4o_mini_transcribe' then 2_000
73
- when 'o1', 'o1_pro', 'o3_mini' then 100_000
74
- when 'o1_mini' then 65_536
75
- when 'gpt4o_mini_tts', 'tts1', 'tts1_hd', 'whisper', 'moderation',
76
- 'embedding3_large', 'embedding3_small', 'embedding_ada' then nil
77
- else 16_384 # rubocop:disable Lint/DuplicateBranch
78
- end
79
- end
80
-
81
- def supports_vision?(model_id)
82
- case model_family(model_id)
83
- when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'chatgpt4o', 'gpt4',
84
- 'gpt4_turbo', 'gpt4o', 'gpt4o_mini', 'o1', 'o1_pro', 'moderation', 'gpt4o_search',
85
- 'gpt4o_mini_search' then true
86
- else false
87
- end
88
- end
89
-
90
- def supports_functions?(model_id)
91
- case model_family(model_id)
92
- when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'gpt4', 'gpt4_turbo', 'gpt4o',
93
- 'gpt4o_mini', 'o1', 'o1_pro', 'o3_mini' then true
94
- when 'chatgpt4o', 'gpt35_turbo', 'o1_mini', 'gpt4o_mini_tts',
95
- 'gpt4o_transcribe', 'gpt4o_search', 'gpt4o_mini_search' then false
96
- else false # rubocop:disable Lint/DuplicateBranch
97
- end
98
- end
99
-
100
- def supports_structured_output?(model_id)
101
- case model_family(model_id)
102
- when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'chatgpt4o', 'gpt4o',
103
- 'gpt4o_mini', 'o1', 'o1_pro', 'o3_mini' then true
104
- else false
105
- end
106
- end
107
-
108
- def supports_json_mode?(model_id)
109
- supports_structured_output?(model_id)
110
- end
111
-
112
45
  PRICES = {
113
46
  gpt5: { input: 1.25, output: 10.0, cached_input: 0.125 },
114
47
  gpt5_mini: { input: 0.25, output: 2.0, cached_input: 0.025 },
@@ -116,21 +49,19 @@ module RubyLLM
116
49
  gpt41: { input: 2.0, output: 8.0, cached_input: 0.5 },
117
50
  gpt41_mini: { input: 0.4, output: 1.6, cached_input: 0.1 },
118
51
  gpt41_nano: { input: 0.1, output: 0.4 },
119
- chatgpt4o: { input: 5.0, output: 15.0 },
120
52
  gpt4: { input: 10.0, output: 30.0 },
121
53
  gpt4_turbo: { input: 10.0, output: 30.0 },
122
- gpt45: { input: 75.0, output: 150.0 },
123
54
  gpt35_turbo: { input: 0.5, output: 1.5 },
124
55
  gpt4o: { input: 2.5, output: 10.0 },
125
- gpt4o_audio: { input: 2.5, output: 10.0, audio_input: 40.0, audio_output: 80.0 },
56
+ gpt4o_audio: { input: 2.5, output: 10.0 },
126
57
  gpt4o_mini: { input: 0.15, output: 0.6 },
127
- gpt4o_mini_audio: { input: 0.15, output: 0.6, audio_input: 10.0, audio_output: 20.0 },
58
+ gpt4o_mini_audio: { input: 0.15, output: 0.6 },
128
59
  gpt4o_mini_realtime: { input: 0.6, output: 2.4 },
129
- gpt4o_mini_transcribe: { input: 1.25, output: 5.0, audio_input: 3.0 },
60
+ gpt4o_mini_transcribe: { input: 1.25, output: 5.0 },
130
61
  gpt4o_mini_tts: { input: 0.6, output: 12.0 },
131
62
  gpt4o_realtime: { input: 5.0, output: 20.0 },
132
63
  gpt4o_search: { input: 2.5, output: 10.0 },
133
- gpt4o_transcribe: { input: 2.5, output: 10.0, audio_input: 6.0 },
64
+ gpt4o_transcribe: { input: 2.5, output: 10.0 },
134
65
  o1: { input: 15.0, output: 60.0 },
135
66
  o1_mini: { input: 1.1, output: 4.4 },
136
67
  o1_pro: { input: 150.0, output: 600.0 },
@@ -146,157 +77,126 @@ module RubyLLM
146
77
  moderation: { price: 0.0 }
147
78
  }.freeze
148
79
 
149
- def model_family(model_id)
150
- MODEL_PATTERNS.each do |family, pattern|
151
- return family.to_s if model_id.match?(pattern)
152
- end
153
- 'other'
80
+ def supports_tool_choice?(_model_id)
81
+ true
154
82
  end
155
83
 
156
- def input_price_for(model_id)
157
- family = model_family(model_id).to_sym
158
- prices = PRICES.fetch(family, { input: default_input_price })
159
- prices[:input] || prices[:price] || default_input_price
160
- end
161
-
162
- def cached_input_price_for(model_id)
163
- family = model_family(model_id).to_sym
164
- prices = PRICES.fetch(family, {})
165
- prices[:cached_input]
84
+ def supports_tool_parallel_control?(_model_id)
85
+ true
166
86
  end
167
87
 
168
- def output_price_for(model_id)
169
- family = model_family(model_id).to_sym
170
- prices = PRICES.fetch(family, { output: default_output_price })
171
- prices[:output] || prices[:price] || default_output_price
88
+ def context_window_for(model_id)
89
+ case model_family(model_id)
90
+ when 'gpt41', 'gpt41_mini', 'gpt41_nano' then 1_047_576
91
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt4_turbo', 'gpt4o', 'gpt4o_audio', 'gpt4o_mini',
92
+ 'gpt4o_mini_audio', 'gpt4o_mini_realtime', 'gpt4o_realtime', 'gpt4o_search',
93
+ 'gpt4o_transcribe', 'o1_mini' then 128_000
94
+ when 'gpt4' then 8_192
95
+ when 'gpt4o_mini_transcribe' then 16_000
96
+ when 'o1', 'o1_pro', 'o3_mini' then 200_000
97
+ when 'gpt35_turbo' then 16_385
98
+ when 'gpt4o_mini_tts', 'tts1', 'tts1_hd', 'whisper', 'moderation',
99
+ 'embedding3_large', 'embedding3_small', 'embedding_ada' then nil
100
+ else 4_096
101
+ end
172
102
  end
173
103
 
174
- def model_type(model_id)
104
+ def max_tokens_for(model_id)
175
105
  case model_family(model_id)
176
- when /embedding/ then 'embedding'
177
- when /^tts|whisper|gpt4o_(?:mini_)?(?:transcribe|tts)$/ then 'audio'
178
- when 'moderation' then 'moderation'
179
- when /dall/ then 'image'
180
- else 'chat'
106
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano' then 400_000
107
+ when 'gpt41', 'gpt41_mini', 'gpt41_nano' then 32_768
108
+ when 'gpt4' then 8_192
109
+ when 'gpt35_turbo' then 4_096
110
+ when 'gpt4o_mini_transcribe' then 2_000
111
+ when 'o1', 'o1_pro', 'o3_mini' then 100_000
112
+ when 'o1_mini' then 65_536
113
+ when 'gpt4o_mini_tts', 'tts1', 'tts1_hd', 'whisper', 'moderation',
114
+ 'embedding3_large', 'embedding3_small', 'embedding_ada' then nil
115
+ else 16_384
181
116
  end
182
117
  end
183
118
 
184
- def default_input_price
185
- 0.50
119
+ def critical_capabilities_for(model_id)
120
+ capabilities = []
121
+ capabilities << 'function_calling' if supports_functions?(model_id)
122
+ capabilities << 'structured_output' if supports_structured_output?(model_id)
123
+ capabilities << 'vision' if supports_vision?(model_id)
124
+ capabilities << 'reasoning' if model_id.match?(/o\d|gpt-5|codex/)
125
+ capabilities
186
126
  end
187
127
 
188
- def default_output_price
189
- 1.50
190
- end
128
+ def pricing_for(model_id)
129
+ standard_pricing = {
130
+ input_per_million: input_price_for(model_id),
131
+ output_per_million: output_price_for(model_id)
132
+ }
191
133
 
192
- def format_display_name(model_id)
193
- model_id.then { |id| humanize(id) }
194
- .then { |name| apply_special_formatting(name) }
195
- end
134
+ cached_price = cached_input_price_for(model_id)
135
+ standard_pricing[:cached_input_per_million] = cached_price if cached_price
196
136
 
197
- def humanize(id)
198
- id.tr('-', ' ')
199
- .split
200
- .map(&:capitalize)
201
- .join(' ')
137
+ { text_tokens: { standard: standard_pricing } }
202
138
  end
203
139
 
204
- def apply_special_formatting(name)
205
- name
206
- .gsub(/(\d{4}) (\d{2}) (\d{2})/, '\1\2\3')
207
- .gsub(/^(?:Gpt|Chatgpt|Tts|Dall E) /) { |m| special_prefix_format(m.strip) }
208
- .gsub(/^O([13]) /, 'O\1-')
209
- .gsub(/^O[13] Mini/, '\0'.tr(' ', '-'))
210
- .gsub(/\d\.\d /, '\0'.sub(' ', '-'))
211
- .gsub(/4o (?=Mini|Preview|Turbo|Audio|Realtime|Transcribe|Tts)/, '4o-')
212
- .gsub(/\bHd\b/, 'HD')
213
- .gsub(/(?:Omni|Text) Moderation/, '\0'.tr(' ', '-'))
214
- .gsub('Text Embedding', 'text-embedding-')
140
+ def model_family(model_id)
141
+ MODEL_PATTERNS.each do |family, pattern|
142
+ return family.to_s if model_id.match?(pattern)
143
+ end
144
+
145
+ 'other'
215
146
  end
216
147
 
217
- def special_prefix_format(prefix)
218
- case prefix # rubocop:disable Style/HashLikeCase
219
- when 'Gpt' then 'GPT-'
220
- when 'Chatgpt' then 'ChatGPT-'
221
- when 'Tts' then 'TTS-'
222
- when 'Dall E' then 'DALL-E-'
148
+ def supports_vision?(model_id)
149
+ case model_family(model_id)
150
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'gpt4',
151
+ 'gpt4_turbo', 'gpt4o', 'gpt4o_mini', 'o1', 'o1_pro', 'moderation', 'gpt4o_search'
152
+ true
153
+ else
154
+ false
223
155
  end
224
156
  end
225
157
 
226
- def self.normalize_temperature(temperature, model_id)
227
- if model_id.match?(/^(o\d|gpt-5)/) && !temperature.nil? && !temperature_close_to_one?(temperature)
228
- RubyLLM.logger.debug "Model #{model_id} requires temperature=1.0, setting that instead."
229
- 1.0
230
- elsif model_id.match?(/-search/)
231
- RubyLLM.logger.debug "Model #{model_id} does not accept temperature parameter, removing"
232
- nil
158
+ def supports_functions?(model_id)
159
+ case model_family(model_id)
160
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'gpt4',
161
+ 'gpt4_turbo', 'gpt4o', 'gpt4o_mini', 'o1', 'o1_pro', 'o3_mini'
162
+ true
233
163
  else
234
- temperature
164
+ false
235
165
  end
236
166
  end
237
167
 
238
- def self.temperature_close_to_one?(temperature)
239
- (temperature.to_f - 1.0).abs <= Float::EPSILON
168
+ def supports_structured_output?(model_id)
169
+ case model_family(model_id)
170
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'gpt4o',
171
+ 'gpt4o_mini', 'o1', 'o1_pro', 'o3_mini'
172
+ true
173
+ else
174
+ false
175
+ end
240
176
  end
241
177
 
242
- def modalities_for(model_id)
243
- modalities = {
244
- input: ['text'],
245
- output: ['text']
246
- }
247
-
248
- # Vision support
249
- modalities[:input] << 'image' if supports_vision?(model_id)
250
- modalities[:input] << 'audio' if model_id.match?(/whisper|audio|tts|transcribe/)
251
- modalities[:input] << 'pdf' if supports_vision?(model_id)
252
- modalities[:output] << 'audio' if model_id.match?(/tts|audio/)
253
- modalities[:output] << 'image' if model_id.match?(/dall-e|image/)
254
- modalities[:output] << 'embeddings' if model_id.match?(/embedding/)
255
- modalities[:output] << 'moderation' if model_id.match?(/moderation/)
256
-
257
- modalities
178
+ def input_price_for(model_id)
179
+ price_for(model_id, :input, 0.50)
258
180
  end
259
181
 
260
- def capabilities_for(model_id) # rubocop:disable Metrics/PerceivedComplexity
261
- capabilities = []
262
-
263
- capabilities << 'streaming' unless model_id.match?(/moderation|embedding/)
264
- capabilities << 'function_calling' if supports_functions?(model_id)
265
- capabilities << 'structured_output' if supports_json_mode?(model_id)
266
- capabilities << 'batch' if model_id.match?(/embedding|batch/)
267
- capabilities << 'reasoning' if model_id.match?(/o\d|gpt-5|codex/)
268
-
269
- if model_id.match?(/gpt-4-turbo|gpt-4o/)
270
- capabilities << 'image_generation' if model_id.match?(/vision/)
271
- capabilities << 'speech_generation' if model_id.match?(/audio/)
272
- capabilities << 'transcription' if model_id.match?(/audio/)
273
- end
274
-
275
- capabilities
182
+ def output_price_for(model_id)
183
+ price_for(model_id, :output, 1.50)
276
184
  end
277
185
 
278
- def pricing_for(model_id)
279
- standard_pricing = {
280
- input_per_million: input_price_for(model_id),
281
- output_per_million: output_price_for(model_id)
282
- }
283
-
284
- if respond_to?(:cached_input_price_for)
285
- cached_price = cached_input_price_for(model_id)
286
- standard_pricing[:cached_input_per_million] = cached_price if cached_price
287
- end
288
-
289
- pricing = { text_tokens: { standard: standard_pricing } }
290
-
291
- if model_id.match?(/embedding|batch/)
292
- pricing[:text_tokens][:batch] = {
293
- input_per_million: standard_pricing[:input_per_million] * 0.5,
294
- output_per_million: standard_pricing[:output_per_million] * 0.5
295
- }
296
- end
186
+ def cached_input_price_for(model_id)
187
+ family = model_family(model_id).to_sym
188
+ PRICES.fetch(family, {})[:cached_input]
189
+ end
297
190
 
298
- pricing
191
+ def price_for(model_id, key, fallback)
192
+ family = model_family(model_id).to_sym
193
+ prices = PRICES.fetch(family, { key => fallback })
194
+ prices[key] || prices[:price] || fallback
299
195
  end
196
+
197
+ module_function :context_window_for, :max_tokens_for, :critical_capabilities_for, :pricing_for,
198
+ :model_family, :supports_vision?, :supports_functions?, :supports_structured_output?,
199
+ :input_price_for, :output_price_for, :cached_input_price_for, :price_for
300
200
  end
301
201
  end
302
202
  end
@@ -11,7 +11,10 @@ module RubyLLM
11
11
 
12
12
  module_function
13
13
 
14
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists
14
+ # rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity
15
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
16
+ thinking: nil, tool_prefs: nil)
17
+ tool_prefs ||= {}
15
18
  payload = {
16
19
  model: model.id,
17
20
  messages: format_messages(messages),
@@ -19,16 +22,22 @@ module RubyLLM
19
22
  }
20
23
 
21
24
  payload[:temperature] = temperature unless temperature.nil?
22
- payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any?
25
+ if tools.any?
26
+ payload[:tools] = tools.map { |_, tool| tool_for(tool) }
27
+ payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
28
+ payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
29
+ end
23
30
 
24
31
  if schema
25
- strict = schema[:strict] != false
32
+ schema_name = schema[:name]
33
+ schema_def = schema[:schema]
34
+ strict = schema[:strict]
26
35
 
27
36
  payload[:response_format] = {
28
37
  type: 'json_schema',
29
38
  json_schema: {
30
- name: 'response',
31
- schema: schema,
39
+ name: schema_name,
40
+ schema: schema_def,
32
41
  strict: strict
33
42
  }
34
43
  }
@@ -40,6 +49,7 @@ module RubyLLM
40
49
  payload[:stream_options] = { include_usage: true } if stream
41
50
  payload
42
51
  end
52
+ # rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity
43
53
 
44
54
  def parse_completion_response(response)
45
55
  data = response.body
@@ -8,7 +8,10 @@ module RubyLLM
8
8
  module_function
9
9
 
10
10
  def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
11
- return content.value if content.is_a?(RubyLLM::Content::Raw)
11
+ if content.is_a?(RubyLLM::Content::Raw)
12
+ value = content.value
13
+ return value.is_a?(Hash) ? value.to_json : value
14
+ end
12
15
  return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
13
16
  return content unless content.is_a?(Content)
14
17
 
@@ -17,14 +17,12 @@ module RubyLLM
17
17
 
18
18
  Model::Info.new(
19
19
  id: model_id,
20
- name: capabilities.format_display_name(model_id),
20
+ name: model_id,
21
21
  provider: slug,
22
- family: capabilities.model_family(model_id),
23
22
  created_at: model_data['created'] ? Time.at(model_data['created']) : nil,
24
23
  context_window: capabilities.context_window_for(model_id),
25
24
  max_output_tokens: 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
  object: model_data['object'],
@@ -9,10 +9,10 @@ module RubyLLM
9
9
 
10
10
  def normalize(temperature, model_id)
11
11
  if model_id.match?(/^(o\d|gpt-5)/) && !temperature.nil? && !temperature_close_to_one?(temperature)
12
- RubyLLM.logger.debug "Model #{model_id} requires temperature=1.0, setting that instead."
12
+ RubyLLM.logger.debug { "Model #{model_id} requires temperature=1.0, setting that instead." }
13
13
  1.0
14
14
  elsif model_id.include?('-search')
15
- RubyLLM.logger.debug "Model #{model_id} does not accept temperature parameter, removing"
15
+ RubyLLM.logger.debug { "Model #{model_id} does not accept temperature parameter, removing" }
16
16
  nil
17
17
  else
18
18
  temperature
@@ -53,7 +53,7 @@ module RubyLLM
53
53
  return nil unless tool_calls&.any?
54
54
 
55
55
  tool_calls.map do |_, tc|
56
- {
56
+ call = {
57
57
  id: tc.id,
58
58
  type: 'function',
59
59
  function: {
@@ -61,6 +61,12 @@ module RubyLLM
61
61
  arguments: JSON.generate(tc.arguments)
62
62
  }
63
63
  }
64
+ if tc.thought_signature
65
+ call[:extra_content] = {
66
+ google: { thought_signature: tc.thought_signature }
67
+ }
68
+ end
69
+ call
64
70
  end
65
71
  end
66
72
 
@@ -87,11 +93,30 @@ module RubyLLM
87
93
  parse_tool_call_arguments(tc)
88
94
  else
89
95
  tc.dig('function', 'arguments')
90
- end
96
+ end,
97
+ thought_signature: extract_tool_call_thought_signature(tc)
91
98
  )
92
99
  ]
93
100
  end
94
101
  end
102
+
103
+ def build_tool_choice(tool_choice)
104
+ case tool_choice
105
+ when :auto, :none, :required
106
+ tool_choice
107
+ else
108
+ {
109
+ type: 'function',
110
+ function: {
111
+ name: tool_choice
112
+ }
113
+ }
114
+ end
115
+ end
116
+
117
+ def extract_tool_call_thought_signature(tool_call)
118
+ tool_call.dig('extra_content', 'google', 'thought_signature')
119
+ end
95
120
  end
96
121
  end
97
122
  end
@@ -35,6 +35,16 @@ module RubyLLM
35
35
  OpenAI::Capabilities
36
36
  end
37
37
 
38
+ def configuration_options
39
+ %i[
40
+ openai_api_key
41
+ openai_api_base
42
+ openai_organization_id
43
+ openai_project_id
44
+ openai_use_system_role
45
+ ]
46
+ end
47
+
38
48
  def configuration_requirements
39
49
  %i[openai_api_key]
40
50
  end