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,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Skill
5
+ # The +load_skill+ tool a chat with skills gives the model. It returns a
6
+ # skill's instructions, or one of the files the instructions mention,
7
+ # and never leaves the skill's folder. Every request also lists the
8
+ # skills, through #listing, so the model knows what it can load.
9
+ class Loader < Tool # :nodoc:
10
+ MAX_FILE_SIZE = 256 * 1024
11
+ MOUNT = 'skills'
12
+
13
+ def self.tool_name
14
+ 'load_skill'
15
+ end
16
+
17
+ def self.mount_path(skill)
18
+ "#{MOUNT}/#{skill.name}"
19
+ end
20
+
21
+ def self.resource_name(skill, file)
22
+ file = file.to_s
23
+ return if file.strip.empty? || file == FILE
24
+
25
+ file.delete_prefix("#{MOUNT}/#{skill}/")
26
+ end
27
+
28
+ def initialize(skills:, code_execution:)
29
+ super()
30
+ @skills = skills
31
+ @code_execution = code_execution
32
+ end
33
+
34
+ def description
35
+ "Loads a skill's instructions, or a file inside a skill's folder that its instructions mention. " \
36
+ 'Call it with only skill to get the instructions before following a skill, ' \
37
+ 'and with file, a path relative to the skill folder, to read one of its files.'
38
+ end
39
+
40
+ def parameters_schema
41
+ {
42
+ 'type' => 'object',
43
+ 'properties' => {
44
+ 'skill' => { 'type' => 'string', 'description' => 'The name of the skill' },
45
+ 'file' => { 'type' => 'string',
46
+ 'description' => 'A file path relative to the skill folder. Leave it out for the instructions' }
47
+ },
48
+ 'required' => %w[skill],
49
+ 'additionalProperties' => false
50
+ }
51
+ end
52
+
53
+ def skill?(name)
54
+ @skills.call.any? { |skill| skill.name == name }
55
+ end
56
+
57
+ def listing
58
+ [
59
+ 'You have skills: folders of instructions, scripts, and files for specific tasks. ' \
60
+ 'When a task matches a skill, call load_skill with its name and follow the instructions it ' \
61
+ 'returns. Load the files the instructions mention with load_skill as well, one at a time.',
62
+ scripts_note,
63
+ "<skills>\n#{@skills.call.map { |skill| "- #{skill.name}: #{skill.description}" }.join("\n")}\n</skills>"
64
+ ].join("\n\n")
65
+ end
66
+
67
+ def execute(skill:, file: nil)
68
+ found = @skills.call.find { |candidate| candidate.name == skill.to_s }
69
+ return { error: "No skill named #{skill}. Skills: #{@skills.call.map(&:name).join(', ')}" } unless found
70
+
71
+ resource = self.class.resource_name(found.name, file)
72
+ resource ? read(found, resource) : instructions(found)
73
+ end
74
+
75
+ private
76
+
77
+ def instructions(skill)
78
+ folder = if @code_execution.call
79
+ mount = self.class.mount_path(skill)
80
+ "Its folder is at #{mount}/ in run_code's working directory, read-only. Paths in these " \
81
+ "instructions are relative to it, so prefix them with #{mount}/. Run its scripts with " \
82
+ 'run_code from the working directory, and write output files to the working directory.'
83
+ else
84
+ 'Paths in these instructions are relative to the skill folder. This conversation cannot run ' \
85
+ "code, so the skill's scripts cannot run: follow the rest of the instructions, and say so " \
86
+ 'when a step needs a script.'
87
+ end
88
+ "# Skill: #{skill.name}\n\n#{folder}\n\n#{skill.instructions.strip}"
89
+ end
90
+
91
+ def read(skill, file)
92
+ path = skill.resolve_file(file)
93
+ return { error: "#{file} is a folder holding: #{folder_entries(path)}" } if File.directory?(path)
94
+ return { error: "#{file} is larger than #{MAX_FILE_SIZE} bytes. #{use_from_code(skill, file)}" } if
95
+ File.size(path) > MAX_FILE_SIZE
96
+
97
+ content = File.binread(path).force_encoding(Encoding::UTF_8)
98
+ return { error: "#{file} is not a text file. #{use_from_code(skill, file)}" } unless text?(content)
99
+
100
+ content
101
+ rescue ArgumentError => e
102
+ { error: e.message }
103
+ rescue SystemCallError
104
+ { error: "The #{skill.name} skill has no file #{file}" }
105
+ end
106
+
107
+ def folder_entries(path)
108
+ Dir.children(path).sort.map { |entry| File.directory?(File.join(path, entry)) ? "#{entry}/" : entry }.join(', ')
109
+ end
110
+
111
+ def text?(content)
112
+ content.valid_encoding? && !content.include?("\0")
113
+ end
114
+
115
+ def use_from_code(skill, file)
116
+ return 'This conversation cannot run code to use it.' unless @code_execution.call
117
+
118
+ "Use it from run_code at #{self.class.mount_path(skill)}/#{file}."
119
+ end
120
+
121
+ def scripts_note
122
+ if @code_execution.call
123
+ 'Each skill folder is mounted read-only in every run_code working directory at skills/<name>/, ' \
124
+ 'so run_code can run its scripts, such as python skills/<name>/scripts/example.py.'
125
+ else
126
+ 'This conversation cannot run code, so skill scripts cannot run. Follow the rest of their instructions.'
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'yaml'
5
+
6
+ module RubyLLM
7
+ # A Skill is a folder of instructions, scripts, and reference files in the
8
+ # open {Agent Skills}[https://agentskills.io/specification] format. Its
9
+ # +SKILL.md+ starts with YAML front matter naming and describing the
10
+ # skill, followed by the instructions in Markdown.
11
+ #
12
+ # Chat#with_skills gives skills to a chat. The model sees each skill's
13
+ # name and description, and loads the instructions and files of the ones
14
+ # a task needs.
15
+ #
16
+ # skill = RubyLLM::Skill.find(:invoices)
17
+ # skill.name # => "invoices"
18
+ # skill.description # => "Creates invoices as PDFs. Use when ..."
19
+ # skill.path # => #<Pathname:/app/app/skills/invoices>
20
+ class Skill
21
+ include Support::Inspectable
22
+
23
+ # The file every skill folder holds.
24
+ FILE = 'SKILL.md'
25
+
26
+ # Characters a name may have at most.
27
+ MAX_NAME = 64
28
+
29
+ # Characters a description may have at most.
30
+ MAX_DESCRIPTION = 1024
31
+
32
+ # Characters the compatibility field may have at most.
33
+ MAX_COMPATIBILITY = 500
34
+
35
+ FRONT_MATTER = /\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\z)/m
36
+ NAME_FORMAT = /\A[[:alnum:]]+(?:-[[:alnum:]]+)*\z/
37
+ private_constant :FRONT_MATTER, :NAME_FORMAT
38
+
39
+ # The skill's name, matching its folder name.
40
+ attr_reader :name
41
+
42
+ # What the skill does and when to use it.
43
+ attr_reader :description
44
+
45
+ # The license the front matter names, or +nil+.
46
+ attr_reader :license
47
+
48
+ # The environment requirements the front matter states, or +nil+.
49
+ attr_reader :compatibility
50
+
51
+ # The tools the front matter pre-approves, as an Array of Strings.
52
+ # RubyLLM reads the field but does not act on it.
53
+ attr_reader :allowed_tools
54
+
55
+ # The front matter's +metadata+, a Hash of Strings.
56
+ attr_reader :metadata
57
+
58
+ # The skill folder, as an absolute Pathname.
59
+ attr_reader :path
60
+
61
+ # The Markdown instructions that follow the front matter.
62
+ attr_reader :instructions
63
+
64
+ class << self
65
+ # Returns the ordered skill directories. Append engine directories
66
+ # with +<<+:
67
+ #
68
+ # initializer "my_engine.skills", before: "ruby_llm.skills" do
69
+ # RubyLLM::Skill.roots << MyEngine::Engine.root.join("app/skills")
70
+ # end
71
+ #
72
+ def roots
73
+ @roots ||= Prompt::Roots.new { root }
74
+ end
75
+
76
+ def root # :nodoc:
77
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
78
+ Rails.root.join('app/skills')
79
+ else
80
+ Pathname.new(Dir.pwd).join('app/skills')
81
+ end
82
+ end
83
+
84
+ # Returns every skill directly under the skill roots. A skill in an
85
+ # earlier root hides one with the same folder name in a later root.
86
+ # Raises InvalidSkillError when a skill's +SKILL.md+ is invalid.
87
+ def all
88
+ folders = roots.flat_map { |root| skill_folders(Pathname.new(root)) }
89
+ folders.uniq(&:basename).map { |folder| new(folder) }
90
+ end
91
+
92
+ # Returns the skill in the folder called +name+ under the first skill
93
+ # root that has one. Underscores in a Symbol stand for hyphens, so
94
+ # <tt>:pdf_processing</tt> finds +pdf-processing+. Raises
95
+ # SkillNotFoundError when no root has it.
96
+ #
97
+ # RubyLLM::Skill.find(:invoices)
98
+ #
99
+ def find(name)
100
+ folder = name.is_a?(Symbol) ? name.to_s.tr('_', '-') : name.to_s
101
+ found = (roots.map { |root| Pathname.new(root).join(folder) } if folder.match?(NAME_FORMAT))
102
+ &.find { |path| path.join(FILE).file? }
103
+ raise SkillNotFoundError, "No skill named #{folder} in #{roots.to_a.join(', ')}" unless found
104
+
105
+ new(found)
106
+ end
107
+
108
+ # Returns +true+, +false+, or the skill names and paths in
109
+ # +selection+, the arguments of Chat#with_skills. Raises
110
+ # ArgumentError for +nil+, and for +true+ or +false+ with names.
111
+ def selection(selection) # :nodoc:
112
+ raise ArgumentError, 'skills cannot be nil; pass false to remove them' if selection.include?(nil)
113
+ return true if selection.empty? || selection == [true]
114
+ return false if selection == [false]
115
+ raise ArgumentError, 'skills true and false take no skill names' if selection.intersect?([true, false])
116
+
117
+ selection.flatten.freeze
118
+ end
119
+
120
+ # Returns the skills +selection+ names: Symbols are folders under the
121
+ # skill roots, Strings and Pathnames are skill folders, and +true+ is
122
+ # every skill under the roots.
123
+ def resolve(selection) # :nodoc:
124
+ case selection
125
+ when true then all
126
+ when false then []
127
+ else selection.map { |entry| skill(entry) }
128
+ end
129
+ end
130
+
131
+ # Returns +current+ with +added+, refusing two skills with one name.
132
+ def merge(current, added) # :nodoc:
133
+ added.each_with_object(current.dup) do |skill, skills|
134
+ existing = skills.find { |other| other.name == skill.name }
135
+ next skills << skill unless existing
136
+ next if existing.path == skill.path
137
+
138
+ raise ArgumentError, "Two skills are named #{skill.name}: #{existing.path} and #{skill.path}"
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def skill(entry)
145
+ case entry
146
+ when Skill then entry
147
+ when Symbol then find(entry)
148
+ when String, Pathname then new(entry)
149
+ else raise ArgumentError, "skills are Symbols, paths, or RubyLLM::Skill objects, got #{entry.inspect}"
150
+ end
151
+ end
152
+
153
+ def skill_folders(root)
154
+ return [] unless root.directory?
155
+
156
+ root.children.select { |child| child.join(FILE).file? }.sort
157
+ end
158
+ end
159
+
160
+ # Reads the skill in the folder at +path+. Raises SkillNotFoundError
161
+ # when the folder has no +SKILL.md+, and InvalidSkillError when its
162
+ # front matter breaks the Agent Skills specification. Front matter keys
163
+ # the specification does not define are ignored.
164
+ #
165
+ # RubyLLM::Skill.new("vendor/skills/pdf")
166
+ #
167
+ def initialize(path)
168
+ @path = Pathname.new(File.realpath(path))
169
+ file = @path.join(FILE)
170
+ raise SkillNotFoundError, "#{file} not found" unless file.file?
171
+
172
+ content = file.read(encoding: 'UTF-8')
173
+ invalid('must be valid UTF-8') unless content.valid_encoding?
174
+
175
+ front_matter, @instructions = parse(content)
176
+ assign(front_matter)
177
+ rescue Errno::ENOENT, Errno::ENOTDIR
178
+ raise SkillNotFoundError, "No skill folder at #{path}"
179
+ end
180
+
181
+ # Returns the absolute path of the file at +relative+ inside the skill
182
+ # folder. Raises ArgumentError when +relative+ leaves the folder, even
183
+ # through a symbolic link, and Errno::ENOENT when nothing is there.
184
+ def resolve_file(relative) # :nodoc:
185
+ parts = relative.to_s.tr('\\', '/').split('/').reject { |part| part.empty? || part == '.' }
186
+ if relative.to_s.start_with?('/', '\\') || relative.to_s.match?(/\A[A-Za-z]:/) || parts.include?('..')
187
+ raise ArgumentError, "#{relative} is not a path inside the #{name} skill"
188
+ end
189
+
190
+ resolved = File.realpath(path.join(*parts))
191
+ inside = resolved == path.to_s || resolved.start_with?(File.join(path.to_s, ''))
192
+ raise ArgumentError, "#{relative} is not a path inside the #{name} skill" unless inside
193
+
194
+ resolved
195
+ end
196
+
197
+ def inspect_attributes # :nodoc:
198
+ { name:, description:, path: path.to_s }
199
+ end
200
+
201
+ private
202
+
203
+ def parse(content)
204
+ content = content.delete_prefix("\u{FEFF}")
205
+ match = FRONT_MATTER.match(content)
206
+ invalid('must start with YAML front matter between --- lines') unless match
207
+
208
+ front_matter = YAML.safe_load(match[1], permitted_classes: [Date, Time])
209
+ invalid('front matter must be a YAML mapping') unless front_matter.is_a?(Hash)
210
+ [front_matter.transform_keys(&:to_s), match.post_match]
211
+ rescue Psych::Exception => e
212
+ invalid("front matter is not valid YAML: #{e.message}")
213
+ end
214
+
215
+ def assign(front_matter)
216
+ @name = validated_name(front_matter['name'])
217
+ @description = required_text(front_matter, 'description', MAX_DESCRIPTION)
218
+ @license = optional_text(front_matter, 'license')
219
+ @compatibility = optional_text(front_matter, 'compatibility', MAX_COMPATIBILITY)
220
+ @allowed_tools = optional_text(front_matter, 'allowed-tools').to_s.split.freeze
221
+ @metadata = validated_metadata(front_matter['metadata'])
222
+ end
223
+
224
+ def validated_name(value)
225
+ name = required_text({ 'name' => value }, 'name', MAX_NAME).unicode_normalize(:nfkc)
226
+ unless name == name.downcase && name.match?(NAME_FORMAT)
227
+ invalid("name #{name.inspect} must be lowercase letters, digits, and single hyphens between them")
228
+ end
229
+ folder = path.basename.to_s.dup.force_encoding(Encoding::UTF_8).scrub.unicode_normalize(:nfkc)
230
+ invalid("name #{name.inspect} must match its folder name, #{folder}") unless name == folder
231
+
232
+ name
233
+ end
234
+
235
+ def required_text(front_matter, key, limit)
236
+ value = front_matter[key]
237
+ invalid("needs a #{key}") if value.nil? || (value.is_a?(String) && value.strip.empty?)
238
+
239
+ optional_text(front_matter, key, limit)
240
+ end
241
+
242
+ def optional_text(front_matter, key, limit = nil)
243
+ value = front_matter[key]
244
+ return if value.nil?
245
+
246
+ invalid("#{key} must be a string") unless value.is_a?(String)
247
+ invalid("#{key} must not be empty") if value.strip.empty?
248
+ invalid("#{key} must be at most #{limit} characters") if limit && value.length > limit
249
+ value.strip
250
+ end
251
+
252
+ def validated_metadata(value)
253
+ return {}.freeze if value.nil?
254
+
255
+ invalid('metadata must be a mapping of strings') unless value.is_a?(Hash)
256
+ value.to_h { |key, entry| [key.to_s, entry.to_s] }.freeze
257
+ end
258
+
259
+ def invalid(reason)
260
+ raise InvalidSkillError, "#{path.join(FILE)} #{reason}"
261
+ end
262
+ end
263
+ end
@@ -9,7 +9,9 @@ module RubyLLM
9
9
  #
