ruby_llm 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -7,10 +7,10 @@ module RubyLLM
7
7
  class TitanMultimodalEmbeddings < InvokeModel
8
8
  # rubocop:disable-next Lint/UnusedMethodArgument
9
9
  def embed(text, model:, dimensions:, task_type: nil, title: nil, with: nil, provider_options: {})
10
- ensure_no_embedding_media!(with)
10
+ image = embedding_image(embedding_attachments(text, with))
11
11
  track_usage(:embedding) do
12
12
  responses = [text].flatten.map do |value|
13
- payload = render_embedding_payload(value, dimensions:, provider_options:)
13
+ payload = render_embedding_payload(value, dimensions:, image:, provider_options:)
14
14
  signed_post(embedding_url(model:), payload).tap { |response| record_embedding_attempt(response) }
15
15
  end
16
16
 
@@ -20,9 +20,17 @@ module RubyLLM
20
20
 
21
21
  private
22
22
 
23
- def render_embedding_payload(text, dimensions:, provider_options:, **)
23
+ def embedding_image(attachments)
24
+ attachment = single_embedding_attachment(attachments, 'Titan Multimodal Embeddings')
25
+ raise UnsupportedAttachmentError, attachment.mime_type if attachment && !attachment.image?
26
+
27
+ attachment
28
+ end
29
+
30
+ def render_embedding_payload(text, dimensions:, provider_options:, image: nil, **)
24
31
  payload = {}
25
32
  payload[:inputText] = text.to_s unless text.nil? || text.to_s.empty?
33
+ payload[:inputImage] = image.encoded if image
26
34
  payload[:embeddingConfig] = { outputEmbeddingLength: dimensions } if dimensions
27
35
 
28
36
  deep_merge_provider_options(payload, provider_options)
@@ -7,6 +7,10 @@ module RubyLLM
7
7
  # Protocols::InvokeModel implement one family each. Requests are signed
8
8
  # by the provider, which knows the credentials and the signing service.
9
9
  class InvokeModel < Protocol
10
+ def rejected_options(operation)
11
+ operation == :embed ? %i[task_type title] : super
12
+ end
13
+
10
14
  private
11
15
 
12
16
  def signed_post(url, payload, additional_headers = {})
@@ -24,8 +28,23 @@ module RubyLLM
24
28
  raise UnsupportedAttachmentError, attachments.first.mime_type if attachments.any?
25
29
  end
26
30
 
31
+ def embedding_attachments(text, with)
32
+ attachments = Attachment.wrap(with, config: @config)
33
+ if attachments.any? && text.is_a?(Array)
34
+ raise ArgumentError, 'embed one text at a time when embedding attachments'
35
+ end
36
+
37
+ attachments
38
+ end
39
+
40
+ def single_embedding_attachment(attachments, label)
41
+ raise ArgumentError, "#{label} embeds one attachment at a time" if attachments.size > 1
42
+
43
+ attachments.first
44
+ end
45
+
27
46
  def parse_single_embedding_responses(responses, model:, text:)
28
- vectors = responses.map { |response| extract_embedding(response.body) }
47
+ vectors = responses.map { |response| parse_embedding(response.body) }
29
48
  input_tokens = Tokens.aggregate(embedding_attempt_tokens(responses)).input
30
49
  vectors = vectors.first unless text.is_a?(Array)
31
50
 
@@ -46,7 +65,7 @@ module RubyLLM
46
65
  Support::Utils.deep_merge(payload, provider_options)
47
66
  end
48
67
 
49
- def extract_embedding(body)
68
+ def parse_embedding(body)
50
69
  body['embedding'] || body.dig('embeddingsByType', 'float') || body['embeddingsByType']&.values&.first
51
70
  end
