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
@@ -30,6 +30,13 @@ module RubyLLM
30
30
  # The detected MIME type string, such as <tt>"image/png"</tt>.
31
31
  attr_reader :mime_type
32
32
 
33
+ # The requested media resolution: +:low+, +:medium+, +:high+,
34
+ # +:ultra_high+, +:original+, or +nil+ for the provider default.
35
+ attr_reader :resolution
36
+
37
+ # Media resolutions accepted by +resolution:+.
38
+ RESOLUTIONS = %i[low medium high ultra_high original].freeze
39
+
33
40
  # File extensions recognized as document attachments when the MIME type
34
41
  # alone is inconclusive.
35
42
  DOCUMENT_EXTENSIONS = %w[
@@ -49,15 +56,25 @@ module RubyLLM
49
56
  case sources
50
57
  when nil then []
51
58
  when Hash then sources.values.flat_map { |group| wrap(group, config:) }
52
- else
53
- Support::Utils.to_safe_array(sources).filter_map do |source|
54
- next if source.nil? || (source.is_a?(String) && source.strip.empty?)
55
-
56
- source.is_a?(Attachment) ? source : new(source, config:)
57
- end
59
+ else Support::Utils.to_safe_array(sources).filter_map { |source| coerce(source, config:) }
58
60
  end
59
61
  end
60
62
 
63
+ def self.coerce(source, config:) # :nodoc:
64
+ return if source.nil? || (source.is_a?(String) && source.strip.empty?)
65
+ return source if source.is_a?(Attachment)
66
+ return source.to_attachment if source.respond_to?(:to_attachment)
67
+ return from_h(source, config:) if source.is_a?(Hash)
68
+
69
+ new(source, config:)
70
+ end
71
+ private_class_method :coerce
72
+
73
+ def self.from_h(data, config: nil) # :nodoc:
74
+ data = data.transform_keys(&:to_sym)
75
+ new(data.fetch(:source), filename: data[:filename], resolution: data[:resolution]&.to_sym, config:)
76
+ end
77
+
61
78
  # Creates an attachment from +source+: a file path, URL, IO-like object,
62
79
  # ActiveStorage object, or UploadedFile. Derives the filename from the
63
80
  # source when +filename:+ is not given, then detects the MIME type.
@@ -65,6 +82,13 @@ module RubyLLM
65
82
  # RubyLLM::Attachment.new("diagram.png")
66
83
  # RubyLLM::Attachment.new(StringIO.new(data), filename: "report.pdf")
67
84
  #
85
+ # +resolution:+ asks the provider to spend more or fewer tokens on an
86
+ # image, video, or PDF. +:original+ requests original image detail where
87
+ # supported, or the highest available resolution. Providers without
88
+ # the setting ignore it.
89
+ #
90
+ # RubyLLM::Attachment.new("page-3.png", resolution: :ultra_high)
91
+ #
68
92
  # +config:+ is the Configuration a URL source is downloaded with, and
69
93
  # defaults to the global one.
70
94
  #
@@ -72,8 +96,13 @@ module RubyLLM
72
96
  # They can be read or fetched during construction to detect the MIME type.
73
97
  # Validate upload parameters before passing them here: an unchecked String
74
98
  # can access local files or internal network endpoints.
75
- def initialize(source, filename: nil, config: nil)
99
+ def initialize(source, filename: nil, resolution: nil, config: nil)
100
+ unless resolution.nil? || RESOLUTIONS.include?(resolution)
101
+ raise ArgumentError, "resolution must be one of #{RESOLUTIONS.join(', ')}, got #{resolution.inspect}"
102
+ end
103
+
76
104
  @config = config
105
+ @resolution = resolution
77
106
  @source = source
78
107
  @source = source_type_cast
79
108
  @filename = filename || source_filename
@@ -100,6 +129,13 @@ module RubyLLM
100
129
  @provider_uploads ||= {}
101
130
  end
102
131
 
132
+ # Where uploads of this attachment outlive the process: an object that
133
+ # responds to <tt>fetch(provider:, account:)</tt>,
134
+ # <tt>store(upload, provider:, account:)</tt>, and
135
+ # <tt>forget(id, provider:, account:)</tt>. The Rails integration sets
136
+ # one for files kept in Active Storage.
137
+ attr_accessor :provider_file_store # :nodoc:
138
+
103
139
  def provider_file_id # :nodoc:
104
140
  @source.id if provider_file?
105
141
  end
@@ -144,10 +180,14 @@ module RubyLLM
144
180
  when :text
145
181
  "<file name='#{filename}' mime_type='#{mime_type}'>#{content}</file>"
146
182
  else
147
- "data:#{mime_type};base64,#{encoded}"
183
+ data_uri
148
184
  end
149
185
  end
150
186
 
187
+ def url_or_data_uri # :nodoc:
188
+ url? ? @source.to_s : data_uri
189
+ end
190
+
151
191
  # Returns the attachment category as a Symbol: +:image+, +:video+,
152
192
  # +:audio+, +:pdf+, +:text+, +:document+, or +:unknown+.
153
193
  def type
@@ -211,7 +251,7 @@ module RubyLLM
211
251
  end
212
252
 
213
253
  def to_h # :nodoc:
214
- { type: type, source: @source }
254
+ { type: type, source: @source, filename: filename, resolution: resolution }.compact
215
255
  end
216
256
 
217
257
  def byte_size # :nodoc:
@@ -220,6 +260,10 @@ module RubyLLM
220
260
 
221
261
  private
222
262
 
263
+ def data_uri
264
+ "data:#{mime_type};base64,#{encoded}"
265
+ end
266
+
223
267
  def file_byte_size
224
268
  return @source.byte_size if provider_file?
225
269
  return File.size(@source) if path?
@@ -362,7 +406,7 @@ module RubyLLM
362
406
  end
363
407
 
364
408
  def inspect_attributes # :nodoc:
365
- { filename: filename, mime_type: mime_type, source: @source }
409
+ { filename: filename, mime_type: mime_type, resolution: resolution, source: @source }
366
410
  end
367
411
  end
368
412
  end
@@ -237,6 +237,7 @@ module RubyLLM
237
237
  # puts message.content
238
238
  # end
239
239
  #
240
+ # Raises Error when the provider returns malformed result indices.
240
241
  def messages
241
242
  return @messages if @messages
242
243
 
@@ -287,6 +288,7 @@ module RubyLLM
287
288
 
288
289
  def collect_results
289
290
  results = @provider.batch_results(id, batch_protocol: @batch_protocol)
291
+ validate_result_indices(results)
290
292
  slots = Array.new(result_slot_count(results))
291
293
 
292
294
  results.each do |index, result, failure_status|
@@ -299,8 +301,22 @@ module RubyLLM
299
301
  slots
300
302
  end
301
303
 
304
+ def validate_result_indices(results)
305
+ count = known_request_count
306
+ indices = results.map(&:first)
307
+ invalid = indices.find { |index| index.negative? || (count && index >= count) }
308
+ raise Error, "Invalid batch result index: #{invalid}" if invalid
309
+
310
+ duplicate, = indices.tally.find { |_, occurrences| occurrences > 1 }
311
+ raise Error, "Duplicate batch result index: #{duplicate}" if duplicate
312
+ end
313
+
314
+ def known_request_count
315
+ chats&.size || requests&.size || @request_count
316
+ end
317
+
302
318
  def result_slot_count(results)
303
- chats&.size || requests&.size || @request_count || ((results.map(&:first).max || -1) + 1)
319
+ known_request_count || ((results.map(&:first).max || -1) + 1)
304
320
  end
305
321
 
306
322
  def fill_missing_statuses(size)
@@ -364,10 +380,17 @@ module RubyLLM
364
380
  status: :succeeded,
365
381
  tokens: result.tokens,
366
382
  cost: @provider.batch_cost(result.tokens, model:, category:),
367
- message: result.is_a?(Message) ? result : nil
383
+ message: result.is_a?(Message) ? result : nil,
384
+ owner: Accounting::Usage.owner
368
385
  )
