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
@@ -6,6 +6,7 @@ require 'faraday/retry'
6
6
  require 'ruby_llm/transport/error_middleware'
7
7
  require 'ruby_llm/transport/signing_middleware'
8
8
  require 'ruby_llm/transport/usage_middleware'
9
+ require 'time'
9
10
  require 'timeout'
10
11
 
11
12
  module RubyLLM
@@ -15,23 +16,37 @@ module RubyLLM
15
16
 
16
17
  IDEMPOTENT_KEY = :ruby_llm_idempotent
17
18
  STREAM_PROGRESS_KEY = :ruby_llm_stream_progress
19
+ FALLBACK_KEY = :ruby_llm_fallback_ready
20
+
21
+ # The longest wait, in seconds, a rate-limited or overloaded model's
22
+ # retry hint may ask for before a fallback takes the request instead.
23
+ # Providers often ask for a second or two, which costs less than
24
+ # moving to another model; waits of ten seconds or more are left to
25
+ # a fallback when one is configured.
26
+ FALLBACK_WAIT = 2
18
27
 
19
28
  # The key a shared Faraday connection is cached under: the provider,
20
29
  # whether it streams, and everything ::build reads.
21
30
  Settings = Struct.new(
22
- :provider, :stream, :api_base, :adapter, :timeout, :proxy, :logger, :log_bodies, :log_regexp_timeout,
23
- :max_retries, :retry_interval, :retry_max_interval, :retry_interval_randomness, :retry_backoff_factor,
31
+ :provider, :stream, :api_base, :adapter, :timeout, :connect_timeout, :idle_timeout, :proxy, :ssl, :logger,
32
+ :log_bodies, :log_regexp_timeout, :max_retries, :retry_interval, :retry_max_interval,
33
+ :retry_interval_randomness, :retry_backoff_factor,
24
34
  keyword_init: true
25
35
  )
26
36
 
27
37
  CACHE = Support::ProcessCache.new
38
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
39
+ REDIRECT_LIMIT = 5
28
40
 
29
41
  attr_reader :provider, :config
30
42
 
31
43
  def self.basic(config = RubyLLM.config, &)
32
44
  Faraday.new do |f|
45
+ f.use ReleaseRequest
33
46
  f.options.timeout = config.request_timeout
47
+ f.options.open_timeout = config.connect_timeout
34
48
  f.proxy = config.http_proxy if config.http_proxy
49
+ f.ssl.update(ssl_snapshot(config.ssl)) if config.ssl
35
50
  f.response :logger,
36
51
  RubyLLM.logger,
37
52
  bodies: false,
@@ -43,10 +58,56 @@ module RubyLLM
43
58
  end
44
59
  end
45
60
 
61
+ # Fetches the body at +url+ with no credentials, so a redirect to
62
+ # another host carries none.
63
+ def self.download(url, config = RubyLLM.config)
64
+ connection = basic(config)
65
+ uri = URI(url.to_s)
66
+ response = connection.get(uri)
67
+ REDIRECT_LIMIT.times do
68
+ break unless REDIRECTS.include?(response.status) && response.headers['location']
69
+
70
+ uri = redirect_target(uri, response.headers['location'])
71
+ response = connection.get(uri)
72
+ end
73
+ return response.body if response.success?
74
+
75
+ raise Error.new("Download from #{uri.host} failed with status #{response.status}", response:)
76
+ end
77
+
78
+ def self.redirect_target(from, location)
79
+ target = URI.join(from, location)
80
+ return target if target.scheme == 'https' || (target.scheme == 'http' && from.scheme == 'http')
81
+
82
+ raise Error, "Download from #{from.host} redirected to an unsafe #{target.scheme} URL"
83
+ rescue URI::InvalidURIError
84
+ raise Error, "Download from #{from.host} redirected to an invalid URL"
85
+ end
86
+ private_class_method :redirect_target
87
+
46
88
  def self.cache
47
89
  CACHE
48
90
  end
49
91
 
