ruby_llm 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +515 -274
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1593 -331
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +50 -24
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +127 -106
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +15 -15
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +1 -7
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +5 -14
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +213 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module RubyLLM
6
+ # A question a tool call waits on until a person answers it: the
7
+ # approval a tool declared with Tool.requires_approval asks before it
8
+ # runs, a question the tool asks itself with Tool#ask, or one an MCP
9
+ # server asks while it works on the call. Chat#pending_questions returns
10
+ # the questions a chat waits on.
11
+ #
12
+ # question = chat.pending_questions.first
13
+ # question.message # => "Window or aisle?"
14
+ # question.fields.first.choices # => ["window", "aisle"]
15
+ # question.answer "window"
16
+ # chat.complete
17
+ #
18
+ # An approval asks whether its call may run, so it is answered yes or no:
19
+ #
20
+ # question.approval? # => true
21
+ # question.tool_call.name # => "issue_refund"
22
+ # question.approve # or question.decline("Not on this account")
23
+ #
24
+ # A server can also ask the person to visit a page, such as to connect an
25
+ # account: #url? is +true+, and answering says they agreed to go. Before
26
+ # a server's question reaches the chat, MCP.before_input_request
27
+ # callbacks can answer it.
28
+ class Question
29
+ include Support::Inspectable
30
+
31
+ ACCEPTED = { 'action' => 'accept' }.freeze # :nodoc:
32
+ DECLINED = { 'action' => 'decline' }.freeze # :nodoc:
33
+ BOOLEANS = { 'true' => true, '1' => true, 'yes' => true, 'on' => true,
34
+ 'false' => false, '0' => false, 'no' => false, 'off' => false }.freeze
35
+ TYPES = { 'string' => 'text', 'integer' => 'a whole number', 'number' => 'a number',
36
+ 'boolean' => 'true or false', 'array' => 'a list' }.freeze
37
+ CLASSES = { 'string' => [String], 'integer' => [Integer], 'number' => [Numeric],
38
+ 'boolean' => [TrueClass, FalseClass], 'array' => [Array] }.freeze
39
+ private_constant :BOOLEANS, :TYPES, :CLASSES
40
+
41
+ # A value a question asks for.
42
+ Field = Struct.new(:name, :type, :title, :description, :required, :choices, :default, keyword_init: true) do
43
+ # Returns whether the question needs this value.
44
+ def required?
45
+ required == true
46
+ end
47
+ end
48
+
49
+ # The question, or +nil+ for an approval.
50
+ attr_reader :message
51
+
52
+ # The page the question asks the person to visit, or +nil+. When an MCP
53
+ # server asks, show the whole address before the person goes, since
54
+ # the server chose it. RubyLLM declines a server's request to visit
55
+ # anything but an +http+ or +https+ address (see MCP.input_requests).
56
+ attr_reader :url
57
+
58
+ # The values the question asks for, as Field objects: one for a question
59
+ # Tool#ask asks without a block, none for an approval or a page to visit.
60
+ attr_reader :fields
61
+
62
+ # The ToolCall that waits on the question, when it came from a chat.
63
+ attr_reader :tool_call
64
+
65
+ attr_reader :key, :response # :nodoc:
66
+
67
+ class << self
68
+ # Returns the question Tool#ask asks as its +index+th: one that takes
69
+ # free text or one of +choices+, a visit to +url+, or the fields
70
+ # +schema+ describes.
71
+ def asked(index, message, choices: nil, url: nil, schema: nil) # :nodoc:
72
+ params = { 'mode' => url ? 'url' : 'form', 'message' => message.to_s }
73
+ if url
74
+ params['url'] = url.to_s
75
+ else
76
+ params['requestedSchema'] = schema || answer_schema(choices)
77
+ end
78
+ new("ask_#{index}", params)
79
+ end
80
+
81
+ # Returns the approval +chat+ waits on before it runs +tool_call+.
82
+ def approval(tool_call, chat:) # :nodoc:
83
+ new('approval', {}, id: tool_call.id, tool_call:, chat:, approval: true)
84
+ end
85
+
86
+ def from_h(data, tool_call: nil, chat: nil) # :nodoc:
87
+ new(data['key'], data['params'], id: data['id'] || data['key'], response: data['response'], tool_call:, chat:)
88
+ end
89
+
90
+ # Returns the response that declines a question, keeping +note+ for
91
+ # the model unless it is blank.
92
+ def declined(note) # :nodoc:
93
+ note = note.to_s.strip unless note.is_a?(Hash)
94
+ note.empty? ? DECLINED : DECLINED.merge('note' => note)
95
+ end
96
+
97
+ # Returns the call +decision+ approves: the ToolCall of an approval,
98
+ # or +decision+ itself, such as a ToolCall or its id. Raises
99
+ # ArgumentError for any other Question, which takes +instead+.
100
+ def decided_call(decision, instead) # :nodoc:
101
+ return decision unless decision.is_a?(Question)
102
+ raise ArgumentError, "This question is not an approval: #{instead} it instead." unless decision.approval?
103
+
104
+ decision.tool_call
105
+ end
106
+
107
+ private
108
+
109
+ def answer_schema(choices)
110
+ raise ArgumentError, 'choices: needs at least one choice.' if choices && Array(choices).empty?
111
+
112
+ answer = { 'type' => 'string', 'enum' => choices&.map(&:to_s) }.compact
113
+ { 'type' => 'object', 'properties' => { 'answer' => answer }, 'required' => ['answer'] }
114
+ end
115
+ end
116
+
117
+ def initialize(key, params, id: SecureRandom.hex(8), response: nil, tool_call: nil, chat: nil, # :nodoc:
118
+ approval: false)
119
+ @key = key
120
+ @params = params
121
+ @nonce = id
122
+ @response = response
123
+ @tool_call = tool_call
124
+ @chat = chat
125
+ @approval = approval
126
+ @message = params['message']
127
+ @url = params['url'] if params['mode'] == 'url'
128
+ @fields = fields_from(params['requestedSchema'] || {})
129
+ end
130
+
131
+ # Returns a String that identifies the question among those of its
132
+ # chat. Chat#answer and the other methods that take a question take its
133
+ # id too, so a form can send it. An approval has its tool call's id.
134
+ def id
135
+ return @nonce if tool_call.nil? || approval?
136
+
137
+ "#{tool_call.id}/#{@nonce}"
138
+ end
139
+
140
+ # Returns whether the question asks whether its call may run: the
141
+ # approval of a tool declared with Tool.requires_approval. Answer it
142
+ # with #approve or #decline.
143
+ def approval?
144
+ @approval
145
+ end
146
+
147
+ # Returns whether the question asks the person to visit #url.
148
+ def url?
149
+ !url.nil?
150
+ end
151
+
152
+ # Returns whether the question asks for #fields.
153
+ def form?
154
+ !url? && !approval?
155
+ end
156
+
157
+ # Returns whether the question has been answered or declined.
158
+ def answered?
159
+ !response.nil?
160
+ end
161
+
162
+ # Answers the question and returns +self+. A question with one field,
163
+ # as Tool#ask asks without a block, takes its value. One with several
164
+ # takes them by name, as keywords or a Hash, and a field left out takes
165
+ # the default the question gives it. An approval or a page to visit
166
+ # takes no value: answering an approval approves its call, and
167
+ # answering a page says the person agreed to visit it.
168
+ #
169
+ # question.answer "window"
170
+ # question.answer environment: "staging", reason: "Release 2.2"
171
+ #
172
+ # A question from a chat records the answer there, as Chat#answer
173
+ # does, and the next Chat#complete carries the call on.
174
+ #
175
+ # Strings become the integers, numbers, and booleans the fields ask
176
+ # for, the way a form sends them, a Symbol becomes text, text and the
177
+ # items of a list lose the spaces around them, and a blank String
178
+ # counts as left out. The ActionController::Parameters a form sends
179
+ # for several fields work as a Hash, keeping only the fields the
180
+ # question asks for. Raises ArgumentError, with a sentence to show the
181
+ # person, for a value the question does not ask for, a choice it does
182
+ # not offer, a value of the wrong type, or a required value left out.
183
+ def answer(value = nil, **values)
184
+ if @chat
185
+ @chat.answer(self, value, **values)
186
+ else
187
+ respond(response_to(value, values))
188
+ end
189
+ self
190
+ end
191
+
192
+ # Declines the question and returns +self+. Declining an approval
193
+ # keeps its call from running. A +note+ tells the model why: an
194
+ # approval's call gets it as its result, in place of a plain refusal,
195
+ # and a tool that asked gets +nil+ from Tool#ask, and the model gets
196
+ # the note after the tool's result. A Hash +note+ is an approval's
197
+ # whole result, as given, and follows a tool's result as JSON. A question from a chat records it
198
+ # there, as Chat#decline does.
199
+ #
200
+ # question.decline
201
+ # question.decline("The order shipped already")
202
+ # question.decline(error: "Denied", reason: "Wrong account")
203
+ #
204
+ def decline(note = nil)
205
+ if @chat
206
+ @chat.decline(self, note)
207
+ else
208
+ respond(self.class.declined(note))
209
+ end
210
+ self
211
+ end
212
+
213
+ # Approves the call an approval asks about, as Chat#approve does, and
214
+ # returns +self+. With <tt>remember: true</tt>, later calls to the same
215
+ # tool run without asking for the rest of the chat.
216
+ #
217
+ # Raises ArgumentError for a question that is not an approval.
218
+ def approve(remember: false)
219
+ self.class.decided_call(self, 'answer')
220
+ @chat ? @chat.approve(self, remember:) : respond(ACCEPTED)
221
+ self
222
+ end
223
+
224
+ # Returns the response an answer of +value+ and +values+ makes, as
225
+ # #answer describes, raising ArgumentError for one it can't take.
226
+ def response_to(value, values) # :nodoc:
227
+ return response_without(value, values) unless form?
228
+
229
+ given = given_values(value, values)
230
+ unknown = given.keys - fields.map { |field| field.name.to_s }
231
+ raise ArgumentError, "The question does not ask for #{listed(unknown, 'or')}." if unknown.any?
232
+
233
+ ACCEPTED.merge('content' => content_from(given))
234
+ end
235
+
236
+ # Returns what Tool#ask returns for the question's answer: +nil+ when
237
+ # it was declined, +true+ for a page to visit, the values by name when
238
+ # +by_name+ (a block declared the fields), or else the one value.
239
+ def asked_answer(by_name) # :nodoc:
240
+ return unless response && response['action'] == 'accept'
241
+ return true if url?
242
+
243
+ content = response['content'].to_h
244
+ by_name ? content.transform_keys(&:to_sym) : content['answer']
245
+ end
246
+
247
+ # Records +response+ as the question's own, and returns +self+.
248
+ def respond(response) # :nodoc:
249
+ @response = response
250
+ self
251
+ end
252
+
253
+ # Returns +input+, the state the question's call paused on, with
254
+ # +response+ as its answer while it still waits for one.
255
+ def answered_input(input, response) # :nodoc:
256
+ requests = input['requests'].map do |data|
257
+ waiting = data['response'].nil? && self.class.from_h(data, tool_call:).id == id
258
+ waiting ? data.merge('response' => response) : data
259
+ end
260
+ input.merge('requests' => requests)
261
+ end
262
+
263
+ def to_h # :nodoc:
264
+ { 'id' => @nonce, 'key' => key, 'params' => @params, 'response' => response }.compact
265
+ end
266
+
267
+ private
268
+
269
+ def response_without(value, values)
270
+ return ACCEPTED if blank?(value) && values.empty?
271
+
272
+ raise ArgumentError, "#{approval? ? 'An approval' : 'A page to visit'} takes no answer."
273
+ end
274
+
275
+ def given_values(value, values)
276
+ value = permitted(value) if value.respond_to?(:permit)
277
+ given = case value
278
+ when nil, Hash then value.to_h
279
+ else
280
+ unless fields.one?
281
+ raise ArgumentError, "The question asks for #{listed(fields.map(&:name), 'and')}: give each by name."
282
+ end
283
+
284
+ { fields.first.name => value }
285
+ end
286
+ given.merge(values).transform_keys(&:to_s)
287
+ end
288
+
289
+ # The values of +params+, such as the ActionController::Parameters a
290
+ # form sends, that the question asks for.
291
+ def permitted(params)
292
+ params.permit(*fields.map { |field| field.type == 'array' ? { field.name => [] } : field.name }).to_h
293
+ end
294
+
295
+ def content_from(given)
296
+ fields.each_with_object({}) do |field, content|
297
+ answer = value_for(field, given[field.name.to_s])
298
+ answer = field.default if answer.nil?
299
+ raise ArgumentError, "#{label(field)} needs an answer." if answer.nil? && field.required?
300
+
301
+ content[field.name.to_s] = answer unless answer.nil?
302
+ end
303
+ end
304
+
305
+ def value_for(field, value)
306
+ value = trimmed(value)
307
+ return if blank?(value)
308
+
309
+ typed(field, value).tap { |typed| offered(field, typed) }
310
+ end
311
+
312
+ def typed(field, value)
313
+ value = parsed(field, value) if value.is_a?(String)
314
+ value = value.to_s if value.is_a?(Symbol) && field.type == 'string'
315
+ return value if CLASSES.fetch(field.type, [Object]).any? { |kind| value.is_a?(kind) }
316
+
317
+ raise ArgumentError, "#{label(field)} takes #{TYPES.fetch(field.type)}."
318
+ end
319
+
320
+ def parsed(field, text)
321
+ case field.type
322
+ when 'integer' then Integer(text, 10)
323
+ when 'number' then Float(text)
324
+ when 'boolean' then BOOLEANS.fetch(text.downcase, text)
325
+ when 'array' then [text]
326
+ else text
327
+ end
328
+ rescue ArgumentError
329
+ text
330
+ end
331
+
332
+ def offered(field, value)
333
+ return unless field.choices
334
+
335
+ Array(value).each do |choice|
336
+ next if field.choices.include?(choice)
337
+
338
+ raise ArgumentError, "#{choice.inspect} is not one of the choices: #{listed(field.choices, 'or')}."
339
+ end
340
+ end
341
+
342
+ def trimmed(value)
343
+ case value
344
+ when String then value.strip
345
+ when Array then value.map { |item| trimmed(item) }.reject { |item| blank?(item) }
346
+ else value
347
+ end
348
+ end
349
+
350
+ def blank?(value)
351
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
352
+ end
353
+
354
+ def label(field)
355
+ field.title || (fields.one? ? 'The question' : humanized(field.name))
356
+ end
357
+
358
+ def humanized(name)
359
+ words = name.to_s.gsub(/([a-z\d])([A-Z])|([A-Z])([A-Z][a-z])/, '\\1\\3 \\2\\4').split(/[\s_-]+/)
360
+ text = words.map { |word| word == word.upcase ? word : word.downcase }.join(' ')
361
+ text.sub(/\A./, &:upcase)
362
+ end
363
+
364
+ def listed(words, conjunction)
365
+ return words.join(" #{conjunction} ") if words.size < 3
366
+
367
+ "#{words[0..-2].join(', ')}, #{conjunction} #{words.last}"
368
+ end
369
+
370
+ def fields_from(schema)
371
+ required = Array(schema['required'])
372
+ schema.fetch('properties', {}).map do |name, property|
373
+ Field.new(name: name.to_sym, type: property['type'], title: property['title'],
374
+ description: property['description'], required: required.include?(name),
375
+ choices: choices(property), default: property['default'])
376
+ end
377
+ end
378
+
379
+ def choices(property)
380
+ options = property['enum'] || property.dig('items', 'enum') ||
381
+ Array(property['oneOf'] || property.dig('items', 'anyOf')).map { |o| o['const'] }
382
+ options unless options.empty?
383
+ end
384
+
385
+ def inspect_attributes
386
+ { id:, message:, url:, approval: (true if approval?), tool_call: (tool_call.name if approval?),
387
+ fields: fields.map(&:name) }
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module RubyLLM
6
+ # A user message waiting to join a conversation, from Chat#queue. The
7
+ # conversation loop takes it between two of its moves, where it becomes
8
+ # an ordinary user Message.
9
+ #
10
+ # queued = chat.queue "Use the EU file instead"
11
+ # chat.queued_messages # => [#<RubyLLM::QueuedMessage ...>]
12
+ # queued.withdraw # => true
13
+ #
14
+ class QueuedMessage
15
+ include Support::Inspectable
16
+
17
+ # A String that identifies this message among the chat's queued ones.
18
+ attr_reader :id
19
+
20
+ # The text of the message, as given to Chat#queue.
21
+ attr_reader :content
22
+
23
+ # The attachments given with +with:+, or +nil+.
24
+ attr_reader :attachments
25
+
26
+ # Whoever queued the message, as given with +sender:+, or +nil+.
27
+ # RubyLLM stores it and hands it back without interpreting it.
28
+ attr_reader :sender
29
+
30
+ # The Time the message was queued.
31
+ attr_reader :queued_at
32
+
33
+ def initialize(content:, attachments: nil, steer: true, sender: nil, queue: nil) # :nodoc:
34
+ @id = SecureRandom.uuid
35
+ @content = content
36
+ @attachments = attachments
37
+ @steer = steer
38
+ @sender = sender
39
+ @queue = queue
40
+ @queued_at = Time.now
41
+ end
42
+
43
+ # Returns whether the message steers the turn that is running, joining
44
+ # it at the next step, rather than waiting for the turn to end.
45
+ def steer?
46
+ @steer
47
+ end
48
+
49
+ # Takes the message out of the queue of the chat it was queued on, as
50
+ # Chat#withdraw does. Returns +true+, or +false+ when it is no longer
51
+ # queued: the loop took it into the conversation, or it was withdrawn
52
+ # already.
53
+ #
54
+ # queued.withdraw # => true
55
+ #
56
+ def withdraw
57
+ !@queue&.delete(self).nil?
58
+ end
59
+
60
+ # Returns a copy of the message that a copied chat's +queue+ holds, so
61
+ # #withdraw takes it out of that chat's queue.
62
+ def requeue(queue) # :nodoc:
63
+ copy = dup
64
+ copy.instance_variable_set(:@queue, queue)
65
+ copy
66
+ end
67
+
68
+ private
69
+
70
+ def inspect_attributes
71
+ { id:, content:, steer: steer? }
72
+ end
73
+ end
74
+ end
@@ -3,6 +3,9 @@
3
3
  if defined?(Rails::Railtie)
