ruby_llm 2.1.1 → 2.2.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 (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +515 -274
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1593 -331
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +50 -24
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +127 -106
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +15 -15
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +1 -7
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +5 -14
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +213 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'monitor'
5
+ require 'stringio'
6
+
7
+ module RubyLLM
8
+ # A FileStore keeps a chat's files: what people attach, what tools
9
+ # return, long tool results, and what code writes. Chat#with_file_store gives
10
+ # a chat one, and the model works with it through tools that list, read,
11
+ # search, write, and edit its files, so a file reaches the model only
12
+ # when the model asks for it.
13
+ #
14
+ # chat = RubyLLM.chat.with_file_store
15
+ # chat.ask "Which clauses changed?", with: ["contract-v1.pdf", "contract-v2.pdf"]
16
+ # chat.file_store.list.map(&:path) # => ["contract-v1.pdf", "contract-v2.pdf"]
17
+ #
18
+ # RubyLLM ships FileStore::Memory, the default, and FileStore::Directory,
19
+ # which keeps the files in a folder. The Rails integration keeps a
20
+ # persisted chat's files in Active Storage.
21
+ #
22
+ # chat.with_file_store("tmp/work")
23
+ #
24
+ # Write your own store by subclassing FileStore and implementing #list,
25
+ # #[], #read, #write, and #delete. RubyLLM only ever passes them paths
26
+ # FileStore.path accepts: relative, with forward slashes, and without
27
+ # empty, <tt>.</tt>, or <tt>..</tt> parts.
28
+ #
29
+ # class BucketStore < RubyLLM::FileStore
30
+ # def initialize(bucket, prefix)
31
+ # super()
32
+ # @bucket = bucket
33
+ # @prefix = prefix
34
+ # end
35
+ #
36
+ # def list(prefix = nil) = ...
37
+ # def [](path) = ...
38
+ # def read(path) = ...
39
+ # def write(path, content, content_type: nil) = ...
40
+ # def delete(path) = ...
41
+ # end
42
+ #
43
+ # The model must read a file before its tools change it, and a file that
44
+ # changed since it read it must be read again. The store remembers what
45
+ # the model read, and #with_lock keeps two changes to the files from
46
+ # interleaving. Override #with_lock in a store that several processes
47
+ # share.
48
+ class FileStore
49
+ include Enumerable
50
+
51
+ # Raised by #delete for a path that is a folder.
52
+ class FolderError < ArgumentError; end # :nodoc:
53
+
54
+ # Converted texts each store keeps in memory, by default.
55
+ CACHED_TEXTS = 100
56
+
57
+ UNSAFE_CHARACTERS = /[\x00-\x1f\x7f\\:*?"<>|]/
58
+ DIGEST_CHUNK = 1024 * 1024
59
+ private_constant :UNSAFE_CHARACTERS, :DIGEST_CHUNK
60
+
61
+ class << self
62
+ # Returns +name+ as a path inside a store: relative, with forward
63
+ # slashes, and with a leading <tt>./</tt> or <tt>/</tt> and repeated
64
+ # slashes removed.
65
+ #
66
+ # RubyLLM::FileStore.path("./reports//q3.pdf") # => "reports/q3.pdf"
67
+ #
68
+ # Raises ArgumentError for an empty path or one with <tt>.</tt> or
69
+ # <tt>..</tt> parts.
70
+ def path(name)
71
+ parts = name.to_s.tr('\\', '/').split('/').reject(&:empty?)
72
+ parts.shift while parts.first == '.'
73
+ if parts.empty? || parts.intersect?(%w[. ..]) || parts.any? { |part| part.match?(/[\x00-\x1f]/) }
74
+ raise ArgumentError, "#{name.inspect} is not a path inside the chat's files"
75
+ end
76
+
77
+ parts.join('/')
78
+ end
79
+
80
+ # Returns whether +path+ is inside the folder +prefix+, or is it.
81
+ # Every path is inside a +nil+ or empty prefix.
82
+ def inside?(path, prefix) # :nodoc:
83
+ return true if prefix.to_s.empty?
84
+
85
+ folder = path(prefix)
86
+ path == folder || path.start_with?("#{folder}/")
87
+ end
88
+
89
+ # Returns whether +path+ matches the glob +pattern+. A pattern
90
+ # without a slash matches the file name in any folder; one with a
91
+ # slash matches the whole path, or the path inside the folder
92
+ # +inside+. <tt>**</tt> matches any number of folders, a trailing
93
+ # <tt>/**</tt> everything under a folder, and <tt>{a,b}</tt> either.
94
+ def match?(path, pattern, inside: nil) # :nodoc:
95
+ pattern = pattern.to_s.delete_prefix('./').delete_prefix('/')
96
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
97
+ return File.fnmatch?(pattern, File.basename(path), flags) unless pattern.include?('/')
98
+
99
+ pattern = "#{pattern}/*" if pattern.end_with?('/**')
100
+ candidates = [path]
101
+ candidates << path.delete_prefix("#{inside}/") unless inside.to_s.empty?
102
+ candidates.any? { |candidate| File.fnmatch?(pattern, candidate, flags) }
103
+ end
104
+
105
+ # Returns a safe file name for an attachment called +name+.
106
+ def filename(name) # :nodoc:
107
+ base = File.basename(name.to_s.tr('\\', '/')).gsub(UNSAFE_CHARACTERS, '_').strip.sub(/\A\.+/, '')
108
+ base.empty? ? 'file' : base
109
+ end
110
+
111
+ # Returns the SHA-256 of +content+, a String or an IO read to its
112
+ # end in chunks, in hex.
113
+ def digest(content) # :nodoc:
114
+ return Digest::SHA256.hexdigest(content) unless content.respond_to?(:read)
115
+
116
+ digest = Digest::SHA256.new
117
+ buffer = String.new(capacity: DIGEST_CHUNK)
118
+ digest << buffer while content.read(DIGEST_CHUNK, buffer)
119
+ digest.hexdigest
120
+ end
121
+
122
+ # Returns the MIME type of +content+, a String or an IO that can
123
+ # rewind, written to +path+. An IO is sniffed from its start and
124
+ # left there.
125
+ def content_type_for(path, content) # :nodoc:
126
+ type = Files::MimeType.for(name: File.basename(path))
127
+ return type unless type == 'application/octet-stream'
128
+
129
+ Files::MimeType.for(content.respond_to?(:read) ? content : StringIO.new(content))
130
+ end
131
+
132
+ # Returns the bytes of +content+, a String or an IO.
133
+ def bytes(content) # :nodoc:
134
+ content = content.read if content.respond_to?(:read)
135
+ content.to_s.b
136
+ end
137
+ end
138
+
139
+ def initialize # :nodoc:
140
+ @texts = {}
141
+ @texts_lock = Mutex.new
142
+ @reads = {}
143
+ @reads_lock = Mutex.new
144
+ @store_lock = Monitor.new
145
+ end
146
+
147
+ # Returns the files inside the folder +prefix+, or every file, as
148
+ # StoredFile objects sorted by path.
149
+ #
150
+ # chat.file_store.list("reports")
151
+ def list(prefix = nil)
152
+ raise NotImplementedError, "#{self.class} must implement list"
153
+ end
154
+
155
+ # Returns the StoredFile at +path+, or +nil+ when there is none.
156
+ #
157
+ # chat.file_store["notes/plan.md"] # => #<RubyLLM::StoredFile path: "notes/plan.md", ...>
158
+ def [](path)
159
+ raise NotImplementedError, "#{self.class} must implement []"
160
+ end
161
+
162
+ # Returns the StoredFile at +path+. Raises FileNotFoundError when there
163
+ # is none.
164
+ #
165
+ # chat.file_store.find("notes/plan.md")
166
+ def find(path)
167
+ self[path] || raise(FileNotFoundError, "No file at #{FileStore.path(path)}")
168
+ end
169
+
170
+ # Returns the content of the file at +path+ as a binary String.
171
+ # Raises FileNotFoundError when there is none.
172
+ def read(path)
173
+ raise NotImplementedError, "#{self.class} must implement read"
174
+ end
175
+
176
+ # Writes +content+, a String or an IO, to +path+, replacing any file
177
+ # there, and returns its StoredFile. The content type is detected from
178
+ # the path and the content unless given.
179
+ #
180
+ # chat.file_store.write("notes/plan.md", "# Plan")
181
+ def write(path, content, content_type: nil)
182
+ raise NotImplementedError, "#{self.class} must implement write"
183
+ end
184
+
185
+ # Deletes the file at +path+ and returns its StoredFile, or +nil+ when
186
+ # there was none. Raises ArgumentError when +path+ is a folder.
187
+ #
188
+ # chat.file_store.delete("notes/plan.md")
189
+ def delete(path)
190
+ raise NotImplementedError, "#{self.class} must implement delete"
191
+ end
192
+
193
+ # Deletes everything inside the folder +path+ and returns the deleted
194
+ # StoredFile objects, sorted by path. Raises ArgumentError when +path+
195
+ # is a file.
196
+ #
197
+ # chat.file_store.delete_folder("reports") # => [#<RubyLLM::StoredFile path: "reports/q3.pdf", ...>]
198
+ def delete_folder(path)
199
+ path = FileStore.path(path)
200
+ refuse_file(path) if self[path]
201
+
202
+ list(path).filter_map { |file| delete(file.path) }
203
+ end
204
+
205
+ # Returns the folder on disk that holds the files, or +nil+. A store
206
+ # with one has code execution run in that folder, reading and writing
207
+ # the files in place; a store without one has each run read the
208
+ # attachments in +attachments/+ and copy in the files it names.
209
+ # FileStore::Directory returns its root.
210
+ def local_root # :nodoc:
211
+ nil
212
+ end
213
+
214
+ # Yields each StoredFile, as #list returns them.
215
+ def each(&)
216
+ list.each(&)
217
+ end
218
+
219
+ # Returns whether there is a file at +path+.
220
+ def exist?(path)
221
+ !self[path].nil?
222
+ end
223
+
224
+ # Returns the digest of the content the model last read at +path+, or
225
+ # +nil+ when it has not read it. The file tools compare it with the
226
+ # file's digest before they change the file.
227
+ def read_digest(path) # :nodoc:
228
+ @reads_lock.synchronize { @reads[FileStore.path(path)] }
229
+ end
230
+
231
+ # Remembers that the model read +digest+, a digest of the content, at
232
+ # +path+, in memory.
233
+ def record_read(path, digest) # :nodoc:
234
+ @reads_lock.synchronize { @reads[FileStore.path(path)] = digest }
235
+ end
236
+
237
+ # Runs the block with the store locked and returns its value, so a tool
238
+ # that reads a file and writes it back sees no other change in
239
+ # between. It is reentrant. Stores lock within this process unless
240
+ # they override it.
241
+ def with_lock(&)
242
+ @store_lock.synchronize(&)
243
+ end
244
+
245
+ # Returns the text of the file at +path+: its content when it is text,
246
+ # or what a converter makes of it, such as the text OCR reads from a
247
+ # PDF. Returns +nil+ when no converter can read the file.
248
+ # Conversions are kept, so each version of a file is converted once.
249
+ # Raises FileNotFoundError when there is no file at +path+.
250
+ #
251
+ # chat.file_store.text("contract.pdf") # => "## Page 1\n\nThis agreement..."
252
+ #
253
+ # See Configuration#file_converters for adding converters.
254
+ def text(path, config: nil)
255
+ file = find(path)
256
+ cached = cached_text(file)
257
+ return cached if cached
258
+
259
+ Converters.convert(attachment(file.path, file:), config: config || RubyLLM.config).tap do |text|
260
+ cache_text(file, text) if text
261
+ end
262
+ end
263
+
264
+ # Returns the file at +path+ as an Attachment, to send it to a model.
265
+ # Raises FileNotFoundError when there is none.
266
+ def attachment(path, file: nil)
267
+ file ||= find(path)
268
+ Attachment.new(StringIO.new(read(file.path)), filename: file.filename)
269
+ end
270
+
271
+ # Returns the converted text kept for +file+, or +nil+, from memory.
272
+ def cached_text(file) # :nodoc:
273
+ @texts_lock.synchronize { @texts[text_key(file)] }
274
+ end
275
+
276
+ # Keeps +text+ as the conversion of +file+, in memory.
277
+ def cache_text(file, text) # :nodoc:
278
+ @texts_lock.synchronize do
279
+ @texts.delete(@texts.keys.first) if @texts.size >= CACHED_TEXTS
280
+ @texts[text_key(file)] = text
281
+ end
282
+ end
283
+
284
+ # Writes +attachment+ under its file name and returns its StoredFile,
285
+ # yielding it when it is new. A file already there with the same
286
+ # content is reused; one with other content keeps its place, and the
287
+ # attachment gets a numbered name beside it, such as
288
+ # <tt>report-2.pdf</tt>.
289
+ def store_attachment(attachment) # :nodoc:
290
+ content = attachment.content.to_s.b
291
+ digest = self.class.digest(content)
292
+ candidates(self.class.filename(attachment.filename)).each do |path|
293
+ existing = self[path]
294
+ return existing if existing && same_content?(existing, content, digest)
295
+ next if existing || folder?(path)
296
+
297
+ file = write_attachment(path, attachment, content)
298
+ yield file if block_given?
299
+ return file
300
+ end
301
+ end
302
+
303
+ private
304
+
305
+ def folder?(_path)
306
+ false
307
+ end
308
+
309
+ def refuse_folder(path)
310
+ raise FolderError, "#{path} is a folder: delete it with delete_folder"
311
+ end
312
+
313
+ def refuse_file(path)
314
+ raise ArgumentError, "#{path} is a file: delete it with delete"
315
+ end
316
+
317
+ def write_attachment(path, attachment, content)
318
+ write(path, content, content_type: attachment.mime_type)
319
+ end
320
+
321
+ def candidates(name)
322
+ extension = File.extname(name)
323
+ base = name.delete_suffix(extension)
324
+ Enumerator.new do |paths|
325
+ paths << name
326
+ (2..).each { |number| paths << "#{base}-#{number}#{extension}" }
327
+ end
328
+ end
329
+
330
+ def same_content?(file, content, digest)
331
+ file.digest ? file.digest == digest : read(file.path).b == content
332
+ end
333
+
334
+ def text_key(file)
335
+ file.digest || [file.path, file.byte_size, file.updated_at]
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Files
5
+ # The #save every complete downloadable result shares. Includers supply
6
+ # #to_blob and document #save in their own terms.
7
+ module Saving # :nodoc:
8
+ def save(path)
9
+ File.binwrite(File.expand_path(path), to_blob)
10
+ path
11
+ end
12
+ end
13
+ end
14
+ end
@@ -11,7 +11,9 @@ module RubyLLM
11
11
  #
12
12
  class Image
13
13
  include Support::Inspectable
14
+ include Files::Saving
14
15
  include Accounting::Usage::Result
16
+ include Accounting::Usage::ModelInfo
15
17
 
16
18
  # The URL of the hosted image, for providers that return one, or +nil+.
17
19
  attr_reader :url
@@ -44,7 +46,7 @@ module RubyLLM
44
46
  # +context:+ supplies a Context whose configuration replaces the
45
47
  # global one. +metadata:+ is included in the instrumentation payload.
46
48
  # +owner:+ attributes the usage to a record, such as a user, and wins
47
- # over RubyLLM.with_usage_owner.
49
+ # over RubyLLM.with_owner.
48
50
  #
49
51
  # image = RubyLLM.paint("A small watercolor robot", model: "gpt-image-2")
50
52
  #
@@ -57,8 +59,19 @@ module RubyLLM
57
59
  # with: "logo.png"
58
60
  # )
59
61
  #
60
- # Providers that cannot generate several images in one request ignore
61
- # +count:+ and return a single Image.
62
+ # Providers that cannot generate several images in one request raise
63
+ # ArgumentError when +count:+ is above 1.
64
+ #
65
+ # +fallbacks:+ lists models to try, in order, when the request fails
66
+ # with one of the errors Chat#with_fallbacks moves on from by default,
67
+ # such as a rate limit or an overloaded provider. Each is a model id or
68
+ # a Model, and gets the same request. A fallback whose model can't be
69
+ # found is skipped. Each attempt has its own +image.ruby_llm+ event
70
+ # and usage entry.
71
+ #
72
+ # RubyLLM.paint("A small watercolor robot", model: "gpt-image-2",
73
+ # fallbacks: ["gemini-3.1-flash-image"])
74
+ #
62
75
  def self.paint(prompt,
63
76
  model: nil,
64
77
  provider: nil,
@@ -70,39 +83,54 @@ module RubyLLM
70
83
  mask: nil,
71
84
  provider_options: {},
72
85
  metadata: nil,
73
- owner: nil)
74
- config = context&.config || RubyLLM.config
75
- model ||= config.default_image_model
76
- model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
77
- config: config)
78
- empty_tokens = Tokens.new
79
- payload = {
80
- provider: provider_instance.slug,
81
- provider_class: provider_instance.name,
82
- model: model.id,
83
- model_info: model,
84
- prompt: prompt,
85
- size: size,
86
- count: count,
87
- provider_options: provider_options,
88
- metadata: metadata,
89
- tokens: empty_tokens,
90
- cost: Cost.new(tokens: empty_tokens, model:, category: :images)
91
- }
86
+ owner: nil,
87
+ fallbacks: [])
88
+ queue = Array(fallbacks).flatten.compact.map { |fallback| Fallback.build(fallback) }
89
+ operation = painting(model, provider:, assume_model_exists:, context:, owner:)
90
+ begin
91
+ with_fallback_ready(queue) do
92
+ operation.run(prompt:, size:, count:, provider_options:, metadata:) do |provider_instance, image_model|
93
+ provider_instance.paint(prompt, model: image_model, size:, count:, with:, mask:, provider_options:)
94
+ end
95
+ end
96
+ rescue StandardError => e
97
+ raise unless Fallback.error?(e, Fallback::DEFAULT_ERRORS)
92
98
 
