ruby_llm 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +515 -274
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1593 -331
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +50 -24
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +127 -106
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +15 -15
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +1 -7
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +5 -14
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +213 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class ChatCompletions
6
+ # Content filter verdicts as Azure reports them beside a completion:
7
+ # prompt_filter_results and each choice's content_filter_results on
8
+ # Chat Completions, a content_filters list on the Responses API.
9
+ # Each category is an object with filtered and either a severity or
10
+ # detected.
11
+ module ContentFilters
12
+ module_function
13
+
14
+ def parse_content_filters(data)
15
+ prompt = Array(data['prompt_filter_results']).flat_map do |result|
16
+ parse_filter_results(result['content_filter_results'], :prompt)
17
+ end
18
+ completion = parse_filter_results(data.dig('choices', 0, 'content_filter_results'), :completion)
19
+ listed = Array(data['content_filters']).flat_map do |result|
20
+ parse_filter_results(result['content_filter_results'], result['source_type'])
21
+ end
22
+ prompt + completion + listed
23
+ end
24
+
25
+ def parse_filter_results(results, source)
26
+ return [] unless results.is_a?(Hash) && ContentFilter::SOURCES.include?(source&.to_sym)
27
+
28
+ results.flat_map do |category, verdicts|
29
+ (verdicts.is_a?(Array) ? verdicts : [verdicts]).filter_map do |verdict|
30
+ parse_verdict(verdict, category, source)
31
+ end
32
+ end
33
+ end
34
+
35
+ # Custom blocklists come as a list, one verdict for each blocklist.
36
+ def parse_verdict(verdict, category, source)
37
+ return unless verdict.is_a?(Hash) && verdict.key?('filtered')
38
+
39
+ ContentFilter.new(source:, category:, severity: verdict['severity'], filtered: verdict['filtered'],
40
+ detected: verdict['detected'])
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -26,29 +26,29 @@ module RubyLLM
26
26
  payload.key?(:input) || payload.key?('input')
27
27
  end
28
28
 
29
- def parse_batch_completion_response(body)
30
- parse_embedding_response(Response.new(body), model: body['model'], text: nil)
29
+ def batch_request_line(request)
30
+ line = super
31
+ line[:custom_id] = "#{line[:custom_id]}:array" if request[:text].is_a?(Array)
32
+ line
33
+ end
34
+
35
+ def parse_batch_completion_response(body, shape: nil)
36
+ parse_embedding_response(Response.new(body), model: body['model'], text: shape == 'array' ? [] : nil)
31
37
  end
32
38
 
33
39
  def parse_batch_result(line)
40
+ custom_id, shape = line['custom_id'].split(':', 2)
41
+ line = line.merge('custom_id' => custom_id)
34
42
  body = line.dig('response', 'body')
35
43
  return super unless body.is_a?(Hash) && body['data'].is_a?(Array)
44
+
45
+ index = batch_result_index(custom_id)
36
46
  unless valid_embedding_positions?(body['data'])
37
- return [batch_result_index(line['custom_id']), nil,
38
- batch_failure(line['custom_id'], 'Invalid or duplicate embedding record positions')]
47
+ return [index, nil, batch_failure(custom_id, 'Invalid or duplicate embedding record positions')]
39
48
  end
49
+ return super unless batch_response_succeeded?(line['response'])
40
50
 
41
- ordered = body.merge('data' => body['data'].sort_by { |row| row['index'] })
42
- super(line.merge('response' => line['response'].merge('body' => ordered)))
43
- end
44
-
45
- # A row's index names the input its vector belongs to, so rows are put
46
- # back in that order and positions that are not exactly 0...N fail the
47
- # request rather than pairing vectors with the wrong texts.
48
- def valid_embedding_positions?(rows)
49
- positions = rows.map { |row| row.is_a?(Hash) ? row['index'] : nil }
50
-
51
- positions.all?(Integer) && positions.sort == (0...positions.size).to_a
51
+ parsed_batch_result(index, custom_id) { parse_batch_completion_response(body, shape:) }
52
52
  end
53
53
  end
54
54
  end
@@ -5,14 +5,17 @@ module RubyLLM
5
5
  class ChatCompletions
6
6
  # Embeddings methods of the OpenAI API integration