4
4
  module RubyLLM
5
5
  class Railtie < Rails::Railtie # :nodoc:
6
+ ROOT = File.expand_path('../..', __dir__)
7
+ JAVASCRIPTS = %w[ruby_llm/chat_message_controller.js ruby_llm/marked.js ruby_llm/dompurify.js].freeze
8
+
6
9
  initializer 'ruby_llm.inflections' do
7
10
  ActiveSupport::Inflector.inflections(:en) do |inflect|
8
11
  inflect.acronym 'RubyLLM'
@@ -13,6 +16,10 @@ if defined?(Rails::Railtie)
13
16
  RubyLLM.config.instrumenter ||= ActiveSupport::Notifications
14
17
  end
15
18
 
19
+ initializer 'ruby_llm.skills', before: :setup_main_autoloader do
20
+ Rails.autoloaders.main.ignore(*RubyLLM::Skill.roots)
21
+ end
22
+
16
23
  initializer 'ruby_llm.active_record' do
17
24
  ActiveSupport.on_load :active_record do
18
25
  require 'ruby_llm/active_record/record'
@@ -23,8 +30,15 @@ if defined?(Rails::Railtie)
23
30
  require 'ruby_llm/active_record/batch'
24
31
  require 'ruby_llm/active_record/mcp_credential'
