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
@@ -27,12 +27,18 @@ module RubyLLM
27
27
 
28
28
  def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
29
29
  schema: nil, thinking: nil, tool_prefs: nil, **)
30
+ raise ArgumentError, 'Mistral has no thinking budget; use effort:' if thinking&.budget
31
+
30
32
  options = super(messages, tools:, temperature:, model:, stream:, max_output_tokens:,
31
33
  schema:, thinking:, citations: false, caching: nil, tool_prefs:)
32
34
  normalize_conversation_choice(options)
35
+ if options[:parallel_tool_calls] == false
36
+ raise ArgumentError, 'Mistral Conversations does not accept calls: :one'
37
+ end
38
+
33
39
  {
34
40
  model: model.id,
35
- inputs: format_entries(messages),
41
+ inputs: render_entries(messages),
36
42
  instructions: messages.select { |message| message.role == :system }.map(&:content).join("\n\n"),
37
43
  completion_args: options.slice(:temperature, :max_tokens, :response_format, :reasoning_effort,
38
44
  :tool_choice),
@@ -51,19 +57,39 @@ module RubyLLM
51
57
  options[:tool_choice] = 'any' if choice == 'required'
52
58
  end
53
59
 
54
- def format_entries(messages)
55
- entries = messages.reject { |message| message.role == :system }.flat_map do |message|
56
- if message.raw_content
57
- Array(message.raw_content)
58
- elsif message.tool_result?
59
- [{ type: 'function.result', tool_call_id: message.tool_call_id, result: message.content.to_s }]
60
- else
61
- format_conversation_message(message)
62
- end
63
- end
60
+ def render_entries(messages)
61
+ entries = messages.reject { |message| message.role == :system }
62
+ .chunk_while { |previous, current| previous.tool_result? && current.tool_result? }
63
+ .flat_map { |group| render_entry_group(group) }
64
64
  entries.each_with_index.flat_map { |entry, index| replay_conversation_entry(entry, index) }
65
65
  end
66
66
 
67
+ # Function results are text-only on the wire and a round's results
68
+ # stay consecutive, so tool attachments ride a user entry after them.
69
+ def render_entry_group(group)
70
+ carriers = group.select do |message|
71
+ message.tool_result? && message.raw_content.nil? && message.attachments.any?
72
+ end
73
+
74
+ group.flat_map { |message| render_entry(message) } +
75
+ carriers.map { |message| { type: 'message.input', **tool_attachment_message(message) } }
76
+ end
77
+
78
+ def render_entry(message)
79
+ if conversation_entries?(message.raw_content)
80
+ Protocol::ReplayedArguments.blank(message.raw_content, message,
81
+ id: 'tool_call_id', arguments: 'arguments', empty: '{}')
82
+ elsif message.tool_result?
83
+ [{ type: 'function.result', tool_call_id: message.provider_call_id, result: message.content.to_s }]
84
+ else
85
+ render_conversation_message(message)
86
+ end
87
+ end
88
+
89
+ def conversation_entries?(content)
90
+ content.is_a?(Array) && content.all? { |entry| entry.is_a?(Hash) && entry['type'] }
91
+ end
92
+
67
93
  def replay_conversation_entry(entry, index)
68
94
  return replay_conversation_execution(entry, index) if entry['type'] == 'tool.execution'
69
95
 
@@ -91,16 +117,16 @@ module RubyLLM
91
117
  ]
92
118
  end
93
119
 
94
- def format_conversation_message(message)
120
+ def render_conversation_message(message)
95
121
  entries = []
96
122
  if message.content || message.attachments.any?
97
123
  entries << {
98
124
  type: 'message.input', role: message.role.to_s,
99
- content: format_message_content(message)
125
+ content: render_message_content(message)
100
126
  }
101
127
  end
