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,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Gemini
6
+ # Gemini can pause a long response with a continuation token. Each
7
+ # hop resends the same request with the newest token, and the hops
8
+ # join into one assistant message.
9
+ module LongDecode # :nodoc:
10
+ def complete(messages, **, &)
11
+ @unfinished = messages.last if messages.last&.continuation
12
+ super
13
+ end
14
+
15
+ def complete_request(payload, headers, usage_recorder: nil, progress_recorder: nil, &)
16
+ segments = [@unfinished].compact
17
+ parts = Array(@unfinished&.raw_content)
18
+
19
+ loop do
20
+ hop = super(payload, headers, usage_recorder:, &)
21
+ segments << hop
22
+ parts.concat(hop_parts(hop))
23
+ return segments.one? ? hop : join_hops(segments, parts) unless hop.continuation
24
+
25
+ if segments[...-1].any? { |segment| segment.continuation == hop.continuation }
26
+ raise Error.new('Gemini repeated a continuation token', response: hop.raw)
27
+ end
28
+
29
+ progress_recorder&.call(join_hops(segments, parts, continuation: hop.continuation))
30
+ payload = payload.merge(continuationToken: hop.continuation)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def stream_response(...)
37
+ @stream_parts = @stream_metadata_calls = @stream_citation_parts = nil
38
+ super
39
+ end
40
+
41
+ def hop_parts(hop)
42
+ hop.raw_content || @stream_parts || hop.raw&.body&.dig('candidates', 0, 'content', 'parts') || []
43
+ end
44
+
45
+ def join_hops(segments, parts, continuation: nil)
46
+ final = segments.last
47
+ Message.new(
48
+ role: :assistant,
49
+ content: join_text(segments.map(&:content)),
50
+ thinking: join_thinking(segments),
51
+ attachments: segments.flat_map(&:attachments),
52
+ citations: join_citations(segments),
53
+ tool_calls: join_tool_calls(segments),
54
+ server_tool_calls: segments.flat_map(&:server_tool_calls),
55
+ raw_content: join_raw_content(parts, continuation),
56
+ continuation: continuation,
57
+ tokens: Tokens.aggregate(segments.map(&:tokens)),
58
+ usage_entries: segments.flat_map(&:ruby_llm_usage_entries),
59
+ finish_reason: final.finish_reason,
60
+ model: final.model,
61
+ raw: final.raw
62
+ )
63
+ end
64
+
65
+ def join_text(values)
66
+ text = values.compact.join
67
+ text unless text.empty?
68
+ end
69
+
70
+ def join_thinking(segments)
71
+ thoughts = segments.filter_map(&:thinking)
72
+ Thinking.build(text: join_text(thoughts.map(&:text)), signature: thoughts.filter_map(&:signature).last)
73
+ end
74
+
75
+ def join_tool_calls(segments)
76
+ calls = segments.filter_map(&:tool_calls).reduce({}, :merge)
77
+ calls unless calls.empty?
78
+ end
79
+
80
+ def join_raw_content(parts, continuation)
81
+ return without_search_suggestions(parts) if continuation
82
+ return unless parts.any? { |part| server_tool_part?(part) } ||
83
+ parts.count { |part| part['thoughtSignature'] } > 1
84
+
85
+ without_search_suggestions(parts)
86
+ end
87
+
88
+ def join_citations(segments)
89
+ offset = 0
90
+ segments.flat_map do |segment|
91
+ citations = segment.citations.map do |citation|
92
+ next citation unless citation.start_index
93
+
94
+ Citation.new(citation.to_h.merge(start_index: citation.start_index + offset,
95
+ end_index: citation.end_index + offset))
96
+ end
97
+ offset += segment.content.to_s.length
98
+ citations
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -14,31 +14,27 @@ module RubyLLM
14
14
  %i[image audio video pdf text].include?(attachment.type)
15
15
  end
16
16
 
17
- def format_content(content, attachments = [])
17
+ def render_content(content, attachments = [])
18
18
  parts = []