10
10
  class Speech
11
11
  include Support::Inspectable
12
+ include Files::Saving
12
13
  include Accounting::Usage::Result
14
+ include Accounting::Usage::ModelInfo
13
15
 
14
16
  # Maps audio format names to their MIME types.
15
17
  MIME_TYPES = {
@@ -38,7 +40,7 @@ module RubyLLM
38
40
  attr_reader :mime_type
39
41
 
40
42
  def initialize(data:, model:, voice: nil, format: 'mp3', mime_type: nil, # :nodoc:
41
- input_tokens: nil, output_tokens: nil)
43
+ input_tokens: nil, output_tokens: nil, characters: nil)
42
44
  @data = data
43
45
  @model = model
44
46
  @voice = voice
@@ -46,6 +48,7 @@ module RubyLLM
46
48
  @mime_type = mime_type || MIME_TYPES.fetch(@format, "audio/#{@format}")
47
49
  @input_tokens = input_tokens
48
50
  @output_tokens = output_tokens
51
+ @characters = characters
49
52
  end
50
53
 
51
54
  # Generates speech for +input+ and returns a Speech holding the audio.
@@ -55,7 +58,7 @@ module RubyLLM
55
58
  # provider's request vocabulary, such as +instructions:+ and +speed:+
56
59
  # for OpenAI, and merges them into the request as-is.