92
+ # Runs the block with a fallback model ready to take over its
93
+ # requests: +ready+ answers whether an error moves on to it. Until the
94
+ # block returns, a timeout or a failed connection is not retried on
95
+ # the same model, nor an error whose retry hint asks for a wait
96
+ # longer than FALLBACK_WAIT, when +ready+ accepts the error.
97
+ def self.with_fallback_ready(ready)
98
+ previous = Fiber[FALLBACK_KEY]
99
+ Fiber[FALLBACK_KEY] = ready
100
+ yield
101
+ ensure
102
+ Fiber[FALLBACK_KEY] = previous
103
+ end
104
+
105
+ # A frozen copy of +ssl+, so a value changed in place afterwards
106
+ # reaches neither a cache key nor a connection already built.
107
+ def self.ssl_snapshot(ssl)
108
+ ssl.to_h.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
109
+ end
110
+
50
111
  # The result is shared across threads and contexts, so the middleware
51
112
  # stack is built now rather than lazily, without a lock, on the first
52
113
  # request, and the defaults every request copies are frozen.
@@ -57,6 +118,7 @@ module RubyLLM
57
118
  setup_retry(faraday, settings)
58
119
  setup_middleware(faraday, settings)
59
120
  setup_http_proxy(faraday, settings)
121
+ setup_ssl(faraday, settings)
60
122
  end
61
123
  [connection.headers, connection.params, connection.options].each(&:freeze)
62
124
  connection.app
@@ -68,36 +130,37 @@ module RubyLLM
68
130
  @config = config
69
131
  @headers = headers
70
132
  @signer = signer
71
- @settings = settings_for(api_base || provider.api_base)
72
- @stream_settings = @settings.dup.tap { |settings| settings.stream = true }.freeze
133
+ @api_base = api_base
73
134
  connection
74
135
  end
75
136
 
76
137
  # The Faraday connection shared by every request with these settings in
77
138
  # this process. Looked up per request, so an object that outlives a fork
78
- # never reaches the parent's sockets. Streaming requests get their own:
79
- # some adapters, such as httpx, only stream if their first request did.
139
+ # never reaches the parent's sockets, and a request goes to the endpoint
140
+ # configured for the credentials it carries. Streaming requests get
141
+ # their own: some adapters, such as httpx, only stream if their first
142
+ # request did.
80
143
  def connection(stream: false)
81
- settings = stream ? @stream_settings : @settings
144
+ settings = settings_for(stream)
82
145
  CACHE.fetch(settings) { self.class.build(settings) }
83
146
  end
84
147
 
85
148
  def post(url, payload, usage: nil, idempotent: true, stream: false, &)
86
149
  instrument_request(:post, url) do
87
- response = connection(stream:).post url, payload do |req|
150
+ connection(stream:).post url, payload do |req|
88
151
  prepare(req)
89
152
  set_usage_tracker(req, usage) if usage
90
153
  mark_non_idempotent(req) unless idempotent
91
154
  yield req if block_given?
92
155
  end
93
- release_request(response)
94
156
  end
95
157
  end
96
158
 
97
- def get(url, &)
159
+ def get(url, raw: false, &)
98
160
  instrument_request(:get, url) do
99
161
  connection.get url do |req|
100
162
  prepare(req)
163
+ keep_raw_body(req) if raw
101
164
  yield req if block_given?
102
165
  end
103
166
  end
@@ -105,11 +168,10 @@ module RubyLLM
105
168
 
106
169
  def patch(url, payload, &)
107
170
  instrument_request(:patch, url) do
108
- response = connection.patch url, payload do |req|
171
+ connection.patch url, payload do |req|
109
172
  prepare(req)
110
173
  yield req if block_given?
111
174
  end
112
- release_request(response)
113
175
  end
114
176
  end
115
177
 
@@ -132,32 +194,29 @@ module RubyLLM
132
194
  }
133
195
 
134
196
  RubyLLM.instrument('request.ruby_llm', payload, config: @config) do |event|
135
- response = yield
197
+ response = ReleaseRequest.release(yield)
136
198
  event[:status] = response.status if response.respond_to?(:status)
137
199
  response
200
+ rescue RubyLLM::Error => e
201
+ ReleaseRequest.release(e.response)
202
+ raise
138
203
  end
139
204
  end
140
205
 
