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
@@ -5,12 +5,38 @@ module RubyLLM
5
5
  # yielded to the block passed to Chat#ask or Chat#complete when streaming.
6
6
  # A Chunk is a Message, so it responds to the same readers, but it holds
7
7
  # only the data received in that part of the stream, such as a piece of
8
- # content, partial tool calls, or the finish reason on the final chunk.
8
+ # content, partial tool calls, or the finish reason.
9
9
  #
10
10
  # chat.ask "Write a haiku about Ruby" do |chunk|
11
11
  # print chunk.content
12
12
  # end
13
13
  #
14
+ # Streamed chunks always have the +:assistant+ role, and most carry the
15
+ # model ID. #finish_reason, with predicates such as #max_tokens?,
16
+ # #content_filtered?, #tool_call_stop?, and #stopped?, arrives near the end
17
+ # of the stream, not necessarily on the last chunk. #tokens is often empty
18
+ # until then: +output+ counts the billable output tokens up to that chunk,
19
+ # including thinking tokens the provider bills as output, and providers
20
+ # differ in when they report it. Read the final reason and usage from the
21
+ # Message that Chat#ask returns.
22
+ #
23
+ # Pass +index:+ to build one with its position, such as in a test:
24
+ #
25
+ # RubyLLM::Chunk.new(role: :assistant, content: "Hel", index: 0)
26
+ #
14
27
  class Chunk < Message
28
+ def initialize(options = {}) # :nodoc:
29
+ super
30
+ @index = options[:index]
31
+ end
32
+
33
+ # Returns the position of this chunk within the response it belongs
34
+ # to, starting at 0. Each response in a Chat#complete loop, such as
35
+ # the one after a tool call, starts again at 0. Counts every chunk,
36
+ # including those without content.
37
+ #
38
+ # chat.ask("Count to three") { |chunk| puts "#{chunk.index}: #{chunk.content}" }
39
+ #
40
+ attr_accessor :index
15
41
  end
16
42
  end
@@ -41,7 +41,8 @@ module RubyLLM
41
41
  # content, or +nil+.
42
42
  attr_reader :end_index
43
43
 
44
- # The provider's identifier for the cited source, or +nil+.
44
+ # The provider's identifier for the cited source, or +nil+. For a cited
45
+ # SearchResults result, this is its +:source_id+ when it has one.
45
46
  attr_reader :source_id
46
47
 