25
32
  require 'ruby_llm/active_record/provider_file'
33
+ require 'ruby_llm/active_record/queued_message'
34
+ require 'ruby_llm/active_record/chat_file'
35
+ require 'ruby_llm/active_record/file_store'
26
36
  require 'ruby_llm/active_record/chat_methods'
37
+ require 'ruby_llm/active_record/chat_files'
38
+ require 'ruby_llm/active_record/chat_column_guard'
27
39
  require 'ruby_llm/active_record/message_methods'
40
+ require 'ruby_llm/active_record/message_broadcasting'
41
+ require 'ruby_llm/active_record/chunk_methods'
28
42
 
29
43
  require 'ruby_llm/active_record/acts_as'
30
44
  ::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs
@@ -33,12 +47,41 @@ if defined?(Rails::Railtie)
33
47
  end
34
48
  end
35
49
 
50
+ initializer 'ruby_llm.solid_queue' do
51
+ ActiveSupport.on_load :solid_queue do
52
+ require 'ruby_llm/active_record/solid_queue_shutdown'
53
+ RubyLLM::ActiveRecord::SolidQueueShutdown.install(self)
54
+ end
55
+ end
56
+
36
57
  initializer 'ruby_llm.active_storage' do
37
58
  ActiveSupport.on_load :active_storage_blob do