7
7
  module Embeddings
8
+ def rejected_options(operation)
9
+ operation == :embed ? %i[task_type title] : super
10
+ end
11
+
8
12
  module_function
9
13
 
10
14
  def embedding_url(...)
11
15
  'embeddings'
12
16
  end
13
17
 
14
- # rubocop:disable-next Lint/UnusedMethodArgument
15
- def render_embedding_payload(text, model:, dimensions:, task_type: nil, title: nil, provider_options: {})
18
+ def render_embedding_payload(text, model:, dimensions:, provider_options: {}, **)
16
19
  {
17
20
  model: model,
18
21
  input: text,
@@ -22,8 +25,11 @@ module RubyLLM
22
25
 
23
26
  def parse_embedding_response(response, model:, text:)
24
27
  data = response.body
28
+ raise_stream_error(JSON.generate(data), data, nil) if data.dig('error', 'message')
29
+ raise Error.new('The provider returned no embeddings', response:) unless data['data'].is_a?(Array)
30
+
25
31
  input_tokens = data.dig('usage', 'prompt_tokens')
26
- rows = data['data']
32
+ rows = order_embedding_rows(data['data'])
27
33
  single = rows.length == 1 && !text.is_a?(Array)
28
34
 
29
35
  vectors = rows.map { |row| row['embedding'] }
@@ -34,6 +40,23 @@ module RubyLLM
34
40
  reported_cost: reported_cost(data['usage'] || {}))
35
41
  end
36
42
 
43
+ # A row's index names the input its vector belongs to, so rows are put
44
+ # back in that order and positions that are not exactly 0...N raise
45
+ # rather than pairing vectors with the wrong texts. Servers that send
46
+ # no index keep the order of the response.
47
+ def order_embedding_rows(rows)
48
+ return rows if rows.none? { |row| row.key?('index') }
49
+ raise Error, 'Invalid or duplicate embedding record positions' unless valid_embedding_positions?(rows)
50
+
51
+ rows.sort_by { |row| row['index'] }
52
+ end
53
+
54
+ def valid_embedding_positions?(rows)
55
+ positions = rows.map { |row| row.is_a?(Hash) ? row['index'] : nil }
56
+
57
+ positions.all?(Integer) && positions.sort == (0...positions.size).to_a
58
+ end
59
+
37
60
  # Sparse-capable models return a token-to-weight map beside the dense
38
61
  # vector, under lexical_weights on BGE-M3 and sparse_embedding
39
62
  # elsewhere. It is an extension: dense-only servers send neither, and
@@ -39,7 +39,7 @@ module RubyLLM
39
39
  entries.map.with_index do |image_data, index|