47
48
  # The 0-indexed position of the source document or search result,
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class CodeExecution
5
+ Language = Data.define(:name, :label, :filename, :program, :check) do # :nodoc:
6
+ def self.[](name)
7
+ self::ALL.fetch(name)
8
+ end
9
+
10
+ def command(path)
11
+ [program, path]
12
+ end
13
+
14
+ def check_command
15
+ [program, *check]
16
+ end
17
+ end
18
+
19
+ Language::ALL = {
20
+ ruby: Language.new(name: :ruby, label: 'Ruby', filename: 'main.rb', program: 'ruby', check: ['-e', 'exit']),
21
+ python: Language.new(name: :python, label: 'Python', filename: 'main.py', program: 'python3',
22
+ check: ['-c', 'pass']),
23
+ javascript: Language.new(name: :javascript, label: 'JavaScript', filename: 'main.js', program: 'node',
24
+ check: ['-e', '0']),
25
+ shell: Language.new(name: :shell, label: 'shell', filename: 'main.sh', program: 'sh', check: ['-c', 'exit 0'])
26
+ }.freeze
27
+ end
28
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RubyLLM
6
+ class CodeExecution
7
+ # The Node.js the application's PATH finds, as code execution mounts
8
+ # it: the installation read-only, wherever a version manager put it.
9
+ # Asks +node+ itself for its executable, since a version manager's
10
+ # shim is a different program.
11
+ class NodeInstallation # :nodoc:
12
+ def self.current
13
+ @current ||= new
14
+ end
15
+
16
+ def initialize(node: 'node')
17
+ @node = node
18
+ end
19
+
20
+ def prefix
21
+ executable && File.dirname(executable, 2)
22
+ end
23
+
24
+ def bin_dir
25
+ executable && File.dirname(executable)
26
+ end
27
+
28
+ def version
29
+ details&.last
30
+ end
31
+
32
+ def readable
33
+ [prefix].compact
34
+ end
35
+
36
+ private
37
+
38
+ def executable
39
+ details&.first
40
+ end
41
+
42
+ def details
43
+ return @details if defined?(@details)
44
+
45
+ @details = begin
46
+ output, status = Open3.capture2(@node, '-p', 'process.execPath + "\\n" + process.version',
47
+ err: File::NULL)
48
+ lines = output.lines.map(&:chomp)
49
+ status.success? && lines.size == 2 ? [File.realpath(lines.first), lines.last] : nil
50
+ rescue SystemCallError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'rbconfig'
6
+ require 'tmpdir'
7
+
8
+ module RubyLLM
9
+ class CodeExecution
10
+ # The Ruby and Node.js installations, gems, Python packages, and npm
11
+ # packages code runs with. Declared packages are installed once per
12
+ # set, in the sandbox with only their registries reachable, into a
13
+ # directory under Configuration#package_cache_dir that every later run
14
+ # mounts read-only. A prebuilt GEM_HOME, virtualenv, or node_modules is
15
+ # mounted as it is. npm packages install without running their scripts.
16
+ class Packages # :nodoc:
17
+ SYSTEM_PATH = %w[/usr/local/bin /usr/bin /bin].freeze
18
+ RESOLVER_CONFIG = '/etc/resolv.conf'
19
+ INSTALL_TIMEOUT = 600
20
+ MAX_FAILURE_OUTPUT = 2000
21
+ REGISTRIES = {
22
+ ruby: %w[rubygems.org *.rubygems.org],
23
+ python: %w[pypi.org files.pythonhosted.org],
24
+ javascript: %w[registry.npmjs.org]
25
+ }.freeze
26
+
27
+ attr_reader :gems, :python, :npm
28
+
29
+ def initialize(sandbox:, cache_dir:, gems: [], python: [], npm: [], gem_home: nil, virtualenv: nil,
30
+ node_modules: nil, node: nil, installation: RubyInstallation.current)
31
+ @sandbox = sandbox
32
+ @installation = installation
33
+ @node = node
34
+ @npm = Array(npm).map(&:to_s)
35
+ @node_modules = node_modules && File.expand_path(node_modules)
36
+ @cache_dir = cache_dir && File.expand_path(cache_dir)
37
+ @gems = Array(gems).map(&:to_s)
38
+ @python = Array(python).map(&:to_s)
39
+ @gem_home = gem_home && File.expand_path(gem_home)
40
+ @virtualenv = virtualenv && File.expand_path(virtualenv)
41
+ @lock = Mutex.new
42
+ end
43
+
44
+ def gem_names
45
+ return gems if gems.any? || @gem_home.nil?
46
+
47
+ Dir.glob(File.join(@gem_home, 'specifications', '*.gemspec'))
48
+ .map { |path| File.basename(path, '.gemspec').sub(/-\d[\w.]*(?:-[a-z][^.]*)?\z/, '') }.uniq.sort
49
+ end
50
+
51
+ def python_names
52
+ return python if python.any? || @virtualenv.nil?
53
+
54
+ Dir.glob(File.join(@virtualenv, 'lib', 'python*', 'site-packages', '*.dist-info'))
55
+ .map { |path| File.basename(path, '.dist-info').sub(/-[^-]+\z/, '') }.uniq.sort
56
+ end
57
+
58
+ def npm_names
59
+ return npm if npm.any? || @node_modules.nil?
60
+
61
+ manifest = File.join(File.dirname(@node_modules), 'package.json')
62
+ return [] unless File.file?(manifest)
63
+
64
+ JSON.parse(File.read(manifest)).fetch('dependencies', {}).keys.sort
65
+ rescue JSON::ParserError
66
+ []
67
+ end
68
+
69
+ def readable
70
+ home = gem_home
71
+ [*runtimes, home, (ruby_gems if home), virtualenv, node_modules].compact
72
+ end
73
+
74
+ def runtimes
75
+ [*@installation.readable, *@node&.readable]
76
+ end
77
+
78
+ # Returns +runner+ able to load the libraries Ruby links, when it can
79
+ # grant them.
80
+ def with_libraries(runner)
81
+ runner.respond_to?(:with_libraries_of) ? runner.with_libraries_of(*@installation.libraries) : runner
82
+ end
83
+
84
+ def denied
85
+ @installation.denied - [@gem_home]
86
+ end
87
+
88
+ def resolver_config(network)
89
+ return [] unless network && File.exist?(RESOLVER_CONFIG)
90
+
91
+ [RESOLVER_CONFIG, File.realpath(RESOLVER_CONFIG)].uniq
92
+ end
93
+
94
+ def env(home, tmp = home)
95
+ base_env(home, tmp).merge(
96
+ 'PATH' => path,
97
+ 'GEM_HOME' => gem_home,
98
+ 'GEM_PATH' => gem_home && [gem_home, ruby_gems].join(File::PATH_SEPARATOR),
99
+ 'VIRTUAL_ENV' => virtualenv
100
+ ).compact
101
+ end
102
+
103
+ def gem_home
104
+ return @gem_home if gems.empty?
105
+
106
+ @lock.synchronize { @installed_gem_home ||= install(:ruby, gems) }
107
+ end
108
+
109
+ def virtualenv
110
+ return @virtualenv if python.empty?
111
+
112
+ @lock.synchronize { @installed_virtualenv ||= install(:python, python) }
113
+ end
114
+
115
+ def node_modules
116
+ return @node_modules if npm.empty?
117
+
118
+ @lock.synchronize { @installed_node_modules ||= File.join(install(:javascript, npm), 'node_modules') }
119
+ end
120
+
121
+ def cache_dir
122
+ @cache_dir || raise(ConfigurationError, 'Set config.package_cache_dir to install code execution packages')
123
+ end
124
+
125
+ def base_env(home, tmp = home)
126
+ {
127
+ 'PATH' => [RbConfig::CONFIG['bindir'], @node&.bin_dir, *SYSTEM_PATH].compact.join(File::PATH_SEPARATOR),
128
+ 'HOME' => home,
129
+ 'XDG_CACHE_HOME' => File.join(home, '.cache'),
130
+ 'TMPDIR' => tmp,
131
+ 'LANG' => 'C.UTF-8'
132
+ }
133
+ end
134
+
135
+ def ruby_gems
136
+ @installation.ruby_gems(@cache_dir)
137
+ end
138
+
139
+ def run(*command, home:, readable: [], writable: [], network: false, env: {})
140
+ sandbox = with_libraries(@sandbox.is_a?(Proc) ? @sandbox.call : @sandbox)
141
+ sandbox.run(*command, readable: [*runtimes, *resolver_config(network),
142
+ *readable], denied:,
143
+ writable: [*writable, home], network:,
144
+ env: base_env(home).merge(env), timeout: INSTALL_TIMEOUT)
145
+ end
146
+
147
+ private
148
+
149
+ def path
150
+ [virtualenv && File.join(virtualenv, 'bin'), gem_home && File.join(gem_home, 'bin'),
151
+ RbConfig::CONFIG['bindir'], @node&.bin_dir, *SYSTEM_PATH].compact.join(File::PATH_SEPARATOR)
152
+ end
153
+
154
+ def install(kind, packages)
155
+ directory = File.join(cache_dir, "#{kind}-#{cache_key(kind, packages)}")
156
+ return directory if File.exist?("#{directory}.ready")
157
+
158
+ FileUtils.mkdir_p(cache_dir)
159
+ File.open("#{directory}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
160
+ lock.flock(File::LOCK_EX)
161
+ build(kind, directory, packages) unless File.exist?("#{directory}.ready")
162
+ end
163
+ directory
164
+ end
165
+
166
+ def build(kind, directory, packages)
167
+ FileUtils.rm_rf(directory)
168
+ FileUtils.mkdir_p(directory)
169
+ Dir.mktmpdir('ruby_llm-install-') do |home|
170
+ case kind
171
+ when :ruby then install_gems(directory, packages, home)
172
+ when :python then install_python(directory, packages, home)
173
+ else install_npm(directory, packages, home)
174
+ end
175
+ end
176
+ File.write("#{directory}.ready", "#{packages.sort.join("\n")}\n")
177
+ end
178
+
179
+ def install_gems(directory, gems, home)
180
+ result = run('ruby', '-S', 'gem', 'install', '--no-document', '--install-dir', directory,
181
+ '--bindir', File.join(directory, 'bin'), *gems,
182
+ home:, readable: [ruby_gems], writable: [directory], network: { allow: REGISTRIES[:ruby] },
183
+ env: { 'GEM_HOME' => directory, 'GEM_PATH' => [directory, ruby_gems].join(File::PATH_SEPARATOR) })
184
+ verify(result, "gems #{gems.join(', ')}")
185
+ end
186
+
187
+ def install_python(directory, packages, home)
188
+ description = "Python packages #{packages.join(', ')}"
189
+ verify(run('python3', '-m', 'venv', directory, home:, writable: [directory]), description)
190
+ result = run(File.join(directory, 'bin', 'python'), '-m', 'pip', 'install', '--no-input',
191
+ '--disable-pip-version-check', *packages,
192
+ home:, writable: [directory], network: { allow: REGISTRIES[:python] })
193
+ verify(result, description)
194
+ end
195
+
196
+ def install_npm(directory, packages, home)
197
+ raise Error, 'Could not install npm packages: node is not on the PATH' unless @node&.bin_dir
198
+
199
+ result = run('npm', 'install', '--prefix', directory, '--ignore-scripts', '--no-audit', '--no-fund',
200
+ '--no-update-notifier', *packages,
201
+ home:, writable: [directory], network: { allow: REGISTRIES[:javascript] })
202
+ verify(result, "npm packages #{packages.join(', ')}")
203
+ end
204
+
205
+ def verify(result, description)
206
+ return if result.exit_code&.zero? && !result.timed_out?
207
+
208
+ raise Error, "Could not install #{description}: #{failure(result)}"
209
+ end
210
+
211
+ def failure(result)
212
+ return "timed out after #{INSTALL_TIMEOUT} seconds" if result.timed_out?
213
+
214
+ stderr = result.stderr.to_s.strip
215
+ blocked = result.respond_to?(:blocked) ? Array(result.blocked) : []
216
+ details = failure_details(stderr)
217
+ blocked.empty? ? details : "#{details} (blocked: #{blocked.join(', ')})"
218
+ end
219
+
220
+ def failure_details(stderr)
221
+ return stderr if stderr.length <= MAX_FAILURE_OUTPUT
222
+
223
+ half = MAX_FAILURE_OUTPUT / 2
224
+ "#{stderr[0, half]}\n[#{stderr.length - MAX_FAILURE_OUTPUT} characters omitted]\n#{stderr[-half..]}"
225
+ end
226
+
227
+ def cache_key(kind, packages)
228
+ Digest::SHA256.hexdigest(JSON.generate([kind, runtime_version(kind), packages.sort]))[0, 24]
229
+ end
230
+
231
+ def runtime_version(kind)
232
+ return [RUBY_VERSION, RUBY_PLATFORM, RbConfig.ruby] if kind == :ruby
233
+ return [@node&.version, RUBY_PLATFORM] if kind == :javascript
234
+
235
+ Dir.mktmpdir('ruby_llm-install-') do |home|
236
+ run('python3', '-c', 'import sys; print(sys.version)', home:).stdout.to_s.strip
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'rbconfig'
6
+ require 'tmpdir'
7
+
8
+ module RubyLLM
9
+ class CodeExecution
10
+ # The application's Ruby, as code execution mounts it: the installation
11
+ # read-only, with every gem in its gem directory hidden except Ruby's
12
+ # default and bundled gems, and every other gem directory the
13
+ # application uses hidden entirely, as are Bundler's git checkouts and
14
+ # RubyGems' cache and documents. RubyGems lists the plugins
15
+ # directory on every +gem+ command, so only the application's plugins
16
+ # are hidden, not the directory: macOS refuses to list a hidden one.
17
+ class RubyInstallation # :nodoc:
18
+ HIDDEN_DIRECTORIES = %w[bundler cache doc].freeze
19
+ BUILD_MARKER = 'gem.build_complete'
20
+
21
+ # Ruby's bundled gems that Gem::BUNDLED_GEMS::SINCE may leave out, by
22
+ # the Ruby version that first bundled them. SINCE lists only the gems
23
+ # that recently stopped being default gems, and an installed Ruby
24
+ # keeps no other list of them.
25
+ BUNDLED_GEMS = {
26
+ '3.2' => %w[minitest power_assert rake test-unit rexml rss net-ftp net-imap net-pop net-smtp matrix prime rbs
27
+ typeprof debug],
28
+ '3.4' => %w[repl_type_completor],
29
+ '4.0' => %w[readline]
30
+ }.freeze
31
+
32
+ def self.current
33
+ @current ||= new
34
+ end
35
+
36
+ def initialize(prefix: RbConfig::CONFIG['prefix'], gem_dir: Gem.default_dir, gem_path: Gem.path)
37
+ @prefix = prefix
38
+ @gem_dir = gem_dir
39
+ @gem_path = gem_path
40
+ end
41
+
42
+ def readable
43
+ @gem_dir.start_with?(File.join(@prefix, '')) ? [@prefix] : [@prefix, @gem_dir]
44
+ end
45
+
46
+ # Returns Ruby, its shared library, and its extensions: the files
47
+ # whose own libraries, such as Homebrew's libyaml and OpenSSL on
48
+ # macOS, a sandbox lets Ruby load.
49
+ def libraries
50
+ @libraries ||= [RbConfig.ruby, *shared_library,
51
+ *Dir.glob(File.join(RbConfig::CONFIG['archdir'], '**', "*.#{RbConfig::CONFIG['DLEXT']}"))]
52
+ .select { |path| File.file?(path) }.freeze
53
+ end
54
+
55
+ def denied
56
+ @denied ||= [
57
+ *application_gems.flat_map { |stub| gem_paths(stub) },
58
+ *HIDDEN_DIRECTORIES.map { |name| File.join(@gem_dir, name) },
59
+ *(@gem_path - [@gem_dir])
60
+ ].select { |path| File.exist?(path) }.uniq.freeze
61
+ end
62
+
63
+ # Returns a directory, built once under +cache_dir+, or under a
64
+ # private temporary directory without one, that holds only Ruby's
65
+ # bundled gems: their gemspecs, and links to their files. A GEM_PATH
66
+ # with it instead of the gem directory finds no hidden application
67
+ # gem, so <tt>gem install</tt> installs every dependency and RubyGems
68
+ # never activates a gem whose files the sandbox hides. Default gems
69
+ # load from any GEM_PATH.
70
+ def ruby_gems(cache_dir)
71
+ cache_dir ||= (@private_dir ||= Dir.mktmpdir('ruby_llm-ruby-gems-'))
72
+ directory = File.join(cache_dir, "bundled-gems-#{ruby_gems_key}")
73
+ return directory if File.exist?("#{directory}.ready")
74
+
75
+ FileUtils.mkdir_p(cache_dir)
76
+ File.open("#{directory}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
77
+ lock.flock(File::LOCK_EX)
78
+ link_ruby_gems(directory) unless File.exist?("#{directory}.ready")
79
+ end
80
+ directory
81
+ end
82
+
83
+ def ruby_gem?(name)
84
+ default_gems.include?(name) || bundled_gems.include?(name)
85
+ end
86
+
87
+ private
88
+
89
+ def shared_library
90
+ return unless RbConfig::CONFIG['ENABLE_SHARED'] == 'yes'
91
+
92
+ File.join(RbConfig::CONFIG['libdir'], RbConfig::CONFIG['LIBRUBY_SO'])
93
+ end
94
+
95
+ def application_gems
96
+ installed_stubs.reject { |stub| ruby_gem?(stub.name) }
97
+ end
98
+
99
+ def installed_stubs
100
+ Dir.glob(File.join(@gem_dir, 'specifications', '*.gemspec')).map do |path|
101
+ Gem::StubSpecification.gemspec_stub(path, @gem_dir, File.join(@gem_dir, 'gems'))
102
+ end
103
+ end
104
+
105
+ def bundled_gem_stubs
106
+ installed_stubs.select { |stub| bundled_gems.include?(stub.name) && !default_gems.include?(stub.name) }
107
+ end
108
+
109
+ def ruby_gems_key
110
+ Digest::SHA256.hexdigest([RbConfig.ruby, RUBY_VERSION, *bundled_gem_stubs.map(&:full_name)].join("\n"))[0, 24]
111
+ end
112
+
113
+ def link_ruby_gems(directory)
114
+ FileUtils.rm_rf(directory)
115
+ FileUtils.mkdir_p(File.join(directory, 'specifications'))
116
+ bundled_gem_stubs.each do |stub|
117
+ [File.join(@gem_dir, 'gems', stub.full_name), extensions_dir(stub)].each do |path|
118
+ next unless File.exist?(path)
119
+
120
+ link = File.join(directory, path.delete_prefix(File.join(@gem_dir, '')))
121
+ FileUtils.mkdir_p(File.dirname(link))
122
+ File.symlink(path, link)
123
+ end
124
+ FileUtils.cp(stub.loaded_from, File.join(directory, 'specifications'))
125
+ end
126
+ File.write("#{directory}.ready", '')
127
+ end
128
+
129
+ def extensions_dir(stub)
130
+ File.join(@gem_dir, 'extensions', Gem::Platform.local.to_s, Gem.extension_api_version, stub.full_name)
131
+ end
132
+
133
+ def gem_paths(stub)
134
+ extensions = extensions_dir(stub)
135
+ built = Dir.exist?(extensions) ? Dir.children(extensions) - [BUILD_MARKER] : []
136
+ [File.join(@gem_dir, 'gems', stub.full_name), *built.map { |name| File.join(extensions, name) },
137
+ File.join(@gem_dir, 'plugins', "#{stub.name}_plugin.rb")]
138
+ end
139
+
140
+ def default_gems
141
+ @default_gems ||= Dir.glob(File.join(@gem_dir, 'specifications', 'default', '*.gemspec')).map do |path|
142
+ Gem::StubSpecification.default_gemspec_stub(path, @gem_dir, File.join(@gem_dir, 'gems')).name
143
+ end
144
+ end
145
+
146
+ def bundled_gems
147
+ @bundled_gems ||= bundled_gems_file || (bundled_gems_table | bundled_gems_constant)
148
+ end
149
+
150
+ def bundled_gems_table
151
+ ruby = Gem::Version.new(RUBY_VERSION)
152
+ BUNDLED_GEMS.select { |version, _| ruby >= Gem::Version.new(version) }.values.flatten
153
+ end
154
+
155
+ def bundled_gems_file
156
+ path = File.join(RbConfig::CONFIG['srcdir'].to_s, 'gems', 'bundled_gems')
157
+ return unless File.file?(path)
158
+
159
+ File.readlines(path).filter_map { |line| line.split.first unless line.start_with?('#') || line.strip.empty? }
160
+ end
161
+
162
+ def bundled_gems_constant
163
+ require 'bundled_gems' unless defined?(Gem::BUNDLED_GEMS)
164
+ Gem::BUNDLED_GEMS::SINCE.keys
165
+ rescue LoadError, NameError
166
+ []
167
+ end
168
+ end
169
+ end
170
+ end