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
@@ -7,11 +7,14 @@ module RubyLLM
7
7
  # operations. The finished video is a Files API URI that requires the
8
8
  # API key to download.
9
9
  module Videos
10
+ OPERATION_NAME = %r{\A(?:[\w-][\w.-]*/)*operations/[\w-][\w.-]*\z}
11
+ private_constant :OPERATION_NAME
12
+
10
13
  def video_url
11
14
  "models/#{model_id(@model)}:predictLongRunning"
12
15
  end
13
16
 
14
- def render_video_payload(prompt, model:, with: [], provider_options: {}) # rubocop:disable Lint/UnusedMethodArgument
17
+ def render_video_payload(prompt, with: [], provider_options: {}, **)
15
18
  instance = { prompt: prompt }
16
19
  instance[:image] = render_video_image(with.first) if with.first
17
20
 
@@ -36,10 +39,10 @@ module RubyLLM
36
39
  end
37
40
 
38
41
  def video_job_url(job)
39
- job.id
42
+ video_operation_name(job)
40
43
  end
41
44
 
42
- def parse_video_job_status(response, job:) # rubocop:disable Lint/UnusedMethodArgument
45
+ def parse_video_job_status(response, **)
43
46
  body = response.body
44
47
  if body['error']
45
48
  { status: :failed, raw: body, error: body.dig('error', 'message') }
@@ -68,6 +71,14 @@ module RubyLLM
68
71
 
69
72
  private
70
73
 
74
+ # The name is the URL, so a job id from outside must not be able to
75
+ # send the API key to another host.
76
+ def video_operation_name(job)
77
+ return job.id if job.id.match?(OPERATION_NAME)
78
+
79
+ raise ArgumentError, "#{job.id.inspect} is not a video operation name"
80
+ end
81
+
71
82
  def render_video_extension(source)
72
83
  if source.is_a?(Video) && source.raw && (video = generated_video(source.raw)) && video['uri']
73
84
  return { uri: video['uri'] }
@@ -16,6 +16,7 @@ module RubyLLM
16
16
  include Gemini::Tools
17
17
  include Gemini::Speech
18
18
  include Gemini::Transcription
19
+ include Gemini::LongDecode
19
20
 
20
21
  # Gemini nests each tool's options inside its key, so aliases are
21
22
  # lambdas placing the options there.
@@ -28,6 +29,21 @@ module RubyLLM
28
29
  web_fetch: ->(options) { { tool: { url_context: Support::Utils.deep_symbolize_keys(options) } } }
29
30
  ).freeze
30
31
 
32
+ # Search results arrive as Google's redirect links. A page the model
33
+ # chose to read through url_context arrives in the same list under its
34
+ # own address, and need not be a result. That rules out the links to
35
+ # Google's own results page too, whose query the model could choose.
36
+ SEARCH_RESULT_LINK = 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/'
37
+
38
+ # Returns the pages the message's searches returned, as WebSource objects.
39
+ def self.parse_web_sources(message)
40
+ searches = message.server_tool_calls.select { |call| call.type == 'google_search' }
41
+ pages = searches.flat_map { |call| Array(call.result) }.map { |chunk| chunk['web'] }
42
+ pages.select { |page| page['uri'].to_s.start_with?(SEARCH_RESULT_LINK) }.map do |page|
43
+ WebSource.new(url: page['uri'], title: page['title'])
44
+ end
45
+ end
46
+
31
47
  def server_tool_aliases
32
48
  SERVER_TOOL_ALIASES
33
49
  end
@@ -40,12 +40,12 @@ module RubyLLM
40
40
  super(payload, merge_mcp_filters(tools))
41
41
  end
42
42
 
43
- def format_assistant_items(message)
43
+ def render_assistant_items(message)
44
44
  super.flat_map do |item|
45
45
  data = Support::Utils.deep_symbolize_keys(item)