93
- RubyLLM.instrument('image.ruby_llm', payload, config: config) do |event|
94
- result = Accounting::Usage.owned_by(owner) do
95
- provider_instance.paint(prompt, model:, size:, count:, with:, mask:, provider_options:)
99
+ _, operation = Fallback.next_usable(queue) do |fallback|
100
+ painting(fallback.id, provider: fallback.provider, context:, owner:, resolved: fallback.model)
96
101
  end
97
- images = Support::Utils.to_safe_array(result)
98
- event[:result] = result
99
- event[:response_model] = images.first&.model
100
- event[:tokens] = Tokens.aggregate(images.map(&:tokens))
101
- event[:cost] = Cost.aggregate(images.map(&:cost))
102
- result
102
+ raise e unless operation
103
+
104
+ retry
103
105
  end
104
106
  end
105
107
 
108
+ # While a fallback is left, a timeout, a failed connection, or a long
109
+ # retry wait moves on to it instead of retrying the same model, as
110
+ # Chat#with_fallbacks does. Every error the transport retries is one
111
+ # paint falls back on, so the fallback takes each of them.
112
+ def self.with_fallback_ready(queue, &)
113
+ return yield if queue.empty?
114
+
115
+ Transport::Connection.with_fallback_ready(->(_error) { true }, &)
116
+ end
117
+ private_class_method :with_fallback_ready
118
+
119
+ def self.painting(model, provider:, context:, owner:, assume_model_exists: false, resolved: nil) # :nodoc:
120
+ Accounting::Operation.new(
121
+ 'image', model, default: :default_image_model, category: :images, provider:, assume_model_exists:, context:,
122
+ owner:, resolved:,
123
+ report: lambda { |event, result|
124
+ images = Support::Utils.to_safe_array(result)
125
+ event[:result] = result
126
+ event[:response_model] = images.first&.model
127
+ event[:tokens] = Tokens.aggregate(images.map(&:tokens))
128
+ event[:cost] = Cost.aggregate(images.map(&:cost))
129
+ }
130
+ )
131
+ end
132
+ private_class_method :painting
133
+
106
134
  # :stopdoc:
107
135
 
108
136
  # Set by the protocol that generated the image, so a Context's
@@ -137,20 +165,19 @@ module RubyLLM
137
165
  if base64?
138
166
  Base64.decode64 @data
139
167
  else
140
- response = Transport::Connection.basic(config).get @url
141
- response.body
168
+ Transport::Connection.download(@url, config)
142
169
  end
143
170
  end
144
171
 
172
+ ##
173
+ # :method: save
174
+ # :call-seq:
175
+ # save(path)
176
+ #
145
177
  # Writes the binary image to +path+, expanding it first. Returns
146
178
  # +path+ as given.
147
179
  #
148
180
  # image.save("steampunk_owl.png")
149
- #
150
- def save(path)
151
- File.binwrite(File.expand_path(path), to_blob)
152
- path
153
- end
154
181
 
155
182
  # Returns a Tokens with usage across every provider attempt.
156
183
  # Its fields are +nil+ when none were reported.
@@ -164,6 +191,7 @@ module RubyLLM
164
191
  @tokens ||= Tokens.new(
165
192
  input: raw_usage['input_tokens'],
166
193
  output: raw_usage['output_tokens'],
194
+ cache_read: raw_usage['cache_read_tokens'],
167
195
  reported_cost: raw_usage['cost']
168
196
  )
