ruby_llm 2.0.0 → 2.1.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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
3
4
  require 'json'
4
5
  require 'ruby_llm/error'
5
6
 
@@ -110,6 +111,10 @@ module RubyLLM
110
111
  self.class.configuration_requirements
111
112
  end
112
113
 
114
+ def original_image_detail? # :nodoc:
115
+ false
116
+ end
117
+
113
118
  def protocols # :nodoc:
114
119
  self.class.protocols
115
120
  end
@@ -200,17 +205,13 @@ module RubyLLM
200
205
  )
201
206
  end
202
207
 
203
- def preprocess_message(message, model:, protocol: nil) # :nodoc:
204
- protocol_class = resolve_protocol(
205
- protocol,
206
- model,
207
- tools: {},
208
- schema: nil,
209
- thinking: nil,
210
- tool_prefs: nil,
211
- citations: false
212
- )
213
- protocol_class.new(self, model).preprocess_message(message)
208
+ def preprocess_messages(messages, model:, protocol: nil, unsupported_attachment: nil) # :nodoc:
209
+ preprocessor = preprocessing_protocol(protocol, model)
210
+ messages.map { |message| preprocessor.preprocess_message(message, unsupported_attachment:) }
211
+ end
212
+
213
+ def discard_missing_uploads(messages, error, model:, protocol: nil) # :nodoc:
214
+ preprocessing_protocol(protocol, model).discard_missing_uploads(messages, error)
214
215
  end
215
216
 
216
217
  def batches? # :nodoc:
@@ -256,7 +257,13 @@ module RubyLLM
256
257
  batch_tier = pricing.batch unless long_context_pricing?(pricing, tokens)
257
258
  batch = Cost.new(tokens:, model:, category:, tier: :batch) if batch_tier
258
259
  amounts = batch_cost_amounts(standard:, batch:, batch_tier:, model:)
259
- Cost.from_h(amounts, tokens:)
260
+ Cost.new(amounts:, missing: batch_cost_missing(amounts, standard), reported: standard.tokens?)
261
+ end
262
+
263
+ def batch_cost_missing(amounts, standard) # :nodoc:
264
+ amounts.filter_map do |component, amount|
265
+ component if amount.nil? && (standard.missing?(component) || standard.public_send(component)&.positive?)
266
+ end
260
267
  end
261
268
 
262
269
  def batch_cost_amounts(standard:, batch:, batch_tier:, model:) # :nodoc:
@@ -288,6 +295,21 @@ module RubyLLM
288
295
  protocols.key?(:files)
289
296
  end
290
297
 
298
+ # Returns a String naming the account that owns the files this provider
299
+ # uploads, or +nil+ to keep each upload within the process that made it.
300
+ # The base implementation returns +nil+. Rails chats reuse an upload in
301
+ # later processes only under the same identity, so build it from what
302
+ # decides where uploaded files live, such as the endpoint and API key,
303
+ # an organization or project, or a storage bucket. Digest anything
304
+ # secret, and leave out credentials that rotate, such as session tokens:
305
+ #
306
+ # def account_identity
307
+ # Digest::SHA256.hexdigest([api_base, @config.acme_api_key].join("\0"))
308
+ # end
309
+ def account_identity
310
+ nil
311
+ end
312
+
291
313
  def list_models # :nodoc:
292
314
  listing_protocol.new(self).list_models
293
315
  end
@@ -316,8 +338,8 @@ module RubyLLM
316
338
  )
317
339
  end
318
340
 
319
- def research_later(prompt, **options) # :nodoc:
320
- fetch_protocol(:research).new(self).create_research_job(prompt, **options)
341
+ def research_later(prompt, **) # :nodoc:
342
+ fetch_protocol(:research).new(self).create_research_job(prompt, **)
321
343
  end
322
344
 
323
345
  def find_research_job(id) # :nodoc:
@@ -374,6 +396,11 @@ module RubyLLM
374
396
  protocol.new(self, model).rerank(query, documents, model: model_id_for(model), top_n:, provider_options:)
375
397
  end
376
398
 
399
+ def judge(input, questions:, model:, with: [], provider_options: {}) # :nodoc:
400
+ protocol = resolve_protocol(nil, model, operation: :judge)
401
+ protocol.new(self, model).judge(input, questions:, model: model_id_for(model), with:, provider_options:)
402
+ end
403
+
377
404
  def upload_file(file, filename: nil, purpose: nil, expires_in: nil, uri: nil, content_type: nil, # :nodoc:
378
405
  provider_options: {})