52
71
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Meta
6
+ module Transcription
7
+ # Assembles a transcript from Meta's voice events. Without turns the
8
+ # transcript arrives whole in the final transcript event. With turns
9
+ # each speechComplete carries a turn's final text, and the transcript,
10
+ # speaker, and timing events between speechStart and it belong to the
11
+ # most recently opened turn. Turns can overlap and finish out of order,
12
+ # so each is held until every turn opened before it has finished.
13
+ class Stream # :nodoc:
14
+ def initialize(model:, &block)
15
+ @model = model
16
+ @block = block
17
+ @turns = {}
18
+ @segments = []
19
+ @latest_ms = 0
20
+ end
21
+
22
+ def process(event)
23
+ @latest_ms = [@latest_ms, event['audioProcessedMs'].to_i].max
24
+ @last_event = event
25
+ case event['type']
26
+ when 'transcript' then process_transcript(event)
27
+ when 'speechStart' then open_turn(event)
28
+ when 'speaker' then @turns[@current_turn]&.store(:speaker, event['label'])
29
+ when 'speechEnd' then @turns[event['turnId']]&.store(:end_ms, event['audioProcessedMs'])
30
+ when 'speechComplete' then complete_turn(event)
31
+ when 'error' then raise Error, event['message'] || 'Meta transcription failed'
32
+ end
33
+ end
34
+
35
+ def finish
36
+ raise Error, 'Meta transcription ended before its final transcript' unless complete?
37
+
38
+ duration = @latest_ms / 1000.0
39
+ result = RubyLLM::Transcription.new(
40
+ text: @turns.empty? ? @final_text : @segments.map { |segment| segment['text'] }.join(' '),
41
+ model: @model, duration:, audio_seconds: duration, segments: @segments.empty? ? nil : @segments
42
+ )
43
+ @block.call(TranscriptionChunk.new(type: TranscriptionChunk::DONE, text: result.text, raw: @last_event))
44
+ result
45
+ end
46
+
47
+ private
48
+
49
+ def process_transcript(event)
50
+ text = event['transcript'].to_s.strip
51
+ return finish_transcript(text, event) if event['final']
52
+ return if text.empty?
53
+
54
+ @block.call(TranscriptionChunk.new(type: TranscriptionChunk::PARTIAL, text:, raw: event))
55
+ end
56
+
57
+ def finish_transcript(text, event)
58
+ @final_text = text
59
+ return if text.empty? || @turns.any?
60
+
61
+ @block.call(TranscriptionChunk.new(type: TranscriptionChunk::DELTA, delta: text, raw: event))
62
+ end
63
+
64
+ def open_turn(event)
65
+ @current_turn = event['turnId']
66
+ @turns[@current_turn] = { start_ms: event['audioProcessedMs'] }
67
+ end
68
+
69
+ def complete_turn(event)
70
+ turn = @turns[event['turnId']] ||= {}
71
+ text = event['transcript'].to_s.strip
72
+ segment = text.empty? ? nil : turn_segment(event['turnId'], text, turn, event)
73
+ turn.merge!(complete: true, segment:, event:)
74
+ emit_completed_turns
75
+ end
76
+
77
+ def emit_completed_turns
78
+ @turns.each_value do |turn|
79
+ break unless turn[:complete]
80
+
81
+ emit_turn(turn) unless turn[:emitted]
82
+ end
83
+ end
84
+
85
+ def emit_turn(turn)
86
+ turn[:emitted] = true
87
+ segment = turn[:segment]
88
+ return unless segment
89
+
90
+ delta = @segments.empty? ? segment['text'] : " #{segment['text']}"
91
+ @segments << segment
92
+ @block.call(TranscriptionChunk.new(type: TranscriptionChunk::SEGMENT, delta:, segment:, raw: turn[:event]))
93
+ end
94
+
95
+ def turn_segment(turn_id, text, turn, event)
96
+ end_ms = turn[:end_ms] || event['audioProcessedMs']
97
+ { 'text' => text, 'turnId' => turn_id, 'start' => turn[:start_ms] && (turn[:start_ms] / 1000.0),
98
+ 'end' => end_ms && (end_ms / 1000.0), 'speaker' => turn[:speaker] }.compact
99
+ end
100
+
101
+ def complete?
102
+ @turns.values.all? { |turn| turn[:complete] } && (!@final_text.nil? || @segments.any?)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Meta
6
+ # Muse Voice Transcribe on /v1/asr/transcribe. The settings travel as a
7
+ # JSON multipart part beside the WAV audio. The mode decides what comes
8
+ # back: one transcript by default, timed turns with ENDPOINTING, and
9
+ # turns labelled with a speaker with DIARIZATION. Accepting an event
10
+ # stream returns the same events as Meta's realtime endpoint.
11
+ # https://dev.meta.ai/docs/speech-to-text
12
+ module Transcription
13
+ STREAM_HEADERS = { 'Accept' => 'text/event-stream' }.freeze
14
+
15
+ def rejected_options(operation)
16
+ operation == :transcribe ? %i[format temperature speaker_references named_speakers] : super
17
+ end
18
+
19
+ def render_transcription_options(timestamps:, streaming:, **)
20
+ options = streaming ? { emitAudioProgress: false } : {}
21
+ return options if timestamps.nil?
22
+ unless Array(timestamps).map(&:to_s) == ['segment']
23
+ raise ArgumentError, 'Meta transcription timestamps must be segment'
24
+ end
25
+
26
+ options.merge(mode: 'ENDPOINTING')
27
+ end
28
+
29
+ def transcription_url
30
+ 'asr/transcribe'
31
+ end
32
+
33
+ def render_transcription_payload(file_part, model:, language:, speaker_names: nil, provider_options: {},
34
+ prompt: nil, **)
35
+ settings = { model:, audioEncoding: 'WAV', languageBias: language && Array(language),
36
+ keywords: prompt && Array(prompt) }.compact
37
+ settings = settings.merge(provider_options.transform_keys(&:to_sym))
38
+ settings[:mode] = 'DIARIZATION' if speaker_names
39
+ { request: Faraday::Multipart::ParamPart.new(JSON.generate(settings), 'application/json'),
40
+ audio: render_transcription_audio(file_part) }
41
+ end
42
+
43
+ def parse_transcription_response(response, model:)
44
+ data = response.body
45
+ duration = data['audioDurationMs'] && (data['audioDurationMs'] / 1000.0)
46
+ turns = data['turns'].to_a
47
+
48
+ RubyLLM::Transcription.new(
49
+ text: data['transcript'], model:, duration:, audio_seconds: duration,
50
+ segments: turns.empty? ? nil : turns.map { |turn| parse_transcription_turn(turn) }
51
+ )
52
+ end
53
+
54
+ def stream_transcription(payload, model:, &)
55
+ stream = Stream.new(model:, &)
56
+ stream_events(transcription_url, payload, STREAM_HEADERS) { |event| stream.process(event) }
57
+ stream.finish
58
+ end
59
+
60
+ private
61
+
62
+ # Meta answers "Malformed multipart body" to a part with its own
63
+ # Content-Length, which a file part always carries.
64
+ def render_transcription_audio(file_part)
65
+ audio = file_part.io.read
66
+ file_part.io.close
67
+ Faraday::Multipart::ParamPart.new(audio, file_part.content_type)
68
+ end
69
+
70
+ def parse_transcription_turn(turn)
71
+ turn.merge('text' => turn['transcript'], 'start' => turn['startMs'] / 1000.0, 'end' => turn['endMs'] / 1000.0)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class MiniMax
6
+ # MiniMax's Files API, which requires a purpose on every upload.
7
+ class Files < Protocols::Files
8
+ INPUT_PURPOSES = %w[voice_clone prompt_audio t2a_async_input video_understanding video_generation_input].freeze
9
+
10
+ def rejected_options(operation)
11
+ operation == :upload ? %i[uri expires_in] : super
12
+ end
13
+
14
+ private
15
+
16
+ def files_url
17
+ media_url('v1/files/upload')
18
+ end
19
+
20
+ def file_info_url(file_id)
21
+ media_url("v1/files/retrieve?file_id=#{URI.encode_uri_component(file_id.to_s)}")
22
+ end
23
+
24
+ def download_file_url(file_id)
25
+ media_url("v1/files/retrieve_content?file_id=#{URI.encode_uri_component(file_id.to_s)}")
26
+ end
27
+
28
+ def media_url(path)
29
+ "#{@provider.media_base}/#{path}"
30
+ end
31
+
32
+ def render_upload_fields(_attachment, purpose: nil, **)
33
+ { purpose: }
34
+ end
35
+
36
+ def parse_upload_response(data, attachment)
37
+ parse_file_response(data, mime_type: file_content_type(attachment))
38
+ end
39
+
40
+ def parse_file_response(data, mime_type: nil)
41
+ file = data['file']
42
+ raise Error, 'MiniMax did not return a file' unless file
43
+
44
+ uploaded_file(
45
+ data,
46
+ id: file['file_id'].to_s,
47
+ filename: file['filename'],
48
+ byte_size: file['bytes'],
49
+ created_at: timestamp(file['created_at']),
50
+ purpose: file['purpose'],
51
+ downloadable: !INPUT_PURPOSES.include?(file['purpose']),
52
+ mime_type:
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class MiniMax
6
+ # Image generation on v1/image_generation. Source images become
7
+ # subject references, which keep a person's likeness in a new scene;
8
+ # there is no mask.
9
+ module Images
10
+ def images_url(**)
11
+ media_url('v1/image_generation')
12
+ end
13
+
14
+ def render_image_payload(prompt, model:, size: nil, count: nil, with: nil, provider_options: {}, **)
15
+ payload = { model: model, prompt: prompt, n: count, response_format: 'base64' }.compact
16
+ payload.merge!(image_dimensions(size))
17
+ references = subject_references(with)
18
+ payload[:subject_reference] = references unless references.empty?
19
+ payload.merge(provider_options)
20
+ end
21
+
22
+ def parse_image_responses(response, model:)
23
+ data = response.body['data'] || {}
24
+ sources = Array(data['image_base64']).map { |image| { data: image } } +
25
+ Array(data['image_urls']).map { |url| { url: } }
26
+ raise Error.new('MiniMax did not return an image', response:) if sources.empty?
27
+
28
+ sources.map { |source| Image.new(**source, mime_type: 'image/jpeg', model:) }
29
+ end
30
+
31
+ private
32
+
33
+ def validate_paint_inputs!(mask:, **)
34
+ raise UnsupportedAttachmentError, 'image mask' if mask
35
+ end
36
+
37
+ def image_dimensions(size)
38
+ return {} unless size
39
+
40
+ dimensions = size.to_s.match(/\A(\d+)x(\d+)\z/)
41
+ return { aspect_ratio: size.to_s } unless dimensions
42
+
43
+ { width: dimensions[1].to_i, height: dimensions[2].to_i }
44
+ end
45
+
46
+ def subject_references(sources)
47
+ Attachment.wrap(sources, config: @config).map do |attachment|
48
+ { type: 'character', image_file: attachment.url_or_data_uri }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class MiniMax
6
+ # Synchronous text to speech on v1/t2a_v2, which returns the audio
7
+ # hex-encoded inside a JSON body.
8
+ module Speech
9
+ DEFAULT_VOICE = 'English_expressive_narrator'
10
+ DEFAULT_FORMAT = 'mp3'
11
+
12
+ def speech_url(**)
13
+ media_url('v1/t2a_v2')
14
+ end
15
+
16
+ def render_speech_payload(input, model:, voice: nil, format: nil, provider_options: {})
17
+ payload = {
18
+ model: model,
19
+ text: input,
20
+ stream: false,
21
+ output_format: 'hex',
22
+ voice_setting: { voice_id: voice || DEFAULT_VOICE },
23
+ audio_setting: { format: (format || DEFAULT_FORMAT).to_s }
24
+ }
25
+ Support::Utils.deep_merge(payload, provider_options).tap do |rendered|
26
+ @effective_voice = Support::Utils.deep_symbolize_keys(rendered).dig(:voice_setting, :voice_id)
27
+ end
28
+ end
29
+
30
+ def parse_speech_response(response, model:, voice:, format:)
31
+ audio = response.body.dig('data', 'audio')
32
+ raise Error.new('MiniMax did not return audio', response:) unless audio
33
+
34
+ RubyLLM::Speech.new(
35
+ data: decode_speech_audio(audio),
36
+ model: model,
37
+ voice: @effective_voice || voice || DEFAULT_VOICE,
38
+ format: response.body.dig('extra_info', 'audio_format') || (format || DEFAULT_FORMAT).to_s,
39
+ characters: response.body.dig('extra_info', 'usage_characters')
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def decode_speech_audio(audio)
46
+ return [audio].pack('H*') unless audio.start_with?('http')
47
+
48
+ Transport::Connection.download(audio, @config)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class MiniMax
6
+ # Speech to text on v1/speech_to_text. The language hint travels as a
7
+ # request header, and verbose_json is the format that carries timed,
8
+ # speaker-labelled segments.
9
+ module Transcription
10
+ TIMESTAMP_LEVELS = { segment: nil, word: 'word' }.freeze
11
+
12
+ def rejected_options(operation)
13
+ operation == :transcribe ? %i[temperature prompt speaker_references named_speakers] : super
14
+ end
15
+
16
+ def render_transcription_options(timestamps:, format: nil, **)
17
+ return {} if timestamps.nil?
18
+
19
+ level = timestamps.respond_to?(:to_sym) ? timestamps.to_sym : timestamps
20
+ unless TIMESTAMP_LEVELS.key?(level)
21
+ raise ArgumentError, 'MiniMax transcription timestamps must be word or segment'
22
+ end
23
+
24
+ { response_format: format || 'verbose_json', timestamp_level: TIMESTAMP_LEVELS[level] }.compact
25
+ end
26
+
27
+ def transcription_url
28
+ media_url('v1/speech_to_text')
29
+ end
30
+
31
+ def transcribe(audio_file, model:, language:, format: nil, speaker_names: nil,
32
+ provider_options: {}, **, &block)
33
+ headers = language ? { 'language' => language.to_s } : {}
34
+ track_usage(:transcription) do
35
+ payload = render_transcription_payload(build_audio_file_part(audio_file), model:, format:, speaker_names:,
36
+ provider_options:)
37
+ next stream_transcription(payload, model:, language:, headers:, &block) if block
38
+
39
+ response = @connection.post(transcription_url, payload, usage: @usage_tracker) do |request|
40
+ request.headers.merge!(headers)
41
+ end
42
+ parse_transcription_response(response, model:, language:, word_level: payload[:timestamp_level] == 'word')
43
+ end
44
+ end
45
+
46
+ def render_transcription_payload(file_part, model:, format: nil, speaker_names: nil, provider_options: {}, **)
47
+ payload = { model: model, response_format: format || ('verbose_json' if speaker_names) }.compact
48
+ payload.merge(provider_options.transform_keys(&:to_sym)).except(:file).merge(file: file_part)
49
+ end
50
+
51
+ def parse_transcription_response(response, model:, language: nil, word_level: false)
52
+ data = response.body
53
+ return RubyLLM::Transcription.new(text: data, model:, language:) if data.is_a?(String)
54
+
55
+ RubyLLM::Transcription.new(
56
+ text: data['text'], model:, language:, duration: data['duration'], audio_seconds: data['duration'],
57
+ segments: (data['segments'] unless word_level), words: (data['segments'] if word_level)
58
+ )
59
+ end
60
+
61
+ def stream_transcription(payload, model:, language: nil, headers: {}, &block)
62
+ text = +''
63
+ duration = nil
64
+ stream_events(transcription_url, payload.merge(stream: 'true'), headers) do |event|
65
+ delta = event['delta'].to_s
66
+ text << delta
67
+ duration = event['duration'] || duration
68
+ block.call(transcription_chunk(:delta, delta:, raw: event)) unless delta.empty?
69
+ block.call(transcription_chunk(:done, text: text.dup, raw: event)) if event['finish']
70
+ end
71
+ RubyLLM::Transcription.new(text:, model:, language:, duration:, audio_seconds: duration)
72
+ end
73
+
74
+ private
75
+
76
+ def transcription_chunk(type, **attributes)
77
+ RubyLLM::TranscriptionChunk.new(type:, **attributes)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class MiniMax
6
+ # Asynchronous video generation on v2/video_generation. The endpoint
7
+ # requires a resolution and duration, and a ratio for text-only
8
+ # requests, so those are defaulted to values every MiniMax-H3 variant
9
+ # takes. A single image is the first frame; other attachments are
10
+ # references.
11
+ module Videos
12
+ DEFAULT_SETTINGS = { resolution: '768P', duration: 5 }.freeze
13
+ DEFAULT_RATIO = '16:9'
14
+ UNTYPED_MIME_TYPES = [nil, 'application/octet-stream'].freeze
15
+ REFERENCE_KINDS = { image: :image_url, video: :video_url, audio: :audio_url }.freeze
16
+
17
+ def video_url
18
+ media_url('v2/video_generation')
19
+ end
20
+
21
+ def video_job_url(job)
22
+ media_url("v2/query/video_generation/#{job.id}")
23
+ end
24
+
25
+ def render_video_payload(prompt, model:, with: [], provider_options: {})
26
+ payload = { model: model, content: [{ type: 'text', text: prompt }] + video_references(with) }
27
+ payload.merge!(DEFAULT_SETTINGS)
28
+ payload[:ratio] = DEFAULT_RATIO if with.empty?
29
+ Support::Utils.deep_merge(payload, provider_options)
30
+ end
31
+
32
+ def parse_video_job(response, model:, **request)
33
+ id = response.body['task_id']
34
+ raise Error.new('MiniMax did not return a video task id', response:) unless id
35
+
36
+ VideoJob.new(id: id.to_s, protocol: self, model: model, raw: response.body, **request)
37
+ end
38
+
39
+ def parse_video_job_status(response, **)
40
+ task = response.body['task']
41
+ raise Error.new('MiniMax did not return a video task', response:) unless task
42
+
43
+ state = case task['status']
44
+ when 'succeeded' then { status: :completed }
45
+ when 'failed' then { status: :failed, error: task.dig('error', 'message') || 'failed' }
46
+ else { status: :pending }
47
+ end
48
+ state.merge(raw: response.body)
49
+ end
50
+
51
+ def download_video(job)
52
+ task = job.raw['task'] || {}
53
+ Video.new(url: task.dig('content', 'url'), mime_type: 'video/mp4', duration: task['duration'],
54
+ model: task['model'] || job.model, raw: job.raw)
55
+ end
56
+
57
+ private
58
+
59
+ def validate_animate_inputs!(with:)
60
+ with.each do |attachment|
61
+ next if reference_kind(attachment) || UNTYPED_MIME_TYPES.include?(attachment.mime_type)
62
+
63
+ raise UnsupportedAttachmentError, attachment.mime_type
64
+ end
65
+ end
66
+
67
+ def video_references(attachments)
68
+ first_frame = attachments.one? && reference_kind_or_image(attachments.first) == :image
69
+ attachments.map do |attachment|
70
+ kind = reference_kind_or_image(attachment)
71
+ part = REFERENCE_KINDS.fetch(kind)
72
+ { type: part.to_s, part => { url: video_reference_url(attachment) },
73
+ role: first_frame ? 'first_frame' : "reference_#{kind}" }
74
+ end
75
+ end
76
+
77
+ def reference_kind(attachment)
78
+ %i[image video audio].find { |kind| attachment.public_send(:"#{kind}?") }
79
+ end
80
+
81
+ def reference_kind_or_image(attachment)
82
+ reference_kind(attachment) || :image
83
+ end
84
+
85
+ def video_reference_url(attachment)
86
+ return "mm_file://#{attachment.provider_file_id}" if attachment.provider_file?
87
+
88
+ attachment.url_or_data_uri
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ # MiniMax's own media API at the root of its host, beside the
6
+ # Anthropic-compatible chat endpoint: images, videos, speech, and
7
+ # transcription.
8
+ class MiniMax < Protocol
9
+ include MiniMax::Images
10
+ include MiniMax::Videos
11
+ include MiniMax::Speech
12
+ include MiniMax::Transcription
13
+
14
+ private
15
+
16
+ def media_url(path)
17
+ "#{@provider.media_base}/#{path}"
18
+ end
19
+ end
20
+ end
21
+ end