19
- parts << format_text(content) if content
19
+ parts << render_text(content) if content
20
20
 
21
21
  attachments.each do |attachment|
22
- parts << with_media_resolution(format_content_attachment(attachment), attachment)
22
+ part = with_media_resolution(render_content_attachment(attachment), attachment)
23
+ parts << with_media_processing(part, attachment)
23
24
  end
24
25
 
25
26
  parts
26
27
  end
27
28
 
28
- def format_content_attachment(attachment)
29
+ def render_content_attachment(attachment)
29
30
  raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
30
31
 
31
- case attachment.type
32
- when :text
33
- format_text_file(attachment)
34
- else
35
- format_attachment(attachment)
36
- end
37
- end
32
+ return render_file_data(attachment) if attachment.provider_file?
38
33
 
39
- def format_attachment(attachment)
40
- return format_file_data(attachment) if attachment.provider_file?
34
+ attachment.type == :text ? render_text_file(attachment) : render_attachment(attachment)
35
+ end
41
36
 
37
+ def render_attachment(attachment)
42
38
  {
43
39
  inline_data: {
44
40
  mime_type: attachment.mime_type,
@@ -55,7 +51,13 @@ module RubyLLM
55
51
  part.merge(media_resolution: { level: "MEDIA_RESOLUTION_#{level.upcase}" })
56
52
  end
57
53
 
58
- def format_file_data(attachment)
54
+ def with_media_processing(part, attachment)
55
+ return part unless attachment.processing && attachment.video?
56
+
57
+ part.merge(media_processing: attachment.processing.to_s.upcase)
58
+ end
59
+
60
+ def render_file_data(attachment)
59
61
  uri = attachment.provider_file_uri || attachment.provider_file_id
60
62
  {
61
63
  file_data: {
@@ -65,17 +67,28 @@ module RubyLLM
65
67
  }
66
68
  end
67
69
 
68
- def format_text_file(text_file)
70
+ def render_text_file(text_file)
69
71
  {
70
72
  text: text_file.for_llm
71
73
  }
72
74
  end
73
75
 
74
- def format_text(text)
76
+ def render_text(text)
75
77
  {
76
78
  text: text
77
79
  }
78
80
  end
81
+
82
+ def attachment_filename(mime_type, index)
83
+ return "gemini_attachment_#{index + 1}" unless mime_type
84
+
85
+ extension = mime_type.split('/').last.to_s
86
+ extension = 'jpg' if extension == 'jpeg'
87
+ extension = 'txt' if extension == 'plain'
88
+ extension = 'mp3' if mime_type == 'audio/mpeg'
89
+ extension = extension.tr('+', '.')
90
+ "gemini_attachment_#{index + 1}.#{extension}"
91
+ end
79
92
  end
80
93
 
81
94
  def build_response_content(parts) # rubocop:disable Metrics/PerceivedComplexity
@@ -108,7 +121,7 @@ module RubyLLM
108
121
  io = StringIO.new(decoded)
109
122
  io.set_encoding(Encoding::BINARY) if io.respond_to?(:set_encoding)
110
123
 
111
- filename = attachment_filename(mime_type, index)
124
+ filename = Media.attachment_filename(mime_type, index)
112
125
  RubyLLM::Attachment.new(io, filename:)
113
126
  rescue ArgumentError => e
114
127
  RubyLLM.logger.warn "Failed to decode Gemini inline data attachment: #{e.message}"
@@ -119,19 +132,9 @@ module RubyLLM
119
132
  uri = file_data['fileUri']
120
133
  return unless uri
121
134
 
122
- filename = file_data['filename'] || attachment_filename(file_data['mimeType'], index)
135
+ filename = file_data['filename'] || Media.attachment_filename(file_data['mimeType'], index)
123
136
  RubyLLM::Attachment.new(uri, filename:)
124
137
  end
125
-
126
- def attachment_filename(mime_type, index)
127
- return "gemini_attachment_#{index + 1}" unless mime_type
128
-
129
- extension = mime_type.split('/').last.to_s
130
- extension = 'jpg' if extension == 'jpeg'
131
- extension = 'txt' if extension == 'plain'
132
- extension = extension.tr('+', '.')
133
- "gemini_attachment_#{index + 1}.#{extension}"
134
- end
135
138
  end
136
139
  end
137
140
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Gemini
6
+ # Google says how long to wait in the error body, as a
7
+ # google.rpc.RetryInfo detail, rather than in a header.
8
+ module RetryInfo # :nodoc: all
9
+ DURATION = /\A\d{1,12}(\.\d{1,9})?s\z/
10
+
11
+ def retry_delay(response)
12
+ retry_info_details(parse_error_body(response)).filter_map { |detail| retry_info_seconds(detail) }.max
13
+ end
14
+
15
+ private
16
+
17
+ def retry_info_details(body)
18
+ errors = [body].flatten.grep(Hash).map { |part| part['error'] }.grep(Hash)
19
+ errors.flat_map { |error| error['details'] }.grep(Hash)
20
+ end
21
+
22
+ def retry_info_seconds(detail)
23
+ delay = detail['retryDelay']
24
+ delay.to_f if delay.is_a?(String) && delay.match?(DURATION)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Gemini
6
+ # JSON Schemas as Gemini accepts them. Gemini refuses a schema nested
7
+ # more than 32 levels deep with a bare "Request contains an invalid
8
+ # argument", so a deeper one goes with its deep subschemas moved into
9
+ # $defs and referenced from where they were, which describes the same
10
+ # values in fewer levels.
11
+ module Schemas
12
+ MAX_DEPTH = 32
13
+ HOIST_DEPTH = 16
14
+ DEFINITION_KEYS = %w[$defs definitions].freeze
15
+ SCHEMA_MAPS = %w[properties patternProperties dependentSchemas $defs definitions].freeze
16
+ SCHEMA_LISTS = %w[anyOf oneOf allOf prefixItems].freeze
17
+ SCHEMA_VALUES = %w[items additionalProperties additionalItems unevaluatedProperties unevaluatedItems
18
+ not if then else contains propertyNames].freeze
19
+
20
+ module_function
21
+
22
+ def render_schema(schema)
23
+ return schema unless schema.is_a?(Hash) && nesting(schema) > MAX_DEPTH
24
+
25
+ schema = Support::Utils.deep_stringify_keys(schema)
26
+ movable?(schema) ? Hoisting.new(schema).schema : schema
27
+ end
28
+
29
+ def movable?(schema)
30
+ references_only_definitions?(schema) && !identified?(schema.except('$id'))
31
+ end
32
+
33
+ def nesting(value)
34
+ case value
35
+ when Hash then 1 + (value.values.map { |child| nesting(child) }.max || 0)
36
+ when Array then 1 + (value.map { |child| nesting(child) }.max || 0)
37
+ else 0
38
+ end
39
+ end
40
+
41
+ # A reference into the schema's own structure, such as
42
+ # "#/properties/address", would point at a subschema that moved.
43
+ def references_only_definitions?(value)
44
+ case value
45
+ when Hash
46
+ value.all? do |key, child|
47
+ key == '$ref' ? definition_reference?(child) : references_only_definitions?(child)
48
+ end
49
+ when Array then value.all? { |child| references_only_definitions?(child) }
50
+ else true
51
+ end
52
+ end
53
+
54
+ # Only a whole definition, since a pointer into one could pass
55
+ # through a subschema that moved.
56
+ def definition_reference?(reference)
57
+ !reference.is_a?(String) || !reference.start_with?('#') ||
58
+ DEFINITION_KEYS.any? { |key| reference.match?(%r{\A#/#{Regexp.escape(key)}/[^/]+\z}) }
59
+ end
60
+
61
+ # A nested $id starts a resource of its own, whose references
62
+ # resolve against it rather than the root.
63
+ def identified?(value)
64
+ case value
65
+ when Hash then value.key?('$id') || value.values.any? { |child| identified?(child) }
66
+ when Array then value.any? { |child| identified?(child) }
67
+ else false
68
+ end
69
+ end
70
+
71
+ # Rewrites one schema, moving each subschema at HOIST_DEPTH or
72
+ # deeper into the root's definitions.
73
+ class Hoisting
74
+ attr_reader :schema
75
+
76
+ def initialize(schema)
77
+ @key = DEFINITION_KEYS.find { |key| schema[key].is_a?(Hash) } || '$defs'
78
+ @definitions = schema.fetch(@key, {}).dup
79
+ @count = 0
80
+ @definitions.keys.each { |name| @definitions[name] = rewrite(@definitions[name], 3) }
81
+ @schema = rewrite(schema.except(@key), 1).merge(@key => @definitions)
82
+ end
83
+
84
+ private
85
+
86
+ def rewrite(schema, depth)
87
+ return schema unless schema.is_a?(Hash)
88
+ return hoist(schema) if depth >= HOIST_DEPTH
89
+
90
+ schema.to_h { |key, value| [key, rewrite_value(key, value, depth)] }
91
+ end
92
+
93
+ def rewrite_value(key, value, depth)
94
+ case [position(key), value]
95
+ in [:map, Hash] then value.transform_values { |child| rewrite(child, depth + 2) }
96
+ in [:list, Array] then value.map { |child| rewrite(child, depth + 2) }
97
+ in [:value, _] then rewrite(value, depth + 1)
98
+ else value
99
+ end
100
+ end
101
+
102
+ def position(key)
103
+ if SCHEMA_MAPS.include?(key) then :map
104
+ elsif SCHEMA_LISTS.include?(key) then :list
105
+ elsif SCHEMA_VALUES.include?(key) then :value
106
+ end
107
+ end
108
+
109
+ def hoist(schema)
110
+ name = next_name
111
+ @definitions[name] = rewrite(schema, 3)
112
+ { '$ref' => "#/#{@key}/#{name}" }
113
+ end
114
+
115
+ def next_name
116
+ loop do
117
+ @count += 1
118
+ name = "nested#{@count}"
119
+ return name unless @definitions.key?(name)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -12,7 +12,9 @@ module RubyLLM
12
12
  end
13
13
 
14
14
  def render_speech_payload(input, model:, voice:, format:, provider_options: {})
15
- RubyLLM.logger.debug { "Ignoring speech format #{format}. Gemini returns PCM audio." } if format
15
+ unless format.nil? || format.to_s == 'pcm'
16
+ raise ArgumentError, "Gemini speech returns PCM audio and does not accept format #{format.inspect}"
17
+ end
16
18
 
17
19
  payload = {
18
20
  contents: [
@@ -39,15 +41,19 @@ module RubyLLM
39
41
  Support::Utils.deep_merge(payload, provider_options)
40
42
  end
41
43
 
42
- def parse_speech_response(response, model:, voice:, format:) # rubocop:disable Lint/UnusedMethodArgument
44
+ def parse_speech_response(response, model:, voice:, **)
43
45
  audio = response.body.dig('candidates', 0, 'content', 'parts', 0, 'inlineData', 'data')
44
46
  raise Error, 'Unexpected response format from Gemini speech generation API' unless audio
45
47
 
48
+ usage = response.body['usageMetadata'] || {}
49
+
46
50
  RubyLLM::Speech.new(
47
51
  data: Base64.decode64(audio),
48
52
  model: model,
49
53
  voice: voice || 'Kore',
50
- format: 'pcm'
54
+ format: 'pcm',
55
+ input_tokens: usage['promptTokenCount'],
56
+ output_tokens: usage['candidatesTokenCount']
51
57
  )
52
58
  end
53
59
  end
@@ -18,21 +18,23 @@ module RubyLLM
18
18
  Chunk.new(
19
19
  role: :assistant,
20
20
  model: data['modelVersion'],
21
- content: extract_text_content(parts),
22
- citations: extract_citations(data, nil),
21
+ content: parse_text_content(parts),
22
+ attachments: build_response_content(parts.reject { |part| part['thought'] }).last,
23
+ citations: parse_stream_citations(data),
23
24
  thinking: Thinking.build(
24
- text: extract_thought_parts(parts),
25
- signature: extract_thought_signature(parts)
25
+ text: parse_thought_parts(parts),
26
+ signature: parse_thought_signature(parts)
26
27
  ),
27
28
  input_tokens: input_tokens(data),
28
- output_tokens: extract_output_tokens(data),
29
+ output_tokens: parse_output_tokens(data),
29
30
  cache_read_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
30
31
  thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
31
32
  server_tool_use: parse_server_tool_use(data),
32
33
  finish_reason: normalize_finish_reason(
33
34
  data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
34
35
  ),
35
- tool_calls: extract_tool_calls(data),
36
+ tool_calls: parse_tool_calls(data),
37
+ continuation: parse_continuation(data),
36
38
  **stream_end_fields(data)
37
39
  )
38
40
  end
@@ -42,12 +44,31 @@ module RubyLLM
42
44
  # Accumulates streamed parts so code-execution turns can be replayed
43
45
  # verbatim, mirroring the non-streaming path.
44
46
  def track_stream_parts(data, parts)
47
+ track_citation_parts(parts)
45
48
  @stream_parts = (@stream_parts || []).concat(parts)
46
49
  @saw_server_part = true if parts.any? { |part| server_tool_part?(part) }
47
50
  metadata_calls = metadata_server_tool_calls(data)
48
51
  @stream_metadata_calls = metadata_calls if metadata_calls.any?
49
52
  end
50
53
 
54
+ def track_citation_parts(parts)
55
+ @stream_citation_parts ||= []
56
+ previous = @stream_citation_parts.last
57
+ first = parts.first
58
+ if continued_text?(previous, first)
59
+ previous['text'] << first['text']
60
+ parts = parts.drop(1)
61
+ end
62
+ @stream_citation_parts.concat(parts.map do |part|
63
+ part['text'] ? part.merge('text' => part['text'].dup) : part
64
+ end)
65
+ end
66
+
67
+ def continued_text?(previous, first)
68
+ previous && first && previous.key?('text') && first.key?('text') &&
69
+ previous.fetch('thought', false) == first.fetch('thought', false)
70
+ end
71
+
51
72
  def stream_end_fields(data)
52
73
  return {} unless data.dig('candidates', 0, 'finishReason')
53
74
 
@@ -60,21 +81,24 @@ module RubyLLM
60
81
  )
61
82
  end
62
83
  calls = part_calls + @stream_metadata_calls.to_a
63
- return {} if calls.empty?
84
+ replay = @saw_server_part || parse_continuation(data)
85
+ fields = { server_tool_calls: calls, raw_content: (without_search_suggestions(@stream_parts) if replay) }
86
+ fields.compact.reject { |_, value| value.empty? }
87
+ end
88
+
89
+ def parse_stream_citations(data)
90
+ return [] unless data.dig('candidates', 0, 'groundingMetadata')
64
91
 
65
- {
66
- server_tool_calls: calls,
67
- raw_content: @saw_server_part ? without_search_suggestions(@stream_parts) : nil
68
- }
92
+ parse_citations(data, @stream_citation_parts)
69
93
  end
70
94
 
71
- def extract_text_content(parts)
95
+ def parse_text_content(parts)
72
96
  text_parts = parts.reject { |p| p['thought'] }
73
97
  text = text_parts.filter_map { |p| p['text'] }.join
74
98
  text.empty? ? nil : text
75
99
  end
76
100
 
77
- def extract_output_tokens(data)
101
+ def parse_output_tokens(data)
78
102
  candidates = data.dig('usageMetadata', 'candidatesTokenCount') || 0
79
103
  thoughts = data.dig('usageMetadata', 'thoughtsTokenCount') || 0
80
104
  total = candidates + thoughts
@@ -14,7 +14,7 @@ module RubyLLM
14
14
  # make, such as one from another provider. Vertex AI accepts no other.
15
15
  PLACEHOLDER_SIGNATURE = 'skip_thought_signature_validator'
16
16
 
17
- def format_tools(tools)
17
+ def render_tools(tools)
18
18
  return [] if tools.empty?
19
19
 
20
20
  [{
@@ -24,12 +24,13 @@ module RubyLLM
24
24
 
25
25
  # Gemini signs only the first of parallel calls, so each call goes back
26
26
  # with its own signature or none, never one another part carried.
27
- def format_tool_call(msg)
27
+ def render_tool_call(msg)
28
28
  parts = []
29
29
 
30
- parts.concat(Media.format_content(msg.content, msg.attachments)) if msg.content && !msg.content.empty?
30
+ text = msg.content unless msg.content.to_s.empty?
31
+ parts.concat(Media.render_content(text, msg.attachments))
31
32
 
32
- msg.tool_calls.each_value do |tool_call|
33
+ msg.tool_calls.each do |tool_call|
33
34
  part = {
34
35
  functionCall: {
35
36
  name: tool_call.name,
@@ -43,27 +44,28 @@ module RubyLLM
43
44
  parts
44
45
  end
45
46
 
46
- def format_tool_result(msg, function_name = nil)
47
+ def render_tool_result(msg, function_name = nil)
47
48
  function_name ||= msg.tool_call_id
48
49
  content = msg.content
49
50
  content = nil if content && content.empty?
50
51
  content = '(no output)' if content.nil? && msg.attachments.empty?
51
52
 
53
+ texts, files = split_text_attachments(msg.attachments)
52
54
  function_response = {
53
55
  name: function_name,
54
56
  response: {
55
57
  name: function_name,
56
- content: Media.format_content(content)
58
+ content: Media.render_content(content) + texts.map { |text| Media.render_content_attachment(text) }
57
59
  }
58
60
  }
59
61
 
60
- media_parts, sibling_parts = partition_tool_result_attachments(msg.attachments)
62
+ media_parts, sibling_parts = partition_tool_result_attachments(files)
61
63
  function_response[:parts] = media_parts if media_parts.any?
62
64
 
63
65
  [{ functionResponse: function_response }, *sibling_parts]
64
66
  end
65
67
 
66
- def extract_tool_calls(data) # rubocop:disable Metrics/PerceivedComplexity
68
+ def parse_tool_calls(data) # rubocop:disable Metrics/PerceivedComplexity
67
69
  return nil unless data
68
70
 
69
71
  candidate = data.is_a?(Hash) ? data.dig('candidates', 0) : nil
@@ -101,11 +103,17 @@ module RubyLLM
101
103
  parts.map { |part| part.equal?(first_call) ? part.merge(thoughtSignature: PLACEHOLDER_SIGNATURE) : part }
102
104
  end
103
105
 
106
+ # Vertex refuses text parts beside a functionResponse, so text files
107
+ # travel inside its response.
108
+ def split_text_attachments(attachments)
109
+ attachments.partition { |attachment| attachment.type == :text && !attachment.provider_file? }
110
+ end
111
+
104
112
  # functionResponse.parts only accepts inline bytes, and pre-Gemini 3 models reject it
105
113
  def partition_tool_result_attachments(attachments)
106
114
  return [[], []] if attachments.empty?
107
115
 
108
- parts = attachments.map { |attachment| Media.format_content_attachment(attachment) }
116
+ parts = attachments.map { |attachment| Media.render_content_attachment(attachment) }
109
117
  return [[], parts] unless multimodal_function_responses_supported?(@model)
110
118
 
111
119
  parts.partition { |part| part.key?(:inline_data) }
@@ -132,7 +140,7 @@ module RubyLLM
132
140
  description: tool.description
133
141
  }
134
142
 
135
- declaration[:parametersJsonSchema] = parameters_schema if parameters_schema
143
+ declaration[:parametersJsonSchema] = Schemas.render_schema(parameters_schema) if parameters_schema
136
144
 
137
145
  return declaration if tool.provider_options.empty?
138
146
 
@@ -7,13 +7,17 @@ module RubyLLM
7
7
  module Transcription
8
8
  DEFAULT_PROMPT = 'Transcribe the provided audio and respond with only the transcript text.'
9
9
 
10
+ def rejected_options(operation)
11
+ operation == :transcribe ? %i[speaker_names speaker_references] : super
12
+ end
13
+
10
14
  # rubocop:disable-next Lint/UnusedMethodArgument
11
15
  def transcribe(audio_file, model:, language:, format: nil, speaker_names: nil,
12
16
  speaker_references: nil, provider_options: {}, prompt: nil, temperature: nil)
13
17
  raise_transcription_streaming_unsupported if block_given?
14
18
 
15
19
  track_usage(:transcription) do
16
- attachment = Attachment.new(audio_file, config: @config)
20
+ attachment = audio_file.is_a?(Attachment) ? audio_file : Attachment.new(audio_file, config: @config)
17
21
  payload = render_transcription_payload(attachment, language:, format:, provider_options:, prompt:,
18
22
  temperature:)
19
23
  response = @connection.post(transcription_url(model), payload, usage: @usage_tracker)
@@ -30,7 +34,7 @@ module RubyLLM
30
34
  def render_transcription_payload(attachment, language:, format: nil, provider_options: {}, prompt: nil,
31
35
  temperature: nil)
32
36
  prompt = build_prompt(prompt, language)
33
- audio_part = format_audio_part(attachment)
37
+ audio_part = render_audio_part(attachment)
34
38
 
35
39
  raise UnsupportedAttachmentError, attachment.mime_type unless attachment.audio?
36
40
 
@@ -63,7 +67,7 @@ module RubyLLM
63
67
  prompt
64
68
  end
65
69
 
66
- def format_audio_part(attachment)
70
+ def render_audio_part(attachment)
67
71
  {
68
72
  inline_data: {
69
73
  mime_type: attachment.mime_type,
@@ -74,10 +78,10 @@ module RubyLLM
74
78
 
75
79
  def parse_transcription_response(response, model:)
76
80
  data = response.body
77
- text = extract_text(data)
81
+ text = parse_text(data)
78
82
  raise_if_transcription_blocked(data, text, response)
79
83
 
80
- usage = extract_usage(data)
84
+ usage = parse_usage(data)
81
85
 
82
86
  RubyLLM::Transcription.new(
83
87
  text: text,
@@ -94,21 +98,21 @@ module RubyLLM
94
98
  reason = data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
95
99
  return unless data.dig('candidates', 0).nil? || normalize_finish_reason(reason) == :content_filter
96
100
 
97
- usage = extract_usage(data)
101
+ usage = parse_usage(data)
98
102
  @usage_tracker&.observe_tokens(Tokens.new(input: usage[:input_tokens], output: usage[:output_tokens]))
99
103
  raise ContentFilterError.new(['Gemini blocked the transcription', reason].compact.join(': '), response:)
100
104
  end
101
105
 
102
- def extract_text(data)
106
+ def parse_text(data)
103
107
  candidate = data.is_a?(Hash) ? data.dig('candidates', 0) : nil
104
108
  return unless candidate
105
109
 
106
110
  parts = candidate.dig('content', 'parts') || []
107
- texts = parts.filter_map { |part| part['text'] }
111
+ texts = parts.reject { |part| part['thought'] }.filter_map { |part| part['text'] }
108
112
  texts.join if texts.any?
109
113
  end
110
114
 
111
- def extract_usage(data)
115
+ def parse_usage(data)
112
116
  metadata = data.is_a?(Hash) ? data['usageMetadata'] : nil
113
117
  return { input_tokens: nil, output_tokens: nil } unless metadata
114
118