102
- message.tool_calls&.each_value do |call|
103
- entries << { type: 'function.call', tool_call_id: call.id, name: call.name,
128
+ message.tool_calls.each do |call|
129
+ entries << { type: 'function.call', tool_call_id: call.provider_call_id, name: call.name,
104
130
  arguments: JSON.generate(call.arguments) }
105
131
  end
106
132
  entries
@@ -127,13 +153,10 @@ module RubyLLM
127
153
  response: raw)
128
154
  end
129
155
 
130
- output.select { |entry| pending_conversation_call?(entry) }.to_h do |entry|
131
- call = parse_tool_calls([
132
- { 'id' => entry['tool_call_id'], 'type' => 'function',
133
- 'function' => entry.slice('name', 'arguments') }
134
- ], response: raw, finish_reason: :tool_calls).values.first
135
- [call.id, call]
156
+ calls = output.select { |entry| pending_conversation_call?(entry) }.map do |entry|
157
+ { 'id' => entry['tool_call_id'], 'type' => 'function', 'function' => entry.slice('name', 'arguments') }
136
158
  end
159
+ parse_tool_calls(calls, response: raw, finish_reason: :tool_calls) || {}
137
160
  end
138
161
 
139
162
  def pending_conversation_call?(entry)
@@ -5,12 +5,14 @@ module RubyLLM
5
5
  module Mistral
6
6
  # Mistral Files API.
7
7
  class Files < Protocols::Files
8
+ def rejected_options(operation)
9
+ operation == :upload ? %i[uri] : super
10
+ end
11
+
8
12
  private
9
13
 
10
- # rubocop:disable-next Lint/UnusedMethodArgument
11
- def render_upload_payload(attachment, purpose: nil, expires_in: nil, visibility: nil,
12
- display_name: nil, uri: nil, content_type: nil)
13
- multipart_payload(attachment, purpose:, expiry: expiry_hours(expires_in), visibility:)
14
+ def render_upload_fields(_attachment, purpose: nil, expires_in: nil)
15
+ { purpose:, expiry: expiry_hours(expires_in) }
14
16
  end
15
17
 
16
18
  def expiry_hours(expires_in)
@@ -17,12 +17,23 @@ module RubyLLM
17
17
  SERVER_TOOL_ALIASES
18
18
  end
19
19
 
20
- def format_message_group(group, **options)
20
+ def render_message_group(group, **options)
21
21
  content = group.first.raw_content
22
22
  return super unless group.one? && content.is_a?(Array) &&
23
- content.all? { |entry| entry.is_a?(Hash) && entry['role'] }
23
+ content.all? { |entry| entry.is_a?(Hash) && entry['role'] && !entry['type'] }
24
24
 
25
- content.map { |entry| entry.except('index') }
25
+ ids = Protocol::ReplayedArguments.malformed_call_ids(group.first)
26
+ content.map { |entry| replayed_multi_entry(entry.except('index'), ids) }
27
+ end
28
+
29
+ def replayed_multi_entry(entry, malformed_ids)
30
+ return entry if malformed_ids.empty? || !entry['tool_calls'].is_a?(Array)
31
+
32
+ entry.merge('tool_calls' => entry['tool_calls'].map do |call|
33
+ next call unless malformed_ids.include?(call['id'])
34
+
35
+ call.merge('function' => call['function'].to_h.merge('arguments' => '{}'))
36
+ end)
26
37
  end
27
38
 
28
39
  def parse_completion_body(data, raw:)
@@ -116,7 +127,7 @@ module RubyLLM
116
127
  append_multi_content(entry, delta['content'])
117
128
  append_multi_calls(entry, delta['tool_calls'])
118
129
  @multi_finish_reason = choice['finish_reason'] if choice['finish_reason']
119
- content, thinking = extract_content_and_thinking(delta['content']) if entry['role'] == 'assistant'
130
+ content, thinking = parse_content_and_thinking(delta['content']) if entry['role'] == 'assistant'
120
131
  Chunk.new(role: :assistant, content: content, thinking: Thinking.build(text: thinking), model: data['model'])
121
132
  end