46
46
  next item unless data[:type] && !Protocols::Responses::Chat::CLIENT_OUTPUT_ITEM_TYPES.include?(data[:type])
47
47
 
48
- format_server_tool_history(data)
48
+ render_server_tool_history(data)
49
49
  end
50
50
  end
51
51
 
@@ -81,7 +81,7 @@ module RubyLLM
81
81
  existing[:allowed_tools] |= tool[:allowed_tools]
82
82
  end
83
83
 
84
- def format_server_tool_history(item)
84
+ def render_server_tool_history(item)
85
85
  if item[:type] == 'mcp_call' && item[:name] && item[:id] && !item[:output].nil?
86
86
  output = item[:output].is_a?(String) ? item[:output] : JSON.generate(item[:output])
87
87
  [{ type: 'function_call', call_id: item[:id], name: item[:name], arguments: item[:arguments] },
@@ -5,6 +5,8 @@ module RubyLLM
5
5
  module GPUStack
6
6
  # vLLM's text tokenizer through GPUStack's model proxy.
7
7
  module Tokenization
8
+ COUNT_TOKENS_KEYS = %i[model messages tools].freeze
9
+
8
10
  def tokenization_url
9
11
  "#{@provider.backend_api_base}/tokenize"
10
12
  end
@@ -16,6 +18,18 @@ module RubyLLM
16
18
  def parse_tokenization_response(response, model:)
17
19
  RubyLLM::Tokenization.new(ids: response.body.fetch('tokens'), model: model, raw: response.body)
18
20
  end
21
+
22
+ def count_tokens_url
23
+ tokenization_url
24
+ end
25
+
26
+ def render_count_tokens_payload(messages, **)
27
+ render_payload(messages, temperature: nil, **).slice(*COUNT_TOKENS_KEYS)
28
+ end
29
+
30
+ def parse_count_tokens_response(response)
31
+ response.body.fetch('count')
32
+ end
19
33
  end
20
34
  end
21
35
  end
@@ -16,13 +16,14 @@ module RubyLLM
16
16
  end
17
17
 
18
18
  def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
19
- schema: nil, thinking: nil, tool_prefs: nil, **)
19
+ schema: nil, thinking: nil, caching: nil, tool_prefs: nil, **)
20
+ ensure_interaction_carries(messages, caching)
20
21
  config = { temperature: temperature, max_output_tokens: max_output_tokens }.compact
21
22
  config.merge!(render_interaction_thinking(thinking)) if thinking
22
23
  choice = tool_prefs&.dig(:choice)
23
24
  config[:tool_choice] = render_interaction_choice(choice) if choice
24
25
  payload = {
25
- model: model.id, input: format_interaction_input(messages), stream: stream, store: false,
26
+ model: model.id, input: render_interaction_input(messages), stream: stream, store: false,
26
27
  system_instruction: messages.select { |message| message.role == :system }.map(&:content).join("\n\n"),
27
28
  generation_config: config, tools: render_interaction_tools(tools)
28
29
  }
@@ -30,6 +31,15 @@ module RubyLLM
30
31
  payload
31
32
  end
32
33
 
34
+ def ensure_interaction_carries(messages, caching)
35
+ if caching.is_a?(Hash) && caching[:id]
36
+ raise ArgumentError, 'Gemini Interactions does not accept a cached content id'
37
+ end
38
+
39
+ attachment = messages.select { |message| message.role == :system }.flat_map(&:attachments).first
40
+ raise UnsupportedAttachmentError, attachment.mime_type if attachment
41
+ end
42
+
33
43
  def render_interaction_thinking(thinking)
34
44
  if thinking.disabled? || thinking.budget&.zero?
35
45
  raise ArgumentError, 'Gemini Interactions does not expose a thinking-off control'
@@ -50,8 +60,8 @@ module RubyLLM
50
60
 
51
61
  # Gemini checks signatures only in the current turn, which starts at