38
59
  after_destroy { RubyLLM::ActiveRecord::ProviderFile.forget_blob(self) }
39
60
  end
40
61
  end
41
62
 
63
+ initializer 'ruby_llm.helpers' do
64
+ ActiveSupport.on_load :action_view do
65
+ require 'ruby_llm/active_record/chat_helper'
66
+ include RubyLLM::ActiveRecord::ChatHelper
67
+ end
68
+ end
69
+
70
+ initializer 'ruby_llm.assets' do |app|
71
+ next unless app.config.respond_to?(:assets)
72
+
73
+ app.config.assets.paths << File.join(ROOT, 'app/assets/javascripts')
74
+ app.config.assets.precompile += JAVASCRIPTS
75
+ end
76
+
77
+ initializer 'ruby_llm.importmap', before: 'importmap' do |app|
78
+ app.config.importmap.paths << File.join(ROOT, 'config/importmap.rb') if app.config.respond_to?(:importmap)
79
+ end
80
+
81
+ config.after_initialize do
82
+ RubyLLM::Sandbox.verify(RubyLLM.sandbox) unless ENV['SECRET_KEY_BASE_DUMMY']
83
+ end
84
+
42
85
  rake_tasks do
43
86
  load 'tasks/ruby_llm.rake'
44
87
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- class RequestShape
4
+ class RequestShape # :nodoc: all
5
5
  # A Part describes one piece of a turn by its kind and size, never by
