ruby_llm 2.1.0 → 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 +162 -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 +522 -277
  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 +1598 -332
  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 +67 -28
  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 +134 -109
  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 +23 -2
  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 +2 -9
  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 +8 -3
  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 +219 -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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module RubyLLM
6
+ class FileStore
7
+ module Converters
8
+ # Reads the text of Word documents, Excel workbooks, and PowerPoint
9
+ # decks (.docx, .xlsx, .pptx) on this machine, with no service and no
10
+ # cost: a paragraph per line, a table or sheet row per line with tabs
11
+ # between cells, and a heading for each sheet and slide, with a slide's
12
+ # speaker notes under it. Excel dates read as dates, like 2026-03-31.
13
+ # Text comes back in document order, without layout or images.
14
+ module Office
15
+ TYPES = {
16
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => :document,
17
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => :workbook,
18
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => :deck
19
+ }.freeze # :nodoc:
20
+ MAX_SIZE = 50 * 1024 * 1024 # :nodoc:
21
+
22
+ class << self
23
+ # Returns the text of +attachment+, or +nil+ when it is not a
24
+ # document, workbook, or deck it can read. A file it can't read
25
+ # never raises.
26
+ def call(attachment)
27
+ kind = TYPES[attachment.mime_type]
28
+ return unless kind
29
+
30
+ send(kind, Zip.new(attachment.content.to_s.b))
31
+ rescue Zip::Error, Zlib::Error
32
+ nil
33
+ rescue StandardError => e
34
+ RubyLLM.logger.warn { "Could not read an Office file: #{e.class}" }
35
+ nil
36
+ end
37
+
38
+ # Returns +true+: it reads files on this machine, for free.
39
+ def local?
40
+ true
41
+ end
42
+
43
+ private
44
+
45
+ def document(zip)
46
+ xml = zip['word/document.xml']
47
+ Prose.lines(xml, outline: Outline.read(zip)).join("\n")
48
+ end
49
+
50
+ def workbook(zip)
51
+ sheets = parts(zip, 'xl/workbook.xml', 'sheet', %r{\Axl/worksheets/[^/]+\.xml\z})
52
+ Workbook.new(zip, sheets).text
53
+ end
54
+
55
+ def deck(zip)
56
+ slides = parts(zip, 'ppt/presentation.xml', 'sldId', %r{\Appt/slides/[^/]+\.xml\z})
57
+ slides.each_with_index.map do |(part, _tag), index|
58
+ notes = notes_of(zip, part)
59
+ sections = ["## Slide #{index + 1}", Prose.lines(zip[part]).join("\n")]
60
+ sections << "Speaker notes:\n#{notes.join("\n")}" if notes.any?
61
+ sections.reject(&:empty?).join("\n\n")
62
+ end.join("\n\n")
63
+ end
64
+
65
+ def notes_of(zip, slide)
66
+ notes = relationships(zip, slide).find { |relationship| relationship[:type]&.end_with?('/notesSlide') }
67
+ notes ? Prose.lines(zip[notes[:target]]) : []
68
+ end
69
+
70
+ # Returns each part the +owner+ lists with a +tag+, in its order,
71
+ # with that tag, or the parts matching +fallback+ in name order
72
+ # when the listing doesn't resolve.
73
+ def parts(zip, owner, tag, fallback)
74
+ targets = relationships(zip, owner).to_h { |relationship| relationship.values_at(:id, :target) }
75
+ listed = Markup.tags(zip[owner], tag).filter_map do |element|
76
+ part = targets[Markup.relationship_id(element)]
77
+ [part, element] if part && zip.include?(part)
78
+ end
79
+ return listed if listed.any?
80
+
81
+ zip.names.grep(fallback).sort_by { |name| name[/\d+/].to_i }.map { |name| [name, nil] }
82
+ end
83
+
84
+ def relationships(zip, part)
85
+ folder, _, file = part.rpartition('/')
86
+ Markup.tags(zip["#{folder}/_rels/#{file}.rels"], 'Relationship').map do |element|
87
+ { id: Markup.attribute(element, 'Id'), type: Markup.attribute(element, 'Type'),
88
+ target: resolve(folder, Markup.attribute(element, 'Target').to_s) }
89
+ end
90
+ end
91
+
92
+ def resolve(folder, target)
93
+ target = target.tr('\\', '/')
94
+ base = target.start_with?('/') ? [] : folder.split('/')
95
+ segments = target.split('/').each_with_object(base) do |segment, path|
96
+ next if segment.empty? || segment == '.'
97
+
98
+ segment == '..' ? path.pop : path.push(segment)
99
+ end
100
+ segments.join('/').force_encoding(Encoding::UTF_8)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Converters
6
+ # Reads a file as UTF-8 text, as it is. Text files are read without
7
+ # registering anything; register it for other types that hold plain
8
+ # text:
9
+ #
10
+ # config.file_converters["application/x-subrip"] = RubyLLM::FileStore::Converters::Text
11
+ #
12
+ module Text
13
+ module_function
14
+
15
+ # Returns the content of +attachment+ as UTF-8 text.
16
+ def call(attachment)
17
+ attachment.content.to_s.dup.force_encoding(Encoding::UTF_8).scrub
18
+ end
19
+
20
+ # Returns true: it reads files here, for free.
21
+ def local?
22
+ true
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ # Converters turn files into text the model can read and search. A
6
+ # converter is any object that answers +call+ with an Attachment and
7
+ # returns the file's text, or +nil+ when it cannot read that file, so
8
+ # the next converter gets a turn. FileStore#text tries the converters
9
+ # in Configuration#file_converters first, then reads text files,
10
+ # including source code, CSV, JSON, and Markdown, as they are.
11
+ #
12
+ # Converters::Office reads Word, Excel, and PowerPoint files without
13
+ # any setup. The OCR converter reads PDFs, scans, and photos with
14
+ # RubyLLM.ocr. It costs a request per file, so it only runs where you
15
+ # register it. Converters::Text reads a file as it is, for other types
16
+ # that hold plain text.
17
+ #
18
+ # A converter that answers +local?+ with +true+ reads files on this
19
+ # machine for free, so a chat runs it as soon as it keeps an
20
+ # attachment, while it prepares the request, so keep it fast, and
21
+ # search_files finds the text without a read_file first. The others wait
22
+ # for read_file, or for a request that must send as text a file the
23
+ # provider cannot take.
24
+ #
25
+ # RubyLLM.configure do |config|
26
+ # config.file_converters["image/*"] = RubyLLM::FileStore::Converters::OCR.new
27
+ # config.file_converters["application/pdf"] = RubyLLM::FileStore::Converters::OCR.new
28
+ # config.file_converters["application/x-subrip"] = RubyLLM::FileStore::Converters::Text
29
+ # config.file_converters["application/vnd.oasis.opendocument.text"] = ->(file) { OdtText.call(file.content) }
30
+ # end
31
+ #
32
+ # Converters for other formats can ship as gems named
33
+ # <tt>ruby_llm-converters-NAME</tt>, like provider gems.
34
+ module Converters
35
+ class << self
36
+ # Returns the text the first converter for +attachment+ makes of it,
37
+ # or +nil+ when none can read it.
38
+ # With <tt>local: true</tt>, stops at the first converter that does
39
+ # not run on this machine for free, so one that may cost money, or
40
+ # that was registered to go first, is never passed over.
41
+ def convert(attachment, config: RubyLLM.config, local: false) # :nodoc:
42
+ converters = for_type(attachment.mime_type, config:)
43
+ converters = converters.take_while { |converter| local?(converter) } if local
44
+ converters.each do |converter|
45
+ text = converter.call(attachment)
46
+ next unless text.is_a?(String)
47
+
48
+ text = text.dup.force_encoding(Encoding::UTF_8).scrub
49
+ return text unless text.strip.empty?
50
+ end
51
+ nil
52
+ end
53
+
54
+ # Returns the converters for files of +type+, in the order they are
55
+ # tried.
56
+ def for_type(type, config: RubyLLM.config) # :nodoc:
57
+ configured = config.file_converters.to_h.select { |pattern, _| matches?(pattern.to_s, type) }
58
+ configured.sort_by { |pattern, _| pattern.to_s.end_with?('*') ? 1 : 0 }
59
+ .flat_map { |_, converters| Array(converters) } + built_in(type)
60
+ end
61
+
62
+ # Returns whether +converter+ reads files on this machine for free.
63
+ def local?(converter) # :nodoc:
64
+ converter.respond_to?(:local?) && converter.local? == true
65
+ end
66
+
67
+ private
68
+
69
+ def matches?(pattern, type)
70
+ return true if pattern == '*/*'
71
+ return type.start_with?(pattern.delete_suffix('*')) if pattern.end_with?('/*')
72
+
73
+ pattern == type
74
+ end
75
+
76
+ def built_in(type)
77
+ return [Text] if Files::MimeType.text?(type)
78
+
79
+ Office::TYPES.key?(type) ? [Office] : []
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'find'
5
+
6
+ module RubyLLM
7
+ class FileStore
8
+ # Keeps files in a folder on disk, created when missing. Files someone
9
+ # puts in the folder are the chat's files too. Hidden files and
10
+ # folders, whose names start with a dot, are left out, and symbolic
11
+ # links are never followed. Code execution runs in the folder itself
12
+ # and can change anything in it, so give each store a folder of its
13
+ # own.
14
+ #
15
+ # chat.with_file_store("tmp/chats/42")
16
+ #
17
+ class Directory < FileStore
18
+ OPEN_FLAGS = File::RDONLY | File::BINARY | (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
19
+ SNIFF_FLAGS = OPEN_FLAGS | (defined?(File::NONBLOCK) ? File::NONBLOCK : 0)
20
+ WRITE_FLAGS = File::WRONLY | File::CREAT | File::EXCL | File::BINARY
21
+ private_constant :OPEN_FLAGS, :SNIFF_FLAGS, :WRITE_FLAGS
22
+
23
+ # The folder holding the files, as an absolute path.
24
+ attr_reader :root
25
+
26
+ # Creates a store for the folder +root+.
27
+ def initialize(root)
28
+ super()
29
+ FileUtils.mkdir_p(root)
30
+ @root = File.realpath(root)
31
+ end
32
+
33
+ # Returns #root: code execution runs in this folder.
34
+ def local_root # :nodoc:
35
+ root
36
+ end
37
+
38
+ # Returns the files inside the folder +prefix+, or every file.
39
+ def list(prefix = nil)
40
+ start = prefix.to_s.empty? ? root : full_path(FileStore.path(prefix))
41
+ return [self[prefix]].compact unless File.lstat(start).directory?
42
+
43
+ walk(start).sort_by(&:path)
44
+ rescue SystemCallError
45
+ []
46
+ end
47
+
48
+ # Returns the StoredFile at +path+, or +nil+.
49
+ def [](path)
50
+ full = full_path(FileStore.path(path))
51
+ stat = File.lstat(full)
52
+ stored_file(full, stat) if stat.file?
53
+ rescue SystemCallError
54
+ nil
55
+ end
56
+
57
+ # Returns the content of the file at +path+.
58
+ def read(path)
59
+ path = FileStore.path(path)
60
+ File.open(full_path(path), OPEN_FLAGS, &:read).b
61
+ rescue SystemCallError
62
+ raise FileNotFoundError, "No file at #{path}"
63
+ end
64
+
65
+ # Writes +content+ to +path+ and returns its StoredFile. Raises
66
+ # ArgumentError when +path+ is a folder.
67
+ def write(path, content, content_type: nil)
68
+ path = FileStore.path(path)
69
+ file = with_lock { write_file(path, content) }
70
+ content_type ? StoredFile.new(**file.to_h, content_type:) : file
71
+ end
72
+
73
+ # Deletes the file at +path+ and returns its StoredFile, or +nil+.
74
+ def delete(path)
75
+ path = FileStore.path(path)
76
+ with_lock do
77
+ refuse_folder(path) if folder?(path)
78
+
79
+ file = self[path]
80
+ File.delete(full_path(path)) if file
81
+ file
82
+ end
83
+ end
84
+
85
+ # Deletes the folder +path+ with everything in it, hidden files
86
+ # included, and returns the deleted StoredFile objects. Links are
87
+ # removed, never followed.
88
+ def delete_folder(path)
89
+ path = FileStore.path(path)
90
+ with_lock do
91
+ refuse_file(path) if self[path]
92
+ next [] unless folder?(path)
93
+
94
+ full = full_path(path)
95
+ walk(full).sort_by(&:path).tap { FileUtils.rm_r(full) }
96
+ end
97
+ end
98
+
99
+ # Runs the block with the store locked, against other threads and,
100
+ # through a lock on #root, other processes, and returns its value.
101
+ def with_lock
102
+ super do
103
+ next yield if @locked
104
+
105
+ File.open(root, File::RDONLY) do |folder|
106
+ folder.flock(File::LOCK_EX)
107
+ @locked = true
108
+ yield
109
+ ensure
110
+ @locked = false
111
+ end
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def write_file(path, content)
118
+ raise ArgumentError, "#{path} is a folder, not a file" if folder?(path)
119
+
120
+ full = full_path(path)
121
+ make_room(full)
122
+ temporary = File.join(File.dirname(full), ".#{SecureRandom.hex(16)}.tmp")
123
+ File.open(temporary, WRITE_FLAGS, binmode: true) do |io|
124
+ content.respond_to?(:read) ? IO.copy_stream(content, io) : io.write(content)
125
+ end
126
+ File.rename(temporary, full)
127
+ stored_file(full, File.lstat(full))
128
+ ensure
129
+ FileUtils.rm_f(temporary) if temporary
130
+ end
131
+
132
+ def full_path(path)
133
+ parent = root
134
+ path.split('/').each do |part|
135
+ raise ArgumentError, "#{path} is not inside the chat's files" if part.start_with?('.')
136
+
137
+ parent = File.join(parent, part)
138
+ end
139
+ folder = File.dirname(parent)
140
+ if File.directory?(folder) && !"#{File.realpath(folder)}/".start_with?("#{root}/")
141
+ raise ArgumentError, "#{path} is not inside the chat's files"
142
+ end
143
+
144
+ parent
145
+ end
146
+
147
+ def walk(start)
148
+ files = []
149
+ Find.find(start) do |path|
150
+ next if path == root
151
+
152
+ stat = File.lstat(path)
153
+ Find.prune if File.basename(path).start_with?('.')
154
+ files << stored_file(path, stat) if stat.file?
155
+ end
156
+ files
157
+ end
158
+
159
+ def make_room(full)
160
+ parent = root
161
+ File.dirname(full).delete_prefix(root).split('/').reject(&:empty?).each do |part|
162
+ parent = File.join(parent, part)
163
+ File.delete(parent) if File.symlink?(parent) || File.file?(parent)
164
+ end
165
+ FileUtils.mkdir_p(File.dirname(full))
166
+ end
167
+
168
+ def folder?(path)
169
+ File.lstat(full_path(path)).directory?
170
+ rescue SystemCallError
171
+ false
172
+ end
173
+
174
+ def file_content_type(full, path)
175
+ type = FileStore.content_type_for(path, '')
176
+ return type unless type == 'application/octet-stream'
177
+
178
+ File.open(full, SNIFF_FLAGS) { |io| io.stat.file? ? Files::MimeType.for(io, name: path) : type }
179
+ rescue SystemCallError
180
+ type
181
+ end
182
+
183
+ def stored_file(full, stat)
184
+ path = full.delete_prefix("#{root}/")
185
+ StoredFile.new(path:, byte_size: stat.size, content_type: file_content_type(full, path),
186
+ updated_at: stat.mtime)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ # Keeps files in memory, for as long as the chat lives. It is the
6
+ # store Chat#with_file_store uses when given none.
7
+ #
8
+ # chat.with_file_store(:memory)
9
+ #
10
+ class Memory < FileStore
11
+ def initialize # :nodoc:
12
+ super
13
+ @files = {}
14
+ @lock = Mutex.new
15
+ end
16
+
17
+ # Returns the files inside the folder +prefix+, or every file.
18
+ def list(prefix = nil)
19
+ @lock.synchronize { @files.values.map(&:first) }
20
+ .select { |file| FileStore.inside?(file.path, prefix) }
21
+ .sort_by(&:path)
22
+ end
23
+
24
+ # Returns the StoredFile at +path+, or +nil+.
25
+ def [](path)
26
+ @lock.synchronize { @files[FileStore.path(path)]&.first }
27
+ end
28
+
29
+ # Returns the content of the file at +path+.
30
+ def read(path)
31
+ path = FileStore.path(path)
32
+ content = @lock.synchronize { @files[path]&.last }
33
+ raise FileNotFoundError, "No file at #{path}" unless content
34
+
35
+ content.dup
36
+ end
37
+
38
+ # Writes +content+ to +path+ and returns its StoredFile.
39
+ def write(path, content, content_type: nil)
40
+ path = FileStore.path(path)
41
+ content = FileStore.bytes(content).freeze
42
+ file = StoredFile.new(path:, byte_size: content.bytesize,
43
+ content_type: content_type || FileStore.content_type_for(path, content),
44
+ digest: FileStore.digest(content), updated_at: Time.now)
45
+ with_lock { @lock.synchronize { @files[path] = [file, content] } }
46
+ file
47
+ end
48
+
49
+ # Deletes the file at +path+ and returns its StoredFile, or +nil+.
50
+ def delete(path)
51
+ path = FileStore.path(path)
52
+ with_lock do
53
+ @lock.synchronize do
54
+ refuse_folder(path) if !@files.key?(path) && @files.keys.any? { |key| FileStore.inside?(key, path) }
55
+ @files.delete(path)&.first
56
+ end
57
+ end
58
+ end
59
+
60
+ # Deletes everything inside the folder +path+ and returns the deleted
61
+ # StoredFile objects.
62
+ def delete_folder(path)
63
+ path = FileStore.path(path)
64
+ with_lock do
65
+ @lock.synchronize do
66
+ refuse_file(path) if @files.key?(path)
67
+
68
+ paths = @files.keys.select { |key| FileStore.inside?(key, path) }
69
+ paths.map { |key| @files.delete(key).first }.sort_by(&:path)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Tools
6
+ # What the file tools share: the chat's store, its configuration,
7
+ # the callback for each file a tool writes, and the check that the
8
+ # model read a file before it changes it.
9
+ class Base < Tool # :nodoc:
10
+ class ToolError < StandardError; end # :nodoc:
11
+
12
+ inline_limit false
13
+
14
+ def initialize(store:, config: -> { RubyLLM.config }, written: ->(_file) {})
15
+ super()
16
+ @store = store
17
+ @config = config
18
+ @written = written
19
+ end
20
+
21
+ private
22
+
23
+ def store
24
+ @store.call
25
+ end
26
+
27
+ def config
28
+ @config.call
29
+ end
30
+
31
+ def path_for(name)
32
+ FileStore.path(name)
33
+ rescue ArgumentError => e
34
+ raise ToolError, e.message
35
+ end
36
+
37
+ def folder_for(name)
38
+ path_for(name) unless name.to_s.strip.empty? || %w[. /].include?(name.to_s.strip)
39
+ end
40
+
41
+ def file_at(name)
42
+ path = path_for(name)
43
+ store[path] || raise(ToolError, "There is no file at #{path}. #{missing_hint(path)}")
44
+ end
45
+
46
+ def missing_hint(path)
47
+ folder = store.list(path).first
48
+ return "#{path} is a folder: list it with list_files." if folder
49
+
50
+ 'List the files with list_files.'
51
+ end
52
+
53
+ def digest_of(file)
54
+ file.digest || FileStore.digest(store.read(file.path))
55
+ end
56
+
57
+ # Yields the file at +path+, or +nil+ for a new one, with the store
58
+ # locked, once the model has read the file as it is now.
59
+ def changing(path)
60
+ store.with_lock do
61
+ file = store[path]
62
+ fresh!(file) if file
63
+ yield file
64
+ end
65
+ end
66
+
67
+ def fresh!(file)
68
+ seen = store.read_digest(file.path)
69
+ raise ToolError, "Read #{file.path} before you change it." unless seen
70
+ return if seen == digest_of(file)
71
+
72
+ raise ToolError, "#{file.path} changed since you read it. Read it again."
73
+ end
74
+
75
+ def guarded_write
76
+ written = []
77
+ result = guarded do
78
+ yield(lambda do |path, content|
79
+ file = store.write(path, content)
80
+ store.record_read(file.path, file.digest || FileStore.digest(content))
81
+ written << file
82
+ file
83
+ end)
84
+ end
85
+ written.each { |file| @written.call(file) }
86
+ result
87
+ end
88
+
89
+ def guarded
90
+ yield
91
+ rescue ToolError, FileStoreFullError, ArgumentError => e
92
+ { error: e.message }
93
+ rescue SystemCallError => e
94
+ { error: SystemCallError.new(e.errno).message }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Tools
6
+ # The +delete_file+ tool: deletes one file. Chats get it only when
7
+ # they ask for it.
8
+ class Delete < Base # :nodoc:
9
+ def self.tool_name
10
+ 'delete_file'
11
+ end
12
+
13
+ def description
14
+ 'Deletes one file.'
15
+ end
16
+
17
+ def parameters_schema
18
+ {
19
+ 'type' => 'object',
20
+ 'properties' => {
21
+ 'path' => { 'type' => 'string', 'description' => 'The file to delete' }
22
+ },
23
+ 'required' => %w[path],
24
+ 'additionalProperties' => false
25
+ }
26
+ end
27
+
28
+ def execute(path:)
29
+ guarded do
30
+ path = path_for(path)
31
+ store.with_lock { delete(path) }
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def delete(path)
38
+ file = store.delete(path)
39
+ raise ToolError, "There is no file at #{path}. List the files with list_files." unless file
40
+
41
+ "Deleted #{file.path}."
42
+ rescue FolderError
43
+ raise ToolError, "#{path} is a folder. Delete its files one by one."
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end