169
197
  end
@@ -179,17 +207,11 @@ module RubyLLM
179
207
  Cost.new(tokens:, model: model_info, category: :images, input_details: input_tokens_details)
180
208
  end
181
209
 
182
- attr_writer :model_info # :nodoc:
183
-
184
210
  # Returns the image's Model from its provider's registry, falling back
185
211
  # to the requested model when the response ID is unknown. Images built
186
212
  # without a request look up #model, or return +nil+ if unknown.
187
213
  def model_info
188
- return unless model
189
-
190
- @model_info ||= RubyLLM.models.find(model)
191
- rescue ModelNotFoundError
192
- nil
214
+ super if model
193
215
  end
194
216
 
195
217
  private
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Raised when a tool asks a person something and nothing can pause the
5
+ # call for the answer: Tool#ask outside a chat, or an MCP server's
6
+ # question that no MCP.before_input_request callback answered when you
7
+ # call the tool yourself (MCP::InputRequiredError). Its message includes
8
+ # what was asked, and #requests has the Question objects. In a chat, the
9
+ # tool call pauses instead, and Chat#pending_questions returns them.
10
+ class InputRequiredError < Error
11
+ def initialize(source, input) # :nodoc:
12
+ @input = input
13
+ asks = requests.map { |question| [question.message, question.url].compact.join(' ') }
14
+ super("#{source} needs input from the user: #{asks.join('; ')}")
15
+ end
16
+
17
+ # The unanswered Question objects.
18
+ def requests
19
+ @input['requests'].reject(&:answered?)
20
+ end
21
+
22
+ # Returns everything needed to answer the questions later and resume
23
+ # the call, as a Hash that serializes to JSON.
24
+ def to_h # :nodoc:
25
+ @input.merge('requests' => @input['requests'].map(&:to_h)).compact
26
+ end
27
+ end
28
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module RubyLLM
4
4
  class Judge