379
406
  ensure_files_supported!
@@ -466,6 +493,20 @@ module RubyLLM
466
493
  nil
467
494
  end
468
495
 
496
+ # Returns the thinking options that turn thinking off for +model_id+, or
497
+ # +nil+ when the provider has no off control beyond the model registry.
498
+ def thinking_off_control(_model_id) # :nodoc:
499
+ nil
500
+ end
501
+
502
+ # Returns the multiple of the input price that a prompt cache write
503
+ # with lifetime +ttl+ costs on +model_id+, or +nil+ when the
504
+ # registry's cache-write price applies. Providers whose models.dev
505
+ # cache-write price covers only the default lifetime override it.
506
+ def cache_write_input_multiplier(_model_id, _ttl) # :nodoc:
507
+ nil
508
+ end
509
+
469
510
  def models_dev_alias(_model_id, _models_dev_by_key, _provider_model = nil) # :nodoc:
470
511
  nil
471
512
  end
@@ -576,6 +617,13 @@ module RubyLLM
576
617
  model_id
577
618
  end
578
619
 
620
+ # Returns the id of the model that the deployment named +model_id+
621
+ # runs, or +nil+ when +model_id+ is not a declared deployment.
622
+ # Providers whose requests name deployments override it.
623
+ def deployed_model_id(_model_id, _config = nil)
624
+ nil
625
+ end
626
+
579
627
  # Returns the global registry of providers, a hash mapping slug
580
628
  # symbols to provider classes.
581
629
  def providers
@@ -624,6 +672,11 @@ module RubyLLM
624
672
  explicit ? fetch_protocol(explicit) : protocol_for(model, **request)
625
673
  end
626
674
 
675
+ def preprocessing_protocol(protocol, model)
676
+ resolve_protocol(protocol, model, tools: {}, schema: nil, thinking: nil, tool_prefs: nil, citations: false)
677
+ .new(self, model)
678
+ end
679
+
627
680
  def default_protocol
628
681
  fetch_protocol(configured_protocol || self.class.default_protocol)
629
682
  end
@@ -667,6 +720,10 @@ module RubyLLM
667
720
  model.respond_to?(:id) ? model.id : model
668
721
  end
669
722
 
723
+ def account_digest(*parts)
724
+ Digest::SHA256.hexdigest(parts.join("\0"))
725
+ end
726
+
670
727
  def try_parse_json(maybe_json)
671
728
  return maybe_json unless maybe_json.is_a?(String)
672
729
 
@@ -689,7 +746,22 @@ module RubyLLM
689
746
  return error if error.is_a?(String)
690
747
 
691
748
  nested_message = error['message'] if error.is_a?(Hash)
692
- [nested_message, part['message'], part['detail']].find { |message| message.is_a?(String) }
749
+ [nested_message, part['message'], part['detail']].find { |message| message.is_a?(String) } ||
750
+ validation_detail_message(part['detail'])
751
+ end
752
+
753
+ def validation_detail_message(detail)
754
+ return unless detail.is_a?(Array)
755
+
756
+ messages = detail.filter_map { |error| format_validation_error(error) }
757
+ messages.join('; ') unless messages.empty?
758
+ end
759
+
760
+ def format_validation_error(error)
761
+ return unless error.is_a?(Hash)
762
+
763
+ text = [Array(error['loc']).join('.'), error['msg']&.to_s].compact.reject(&:empty?).join(': ')
764
+ text unless text.empty?
693
765
  end
694
766
 
695
767
  def ensure_configured!
@@ -4,6 +4,11 @@ module RubyLLM
4
4
  module Providers
5
5
  # Anthropic Claude API integration.
6
6
  class Anthropic < Provider
7
+ SONNET_55_MODEL_IDS = %w[claude-sonnet-5-5 anthropic.claude-sonnet-5-5].freeze
8
+ private_constant :SONNET_55_MODEL_IDS
9
+ CACHE_WRITE_INPUT_MULTIPLIERS = { '1h' => 2 }.freeze
10
+ private_constant :CACHE_WRITE_INPUT_MULTIPLIERS
11
+
7
12
  protocol :anthropic, Protocols::Anthropic, batches: Protocols::Anthropic::Batches
8
13
  protocol :files, Protocols::Anthropic::Files
9
14
 
@@ -18,6 +23,10 @@ module RubyLLM
18
23
  }
19
24
  end
20
25
 