369
386
  result.ruby_llm_usage_entries = [entry]
370
- Accounting::Usage.instrument(entry, config: @provider.config) if instrument
387
+ return unless instrument
388
+
389
+ if operation == :chat
390
+ Accounting::Usage.instrument(entry, config: @provider.config)
391
+ else
392
+ Accounting::Usage.report(entry, config: @provider.config)
393
+ end
371
394
  end
372
395
 
373
396
  # A plain answer is the chat's last message once it arrives. A tool-call
@@ -18,13 +18,16 @@ module RubyLLM
18
18
  end
19
19
 
20
20
  def run_with_threads(tool_calls, on_result:, &execute)
21
- executor = rails_executor
21
+ caller_context = execution_context
22
22
  workflow_context = Support::Instrumentation.current_workflow
23
+ instrumentation_context = Support::Instrumentation.capture_context
23
24
  queue = Queue.new
24
25
  threads = tool_calls.each_value.with_index.map do |tool_call, index|
25
26
  thread = Thread.new do
26
27
  Support::Instrumentation.with_workflow(workflow_context) do
27
- queue << capture_result(index, tool_call, executor, execute)
28
+ Support::Instrumentation.with_context(instrumentation_context) do
29
+ queue << capture_result(index, tool_call, caller_context, execute)
30
+ end
28
31
  end
29
32
  end
30
33
  thread.report_on_exception = false