141
- # The response outlives the call as Message#raw and other results. A chat
142
- # request body is the whole serialized conversation, and a streaming
143
- # callback closes over the payload it was built from.
144
- def release_request(response)
145
- response.env.request_body = nil
146
- response.env.request.on_data = nil
147
- response.env.request.context&.delete(ErrorMiddleware::STREAM_RESET_KEY)
148
- response
149
- end
150
-
151
- def settings_for(api_base)
206
+ def settings_for(stream)
207
+ logger = @config.logger || RubyLLM.logger
152
208
  Settings.new(
153
209
  provider: @provider.class,
154
- stream: false,
155
- api_base: api_base,
210
+ stream: stream,
211
+ api_base: @api_base || @provider.api_base,
156
212
  adapter: @config.faraday_adapter,
157
213
  timeout: @config.request_timeout,
214
+ connect_timeout: @config.connect_timeout,
215
+ idle_timeout: (@config.stream_idle_timeout if stream),
158
216
  proxy: @config.http_proxy,
159
- logger: RubyLLM.logger,
160
- log_bodies: RubyLLM.logger.debug?,
217
+ ssl: (self.class.ssl_snapshot(@config.ssl) if @config.ssl),
218
+ logger: logger,
219
+ log_bodies: logger.debug?,
161
220
  log_regexp_timeout: @config.log_regexp_timeout,
162
221
  max_retries: @config.max_retries,
163
222
  retry_interval: @config.retry_interval,
@@ -177,8 +236,12 @@ module RubyLLM
177
236
  context[SigningMiddleware::CONTEXT_KEY] = @signer if @signer
178
237
  end
179
238
 
239
+ # Adapters time each read of a response with +read_timeout+, so on a
240
+ # streaming connection it is the longest wait for the next bytes.
180
241
  def self.setup_timeout(faraday, settings)
181
242
  faraday.options.timeout = settings.timeout
243
+ faraday.options.open_timeout = settings.connect_timeout
244
+ faraday.options.read_timeout = settings.idle_timeout if settings.idle_timeout
182
245
  end
183
246
 
184
247
  def self.setup_logging(faraday, settings)
@@ -187,7 +250,8 @@ module RubyLLM
187
250
  bodies: settings.log_bodies,
188
251
  errors: true,
189
252
  headers: false,
190
- log_level: :debug do |logger|
253
+ log_level: :debug,
254
+ formatter: LogFormatter do |logger|
191
255
  logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}', settings), '[BASE64 DATA]')
192
256
  logger.filter(logging_regexp('[-\\d.e,\\s]{100,}', settings), '[EMBEDDINGS ARRAY]')
193
257
  end
@@ -207,8 +271,10 @@ module RubyLLM
207
271
  interval_randomness: settings.retry_interval_randomness,
208
272
  backoff_factor: settings.retry_backoff_factor,
209
273
  methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS,
210
- retry_if: lambda { |env, _exception|
211
- env[:method] == :post && idempotent?(env) && !stream_delivered?(env)
274
+ retry_if: lambda { |env, exception|
275
+ Support::Cancellation.check
276
+ env[:method] == :post && idempotent?(env) && !stream_delivered?(env) &&
277
+ !fall_back_instead?(env, exception)
212
278
  },
213
279
  exceptions: retry_exceptions
214
280
  }
@@ -223,21 +289,61 @@ module RubyLLM
223
289
  env[:request]&.context&.dig(IDEMPOTENT_KEY) != false
224
290
  end
225
291
 
292
+ # Whether a fallback set up by ::with_fallback_ready takes this
293
+ # failure instead of a retry on the same model.
294
+ def self.fall_back_instead?(env, exception)
295
+ ready = Fiber[FALLBACK_KEY]
296
+ return false unless ready && exception
297
+
298
+ error = exception.is_a?(RubyLLM::Error) ? exception : RubyLLM::ConnectionError.new(exception.message)
299
+ return false unless ready.call(error)
300
+
301
+ error.is_a?(RubyLLM::ConnectionError) || retry_wait(env, error) > FALLBACK_WAIT
302
+ end
303
+
304
+ # The wait in seconds the response's retry hints ask for, read as the
305
+ # retry middleware reads them, or 0 without one.
306
+ def self.retry_wait(env, error)
307
+ headers = env[:response_headers] || error.response&.headers || {}
308
+ %w[Retry-After RateLimit-Reset].filter_map { |name| header_seconds(headers[name]) }.max || 0
309
+ end
310
+
311
+ def self.header_seconds(value)
312
+ text = value.to_s.strip
313
+ Float(text, exception: false) || (Time.httpdate(text) - Time.now)
314
+ rescue ArgumentError
315
+ nil
316
+ end
317
+
226
318
  def self.setup_middleware(faraday, settings)