26
+ def account_identity
27
+ account_digest(api_base, @config.anthropic_api_key)
28
+ end
29
+
21
30
  def batch_cost_multiplier(**) = 0.5
22
31
 
23
32
  class << self
@@ -25,6 +34,20 @@ module RubyLLM
25
34
  Anthropic::Capabilities
26
35
  end
27
36
 
37
+ def between_tools_off?(model_id) # :nodoc:
38
+ SONNET_55_MODEL_IDS.include?(model_id.to_s)
39
+ end
40
+
41
+ def thinking_off_control(model_id) # :nodoc:
42
+ { enabled: false } if between_tools_off?(model_id)
43
+ end
44
+
45
+ # models.dev prices the five-minute cache write; Anthropic bills a
46
+ # one-hour write at twice the input price.
47
+ def cache_write_input_multiplier(_model_id, ttl) # :nodoc:
48
+ CACHE_WRITE_INPUT_MULTIPLIERS[ttl.to_s]
49
+ end
50
+
28
51
  def configuration_options
29
52
  %i[anthropic_api_key anthropic_api_base]
30
53
  end
@@ -9,6 +9,10 @@ module RubyLLM
9
9
  azure_endpoint(:chat)
10
10
  end
11
11
 
12
+ def supported_attachment?(attachment)
13
+ Azure::Media.supported_attachment?(attachment)
14
+ end
15
+
12
16
  def format_content(content, attachments = [])
13
17
  Media.format_content(content, attachments)
14
18
  end
@@ -9,8 +9,8 @@ module RubyLLM
9
9
  @provider.azure_media_url(super)
10
10
  end
11
11
 
12
- def render_edit_payload(prompt, size:, provider_options:, **options)
13
- super(prompt, size:, provider_options: { size: size }.compact.merge(provider_options), **options)
12
+ def render_edit_payload(prompt, size:, provider_options:, **)
13
+ super(prompt, size:, provider_options: { size: size }.compact.merge(provider_options), **)
14
14
  end
15
15
 
16
16
  def json_image_references?(_model)
@@ -7,17 +7,22 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
+ def supported_attachment?(attachment)
11
+ %i[image audio text].include?(attachment.type)
12
+ end
13
+
10
14
  def format_content(content, attachments = [])
11
15
  Protocols::ChatCompletions::Media.format_parts(content, attachments) do |attachment|
16
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
17
+
12
18
  case attachment.type
13
19
  when :image
14
- format_image(attachment)
20
+ Protocols::ChatCompletions::Media.with_image_detail(format_image(attachment), attachment,
21
+ original_detail: true)
15
22
  when :audio
16
23
  Protocols::ChatCompletions::Media.format_audio(attachment)
17
24
  when :text
18
25
  Protocols::ChatCompletions::Media.format_text_file(attachment)
19
- else
20
- raise UnsupportedAttachmentError, attachment.mime_type
21
26
  end
22
27
  end
23
28
  end
@@ -17,11 +17,16 @@ module RubyLLM
17
17
  { model: model, prompt: prompt }.merge(provider_options)
18
18
  end
19
19
 
20
- def parse_video_job(response, model:)
20
+ def parse_video_job(response, model:, **request)
21
21
  id = response.body['id']
22
22
  raise Error.new('Azure did not return a video generation job', response:) unless id
23
23
 
24
- VideoJob.new(id: id, protocol: self, model: model, raw: response.body)
24
+ VideoJob.new(id: id, protocol: self, model: model, raw: response.body, **request)
25
+ end
26
+
27
+ def video_request_settings(request)
28
+ width, height = request.values_at(:width, :height)
29
+ { duration: request[:n_seconds], resolution: ("#{width}x#{height}" if width && height) }
25
30
  end
26
31
 
27
32
  def video_job_url(job)
@@ -13,12 +13,22 @@ module RubyLLM
13
13
  protocol :files, Protocols::Azure::Files
14
14
  protocol :cohere, Azure::Cohere
15
15
 
16
+ def original_image_detail? # :nodoc:
17
+ true
18
+ end
19
+
16
20
  def api_base
17
21
  @config.azure_api_base
18
22
  end
19
23
 
20
- # Deployments named after gpt-5.4+ models need the Responses API for
21
- # tool use, so they route there automatically. Deployment names often
24
+ # Files belong to the Azure resource, whichever key or Entra ID token
25
+ # signs the request.
26
+ def account_identity
27
+ account_digest(azure_openai_v1_base)
28
+ end
29
+
30
+ # Deployments named after gpt-5.4+ and gpt-6+ models need the Responses
31
+ # API for tool use, so they route there automatically. Deployment names often
22
32
  # differ from model ids, so the routing stays conservative; an explicit