40
40
  Image.new(
41
41
  url: image_data['url'],
42
- mime_type: 'image/png', # DALL-E typically returns PNGs
42
+ mime_type: "image/#{data['output_format'] || 'png'}",
43
43
  revised_prompt: image_data['revised_prompt'],
44
44
  model: model,
45
45
  data: image_data['b64_json'],
@@ -59,11 +59,11 @@ module RubyLLM
59
59
  if json_image_references?(model)
60
60
  payload[:images] = build_image_references(with)
61
61
  payload[:mask] = build_image_reference(mask) if mask
62
- payload[:size] = size if size
63
62
  else
64
63
  payload[:image] = single_upload_part(build_upload_parts(with))
65
64
  payload[:mask] = build_upload_part(mask) if mask
66
65
  end
66
+ payload[:size] = size if size
67
67
  payload.merge(provider_options)
68
68
  end
69
69
 
@@ -72,7 +72,7 @@ module RubyLLM
72
72
  end
73
73
 
74
74
  def build_image_references(sources)
75
- Array(sources).filter_map do |source|
75
+ Support::Utils.to_safe_array(sources).filter_map do |source|
76
76
  next if blank_attachment?(source)
77
77
 
78
78
  build_image_reference(source)
@@ -80,7 +80,7 @@ module RubyLLM
80
80
  end
81
81
 
82
82
  def build_image_reference(source)
83
- attachment = Attachment.new(source, config: @config)
83
+ attachment = source.is_a?(Attachment) ? source : Attachment.new(source, config: @config)
84
84
  return { file_id: attachment.provider_file_id } if attachment.provider_file?
85
85
  return { image_url: attachment.source.to_s } if attachment.url?
86
86
 
@@ -90,21 +90,19 @@ module RubyLLM
90
90
  end
91
91
 
92
92
  def build_upload_parts(sources)
93
- Array(sources).filter_map do |source|
93
+ Support::Utils.to_safe_array(sources).filter_map do |source|
94
94
  next if blank_attachment?(source)
95
95
 
96
96
  build_upload_part(source)
97
97
  end
98
98
  end
99
99
 
100
- # Retries only rewind upload parts at the top level of the payload,
101
- # so a lone image rides there instead of inside an array.
102
100
  def single_upload_part(parts)
103
101
  parts.one? ? parts.first : parts
104
102
  end
105
103
 
106
104
  def build_upload_part(source)
107
- attachment = Attachment.new(source, config: @config)
105
+ attachment = source.is_a?(Attachment) ? source : Attachment.new(source, config: @config)
108
106
  raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
109
107
 
110
108
  Faraday::UploadIO.new(StringIO.new(attachment.content), attachment.mime_type, attachment.filename)
@@ -115,7 +113,7 @@ module RubyLLM
115
113
  end
116
114
 
117
115
  def attachments?(value)
118
- Array(value).any? { |item| !blank_attachment?(item) }
116
+ Support::Utils.to_safe_array(value).any? { |item| !blank_attachment?(item) }
119
117
  end
120
118
 
121
119
  def blank_attachment?(value)
@@ -21,10 +21,10 @@ module RubyLLM
21
21
  end
22
22
  end
23
23
 
24
- def format_content(content, attachments = [], document_attachments: :pdf, image_attachments: true,
24
+ def render_content(content, attachments = [], document_attachments: :pdf, image_attachments: true,
25
25
  audio_attachments: true)
26
- format_parts(content, attachments) do |attachment|
27
- format_attachment(
26
+ render_parts(content, attachments) do |attachment|
27
+ render_attachment(
28
28
  attachment,
29
29
  document_attachments:,
30
30
  image_attachments:,
@@ -35,11 +35,11 @@ module RubyLLM
35
35
 
36
36
  # Shared preamble and attachment loop for OpenAI-compatible providers.
37
37
  # The block formats a single attachment in the provider's dialect.
38
- def format_parts(content, attachments = [])
38
+ def render_parts(content, attachments = [])
39
39
  return content if attachments.empty?
40
40
 
41
41
  parts = []
42
- parts << format_text(content) if content
42
+ parts << render_text(content) if content
43
43
 
44
44
  attachments.each do |attachment|
45
45
  parts << yield(attachment)
@@ -48,26 +48,26 @@ module RubyLLM
48
48
  parts
49
49
  end
50
50
 
51
- def format_attachment(attachment, document_attachments:, image_attachments:, audio_attachments:)
51
+ def render_attachment(attachment, document_attachments:, image_attachments:, audio_attachments:)
52
52
  unless supported_attachment?(attachment, document_attachments:, image_attachments:, audio_attachments:)
53
53
  raise UnsupportedAttachmentError, attachment.mime_type
54
54
  end
55
55
 
56
- return format_provider_file(attachment, document_attachments:) if attachment.provider_file?
56
+ return render_provider_file(attachment, document_attachments:) if attachment.provider_file?
57
57
 
58
58
  case attachment.type
59
59
  when :image
60
- with_image_detail(format_image(attachment), attachment)
60
+ with_image_detail(render_image(attachment), attachment)
61
61
  when :audio
62
- format_audio(attachment)
62
+ render_audio(attachment)
63
63
  when :pdf, :document
64
- format_document_attachment(attachment, document_attachments)
64
+ render_document_attachment(attachment, document_attachments)
65
65
  when :text
66
- format_text_file(attachment)
66
+ render_text_file(attachment)
67
67
  end
68
68
  end
69
69
 
70
- def format_image(image)
70
+ def render_image(image)
71
71
  {
72
72
  type: 'image_url',
73
73
  image_url: {
@@ -90,7 +90,7 @@ module RubyLLM
90
90
  'high'
91
91
  end
92
92
 
93
- def format_document(document)
93
+ def render_document(document)
94
94
  {
95
95
  type: 'file',
96
96
  file: {
@@ -100,7 +100,7 @@ module RubyLLM
100
100
  }
101
101
  end
102
102
 
103
- def format_provider_file(file, document_attachments:)
103
+ def render_provider_file(file, document_attachments:)
104
104
  raise UnsupportedAttachmentError, file.mime_type if document_attachments == :none
105
105
 
106
106
  {
@@ -111,14 +111,14 @@ module RubyLLM
111
111
  }
112
112
  end
113
113
 
114
- def format_text_file(text_file)
114
+ def render_text_file(text_file)
115
115
  {
116
116
  type: 'text',
117
117
  text: text_file.for_llm
118
118
  }
119
119
  end
120
120
 
121
- def format_audio(audio)
121
+ def render_audio(audio)
122
122
  {
123
123
  type: 'input_audio',
124
124
  input_audio: {
@@ -128,16 +128,16 @@ module RubyLLM
128
128
  }
129
129
  end
130
130
 
131
- def format_text(text)
131
+ def render_text(text)
132
132
  {
133
133
  type: 'text',
134
134
  text: text
135
135
  }
136
136
  end
137
137
 
138
- def format_document_attachment(attachment, strategy)
139
- return format_document(attachment) if strategy == :all
140
- return format_document(attachment) if strategy == :pdf && attachment.pdf?
138
+ def render_document_attachment(attachment, strategy)
139
+ return render_document(attachment) if strategy == :all
140
+ return render_document(attachment) if strategy == :pdf && attachment.pdf?
141
141
 
142
142
  raise UnsupportedAttachmentError, attachment.mime_type
143
143
  end
@@ -22,7 +22,10 @@ module RubyLLM
22
22
 
23
23
  def parse_moderation_response(response, model:)
24
24
  data = response.body
25
- raise Error.new(data.dig('error', 'message'), response:) if data.dig('error', 'message')
25
+ if data.dig('error', 'message')
26
+ raise Error.new(provider_message: data.dig('error', 'message'), error_code: data.dig('error', 'code'),
27
+ response:, redact: @config&.redact_error_messages)
28
+ end
26
29
 
27
30
  RubyLLM::Moderation.new(
28
31
  id: data['id'],
@@ -36,15 +39,15 @@ module RubyLLM
36
39
  return input if attachments.empty?
37
40
 
38
41
  parts = []
39
- parts << Media.format_text(input) if input
40
- parts.concat(attachments.map { |attachment| format_moderation_attachment(attachment) })
42
+ parts << Media.render_text(input) if input
43
+ parts.concat(attachments.map { |attachment| render_moderation_attachment(attachment) })
41
44
  parts
42
45
  end
43
46
 
44
- def format_moderation_attachment(attachment)
47
+ def render_moderation_attachment(attachment)
45
48
  raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
46
49
 
47
- Media.format_image(attachment)
50
+ Media.render_image(attachment)
48
51
  end
49
52
  end
50
53
  end
@@ -5,9 +5,14 @@ module RubyLLM
5
5
  class ChatCompletions
6
6
  # Speech generation methods for the OpenAI API integration
7
7
  module Speech
8
+ def speak(input, **, &)
9
+ @speech_characters = input.to_s.length
10
+ super
11
+ end
12
+
8
13
  module_function
9
14
 
10
- def speech_url(model:) # rubocop:disable Lint/UnusedMethodArgument
15
+ def speech_url(**)
11
16
  'audio/speech'
12
17
  end
13
18
 
@@ -31,7 +36,8 @@ module RubyLLM
31
36
  data: response.body,
32
37
  model: model,
33
38
  voice: voice || 'alloy',
34
- format: resolved_format
39
+ format: resolved_format,
40
+ characters: @speech_characters
35
41
  )
36
42
  end
37
43
  end
@@ -7,27 +7,42 @@ module RubyLLM
7
7
  class ChatCompletions
8
8
  # Streaming methods of the OpenAI API integration
9
9
  module Streaming
10
+ ERROR_STATUSES = {
11
+ 'authentication_error' => 401,
12
+ 'invalid_api_key' => 401,
13
+ 'rate_limit_exceeded' => 429,
14
+ 'insufficient_quota' => 429,
15
+ 'server_error' => 500,
16
+ 'service_unavailable' => 503,
17
+ 'overloaded_error' => 529
18
+ }.freeze
19
+
10
20
  module_function
11
21
 
12
22
  def stream_url
13
23
  completion_url
14
24
  end
15
25
 
26
+ def stream_response(...)
27
+ @think_tags = split_think_tags? ? ThinkTags.new : nil
28
+ super
29
+ end
30
+
16
31
  def build_chunk(data)
17
32
  usage = data['usage'] || {}
18
33
  delta = data.dig('choices', 0, 'delta') || {}
19
- content_source = delta['content'] || data.dig('choices', 0, 'message', 'content')
20
- content, thinking_from_blocks = extract_content_and_thinking(content_source)
34
+ content_source = delta['content'] || delta['refusal'] || data.dig('choices', 0, 'message', 'content')
35
+ content, thinking_from_blocks = parse_content_and_thinking(content_source)
36
+ thinking_text = thinking_from_blocks || delta['reasoning_content'] || delta['reasoning']
37
+ citations = parse_chunk_citations(delta, data)
38
+ content, thinking_text, citations = split_chunk_think_tags(content, thinking_text, citations) if @think_tags
21
39
 
22
40
  Chunk.new(
23
41
  role: :assistant,
24
42
  model: data['model'],
25
43
  content: content,
26
- citations: extract_chunk_citations(delta, data),
27
- thinking: Thinking.build(
28
- text: thinking_from_blocks || delta['reasoning_content'] || delta['reasoning'],
29
- signature: delta['reasoning_signature']
30
- ),
44
+ citations: citations,
45
+ thinking: Thinking.build(text: thinking_text, signature: delta['reasoning_signature']),
31
46
  tool_calls: parse_tool_calls(delta['tool_calls'], parse_arguments: false, stream_keys: true),
32
47
  input_tokens: input_tokens(usage),
33
48
  output_tokens: output_tokens(usage),
@@ -36,11 +51,24 @@ module RubyLLM
36
51
  thinking_tokens: thinking_tokens(usage),
37
52
  server_tool_use: server_tool_use(usage),
38
53
  reported_cost: reported_cost(usage),
39
- finish_reason: normalize_finish_reason(data.dig('choices', 0, 'finish_reason'))
54
+ finish_reason: normalize_finish_reason(data.dig('choices', 0, 'finish_reason')),
55
+ content_filters: ContentFilters.parse_content_filters(data)
40
56
  )
41
57
  end
42
58
 
43
- def extract_chunk_citations(delta, data)
59
+ def split_chunk_think_tags(content, thinking_text, citations)
60
+ content, thinking_text = @think_tags.feed(content, thinking_text)
61
+ [content, thinking_text, offset_citations(citations, @think_tags.offset)]
62
+ end
63
+
64
+ def stream_remainder
65
+ content, thinking = @think_tags&.finish
66
+ return unless content || thinking
67
+
68
+ Chunk.new(role: :assistant, content: content, thinking: Thinking.build(text: thinking))
69
+ end
70
+
71
+ def parse_chunk_citations(delta, data)
44
72
  annotations = parse_annotations(delta['annotations'], nil)
45
73
  return annotations if annotations.any?
46
74
 
@@ -57,14 +85,7 @@ module RubyLLM
57
85
  code = error['code']
58
86
  return [code, error['message']] if code.is_a?(Integer) && code.between?(400, 599)
59
87
 
60
- case error['type']
61
- when 'server_error'
62
- [500, error['message']]
63
- when 'rate_limit_exceeded', 'insufficient_quota'
64
- [429, error['message']]
65
- else
66
- [400, error['message']]
67
- end
88
+ [ERROR_STATUSES[code] || ERROR_STATUSES.fetch(error['type'], 400), error['message']]
68
89
  end
69
90
  end
70
91
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class ChatCompletions
6
+ # Splits a <think>...</think> block that opens the content into
7
+ # thinking. Model output is untrusted: each byte is scanned once,
8
+ # without regular expressions, and only a partial tag and a little
9
+ # leading whitespace are ever held back.
10
+ class ThinkTags # :nodoc:
11
+ OPEN = '<think>'
12
+ CLOSE = '</think>'
13
+ MAX_LEADING_WHITESPACE = 64
14
+ WHITESPACE = [9, 10, 11, 12, 13, 32].freeze
15
+
16
+ def self.split(text)
17
+ splitter = new
18
+ content, thinking = splitter.feed(text)
19
+ rest_content, rest_thinking = splitter.finish
20
+ [join(content, rest_content), join(thinking, rest_thinking), splitter.offset]
21
+ end
22
+
23
+ def self.join(first, second)
24
+ return second if first.nil? || first.empty?
25
+ return first if second.nil? || second.empty?
26
+
27
+ first + second
28
+ end
29
+
30
+ def initialize
31
+ @state = :start
32
+ @held = +''
33
+ @seen = 0
34
+ end
35
+
36
+ def offset
37
+ @offset || (@state == :start ? 0 : @seen)
38
+ end
39
+
40
+ def feed(text, explicit_thinking = nil)
41
+ return stop(text, explicit_thinking) if explicit_thinking
42
+ return [text, nil] if text.nil? || text.empty? || @state == :off
43
+
44
+ @seen += text.length
45
+ case @state
46
+ when :start then feed_start(text)
47
+ when :thinking then feed_thinking(text)
48
+ else feed_answer(text)
49
+ end
50
+ end
51
+
52
+ def finish
53
+ stop(nil, nil)
54
+ end
55
+
56
+ private
57
+
58
+ def stop(text, thinking)
59
+ held = take_held
60
+ state = @state
61
+ @offset ||= offset
62
+ @state = :off
63
+ return [presence(self.class.join(held, text)), thinking] if state == :start
64
+
65
+ [text, presence(self.class.join(held, thinking))]
66
+ end
67
+
68
+ def feed_start(text)
69
+ buffer = with_held(text)
70
+ leading = whitespace_length(buffer, MAX_LEADING_WHITESPACE + 1)
71
+ if leading <= MAX_LEADING_WHITESPACE
72
+ body = buffer.byteslice(leading..)
73
+ return open_block(body) if body.start_with?(OPEN)
74
+ return hold(buffer) if OPEN.start_with?(body)
75
+ end
76
+
77
+ @state = :off
78
+ @offset = 0
79
+ [buffer, nil]
80
+ end
81
+
82
+ def open_block(body)
83
+ @state = :thinking
84
+ feed_thinking(body.byteslice(OPEN.bytesize..))
85
+ end
86
+
87
+ def feed_thinking(text)
88
+ buffer = with_held(text)
89
+ close = buffer.byteindex(CLOSE)
90
+ return close_block(buffer, close) if close
91
+
92
+ kept = partial_close_length(buffer)
93
+ @held = buffer.byteslice(buffer.bytesize - kept, kept)
94
+ [nil, presence(buffer.byteslice(0, buffer.bytesize - kept))]
95
+ end
96
+
97
+ def close_block(buffer, close)
98
+ @state = :answer
99
+ content, = feed_answer(buffer.byteslice((close + CLOSE.bytesize)..))
100
+ [content, presence(buffer.byteslice(0, close))]
101
+ end
102
+
103
+ def feed_answer(text)
104
+ leading = whitespace_length(text, text.bytesize)
105
+ return [nil, nil] if leading == text.bytesize
106
+
107
+ answer = text.byteslice(leading..)
108
+ @state = :off
109
+ @offset = @seen - answer.length
110
+ [answer, nil]
111
+ end
112
+
113
+ def whitespace_length(text, limit)
114
+ length = 0
115
+ length += 1 while length < limit && WHITESPACE.include?(text.getbyte(length))
116
+ length
117
+ end
118
+
119
+ def partial_close_length(buffer)
120
+ (CLOSE.bytesize - 1).downto(1).find { |length| buffer.end_with?(CLOSE.byteslice(0, length)) } || 0
121
+ end
122
+
123
+ def hold(buffer)
124
+ @held = buffer
125
+ [nil, nil]
126
+ end
127
+
128
+ def with_held(text)
129
+ return text if @held.empty?
130
+
131
+ take_held + text
132
+ end
133
+
134
+ def take_held
135
+ held = @held
136
+ @held = +''
137
+ held
138
+ end
139
+
140
+ def presence(text)
141
+ return if text.nil? || text.empty?
142
+
143
+ text
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end