227
319
  faraday.request :multipart
228
320
  faraday.request :json
229
321
  faraday.use :llm_signing
230
322
  faraday.use JsonResponse
231
- faraday.adapter(settings.adapter)
323
+ faraday.adapter(settings.adapter, **adapter_options(settings))
232
324
  faraday.use :llm_errors
233
325
  end
234
326
 
327
+ # async-http-faraday reads its timeout for each read from its own
328
+ # options rather than the request's.
329
+ def self.adapter_options(settings)
330
+ return {} unless settings.adapter == :async_http && settings.idle_timeout
331
+
332
+ { read_timeout: settings.idle_timeout }
333
+ end
334
+
235
335
  def self.setup_http_proxy(faraday, settings)
236
336
  return unless settings.proxy
237
337
 
238
338
  faraday.proxy = settings.proxy
239
339
  end
240
340
 
341
+ def self.setup_ssl(faraday, settings)
342
+ return unless settings.ssl
343
+
344
+ faraday.ssl.update(settings.ssl)
345
+ end
346
+
241
347
  def self.retry_exceptions
242
348
  [
243
349
  Errno::ETIMEDOUT,
@@ -246,6 +352,7 @@ module RubyLLM
246
352
  Faraday::ConnectionFailed,
247
353
  Faraday::SSLError,
248
354
  Faraday::RetriableResponse,
355
+ RubyLLM::ConnectionError,
249
356
  RubyLLM::RateLimitError,
250
357
  RubyLLM::ServerError,
251
358
  RubyLLM::ServiceUnavailableError,
@@ -254,7 +361,8 @@ module RubyLLM
254
361
  end
255
362
 
256
363
  private_class_method :setup_timeout, :setup_logging, :logging_regexp, :setup_retry, :stream_delivered?,
257
- :idempotent?, :setup_middleware, :setup_http_proxy, :retry_exceptions
364
+ :idempotent?, :fall_back_instead?, :retry_wait, :header_seconds, :setup_middleware,
365
+ :adapter_options, :setup_http_proxy, :setup_ssl, :retry_exceptions
258
366
 
259
367
  def set_usage_tracker(request, tracker)
260
368
  context = request.options.context ||= {}
@@ -268,6 +376,11 @@ module RubyLLM
268
376
  context[IDEMPOTENT_KEY] = false
269
377
  end
270
378
 
379
+ def keep_raw_body(request)
380
+ context = request.options.context ||= {}
381
+ context[JsonResponse::RAW_KEY] = true
382
+ end
383
+
271
384
  def inspect_attributes # :nodoc:
272
385
  { provider: @provider.slug }
273
386
  end
@@ -8,6 +8,8 @@ module RubyLLM
8
8
  class ErrorMiddleware < Faraday::Middleware # :nodoc: all
9
9
  PROVIDER_KEY = :ruby_llm_provider
10
10
  STREAM_RESET_KEY = :ruby_llm_stream_reset
11
+ STREAM_FAILURE_KEY = :ruby_llm_stream_failure
12
+ NETWORK_FAILURES = [Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError].freeze
11
13
 
12
14
  def initialize(app, options = {})
13
15
  super(app)
@@ -16,25 +18,59 @@ module RubyLLM
16
18
 
17
19
  # Sits directly above the adapter, inside the retry middleware, so this
18
20
  # runs once per attempt: streaming state stored on the env by a previous
19
- # attempt must not leak into the next one.
21
+ # attempt must not leak into the next one, and a body the previous
22
+ # attempt read to its end is sent again from its start.
20
23
  def call(env)
24
+ context = env[:request]&.context
25
+ provider = context&.[](PROVIDER_KEY) || @provider
26
+ attempt(env, context, provider)
27
+ rescue *NETWORK_FAILURES => e
28
+ raise if context&.dig(Connection::STREAM_PROGRESS_KEY, :callback_error).equal?(e)
29
+
30
+ raise ConnectionError.new(e.message, provider: (provider.slug if provider.respond_to?(:slug)))
31
+ end
32
+
33
+ private
34
+
35
+ def attempt(env, context, provider)
21
36
  env[:streaming_error_response] = nil
22
37
  env[:streaming_state] = nil
23
- context = env[:request]&.context
38
+ env[:body].rewind if env[:body].respond_to?(:rewind)
24
39
  context&.[](STREAM_RESET_KEY)&.call
25
- provider = context&.[](PROVIDER_KEY) || @provider
26
40
  @app.call(env).on_complete do |response|
41
+ context&.[](STREAM_FAILURE_KEY)&.call(response)
27
42
  apply_retry_delay(response, provider)
28
43
  self.class.parse_error(provider:, response: streaming_error_response(response))
29
44
  end
45
+ rescue RubyLLM::Error => e
46
+ apply_raised_retry_delay(env, provider, e)
47
+ raise
30
48
  end
31
49
 
32
- private
50
+ # A rate limit reported inside a stream raises with no failed HTTP
51
+ # status, and without an env on Faraday 1, so its hint is only on the
52
+ # error's own response.
53
+ def apply_raised_retry_delay(env, provider, error)
54
+ return apply_retry_delay(env, provider) unless rate_limit_report?(error)
55
+
56
+ env[:streaming_error_response] ||= error.response
57
+ env[:response_headers] ||= Faraday::Utils::Headers.new
58
+ apply_retry_delay(env, provider, status: 429)
59
+ end
60
+
61
+ def rate_limit_report?(error)
62
+ return true if error.is_a?(RateLimitError)
63
+
64
+ [error.response&.body].flatten.grep(Hash).any? do |part|
65
+ part['error'].is_a?(Hash) && part['error']['code'] == 429
66
+ end
67
+ end
33
68
 
34
69
  # The retry middleware only reads the standard Retry-After header, so
35
- # other retry hints are normalized into seconds here.
36
- def apply_retry_delay(response, provider)
37
- status = response.respond_to?(:status) ? response.status : response[:status]
70
+ # other retry hints are normalized into seconds here. A failed stream
71
+ # usually raises while it is read, before on_complete, and keeps its
72
+ # body in the stored streaming error rather than on the response.
73
+ def apply_retry_delay(response, provider, status: response_status(response))
38
74
  return unless status && status >= 400
39
75
 
40
76
  headers = response[:response_headers]
@@ -44,8 +80,12 @@ module RubyLLM
44
80
  headers['Retry-After'] = delay.to_s if delay
45
81
  end
46
82
 
83
+ def response_status(response)
84
+ response.respond_to?(:status) ? response.status : response[:status]
85
+ end
86
+
47
87
  def provider_retry_delay(provider, response, status)
48
- provider&.retry_delay(response) if status == 429
88
+ provider&.retry_delay(streaming_error_response(response)) if status == 429
49
89
  end
50
90
 
51
91
  def millisecond_retry_delay(headers)
@@ -78,7 +118,11 @@ module RubyLLM
78
118
  /input or output tokens? must be reduced/i,
79
119
  /reduce the length of messages/i,
80
120
  /prompt is too long/i,
81
- /context limit/i
121
+ /context limit/i,
122
+ /exceeded model token limit/i,
123
+ /prompt exceeds max length/i,
124
+ /input is too long/i,
125
+ /longer than the maximum model length/i
82
126
  ].freeze