6
6
  # its content.
7
7
  #
@@ -12,7 +12,7 @@ module RubyLLM
12
12
  # part.signed? # => true
13
13
  # part.to_s # => "call find_tasks (args 42 chars), signed"
14
14
  #
15
- class Part
15
+ class Part # :nodoc:
16
16
  include Support::Inspectable
17
17
 
18
18
  TEXT_KINDS = %i[text thinking].freeze
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- class RequestShape
4
+ class RequestShape # :nodoc: all
5
5
  # A Problem is something in a request that providers are known to
6
6
  # reject, found by the protocol that rendered it: a part with no data, a
7
7
  # tool round whose calls and results do not pair up, a step that lacks
@@ -12,7 +12,7 @@ module RubyLLM
12
12
  # problem.part # => 0
13
13
  # problem.to_s # => "problem at #3, part 0: thinking part carries no data"
14
14
  #
15
- class Problem
15
+ class Problem # :nodoc:
16
16
  include Support::Inspectable
17
17
 
18
18
  # What is wrong: +:part_without_data+, +:empty_turn+,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- class RequestShape
4
+ class RequestShape # :nodoc: all
5
5
  # A ToolRound describes a model turn that called tools and the results
6
6
  # the request sends back before the next model turn: how many of each,
7
7
  # and whether they pair up the way the provider matches them.