5
- class Builder # :nodoc:
5
+ class Builder < BasicObject # :nodoc:
6
6
  def self.resolve(value, scope:)
7
7
  return value unless value.is_a?(::Proc)
8
8
 
@@ -43,6 +43,11 @@ module RubyLLM
43
43
 
44
44
  private
45
45
 
46
+ def proc(&block)
47
+ block
48
+ end
49
+ alias lambda proc
50
+
46
51
  def runtime_input?(name, values, block)
47
52
  values.empty? && !block && @scope.respond_to?(name)
48
53
  end
@@ -136,7 +136,7 @@ module RubyLLM
136
136
  # Judges the supplied input and returns a Judgment. Accepts model and
137
137
  # provider overrides, images as +with:+, an isolated +context:+,
138
138
  # +provider_options:+, instrumentation +metadata:+, and a usage +owner:+,
139
- # as RubyLLM.with_usage_owner describes. Additional
139
+ # as RubyLLM.with_owner describes. Additional
140
140
  # +questions:+ are a Hash keyed by question name, with +type:+,
141
141
  # +instructions:+, and +criteria:+ (probability), +options:+ (choice), or
142
142
  # +levels:+ (score). The block supplies input only. Input may be omitted
@@ -76,30 +76,12 @@ module RubyLLM
76
76
  validate_input!(input, attachments)