57
60
  # +owner:+ attributes the usage to a record, such as a user, and wins
58
- # over RubyLLM.with_usage_owner.
61
+ # over RubyLLM.with_owner.
59
62
  #
60
63
  # speech = RubyLLM.speak "Hello, welcome to RubyLLM!"
61
64
  # speech.save "welcome.mp3"
@@ -86,42 +89,28 @@ module RubyLLM
86
89
  metadata: nil,
87
90
  owner: nil,
88
91
  &block)
89
- config = context&.config || RubyLLM.config
90
- model ||= config.default_speech_model
91
- model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
92
- config: config)
93
- empty_tokens = Tokens.new
94
-
95
- payload = {
96
- provider: provider_instance.slug,
97
- provider_class: provider_instance.name,
98
- model: model.id,
99
- model_info: model,
100
- input: input,
101
- voice: voice,
102
- format: format,
103
- provider_options: provider_options,
104
- metadata: metadata,
105
- streaming: !block.nil?,
106
- tokens: empty_tokens,
107
- cost: Cost.new(tokens: empty_tokens, model:, category: :audio_tokens)
108
- }
109
-
110
- RubyLLM.instrument('speech.ruby_llm', payload, config: config) do |event|
111
- result = Accounting::Usage.owned_by(owner) do
112
- provider_instance.speak(input, model:, voice:, format:, provider_options:, &block)
113
- end
114
- event[:result] = result
115
- event[:response_model] = result.model
116
- event[:voice] = result.voice
117
- event[:format] = result.format
118
- event[:audio_bytes] = result.to_blob.bytesize
119
- event[:tokens] = result.tokens
120
- event[:cost] = result.cost
121
- result
92
+ Accounting::Operation.new(
93
+ 'speech', model, default: :default_speech_model, category: :audio_tokens, provider:, assume_model_exists:,
94
+ context:, owner:,
95
+ details: lambda { |event, result|
96
+ event[:voice] = result.voice
97
+ event[:format] = result.format
98
+ event[:audio_bytes] = result.to_blob.bytesize
99
+ }
100
+ ).run(input:, voice:, format:, provider_options:, metadata:, streaming: !block.nil?) do |provider_instance, model|
101
+ provider_instance.speak(input, model:, voice:, format:, provider_options:, &block)
122
102
  end