23
33
  # protocol: or the azure_protocol configuration option overrides it.
24
34
  def protocol_for(model, operation: nil, **)
@@ -28,7 +38,7 @@ module RubyLLM
28
38
  return protocols[:cohere]
29
39
  end
30
40
 
31
- model.id.match?(/gpt-5\.[4-9]|gpt-5\d/) ? protocols[:responses] : super
41
+ model.id.match?(/gpt-5\.[4-9]|gpt-5\d|gpt-[6-9]/) ? protocols[:responses] : super
32
42
  end
33
43
 
34
44
  def headers
@@ -108,7 +118,17 @@ module RubyLLM
108
118
  end
109
119
 
110
120
  def configuration_options
111
- %i[azure_api_base azure_api_key azure_ai_auth_token]
121
+ %i[azure_api_base azure_api_key azure_ai_auth_token azure_deployments]
122
+ end
123
+
124
+ def deployed_model_id(deployment, config = RubyLLM.config)
125
+ deployments = config.azure_deployments || {}
126
+ unless deployments.is_a?(Hash)
127
+ raise ConfigurationError, 'azure_deployments must be a Hash of deployment names to model ids, ' \
128
+ "got #{deployments.class}"
129
+ end
130
+
131
+ (deployments[deployment.to_s] || deployments[deployment.to_sym])&.to_s
112
132
  end
113
133
 
114
134
  def configuration_requirements
@@ -79,7 +79,7 @@ module RubyLLM
79
79
  service = options.fetch(:service, 'bedrock')
80
80
  conn = Transport::Connection.basic(@config) do |f|
81
81
  f.request :json
82
- f.response :json
82
+ f.use Transport::JsonResponse
83
83
  f.adapter :net_http
84
84
  f.use :llm_errors, provider: self
85
85
  end
@@ -29,7 +29,7 @@ module RubyLLM
29
29
  signed_post(count_tokens_url, payload)
30
30
  end
31
31
 
32
- def mantle_headers(url, body)
32
+ def mantle_headers
33
33
  super.merge('anthropic-version' => ANTHROPIC_VERSION)
34
34
  end
35
35
  end
@@ -61,9 +61,7 @@ module RubyLLM
61
61
  raise ArgumentError, 'Transcription requires exactly one audio file' unless attachments.one?
62
62
 
63
63
  attachment = attachments.first
64
- unless attachment.audio? && %w[mp3 wav].include?(attachment.format)
65
- raise UnsupportedAttachmentError, attachment.mime_type
66
- end
64
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.audio?
67
65
 