52
62
  # the last user message.
53
- def format_interaction_input(messages)
54
- calls = messages.flat_map { |message| message.tool_calls.to_h.values }.to_h { |call| [call.id, call] }
63
+ def render_interaction_input(messages)
64
+ calls = messages.flat_map { |message| message.tool_calls.to_a }.to_h { |call| [call.id, call] }
55
65
  turn_start = messages.rindex { |message| message.role == :user }
56
66
  messages.each_with_index.flat_map do |message, index|
57
67
  next [] if message.role == :system
@@ -62,7 +72,9 @@ module RubyLLM
62
72
 
63
73
  def render_interaction_turn(message, calls, current:)
64
74
  if interaction_state?(message.raw_content)
65
- render_interaction_history(message.raw_content.dig('response', 'steps') || [])
75
+ steps = message.raw_content.dig('response', 'steps') || []
76
+ steps = Protocol::ReplayedArguments.blank(steps, message, id: 'id', arguments: 'arguments', empty: {})
77
+ render_interaction_history(steps)
66
78
  elsif message.tool_result?
67
79
  [render_interaction_result(message, calls)]
68
80
  else
@@ -71,7 +83,7 @@ module RubyLLM
71
83
  end
72
84
 
73
85
  def render_interaction_result(message, calls)
74
- { type: 'function_result', call_id: message.tool_call_id, name: calls[message.tool_call_id]&.name,
86
+ { type: 'function_result', call_id: message.provider_call_id, name: calls[message.tool_call_id]&.name,
75
87
  result: render_interaction_content(message.content, message.attachments) }.compact
76
88
  end
77
89
 
@@ -90,9 +102,9 @@ module RubyLLM
90
102
  steps = []
91
103
  content = render_interaction_content(message.content, message.attachments)
92
104
  steps << { type: message.role == :assistant ? 'model_output' : 'user_input', content: } if content.any?
93
- message.tool_calls.to_h.values.each_with_index do |call, position|
105
+ message.tool_calls.each_with_index do |call, position|
94
106
  signature = call.thought_signature || (Gemini::Tools::PLACEHOLDER_SIGNATURE if current && position.zero?)
95
- steps << { type: 'function_call', id: call.id, name: call.name, arguments: call.arguments,
107
+ steps << { type: 'function_call', id: call.provider_call_id, name: call.name, arguments: call.arguments,
96
108
  signature: }.compact
97
109
  end
98
110
  steps
@@ -102,16 +114,19 @@ module RubyLLM
102
114
  content.is_a?(Hash) && content.dig('response', 'object') == 'interaction'
103
115
  end
104
116
 
117
+ def raise_failed_interaction(data, raw)
118
+ message = Array(data['errors']).filter_map { |error| error['message'] }.join('; ')
119
+ raise Error.new("Gemini interaction ended with status #{data['status']}", response: raw) if message.empty?
120
+
121
+ raise Error.new(provider_message: message, response: raw, redact: @config&.redact_error_messages)
122
+ end
123
+
105
124
  def parse_completion_body(data, raw:, model: data['model'] || @model&.id, cost: nil)
106
- unless %w[completed requires_action incomplete].include?(data['status'])
107
- message = Array(data['errors']).filter_map { |error| error['message'] }.join('; ')
108
- raise Error.new(message.empty? ? "Gemini interaction ended with status #{data['status']}" : message,
109
- response: raw)
110
- end
125
+ raise_failed_interaction(data, raw) unless %w[completed requires_action incomplete].include?(data['status'])
111
126
 
112
127
  steps = data.fetch('steps', [])
113
128
  content = parse_interaction_content(steps)
114
- calls = parse_interaction_calls(steps)
129
+ calls = parse_interaction_calls(steps, response: raw, finish_reason: finish_reasons[data['status']])
115
130
  status = interaction_status(data, calls, steps)
116
131
  if status == 'requires_action' && calls.empty?
117
132
  raise Error.new('Gemini interaction requires an unsupported action', response: raw)
@@ -16,7 +16,7 @@ module RubyLLM
16
16
  end
17
17
 
18
18
  def supported_attachment?(attachment)
19
- attachment.text? || !interaction_attachment_type(attachment).nil?
19
+ (attachment.text? && !attachment.provider_file?) || !interaction_attachment_type(attachment).nil?
20
20
  end
21
21
 
22
22
  def render_interaction_attachment(attachment)
@@ -24,18 +24,18 @@ module RubyLLM
24
24
  raise ArgumentError, "Gemini Interactions does not support #{attachment.mime_type} input"
25
25
  end
26
26
 
27
- return { type: 'text', text: attachment.content } if attachment.text?
27
+ return { type: 'text', text: attachment.content } if attachment.text? && !attachment.provider_file?
28
28
 
29
- type = interaction_attachment_type(attachment)
29
+ part = { type: interaction_attachment_type(attachment), mime_type: attachment.mime_type }
30
+ part[:processing] = attachment.processing.to_s if attachment.processing && attachment.video?
31
+ part.merge(interaction_attachment_source(attachment))
32
+ end
30
33
 
31
- part = { type: type, mime_type: attachment.mime_type }
32
- if attachment.provider_file?
33
- part.merge(uri: attachment.provider_file_uri)
34
- elsif attachment.url?
35
- part.merge(uri: attachment.source.to_s)
36
- else
37
- part.merge(data: attachment.encoded)
38
- end
34
+ def interaction_attachment_source(attachment)
35
+ return { uri: attachment.provider_file_uri } if attachment.provider_file?
36
+ return { uri: attachment.source.to_s } if attachment.url?
37
+
38
+ { data: attachment.encoded }
39
39
  end
40
40
 
41
41
  def interaction_attachment_type(attachment)
@@ -43,7 +43,7 @@ module RubyLLM
43
43
  return 'audio' if attachment.audio?
44
44
  return 'video' if attachment.video?
45
45
 
46
- 'document' if attachment.pdf?
46
+ 'document' if attachment.pdf? || (attachment.provider_file? && attachment.mime_type == 'text/csv')
47
47
  end
48
48
 
49
49
  def parse_interaction_content(steps)
@@ -60,11 +60,16 @@ module RubyLLM
60
60
  result[:citations].concat(parse_interaction_citations(part, result[:text].length))
61
61
  result[:text] << text
62
62
  elsif part['data'] || part['uri']
63
- source = part['uri'] || StringIO.new(Base64.decode64(part['data']))
64
- result[:attachments] << Attachment.new(source, config: @config)
63
+ result[:attachments] << parse_interaction_attachment(part, result[:attachments].size)
65
64
  end
66
65
  end
67
66
 
67
+ def parse_interaction_attachment(part, index)
68
+ source = part['uri'] || StringIO.new(Base64.decode64(part['data']))
69
+ filename = Gemini::Media.attachment_filename(part['mime_type'], index) if part['mime_type']
70
+ Attachment.new(source, filename:, config: @config)
71
+ end
72
+
68
73
  def parse_interaction_citations(part, offset)
69
74
  Array(part['annotations']).filter_map do |annotation|
70
75
  next unless CITATION_TYPES.include?(annotation['type'])
@@ -80,7 +80,7 @@ module RubyLLM
80
80
  steps = @interaction_steps.sort.map do |_index, step|
81
81
  next step unless step['type'] == 'function_call'
82
82
 
83
- step.merge('arguments' => parse_interaction_arguments(step['arguments']))
83
+ step.merge('arguments' => ToolCall.parse_arguments(step['arguments']).fetch(:arguments, step['arguments']))
84
84
  end
85
85
  @interaction_response.merge('steps' => steps)
86
86
  end
@@ -22,13 +22,18 @@ module RubyLLM
22
22
  { allowed_tools: { mode: 'any', tools: [choice.to_s] } }
23
23
  end
24
24
 
25
- def parse_interaction_calls(steps)
25
+ def parse_interaction_calls(steps, response: nil, finish_reason: nil)
26
26
  answered = interaction_answered_calls(steps)
27
- steps.select { |step| step['type'] == 'function_call' && !answered.include?(step['id']) }.to_h do |step|
28
- [step.fetch('id'), ToolCall.new(id: step.fetch('id'), name: step.fetch('name'),
29
- arguments: parse_interaction_arguments(step['arguments']),
30
- thought_signature: step['signature'])]
27
+ pending = steps.select { |step| step['type'] == 'function_call' && !answered.include?(step['id']) }
28
+ cut_off = finish_reason == :max_tokens && pending.any? && [nil, '', {}].include?(pending.last['arguments'])
29
+ raise ToolCallTruncatedError.new(response:) if cut_off
30
+
31
+ calls = pending.map do |step|
32
+ ToolCall.new(provider_call_id: step.fetch('id'), name: step.fetch('name'),
33
+ **ToolCall.parse_arguments(step['arguments'], response:, finish_reason:),
34
+ thought_signature: step['signature'])
31
35
  end
36
+ ToolCall.index(calls, by: :provider_call_id)
32
37
  end
33
38
 
34
39
  def interaction_answered_calls(steps)
@@ -37,14 +42,6 @@ module RubyLLM
37
42
  calls & results
38
43
  end
39
44
 
40
- def parse_interaction_arguments(arguments)
41
- return {} if arguments.nil? || (arguments.is_a?(String) && arguments.empty?)
42
-
43
- arguments.is_a?(String) ? JSON.parse(arguments) : arguments
44
- rescue JSON::ParserError => e
45
- raise ToolCallParseError.new(finish_reason: :tool_calls), cause: e
46
- end
47
-
48
45
  def parse_interaction_server_calls(steps)
49
46
  answered = interaction_answered_calls(steps)
50
47
  steps.filter_map do |step|
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Interactions
6
+ # Gemini Omni video generation. A background interaction is the only way
7
+ # Gemini lets a job be polled, and it requires store: true, so the job
8
+ # stays on Google's servers. Each job is a single turn, not a conversation.
9
+ module Videos # :nodoc:
10
+ JOB_ID = /\A[\w-]+\z/
11
+ FINISHED_WITHOUT_VIDEO = %w[failed cancelled incomplete budget_exceeded].freeze
12
+ private_constant :JOB_ID, :FINISHED_WITHOUT_VIDEO
13
+
14
+ def video_url
15
+ 'interactions'
16
+ end
17
+
18
+ def render_video_payload(prompt, model:, with: [], provider_options: {}, **)
19
+ text = prompt.to_s.empty? ? [] : [{ type: 'text', text: prompt.to_s }]
20
+ payload = { model:, input: with.map { |attachment| render_interaction_attachment(attachment) } + text,
21
+ background: true, store: true, response_format: { type: 'video' } }
22
+
23
+ Support::Utils.deep_merge(payload, provider_options)
24
+ end
25
+
26
+ # Gemini refuses uploaded sources in some regions but continues a
27
+ # video it generated everywhere, so a generated Video names its job.
28
+ def render_video_extension_payload(prompt, extend:, model:, provider_options: {}, **)
29
+ generated = extend.is_a?(Video) && extend.raw && generated_video(extend.raw)
30
+ unless generated
31
+ return render_video_payload(prompt, model:, with: [video_extension_attachment(extend)],
32
+ provider_options:)
33
+ end
34
+
35
+ render_video_payload(prompt, model:, provider_options:).merge(previous_interaction_id: extend.raw['id'])
36
+ end
37
+
38
+ def video_request_settings(request)
39
+ request[:response_format].slice(:duration, :resolution)
40
+ end
41
+
42
+ def parse_video_job(response, model:, **request)
43
+ id = response.body['id']
44
+ raise Error.new('Gemini did not return a video generation interaction', response:) unless id
45
+
46
+ VideoJob.new(id:, protocol: self, model:, **request, **parse_video_job_status(response))
47
+ end
48
+
49
+ def video_job_url(job)
50
+ return "interactions/#{job.id}" if job.id.match?(JOB_ID)
51
+
52
+ raise ArgumentError, "#{job.id.inspect} is not a video interaction id"
53
+ end
54
+
55
+ def parse_video_job_status(response, **)
56
+ body = response.body
57
+ return { status: :pending, raw: body } unless finished_video_job?(body)
58
+
59
+ tokens = Tokens.new(**parse_video_tokens(body))
60
+ return { status: :completed, raw: body, tokens: } if body['status'] == 'completed' && generated_video(body)
61
+
62
+ { status: :failed, raw: body, tokens:, error: video_job_error(body) }
63
+ end
64
+
65
+ def download_video(job)
66
+ video = generated_video(job.raw)
67
+
68
+ Video.new(data: Base64.decode64(video['data']), mime_type: video['mime_type'] || 'video/mp4',
69
+ model: job.raw['model'] || job.model, raw: job.raw)
70
+ end
71
+
72
+ private
73
+
74
+ def validate_animate_inputs!(with:); end
75
+
76
+ def finished_video_job?(body)
77
+ body['status'] == 'completed' || FINISHED_WITHOUT_VIDEO.include?(body['status'])
78
+ end
79
+
80
+ def parse_video_tokens(body)
81
+ usage = parse_interaction_usage(body['usage'] || {})
82
+ { input: usage[:input_tokens], output: usage[:output_tokens], cache_read: usage[:cache_read_tokens],
83
+ thinking: usage[:thinking_tokens] }
84
+ end
85
+
86
+ def video_job_error(body)
87
+ message = Array(body['errors']).filter_map { |error| error['message'] }.join('; ')
88
+ message.empty? ? 'Gemini returned no video' : message
89
+ end
90
+
91
+ def generated_video(body)
92
+ Array(body['steps']).select { |step| step['type'] == 'model_output' }
93
+ .flat_map { |step| Array(step['content']) }
94
+ .find { |part| part['type'] == 'video' && part['data'] }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -10,6 +10,7 @@ module RubyLLM
10
10
  include Interactions::Tools
11
11
  include Interactions::Streaming
12
12
  include Interactions::Transcription
13
+ include Interactions::Videos
13
14
 
14
15
  public :render
15
16
 
@@ -5,11 +5,15 @@ module RubyLLM
5
5
  class InvokeModel
6
6
  # Cohere embedding models over Bedrock InvokeModel.
7
7
  class CohereEmbeddings < InvokeModel
8
+ def rejected_options(operation)
9
+ operation == :embed ? %i[title] : super
10
+ end
11
+
8
12
  # rubocop:disable-next Lint/UnusedMethodArgument
9
13
  def embed(text, model:, dimensions:, task_type: nil, title: nil, with: nil, provider_options: {})
10
- ensure_no_embedding_media!(with)
14
+ attachments = embedding_attachments(text, with)
11
15
  track_usage(:embedding) do
12
- payload = render_embedding_payload(text, model:, dimensions:, task_type:, provider_options:)
16
+ payload = render_embedding_payload(text, model:, dimensions:, task_type:, attachments:, provider_options:)
13
17
  response = signed_post(embedding_url(model:), payload)
14
18
 
15
19
  parse_embedding_response(response, model:, text:)
@@ -18,23 +22,37 @@ module RubyLLM
18
22
 
19
23
  private
20
24
 
21
- def render_embedding_payload(text, dimensions:, provider_options:, task_type: nil, **)
22
- payload = {
23
- input_type: task_type || 'search_document'
24
- }
25
- texts = [text].flatten.compact.map(&:to_s).reject(&:empty?)
26
- payload[:texts] = texts unless texts.empty?
25
+ def render_embedding_payload(text, dimensions:, provider_options:, task_type: nil, attachments: [], **)
26
+ payload = { input_type: task_type || 'search_document' }
27
+ payload.merge!(attachments.any? ? media_embedding_inputs(text, attachments) : text_embedding_inputs(text))
27
28
  payload[:output_dimension] = dimensions if dimensions
28
29
 
29
30
  deep_merge_provider_options(payload, provider_options)
30
31
  end
31
32
 
33
+ def text_embedding_inputs(text)
34
+ texts = text.is_a?(Array) ? text.map(&:to_s) : [text.to_s].reject(&:empty?)
35
+ texts.empty? ? {} : { texts: }
36
+ end
37
+
38
+ def media_embedding_inputs(text, attachments)
39
+ content = Cohere::Media.render_content(text.to_s.empty? ? nil : text, attachments)
40
+ { inputs: [{ content: }], embedding_types: ['float'] }
41
+ end
42
+
32
43
  def parse_embedding_response(response, model:, text:)
33
44
  vectors = response.body['embeddings']
34
45
  vectors = vectors['float'] || vectors.values.first if vectors.is_a?(Hash)
35
- vectors = vectors.first unless text.is_a?(Array)
46
+ vectors = text.is_a?(Array) ? aligned_vectors(vectors, text, response) : vectors.first
47
+
48
+ Embedding.new(vectors:, model:,
49
+ input_tokens: response.headers['x-amzn-bedrock-input-token-count']&.to_i)
50
+ end
51
+
52
+ def aligned_vectors(vectors, text, response)
53
+ return vectors if text.empty? || vectors.size == text.size
36
54
 
37
- Embedding.new(vectors:, model:)
55
+ raise Error.new("Expected #{text.size} embeddings, got #{vectors.size}", response:)
38
56
  end
39
57
  end
40
58
  end
@@ -102,7 +102,7 @@ module RubyLLM
102
102
  end
103
103
 
104
104
  def embedding_batch_vectors(bodies)
105
- vectors = bodies.map { |body| extract_embedding(body) }
105
+ vectors = bodies.map { |body| parse_embedding(body) }
106
106
  vectors if vectors.all? { |vector| vector.is_a?(Array) && !vector.empty? && vector.all?(Numeric) }
107
107
  end
108
108
  end
@@ -6,13 +6,19 @@ module RubyLLM
6
6
  # Amazon Nova multimodal embedding models over Bedrock InvokeModel.
7
7
  class NovaEmbeddings < InvokeModel
8
8
  DEFAULT_EMBEDDING_PURPOSE = 'GENERIC_INDEX'
9
+ INPUT_TOKENS_HEADER = 'x-amzn-bedrock-input-token-count'
10
+ MEDIA_TYPES = %i[image audio video].freeze
11
+
12
+ def rejected_options(operation)
13
+ operation == :embed ? %i[title] : super
14
+ end
9
15
 
10
16
  # rubocop:disable-next Lint/UnusedMethodArgument
11
17
  def embed(text, model:, dimensions:, task_type: nil, title: nil, with: nil, provider_options: {})
12
- ensure_no_embedding_media!(with)
18
+ attachment = embedding_attachment(text, embedding_attachments(text, with))
13
19
  track_usage(:embedding) do
14
20
  responses = [text].flatten.map do |value|
15
- payload = render_embedding_payload(value, dimensions:, task_type:, provider_options:)
21
+ payload = render_embedding_payload(value, dimensions:, task_type:, attachment:, provider_options:)
16
22
  signed_post(embedding_url(model:), payload).tap { |response| record_embedding_attempt(response) }
17
23
  end
18
24
 
@@ -22,14 +28,22 @@ module RubyLLM
22
28
 
23
29
  private
24
30
 
25
- def render_embedding_payload(text, dimensions:, task_type:, provider_options:)
26
- params = {
27
- embeddingPurpose: task_type || DEFAULT_EMBEDDING_PURPOSE,
28
- text: {
29
- truncationMode: 'END',
30
- value: text.to_s
31
- }
32
- }
31
+ def embedding_attachment(text, attachments)
32
+ attachment = single_embedding_attachment(attachments, 'Nova Multimodal Embeddings')
33
+ return unless attachment
34
+
35
+ unless text.to_s.empty?
36
+ raise ArgumentError,
37
+ 'Nova Multimodal Embeddings embeds text or an attachment, not both'
38
+ end
39
+ raise UnsupportedAttachmentError, attachment.mime_type unless MEDIA_TYPES.include?(attachment.type)
40
+
41
+ attachment
42
+ end
43
+
44
+ def render_embedding_payload(text, dimensions:, task_type:, provider_options:, attachment: nil)
45
+ params = { embeddingPurpose: task_type || DEFAULT_EMBEDDING_PURPOSE }
46
+ params.merge!(attachment ? render_embedding_media(attachment) : render_embedding_text(text))
33
47
  params[:embeddingDimension] = dimensions if dimensions
34
48
 
35
49
  deep_merge_provider_options(
@@ -41,9 +55,29 @@ module RubyLLM
41
55
  )
42
56
  end
43
57
 
44
- def extract_embedding(body)
58
+ def render_embedding_text(text)
59
+ { text: { truncationMode: 'END', value: text.to_s } }
60
+ end
61
+
62
+ def render_embedding_media(attachment)
63
+ source = { source: { bytes: attachment.encoded } }
64
+ case attachment.type
65
+ when :video
66
+ format = attachment.extension || attachment.format
67
+ { video: { format:, embeddingMode: 'AUDIO_VIDEO_COMBINED' }.merge(source) }
68
+ else
69
+ { attachment.type => { format: attachment.format }.merge(source) }
70
+ end
71
+ end
72
+
73
+ def parse_embedding(body)
45
74
  body.dig('embeddings', 0, 'embedding')
46
75
  end
76
+
77
+ # Nova leaves the count out of its body and sends it as a header.
78
+ def embedding_attempt_tokens(responses)
79
+ responses.map { |response| Tokens.new(input: response.headers[INPUT_TOKENS_HEADER]&.to_i) }
80
+ end
47
81
  end
48
82
  end
49
83
  end
@@ -12,6 +12,10 @@ module RubyLLM
12
12
  MODELS = (GENERATION_MODELS + [INPAINT_MODEL]).freeze
13
13
  ASPECT_RATIOS = %w[16:9 1:1 21:9 2:3 3:2 4:5 5:4 9:16 9:21].freeze
14
14
 
15
+ def rejected_options(operation)
16
+ operation == :paint ? %i[count] : super
17
+ end
18
+
15
19
  def paint(prompt, model:, size:, count: nil, with: nil, mask: nil, provider_options: {})
16
20
  track_usage(:image) do
17
21
  payload = render_image_payload(prompt, model:, size:, count:, with:, mask:, provider_options:)
@@ -22,8 +26,7 @@ module RubyLLM
22
26
  end
23
27
  end
24
28
 
25
- def render_image_payload(prompt, model:, size:, count: nil, with: nil, mask: nil, provider_options: {})
26
- RubyLLM.logger.debug { 'Stability image models return one image per request' } if count && count > 1
29
+ def render_image_payload(prompt, model:, size:, with: nil, mask: nil, provider_options: {}, **)
27
30
  attachments = Attachment.wrap(with, config: @config)
28
31
  payload = { prompt: }.merge(render_image_inputs(attachments, model:, mask:, size:))
29
32
  payload.merge(provider_options.transform_keys(&:to_sym))