122
133
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Moonshot
6
+ # Kimi's Files API. A file's purpose decides what Kimi does with it, so
7
+ # an upload without one follows its content: images and videos are
8
+ # stored for the models to read, and everything else has its text
9
+ # extracted.
10
+ class Files < Protocols::OpenAI::Files
11
+ def rejected_options(operation)
12
+ operation == :upload ? %i[expires_in uri] : super
13
+ end
14
+
15
+ # Kimi wraps a file's extracted text in a JSON object; any other
16
+ # stored file comes back as it was written.
17
+ def download(file_id)
18
+ body = super
19
+ extracted_text(body) || body
20
+ end
21
+
22
+ private
23
+
24
+ def render_upload_fields(attachment, purpose: nil, **)
25
+ super(attachment, purpose: purpose || default_purpose(attachment))
26
+ end
27
+
28
+ def default_purpose(attachment)
29
+ return 'image' if attachment.image?
30
+ return 'video' if attachment.video?
31
+
32
+ 'file-extract'
33
+ end
34
+
35
+ def parse_file_response(data, mime_type: nil)
36
+ super(data, mime_type: mime_type || data['file_type'])
37
+ end
38
+
39
+ def extracted_text(body)
40
+ data = JSON.parse(body)
41
+ data['content'] if data.is_a?(Hash) && data['type'] == 'file'
42
+ rescue JSON::ParserError
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -114,8 +114,9 @@ module RubyLLM
114
114
  index = batch_result_index(line['custom_id'])
115
115
  response = line['response']
116
116
 
117
- if response && response['status_code'].to_i.between?(200, 299)
118
- [index, parse_batch_completion_response(response['body'])]
117
+ if batch_response_succeeded?(response)
118
+ body = response['body']
119
+ parsed_batch_result(index, line['custom_id'], body) { parse_batch_completion_response(body) }
119
120
  else
120
121
  [index, nil, batch_failure(line['custom_id'], batch_error_message(line))]
121
122
  end
@@ -27,13 +27,15 @@ module RubyLLM
27
27
 
28
28
  def parse_judgment_response(response, questions:)
29
29
  body = response.body
30
- answers = questions.values.zip(body.fetch('answers')).to_h do |question, answer|
31
- [question.name, parse_answer(answer, question)]
30
+ named = body.fetch('answers').to_h { |answer| [answer.fetch('name'), answer] }
31
+ answers = questions.values.to_h do |question|
32
+ [question.name, parse_answer(named.fetch(question.name.to_s), question)]
32
33
  end
33
34
  RubyLLM::Judgment.new(answers:, model: body.fetch('model'), raw: response, model_info: @model,
34
35
  tokens: parse_tokens(body['usage']))
35
36
  rescue KeyError, NoMethodError, TypeError => e
36
- raise Error.new("OpenAI Decisions returned an invalid judgment: #{e.message}", response:)
37
+ raise Error.new('OpenAI Decisions returned an invalid judgment', provider_message: e.message, response:,
38
+ redact: @config&.redact_error_messages)
37
39
  end
38
40
 
39
41
  private
@@ -51,7 +53,7 @@ module RubyLLM
51
53
  raise UnsupportedAttachmentError, image.mime_type unless image.image?
52
54
  return { type: 'input_image', file_id: image.provider_file_id } if image.provider_file?
53
55
 