@@ -12,7 +12,7 @@ module RubyLLM
12
12
  # round.paired? # => false
13
13
  # round.to_s # => "tool round at #1: 2 calls, 1 result, not paired"
14
14
  #
15
- class ToolRound
15
+ class ToolRound # :nodoc:
16
16
  include Support::Inspectable
17
17
 
18
18
  # The index of the turn that made the calls.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- class RequestShape
4
+ class RequestShape # :nodoc: all
5
5
  # A Turn describes one entry of a request's conversation: a message, or
6
6
  # one item where the provider sends a model's reasoning and tool calls as
7
7
  # items of their own.
@@ -11,7 +11,7 @@ module RubyLLM
11
11
  # turn.role # => "user"
12
12
  # turn.to_s # => "#4 user: text (31 chars)"
13
13
  #
14
- class Turn
14
+ class Turn # :nodoc:
15
15
  include Support::Inspectable
16
16
 
17
17
  # The turn's 0-indexed position in the request, the position a
@@ -20,7 +20,7 @@ module RubyLLM
20
20
  # file data, or setting values beyond thinking settings, so you can log it
21
21
  # and send it to an error tracker. A long conversation keeps its first 10
22
22
  # and last 50 turns.
23
- class RequestShape
23
+ class RequestShape # :nodoc: all
24
24
  include Support::Inspectable
25
25
 
26
26
  # The provider's slug, such as <tt>"gemini"</tt>.