123
103
  end
124
104
 
105
+ ##
106
+ # :method: save
107
+ # :call-seq:
108
+ # save(path)
109
+ #
110
+ # Writes the audio to +path+ in binary mode and returns +path+.
111
+ #
112
+ # speech.save "welcome.mp3"
113
+
125
114
  # Returns the raw audio bytes. Alias for #data, mirroring Image#to_blob.
126
115
  def to_blob
127
116
  data
@@ -132,7 +121,7 @@ module RubyLLM
132
121
  def tokens
133
122
  return ruby_llm_usage_tokens unless ruby_llm_usage_entries.empty?
134
123
 
135
- Tokens.new(input: @input_tokens, output: @output_tokens)
124
+ Tokens.new(input: @input_tokens, output: @output_tokens, characters: @characters)
136
125
  end
137
126
 
138
127
  # Returns the speech cost across every provider attempt.
@@ -142,23 +131,6 @@ module RubyLLM
142
131
  Cost.new(tokens:, model: model_info, category: :audio_tokens)
143
132
  end
144
133
 
145
- attr_writer :model_info # :nodoc:
146
-
147
- def model_info # :nodoc:
148
- @model_info ||= RubyLLM.models.find(model)
149
- rescue ModelNotFoundError
150
- nil
151
- end
152
-
153
- # Writes the audio to +path+ in binary mode and returns +path+.
154
- #
155
- # speech.save "welcome.mp3"
156
- #
157
- def save(path)
158
- File.binwrite(File.expand_path(path), to_blob)
159
- path
160
- end
161
-
162
134
  def inspect_attributes # :nodoc:
163
135
  { model: model, voice: voice, format: format, data: data && "#{data.bytesize} bytes" }
164
136
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # A file in a FileStore: its path, size, content type, and a digest of
5
+ # its content. FileStore#list and FileStore#find return them, and
6
+ # Chat#after_file_write callbacks receive each one the conversation
7
+ # writes.
8
+ #
9
+ # chat.file_store.list
10
+ # # => [#<RubyLLM::StoredFile path: "report.pdf", byte_size: 2201344, content_type: "application/pdf">]
11
+ #
12
+ class StoredFile
13
+ include Support::Inspectable
14
+
15
+ # The path of the file inside its store, relative and with forward
16
+ # slashes, such as <tt>"reports/q3.pdf"</tt>.
17
+ attr_reader :path
18
+
19
+ # The size of the file in bytes.
20
+ attr_reader :byte_size
21
+
22
+ # The MIME type of the file, such as <tt>"application/pdf"</tt>.
23
+ attr_reader :content_type
24
+
25
+ # A digest of the file's content that changes whenever the content
26
+ # does, or +nil+ when the store does not know one without reading the
27
+ # file. Stores in the box use SHA-256 in hex.
28
+ attr_reader :digest
29
+
30
+ # When the file was last written, or +nil+ when the store does not say.
31
+ attr_reader :updated_at
32
+
33
+ # Who wrote the file, for stores that keep one, or +nil+. RubyLLM
34
+ # stores it and hands it back without interpreting it.
35
+ attr_reader :owner
36
+
37
+ def initialize(path:, byte_size:, content_type:, digest: nil, updated_at: nil, owner: nil) # :nodoc:
38
+ @path = path
39
+ @byte_size = byte_size
40
+ @content_type = content_type
41
+ @digest = digest
42
+ @updated_at = updated_at
43
+ @owner = owner
44
+ end
45
+
46
+ # Returns the last part of #path.
47
+ #
48
+ # file.path # => "reports/q3.pdf"
49
+ # file.filename # => "q3.pdf"
50
+ #
51
+ def filename
52
+ File.basename(path)
53
+ end
54
+
55
+ # Returns whether the content is text, like source code, CSV, or
56
+ # Markdown, judged by its content type.
57
+ def text?
58
+ Files::MimeType.text?(content_type)
59
+ end
60
+
61
+ # Returns the file's size for people and models to read, such as
62
+ # <tt>"2.1 MB"</tt>.
63
+ def human_size
64
+ units = %w[bytes KB MB GB TB]
65
+ size = byte_size.to_f
66
+ exponent = size.zero? ? 0 : [(Math.log(size) / Math.log(1024)).floor, units.size - 1].min
67
+ return "#{byte_size} #{byte_size == 1 ? 'byte' : 'bytes'}" if exponent.zero?
68
+
69
+ "#{(size / (1024**exponent)).round(1)} #{units[exponent]}"
70
+ end
71
+
72
+ # Returns whether +other+ is a StoredFile with the same path, size,
73
+ # content type, and digest.
74
+ def ==(other)
75
+ other.is_a?(StoredFile) && other.to_h.except(:updated_at, :owner) == to_h.except(:updated_at, :owner)
76
+ end
77
+ alias eql? ==
78
+
79
+ def hash # :nodoc:
80
+ to_h.except(:updated_at, :owner).hash
81
+ end
82
+
83
+ # Returns the attributes as a Hash.
84
+ def to_h
85
+ { path:, byte_size:, content_type:, digest:, updated_at:, owner: }
86
+ end
87
+
88
+ private
89
+
90
+ def inspect_attributes
91
+ { path:, byte_size:, content_type: }
92
+ end
93
+ end
94
+ end
@@ -13,15 +13,15 @@ module RubyLLM
13
13
  module_function