54
- Responses::Media.format_image(image, image_url: image.for_llm,
56
+ Responses::Media.render_image(image, image_url: image.for_llm,
55
57
  original_detail: @provider.original_image_detail?)
56
58
  end
57
59
 
@@ -5,19 +5,25 @@ module RubyLLM
5
5
  module OpenAI
6
6
  # OpenAI Files API.
7
7
  class Files < Protocols::Files
8
+ def rejected_options(operation)
9
+ operation == :upload ? %i[uri] : super
10
+ end
11
+
8
12
  private
9
13
 
10
- # rubocop:disable-next Lint/UnusedMethodArgument
11
- def render_upload_payload(attachment, purpose: nil, expires_in: nil, visibility: nil,
12
- display_name: nil, uri: nil, content_type: nil)
13
- multipart_payload(attachment, purpose:, expires_after: expires_after(expires_in))
14
+ def render_upload_fields(_attachment, purpose: nil, expires_in: nil)
15
+ { purpose:, expires_after: expires_after(expires_in) }
14
16
  end
15
17
 
16
18
  def expires_after(expires_in)
17
19
  { anchor: 'created_at', seconds: expires_in } if expires_in
18
20
  end
19
21
 
20
- def parse_file_response(data)
22
+ def parse_upload_response(data, attachment)
23
+ parse_file_response(data, mime_type: file_content_type(attachment))
24
+ end
25
+
26
+ def parse_file_response(data, mime_type: nil)
21
27
  uploaded_file(
22
28
  data,
23
29
  id: data['id'],
@@ -26,7 +32,8 @@ module RubyLLM
26
32
  created_at: timestamp(data['created_at']),
27
33
  expires_at: timestamp(data['expires_at']),
28
34
  status: data['status'],
29
- purpose: data['purpose']
35
+ purpose: data['purpose'],
36
+ mime_type:
30
37
  )
31
38
  end
32
39
  end
@@ -7,10 +7,8 @@ module RubyLLM
7
7
  include RubyLLM::Batch::Helpers
8
8
 
9
9
  TERMINAL_STATUSES = %w[completed failed expired cancelled].freeze
10
- MEDIA_TYPES = %w[image image_url input_image input_audio audio video video_url input_video file input_file
11
- document].freeze
12
10
  Response = Struct.new(:body)
13
- private_constant :TERMINAL_STATUSES, :MEDIA_TYPES, :Response
11
+ private_constant :TERMINAL_STATUSES, :Response
14
12
 
15
13
  def create_batch(requests)
16
14
  model = single_batch_model!(requests, 'OpenRouter')
@@ -68,8 +66,8 @@ module RubyLLM
68
66
  raise ArgumentError,
69
67
  'OpenRouter embedding batches accept text only, without task_type or provider preferences'
70
68
  end
71
- elsif batch_media?(body) || body.keys.intersect?(%i[modalities audio image_config])
72
- raise ArgumentError, 'OpenRouter batches accept text input and output only'
69
+ elsif body.keys.intersect?(%i[modalities audio image_config])
70
+ raise ArgumentError, 'OpenRouter batches return text only'
73
71
  end
74
72
  end
75
73
 
@@ -81,14 +79,6 @@ module RubyLLM
81
79
  end
82
80
  end
83
81
 
84
- def batch_media?(value)
85
- case value
86
- when Hash then MEDIA_TYPES.include?(value[:type]) || value.values.any? { |part| batch_media?(part) }
87
- when Array then value.any? { |part| batch_media?(part) }
88
- else false
89
- end
90
- end
91
-
92
82
  def parse_batch_response(data)
93
83
  {
94
84
  id: data.fetch('id'), raw_status: data.fetch('status'),
@@ -116,7 +106,7 @@ module RubyLLM
116
106
  custom_id, shape = row.fetch('custom_id').split(':', 2)
117
107
  index = batch_result_index(custom_id)
118
108
  response = row['response']
119
- unless response && response['status_code'].to_i.between?(200, 299) && !row['error']
109
+ unless batch_response_succeeded?(response) && !row['error']
120
110
  return [index, nil, batch_failure(custom_id, batch_error_message(row))]
121
111
  end
122
112
 
@@ -126,7 +116,10 @@ module RubyLLM
126
116
  return [index, nil, batch_failure(custom_id, 'Invalid or duplicate embedding record positions')]
127
117
  end
128
118
 
129
- [index, parse_batch_body(body, endpoint:, model: data.fetch('model'), shape:)]
119
+ billed = body unless endpoint == '/v1/embeddings'
120
+ parsed_batch_result(index, custom_id, billed, parser: batch_parser(endpoint)) do
121
+ parse_batch_body(body, endpoint:, model: data.fetch('model'), shape:)
122
+ end
130
123
  end
131
124
 
132
125
  def embedding_positions?(body)
@@ -137,8 +130,11 @@ module RubyLLM
137
130
  def parse_batch_body(body, endpoint:, model:, shape:)
138
131
  return parse_batch_embedding(body, model:, shape:) if endpoint == '/v1/embeddings'
139
132
 
140
- parser = endpoint == '/v1/responses' ? OpenRouter::Responses.new(@provider) : self
141
- parser.send(:parse_completion_body, body, raw: body)
133
+ batch_parser(endpoint).send(:parse_completion_body, body, raw: body)
134
+ end
135
+
136
+ def batch_parser(endpoint)
137
+ endpoint == '/v1/responses' ? OpenRouter::Responses.new(@provider) : self
142
138
  end
143
139
 
144
140
  def parse_batch_embedding(body, model:, shape:)
@@ -17,16 +17,12 @@ module RubyLLM
17
17
  SERVER_TOOL_ALIASES
18
18
  end
19
19
 
20
- def merge_server_tool_entries(payload, entries)
21
- entries.each do |entry|
22
- tool = Support::Utils.deep_symbolize_keys(entry)
23
- next unless tool[:type] == 'mcp'
24
- next if tool[:require_approval].to_s == 'never'
25
-
26
- raise ArgumentError,
27
- "OpenRouter MCP requires explicit require_approval: 'never'; approval events are not returned"
28
- end
29
- super
20
+ def document_urls?
21
+ true
22
+ end
23
+
24
+ def provider_file_upload_options(_attachment)
25
+ {}
30
26
  end
31
27
 
32
28
  def parse_usage(usage)
@@ -5,17 +5,23 @@ module RubyLLM
5
5
  module Perplexity
6
6
  # Perplexity's Agent API, a dialect of the Responses API. A request names
7
7
  # either a preset, which bundles a model, search settings, and
8
- # instructions, or a model in provider/model form. The retired Sonar
9
- # model ids run the presets Perplexity recommends in their place.
8
+ # instructions, or a model in provider/model form.
10
9
  class Agent < Responses
11
10
  PRESETS = %w[fast low medium high xhigh wide-research].freeze
12
- SONAR_PRESETS = {
13
- 'sonar' => 'fast',
14
- 'sonar-pro' => 'low',
15
- 'sonar-reasoning-pro' => 'medium',
16
- 'sonar-deep-research' => 'high'
11
+ SERVER_TOOL_ALIASES = Responses::SERVER_TOOL_ALIASES.merge(
12
+ web_fetch: { tool: { type: 'fetch_url' } },
13
+ code_execution: { tool: { type: 'sandbox' } },
14
+ code_interpreter: { tool: { type: 'sandbox' } }
15
+ ).freeze
16
+ SERVER_TOOL_USAGE_NAMES = {
17
+ 'search_web' => 'web_search',
18
+ 'fetch_url' => 'web_fetch',
19
+ 'sandbox' => 'code_execution'
17
20
  }.freeze
18
- SERVER_TOOL_USAGE_NAMES = { 'search_web' => 'web_search', 'fetch_url' => 'web_fetch' }.freeze
21
+
22
+ def server_tool_aliases
23
+ SERVER_TOOL_ALIASES
24
+ end
19
25
 
20
26
  def completion_url
21
27
  @provider.agent_url
@@ -24,19 +30,10 @@ module RubyLLM
24
30
  def render_payload(messages, model:, max_output_tokens: nil, **)
25
31
  max_output_tokens ||= default_max_output_tokens(model)
26
32
  payload = super
27
- preset = preset_for(model.id)
28
- return payload unless preset
33
+ return payload unless PRESETS.include?(model.id)
29
34
 
30
35
  payload.delete(:model)
31
- payload.merge(preset: preset)
32
- end
33
-
34
- def format_document(document)
35
- raise UnsupportedAttachmentError, document.mime_type
36
- end
37
-
38
- def supported_attachment?(attachment)
39
- attachment.provider_file? || (%i[image text].include?(attachment.type) && super)
36
+ payload.merge(preset: model.id)
40
37
  end
41
38
 
42
39
  private
@@ -45,19 +42,6 @@ module RubyLLM
45
42
  model.max_output_tokens || Anthropic::DEFAULT_MAX_OUTPUT_TOKENS if model.id.start_with?('anthropic/')
46
43
  end
47
44
 
48
- def preset_for(model_id)
49
- return model_id if PRESETS.include?(model_id)
50
-
51
- preset = SONAR_PRESETS[model_id]
52
- if preset
53
- RubyLLM.deprecator.warn(
54
- "Perplexity retires Sonar on September 27, 2026. #{model_id} now runs the #{preset} " \
55
- "Agent API preset; use model: \"#{preset}\" instead."
56
- )
57
- end
58
- preset
59
- end
60
-
61
45
  def parse_citations(data, output, content)
62
46
  citations = super
63
47
  return citations if citations.any?
@@ -92,7 +76,7 @@ module RubyLLM
92
76
  return super unless item['type'] == 'function_call'
93
77
 
94
78
  chunk tool_calls: {
95
- data['output_index'] => ToolCall.new(id: item['call_id'], name: item['name'],
79
+ data['output_index'] => ToolCall.new(provider_call_id: item['call_id'], name: item['name'],
96
80
  arguments: +item['arguments'].to_s)
97
81
  }
98
82
  end
@@ -100,14 +84,14 @@ module RubyLLM
100
84
  # Perplexity rejects its own search_results and fetch_url_results
101
85
  # items as input, so a replayed turn keeps only messages, reasoning,
102
86
  # and function calls.
103
- def format_assistant_items(msg)
87
+ def render_assistant_items(msg)
104
88
  super.select do |item|
105
89
  type = item[:type] || item['type']
106
90
  type.nil? || Responses::Chat::CLIENT_OUTPUT_ITEM_TYPES.include?(type)
107
91
  end
108
92
  end
109
93
 
110
- def supports_provider_file_references?
94
+ def provider_file_references?
111
95
  false
112
96
  end
113
97
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Perplexity
6
+ # Perplexity's Batch API, the OpenAI batch shape for Agent API requests.
7
+ module Batches
8
+ include Protocols::Responses::Batches
9
+
10
+ # The Idempotency-Key lets the connection retry the create after a 429,
11
+ # which arrives whenever the upload just used the 1 request per second
12
+ # that the batch and file endpoints allow at the lowest usage tier.
13
+ def create_batch(requests)
14
+ single_batch_model!(requests, @provider.slug)
15
+ validate_batch_requests!(requests)
16
+
17
+ payload = { input_file_id: upload_batch_file(requests), endpoint: batch_endpoint, completion_window: '24h' }
18
+ response = @connection.post(batch_create_url, payload) do |request|
19
+ request.headers['Idempotency-Key'] = SecureRandom.uuid
20
+ end
21
+
22
+ parse_batch_response(response.body)
23
+ end
24
+
25
+ private
26
+
27
+ def batch_create_url
28
+ 'v1/batches'
29
+ end
30
+
31
+ def batch_url(id)
32
+ "v1/batches/#{id}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -3,14 +3,18 @@
3
3
  module RubyLLM
4
4
  module Protocols
5
5
  module Perplexity
6
- # Generated files belonging to a Perplexity Agent response.
7
- class Files < Protocols::Files
8
- def upload(*)
9
- raise Error, 'Perplexity Agent only supports downloading generated response files'
10
- end
6
+ # Perplexity's two kinds of stored file: batch input and output under
7
+ # /v1/files, and files an Agent response generated, which belong to it
8
+ # and are named response_id/files/file_id.
9
+ class Files < Protocols::OpenAI::Files
10
+ STORED_FILE_ID = /\A[A-Za-z0-9_-]+\z/
11
+ RESPONSE_FILE_ID = %r{\A([A-Za-z0-9_-]+)/files/([A-Za-z0-9_-]+)\z}
12
+ private_constant :STORED_FILE_ID, :RESPONSE_FILE_ID
11
13
 
12
14
  def find(resource_id)
13
15
  response_id, file_id = split_resource_id(resource_id)
16
+ return super unless response_id
17
+
14
18
  response = @connection.get("v1/agent/#{response_id}/files")
15
19
  data = Array(response.body['data']).find { |item| item['id'] == file_id }
16
20
  raise Error.new('Perplexity response file was not found', response:) unless data
@@ -31,16 +35,22 @@ module RubyLLM
31
35
 
32
36
  private
33
37
 
38
+ def files_url
39
+ 'v1/files'
40
+ end
41
+
34
42
  def download_file_url(resource_id)
35
- split_resource_id(resource_id)
36
- "v1/agent/#{resource_id}/content"
43
+ split_resource_id(resource_id) ? "v1/agent/#{resource_id}/content" : super
37
44
  end
38
45
 
39
46
  def split_resource_id(resource_id)
40
- match = resource_id.to_s.match(%r{\A([A-Za-z0-9_-]+)/files/([A-Za-z0-9_-]+)\z})
47
+ id = resource_id.to_s
48
+ return if id.match?(STORED_FILE_ID)
49
+
50
+ match = id.match(RESPONSE_FILE_ID)
41
51
  return match.captures if match
42
52
 
43
- raise ArgumentError, 'Perplexity file IDs must include their response: response_id/files/file_id'
53
+ raise ArgumentError, 'Perplexity file IDs must be a file id or response_id/files/file_id'
44
54
  end
45
55
  end
46
56
  end
@@ -7,24 +7,25 @@ module RubyLLM
7
7
  module Approvals
8
8
  module_function
9
9
 
10
- def render_tool_approval_response(tool_call, approved:)
11
- [{ type: 'mcp_approval_response', approval_request_id: tool_call.id, approve: approved }]
10
+ def render_tool_approval_response(tool_call, approved:, note: nil)
11
+ [{ type: 'mcp_approval_response', approval_request_id: tool_call.provider_call_id, approve: approved,
12
+ reason: note }.compact]
12
13
  end
13
14
 
14
15
  def parse_tool_approvals(output, response: nil, finish_reason: nil)
15
- output.select { |item| item['type'] == 'mcp_approval_request' }.to_h do |item|
16
- arguments = item['arguments']
17
- arguments = parse_function_call_arguments(arguments, response:, finish_reason:) unless arguments.is_a?(Hash)
18
- call = ToolCall.new(id: item.fetch('id'), name: item.fetch('name'), arguments:,
19
- remote: true)
20
- [call.id, call]
16
+ calls = output.filter_map do |item|
17
+ next unless item['type'] == 'mcp_approval_request'
18
+
19
+ arguments = ToolCall.parse_arguments(item['arguments'], response:, finish_reason:)
20
+ ToolCall.new(provider_call_id: item.fetch('id'), name: item.fetch('name'), **arguments, remote: true)
21
21
  end
22
+ ToolCall.index(calls, by: :provider_call_id)
22
23
  end
23
24
 
24
25
  def parse_pending_tool_calls(output, response: nil, finish_reason: nil)
25
- calls = (parse_function_calls(output, response:, finish_reason:) || {})
26
- .merge(parse_tool_approvals(output, response:, finish_reason:))
27
- calls unless calls.empty?
26
+ calls = (parse_function_calls(output, response:, finish_reason:) || {}).values +
27
+ parse_tool_approvals(output, response:, finish_reason:).values
28
+ ToolCall.index(calls, by: :provider_call_id) unless calls.empty?
28
29
  end
29
30
  end
30
31
  end