77
77
  raise ArgumentError, 'A judgment requires a model' unless model || config.default_judgment_model
78
78
 
79
- model, provider_instance = Models.resolve(model, provider:, assume_model_exists:, config:, operation: :judge,
80
- default_model: config.default_judgment_model)
81
- empty_tokens = Tokens.new
82
- payload = {
83
- provider: provider_instance.slug,
84
- provider_class: provider_instance.name,
85
- model: model.id,
86
- model_info: model,
87
- question_count: questions.size,
88
- attachment_count: attachments.size,
89
- provider_options:,
90
- metadata:,
91
- tokens: empty_tokens,
92
- cost: Cost.new(tokens: empty_tokens, model:)
93
- }
94
-
95
- RubyLLM.instrument('judgment.ruby_llm', payload, config:) do |event|
96
- result = Accounting::Usage.owned_by(owner) do
97
- provider_instance.judge(input, questions:, model:, with: attachments, provider_options:)
98
- end
99
- event[:result] = result
100
- event[:tokens] = result.tokens
101
- event[:cost] = result.cost
102
- result
79
+ Accounting::Operation.new(
80
+ 'judgment', model, operation: :judge, default: :default_judgment_model, provider:, assume_model_exists:,
81
+ context:, owner:
82
+ ).run(question_count: questions.size, attachment_count: attachments.size, provider_options:,
83
+ metadata:) do |provider_instance, model|
84
+ provider_instance.judge(input, questions:, model:, with: attachments, provider_options:)
103
85
  end
104
86
  end
105
87