14
14
 
15
15
  def watch(checkpoint)
16
- previous = Thread.current[KEY]
17
- Thread.current[KEY] = checkpoint
16
+ previous = Fiber[KEY]
17
+ Fiber[KEY] = checkpoint
18
18
  yield
19
19
  ensure
20
- Thread.current[KEY] = previous
20
+ Fiber[KEY] = previous
21
21
  end
22
22
 
23
23
  def check
24
- Thread.current[KEY]&.call
24
+ Fiber[KEY]&.call
25
25
  end
26
26
 
27
27
  def pause(seconds)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Support
5
+ # Keeps chats, agents, and <tt>acts_as_chat</tt> records Enumerable over
6
+ # their messages through RubyLLM 2.x. Their +each+ is deprecated, and
7
+ # the Enumerable methods built on it with it: iterate +messages+
8
+ # instead. RubyLLM 3.0 removes them.
9
+ module DeprecatedEnumerable # :nodoc:
10
+ include Enumerable
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Support
5
+ module DeprecatedJobPredicates # :nodoc:
6
+ def done?
7
+ warn_about_predicate(:done?, :complete?)
8
+ complete?
9
+ end
10
+
11
+ def completed?
12
+ warn_about_predicate(:completed?, :succeeded?)
13
+ succeeded?
14
+ end
15
+
16
+ private
17
+
18
+ def warn_about_predicate(name, replacement)
19
+ RubyLLM.deprecator.deprecate("#{self.class}##{name}", replacement: "#{self.class}##{replacement}",
20
+ removal: '3.0')
21
+ end
22
+ end
23
+ end
24
+ end
@@ -17,7 +17,7 @@ module RubyLLM
17
17
  attributes = inspect_attributes
18
18
  .reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
19
19
  .map { |name, value| "#{name}: #{format_for_inspect(value)}" }
20
- class_name = self.class.ancestors.find(&:name).name
20
+ class_name = self.class.ancestors.grep(Class).find(&:name).name
21
21
  return "#<#{class_name}>" if attributes.empty?
22
22
 
23
23
  "#<#{class_name} #{attributes.join(', ')}>"