83
127
 
84
128
  RATE_LIMIT_PATTERNS = [
@@ -105,59 +149,60 @@ module RubyLLM
105
149
  ].freeze
106
150
 
107
151
  def parse_error(provider:, response:)
108
- return if (200..399).cover?(response.status)
152
+ status = failure_status(provider, response)
153
+ return if (200..399).cover?(status)
109
154
 
110
155
  message = provider&.parse_error(response)
111
-
112
- case response.status
113
- when 400
114
- raise_bad_request(message, response)
115
- when 401
116
- raise UnauthorizedError.new(message, response:)
117
- when 402
118
- raise PaymentRequiredError.new(message, response:)
119
- when 403
120
- raise_forbidden(message, response)
121
- when 413
122
- raise_too_large(message, response)
123
- when 429
124
- raise RateLimitError.new(message, response:) if rate_limited?(message)
125
- raise ContextLengthExceededError.new(message, response:) if context_length_exceeded?(message)
126
-
127
- raise RateLimitError.new(message, response:)
128
- when 500
129
- raise ServerError.new(message, response:)
130
- when 502..504
131
- raise ServiceUnavailableError.new(message, response:)
132
- when 529
133
- raise OverloadedError.new(message, response:)
134
- else
135
- raise Error.new(message, response:)
136
- end
156
+ raise error_class(message, status).new(response:, provider_message: message,
157
+ **provider_details(provider, response))
137
158
  end