68
66
  content = [
69
67
  Protocols::ChatCompletions::Media.format_audio(attachment),
@@ -35,21 +35,17 @@ module RubyLLM
35
35
  end
36
36
 
37
37
  def signed_post(url, payload, additional_headers = {})
38
- body = JSON.generate(payload)
39
-
40
- @connection.post(url, payload, usage: @usage_tracker) do |req|
41
- req.headers.merge!(mantle_headers(url, body))
42
- req.headers.merge!(additional_headers) unless additional_headers.empty?
38
+ @connection.post(url, JSON.generate(payload), usage: @usage_tracker) do |req|
39
+ req.headers.merge!(mantle_headers.merge(additional_headers))
43
40
  end
44
41
  end
45
42
 
46
43
  def stream_response(payload, additional_headers = {}, &)
47
- body = JSON.generate(payload)
48
- super(payload, mantle_headers(stream_url, body).merge(additional_headers), &)
44
+ super(JSON.generate(payload), mantle_headers.merge(additional_headers), &)
49
45
  end
50
46
 
51
- def mantle_headers(url, body)
52
- @provider.sign_headers('POST', url, body, base_url: @provider.mantle_api_base, service: SIGNING_SERVICE)
47
+ def mantle_headers
48
+ {}
53
49
  end
54
50
  end
55
51
  end
@@ -28,6 +28,20 @@ module RubyLLM
28
28
  Bedrock::Capabilities
29
29
  end
30
30
 
31
+ def self.between_tools_off?(model_id) # :nodoc:
32
+ Providers::Anthropic.between_tools_off?(Protocols::Converse::Chat.foundation_model_id(model_id))
33
+ end
34
+
35
+ def self.thinking_off_control(model_id) # :nodoc:
36
+ { enabled: false } if between_tools_off?(model_id)
37
+ end
38
+
39
+ def self.cache_write_input_multiplier(model_id, ttl) # :nodoc:
40
+ return unless Protocols::Converse::Chat.foundation_model_id(model_id).start_with?('anthropic.')
41
+
42
+ Providers::Anthropic.cache_write_input_multiplier(model_id, ttl)
43
+ end
44
+
31
45
  def self.resolve_registry_id(model_id, models, config = RubyLLM.config)
32
46
  Models.resolve_registry_id(model_id, models, config)
33
47
  end
@@ -36,6 +50,11 @@ module RubyLLM
36
50
  Models.models_dev_alias(...)
37
51
  end
38
52
 
53
+ def initialize(config) # :nodoc:
54
+ super
55
+ @connection = signed_connection(api_base)
56
+ end
57
+
39
58
  def protocol_for(model, operation: nil, **)
40
59
  return protocols[:guardrails] if operation == :moderate
41
60
 
@@ -62,13 +81,20 @@ module RubyLLM
62
81
  end
63
82
 
64
83
  def mantle_connection
65
- @mantle_connection ||= Transport::Connection.new(self, @config, api_base: mantle_api_base)
84
+ @mantle_connection ||= signed_connection(mantle_api_base, service: Bedrock::Mantle::SIGNING_SERVICE)
66
85
  end
67
86
 
68
87
  def api_base
69
88
  @config.bedrock_api_base || "https://bedrock-runtime.#{bedrock_region}.amazonaws.com"
70
89
  end
71
90
 
91
+ # Uploads are objects in the configured bucket, readable with any
92
+ # credentials the bucket admits.
93
+ def account_identity
94
+ bucket = @config.bedrock_batch_s3_uri
95
+ account_digest(bucket) if bucket
96
+ end
97
+
72
98
  def control_api_base
73
99
  @config.bedrock_api_base || "https://bedrock.#{bedrock_region}.amazonaws.com"
74
100
  end
@@ -78,16 +104,12 @@ module RubyLLM
78
104
  end
79
105
 
80
106
  def agent_connection # :nodoc:
81
- @agent_connection ||= Transport::Connection.new(self, @config, api_base: agent_api_base)
107
+ @agent_connection ||= signed_connection(agent_api_base)
82
108
  end
83
109
 
84
110
  def rerank_model_arn(model_id) # :nodoc:
85
111
  return model_id if model_id.start_with?('arn:')
86
112
 
87
- unless %w[amazon.rerank-v1:0 cohere.rerank-v3-5:0].include?(model_id)
88
- raise Error, "Bedrock reranking is not supported for #{model_id.inspect}"
89
- end
90
-
91
113
  "arn:aws:bedrock:#{bedrock_region}::foundation-model/#{model_id}"
92
114
  end
93
115
 
@@ -190,6 +212,16 @@ module RubyLLM
190
212
 
191
213
  private
192
214
 
215
+ # Signs each attempt as it is sent, so a retry never replays a
216
+ # signature that expired while the previous attempt waited.
217
+ def signed_connection(base_url, service: 'bedrock')
218
+ signer = lambda do |env|
219
+ signed = sign_headers(env.method.to_s.upcase, env.url.request_uri, env.body.to_s, base_url:, service:)
220
+ { 'X-Amz-Security-Token' => nil }.merge(signed)
221
+ end
222
+ Transport::Connection.new(self, @config, api_base: base_url, signer:)
223
+ end
224
+
193
225
  def voxtral_transcription?(operation, model_id)
194
226
  operation == :transcribe && model_id == 'mistral.voxtral-small-24b-2507'
195
227
  end
@@ -57,12 +57,17 @@ module RubyLLM
57
57
  payload
58
58
  end
59
59
 
60
+ def supported_attachment?(attachment)
61
+ Protocols::ChatCompletions::Media.supported_attachment?(
62
+ attachment, document_attachments: :none, audio_attachments: false
63
+ )
64
+ end
65
+
60
66
  def format_content(content, attachments = [])
61
67
  Protocols::ChatCompletions::Media.format_content(
62
68
  content,
63
69
  attachments,
64
70
  document_attachments: :none,
65
- image_attachments: false,
66
71
  audio_attachments: false
67
72
  )
68
73
  end
@@ -61,6 +61,12 @@ module RubyLLM
61
61
  def format_document(document)
62
62
  raise UnsupportedAttachmentError, document.mime_type
63
63
  end
64
+
65
+ def supported_attachment?(attachment)
66
+ return attachment.image? if attachment.provider_file?
67
+
68
+ %i[image text].include?(attachment.type)
69
+ end
64
70
  end
65
71
  end
66
72
  end
@@ -23,6 +23,10 @@ module RubyLLM
23
23
  }