@@ -48,22 +51,29 @@ module RubyLLM
48
51
  raise LoadError, "The 'async' gem version 2.0 or newer is required for concurrent tool execution with fibers."
49
52
  end
50
53
 
51
- executor = rails_executor
54
+ caller_context = execution_context
52
55
  workflow_context = Support::Instrumentation.current_workflow
53
- Async do |task|
54
- queue = Async::Queue.new
55
- tasks = tool_calls.each_value.with_index.map do |tool_call, index|
56
- task.async do
57
- Support::Instrumentation.with_workflow(workflow_context) do
58
- queue << capture_result(index, tool_call, executor, execute)
56
+ instrumentation_context = Support::Instrumentation.capture_context
57
+ # Inside a reactor, Sync runs the block in the calling fiber, so
58
+ # results persist with the caller's execution state and connection.
59
+ Sync do |task|
60
+ isolated(caller_context) do
61
+ queue = Async::Queue.new
62
+ tasks = tool_calls.each_value.with_index.map do |tool_call, index|
63
+ task.async do
64
+ Support::Instrumentation.with_workflow(workflow_context) do
65
+ Support::Instrumentation.with_context(instrumentation_context) do
66
+ queue << capture_result(index, tool_call, caller_context, execute)
67
+ end
68
+ end
59
69
  end
60
70
  end
61
- end
62
71
 
63
- collect_results(queue, tasks.size, on_result:)
64
- ensure
65
- tasks&.each(&:wait)
66
- end.wait
72
+ collect_results(queue, tasks.size, on_result:)
73
+ ensure
74
+ tasks&.each(&:wait)
75
+ end
76
+ end
67
77
  end
68
78
 
69
79
  def collect_results(queue, count, on_result:)
@@ -85,27 +95,41 @@ module RubyLLM
85
95
  results
86
96
  end
87
97
 
88
- def capture_result(index, tool_call, rails_executor, execute)
89
- tool_call, value = run_tool_call(tool_call, rails_executor, execute)
98
+ def capture_result(index, tool_call, caller_context, execute)
99
+ value = isolated(caller_context) { execute.call(tool_call) }
90
100
  Result.new(index:, tool_call:, value:)
91
101
  rescue Exception => e # rubocop:disable Lint/RescueException
92
102
  Result.new(index:, tool_call:, error: e)
93
103
  end
94
104
 
95
- def run_tool_call(tool_call, rails_executor, execute)
96
- if rails_executor
97
- rails_executor.wrap { [tool_call, execute.call(tool_call)] }
98
- else
99
- [tool_call, execute.call(tool_call)]
105
+ # Rails gives a thread, and a fiber under fiber isolation, execution
106
+ # state of its own: the block runs as its own unit of work, so its
107
+ # connections return to the pool. A fiber sharing the caller's state
108
+ # must not start one, whose completion resets the caller's Current
109
+ # attributes. Unlike wrap, run! reports no errors: they reach the
110
+ # caller, whose own executor reports what nothing rescues.
111
+ def isolated(caller_context)
112
+ executor = rails_executor
113
+ return yield unless executor && !execution_context.equal?(caller_context)
114
+
115
+ execution = executor.run!
116
+ begin
117
+ yield
118
+ ensure
119
+ execution.complete!
100
120
  end
101
121
  end
102
122
 
123
+ def execution_context
124
+ defined?(ActiveSupport::IsolatedExecutionState) ? ActiveSupport::IsolatedExecutionState.context : Thread.current
125
+ end
126
+
103
127
  def rails_executor
104
128
  defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.executor
105
129
  end
106
130
 
107
- private_class_method :run_with_threads, :run_with_fibers, :collect_results, :capture_result, :run_tool_call,
108
- :rails_executor
131
+ private_class_method :run_with_threads, :run_with_fibers, :collect_results, :capture_result, :isolated,
132
+ :execution_context, :rails_executor
109
133
  end
110
134
  end
111
135
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Chat
5
+ # Deferred tool registration and the tool set rendered for the request.
6
+ module ToolSearch # :nodoc:
7
+ private
8
+
9
+ def deferred_tool_names
10
+ names = @tool_deferrals.filter_map { |name, deferred| name if deferred }
11
+ mcp.each do |server|
12
+ explicit = @mcp_deferrals[server.name.to_sym]
13
+ server.tools.each do |tool|
14
+ names << tool.name.to_sym if explicit.nil? ? server.defers?(tool) : explicit
15
+ end
16
+ end
17
+ names
18
+ end
19
+
20
+ def request_tools
21
+ deferred = deferred_tool_names
22
+ tools.to_h { |name, tool| [name, deferred.include?(name) ? Tool::Deferred.new(tool) : tool] }
23
+ end
24
+ end
25
+ end
26
+ end