138
159
 
139
160
  private
140
161
 
141
- def raise_bad_request(message, response)
142
- raise UnauthorizedError.new(message, response:) if authentication_failed?(message)
143
- raise ContextLengthExceededError.new(message, response:) if context_length_exceeded?(message)
144
- raise RateLimitError.new(message, response:) if rate_limited?(message)
145
- raise OverloadedError.new(message, response:) if overloaded?(message)
146
- raise PaymentRequiredError.new(message, response:) if payment_required?(message)
162
+ def failure_status(provider, response)
163
+ return response.status unless (200..299).cover?(response.status)
164
+
165
+ provider&.error_status(response) || response.status
166
+ end
147
167
 
148
- raise BadRequestError.new(message, response:)
168
+ def provider_details(provider, response)
169
+ {
170
+ provider: (provider.slug if provider.respond_to?(:slug)),
171
+ error_code: (provider.parse_error_code(response) if provider.respond_to?(:parse_error_code)),
172
+ redact: (provider.config&.redact_error_messages if provider.respond_to?(:config))
173
+ }
149
174
  end
150
175
 
151
- def raise_forbidden(message, response)
152
- raise UnauthorizedError.new(message, response:) if authentication_failed?(message)
176
+ def error_class(message, status)
177
+ case status
178
+ when 400 then bad_request_class(message)
179
+ when 401 then UnauthorizedError
180
+ when 402 then PaymentRequiredError
181
+ when 403 then authentication_failed?(message) ? UnauthorizedError : ForbiddenError
182
+ when 413 then context_length_exceeded?(message) ? ContextLengthExceededError : Error
183
+ when 429 then rate_limit_class(message)
184
+ when 500 then ServerError
185
+ when 502..504 then ServiceUnavailableError
186
+ when 529 then OverloadedError
187
+ else Error
188
+ end
189
+ end
153
190
 
154
- raise ForbiddenError.new(message, response:)
191
+ def bad_request_class(message)
192
+ if authentication_failed?(message) then UnauthorizedError
193
+ elsif context_length_exceeded?(message) then ContextLengthExceededError
194
+ elsif rate_limited?(message) then RateLimitError
195
+ elsif overloaded?(message) then OverloadedError
196
+ elsif payment_required?(message) then PaymentRequiredError
197
+ else BadRequestError
198
+ end
155
199
  end
156
200
 
157
- def raise_too_large(message, response)
158
- raise ContextLengthExceededError.new(message, response:) if context_length_exceeded?(message)
201
+ def rate_limit_class(message)
202
+ return RateLimitError if rate_limited?(message)
203
+ return ContextLengthExceededError if context_length_exceeded?(message)
159
204
 
160
- raise Error.new(message, response:)
205
+ RateLimitError
161
206
  end
162
207
 
163
208
  def context_length_exceeded?(message)
@@ -6,6 +6,8 @@ module RubyLLM
6
6
  # whatever pieces the network delivers. Each dispatched event is yielded
7
7
  # as its type, data, and last event ID. The last event ID and the
8
8
  # reconnection time the server asked for stay readable between events.
9
+ # The standard ignores a line that names no known field; +on_unknown_line+
10
+ # receives each one, undecoded, for a caller that wants to look at it.
9
11
  class EventStreamParser # :nodoc:
10
12
  BOM = "\xEF\xBB\xBF".b.freeze