24
24
  end
25
25
 
26
+ def account_identity
27
+ account_digest(api_base, @config.deepseek_api_key)
28
+ end
29
+
26
30
  class << self
27
31
  def capabilities
28
32
  DeepSeek::Capabilities
@@ -26,6 +26,10 @@ module RubyLLM
26
26
  }
27
27
  end
28
28
 
29
+ def account_identity
30
+ account_digest(api_base, @config.gemini_api_key)
31
+ end
32
+
29
33
  def batch_cost_multiplier(component:, **)
30
34
  %i[cache_read cache_write].include?(component) ? 1 : 0.5
31
35
  end
@@ -3,13 +3,18 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class GPUStack
6
- # Handles formatting of media content for GPUStack. Images ride inline
7
- # as base64 data URLs so the cluster never has to fetch remote sources.
6
+ # Formats media content for GPUStack chat completions.
8
7
  module Media
9
8
  module_function
10
9
 
10
+ def supported_attachment?(attachment)
11
+ %i[image audio video text].include?(attachment.type)
12
+ end
13
+
11
14
  def format_content(content, attachments = [])
12
15
  Protocols::ChatCompletions::Media.format_parts(content, attachments) do |attachment|
16
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
17
+
13
18
  case attachment.type
14
19
  when :image
15
20
  format_image(attachment)
@@ -19,8 +24,6 @@ module RubyLLM
19
24
  format_video(attachment)
20
25
  when :text
21
26
  Protocols::ChatCompletions::Media.format_text_file(attachment)
22
- else
23
- raise UnsupportedAttachmentError, attachment.mime_type
24
27
  end
25
28
  end
26
29
  end
@@ -38,7 +41,7 @@ module RubyLLM
38
41
  def format_video(video)
39
42
  {
40
43
  type: 'video_url',
41
- video_url: { url: video.url? ? video.source.to_s : video.for_llm }
44
+ video_url: { url: video.for_llm }
42
45
  }
43
46
  end
44
47
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Hetzner
6
+ # Chat methods of the Hetzner Inference API integration
7
+ module Chat
8
+ module_function
9
+
10
+ def format_role(role)
11
+ role.to_s
12
+ end
13
+
14
+ def supported_attachment?(attachment)
15
+ Protocols::ChatCompletions::Media.supported_attachment?(
16
+ attachment, document_attachments: :none, audio_attachments: false
17
+ )
18
+ end
19
+
20
+ def format_content(content, attachments = [])
21
+ Protocols::ChatCompletions::Media.format_parts(content, attachments) do |attachment|
22
+ if attachment.type == :image && !attachment.provider_file?
23
+ format_inline_image(attachment)
24
+ else
25
+ Protocols::ChatCompletions::Media.format_attachment(
26
+ attachment, document_attachments: :none, image_attachments: true, audio_attachments: false
27
+ )
28
+ end
29
+ end
30
+ end
31
+
32
+ # Hetzner's servers can't fetch URLs, so remote images travel inline too.
33
+ def format_inline_image(image)
34
+ { type: 'image_url', image_url: { url: image.for_llm } }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Hetzner Inference API integration.
6
+ class Hetzner < Provider
7
+ # Hetzner's dialect of the Chat Completions API.
8
+ class ChatCompletions < Protocols::ChatCompletions
9
+ include Hetzner::Chat
10
+ end
11
+
12
+ protocol :chat_completions, ChatCompletions
13
+
14
+ def api_base
15
+ @config.hetzner_api_base || 'https://inference.hetzner.com/api/v1'
16
+ end
17
+
18
+ def headers
19
+ { 'Authorization' => "Bearer #{@config.hetzner_api_key}" }
20
+ end
21
+
22
+ class << self
23
+ def configuration_options
24
+ %i[hetzner_api_key hetzner_api_base]
25
+ end
26
+
27
+ def configuration_requirements
28
+ %i[hetzner_api_key]
29
+ end
30
+
31
+ # Returns +true+: Hetzner Inference is experimental and changes its
32
+ # model selection faster than the registry tracks it, so model ids
33
+ # are accepted as given. Call RubyLLM.models.refresh to pull the
34
+ # live catalog.
35
+ def assume_models_exist?
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end