11
13
  LINE_BREAK = /[\r\n]/
@@ -16,7 +18,8 @@ module RubyLLM
16
18
 
17
19
  attr_reader :last_event_id, :reconnection_time
18
20
 
19
- def initialize
21
+ def initialize(on_unknown_line: nil)
22
+ @on_unknown_line = on_unknown_line
20
23
  @buffer = String.new(encoding: Encoding::BINARY)
21
24
  @position = 0
22
25
  @searched = 0
@@ -43,6 +46,16 @@ module RubyLLM
43
46
  @searched = @buffer.bytesize
44
47
  end
45
48
 
49
+ # Reads a last line the stream ended without a line break. It can't
50
+ # complete an event, which needs a blank line after it.
51
+ def finish
52
+ return if @position >= @buffer.bytesize
53
+
54
+ line = @buffer.byteslice(@position, @buffer.bytesize - @position)
55
+ @position = @searched = @buffer.bytesize
56
+ process_line(line)
57
+ end
58
+
46
59
  private
47
60
 
48
61
  def compact
@@ -85,18 +98,19 @@ module RubyLLM
85
98
  return if line.start_with?(':')
86
99
 
87
100
  colon = line.index(':')
88
- return process_field(line, +'') unless colon
101
+ return process_field(line, +'', line) unless colon
89
102
 
90
103
  offset = line.getbyte(colon + 1) == SPACE ? colon + 2 : colon + 1
91
- process_field(line.byteslice(0, colon), line.byteslice(offset, line.bytesize - offset))
104
+ process_field(line.byteslice(0, colon), line.byteslice(offset, line.bytesize - offset), line)
92
105
  end
93
106
 
94
- def process_field(field, value)
107
+ def process_field(field, value, line)
95
108
  case field
96
109
  when 'event' then @event_type = decode(value)
97
110
  when 'data' then @data << decode(value) << "\n"
98
111
  when 'id' then @id_buffer = decode(value) unless value.include?("\0")
99
112
  when 'retry' then @reconnection_time = value.to_i if value.match?(DIGITS)
113
+ else @on_unknown_line&.call(line)
100
114
  end
101
115
  end
102
116
 
@@ -6,6 +6,8 @@ require 'json'
6
6
  module RubyLLM
7
7
  module Transport
8
8
  class JsonResponse < Faraday::Middleware # :nodoc:
9
+ RAW_KEY = :ruby_llm_raw_body
10
+
9
11
  def initialize(app, parser_options: {}, preserve_raw: false)
10
12
  super(app)
11
13
  @parser_options = parser_options || {}
@@ -14,16 +16,20 @@ module RubyLLM
14
16
 
15
17
  def on_complete(env)
16
18
  body = env[:body]
17
- return unless body.respond_to?(:to_str) && json_response?(env)
19
+ return unless body.respond_to?(:to_str) && json_response?(env) && !raw?(env)
18
20
 
19
21
  env[:raw_body] = body if @preserve_raw
20
22
  env[:body] = body.strip.empty? ? nil : JSON.parse(body, **@parser_options)
21
- rescue StandardError, SyntaxError => e
22
- raise Faraday::ParsingError.new(e, env[:response])
23
+ rescue StandardError, SyntaxError
24
+ raise Error.new('The response body is not valid JSON', response: env[:response])
23
25
  end
24
26
 
25
27
  private
26
28
 
29
+ def raw?(env)
30
+ env[:request]&.context&.dig(RAW_KEY)
31
+ end
32
+
27
33
  def json_response?(env)
28
34
  content_type = env[:response_headers]['content-type'].to_s.split(';', 2).first
29
35
  content_type&.match?(/\bjson$/)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/logging/formatter'
5
+
6
+ module RubyLLM
7
+ module Transport
8
+ # A request that succeeded and was billed must not fail because a debug
9
+ # log filter ran out of time on a large body.
10
+ class LogFormatter < Faraday::Logging::Formatter # :nodoc:
11
+ private
12
+
13
+ def apply_filters(output)
14
+ super
15
+ rescue Regexp::TimeoutError
16
+ '[NOT LOGGED: FILTER TIMED OUT]'
17
+ end
18
+ end
19
+ end
20
+ end