ruby_llm 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -6,7 +6,7 @@ module RubyLLM
6
6
  # Chat methods for the Gemini API implementation
7
7
  module Chat
8
8
  FINISH_REASONS = {
9
- 'STOP' => :stop, 'MAX_TOKENS' => :max_tokens,
9
+ 'STOP' => :stop, 'MAX_TOKENS' => :max_tokens, 'CONTINUATION' => nil,
10
10
  'SAFETY' => :content_filter, 'RECITATION' => :content_filter, 'BLOCKLIST' => :content_filter,
11
11
  'PROHIBITED_CONTENT' => :content_filter, 'SPII' => :content_filter, 'IMAGE_SAFETY' => :content_filter,
12
12
  'IMAGE_RECITATION' => :content_filter, 'IMAGE_PROHIBITED_CONTENT' => :content_filter,
@@ -23,7 +23,7 @@ module RubyLLM
23
23
  def normalize_finish_reason(reason)
24
24
  return nil if reason.nil?
25
25
 
26
- finish_reasons.fetch(reason.to_s) { reason.to_s.to_sym }
26
+ finish_reasons.fetch(reason.to_s) { reason.to_s.downcase.to_sym }
27
27
  end
28
28
 
29
29
  def completion_url
@@ -35,11 +35,13 @@ module RubyLLM
35
35
  thinking: nil, citations: false, caching: nil, tool_prefs: nil)
36
36
  warn_unsupported_citations(model) if citations && !model.supports?(:citations)
37
37
  tool_prefs ||= {}
38
+ unfinished = messages.last if messages.last&.continuation
38
39
  payload = {
39
- contents: format_messages(messages.reject { |msg| msg.role == :system }),
40
+ contents: render_messages(messages.reject { |msg| msg.role == :system || msg.equal?(unfinished) }),
40
41
  generationConfig: {}
41
42
  }
42
- system_instruction = format_system_instruction(messages)
43
+ payload[:continuationToken] = unfinished.continuation if unfinished
44
+ system_instruction = render_system_instruction(messages)
43
45
  payload[:systemInstruction] = system_instruction if system_instruction
44
46
 
45
47
  payload[:generationConfig][:temperature] = temperature unless temperature.nil?
@@ -49,7 +51,7 @@ module RubyLLM
49
51
  payload[:generationConfig][:thinkingConfig] = build_thinking_config(model, thinking) if thinking&.enabled?
50
52
 
51
53
  if tools.any?
52
- payload[:tools] = format_tools(tools)
54
+ payload[:tools] = render_tools(tools)
53
55
  # Gemini doesn't support controlling parallel tool calls
54
56
  payload[:toolConfig] = build_tool_config(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
55
57
  end
@@ -108,9 +110,10 @@ module RubyLLM
108
110
  end
109
111
 
110
112
  def build_thinking_config(_model, thinking)
111
- return { includeThoughts: false, thinkingBudget: 0 } if thinking.enabled == false
113
+ off = thinking.enabled == false || (thinking.budget.is_a?(Integer) && thinking.budget.zero?)
114
+ return { includeThoughts: false, thinkingBudget: 0 } if off
112
115
 
113
- config = { includeThoughts: true }
116
+ config = { includeThoughts: include_thoughts?(thinking.display) }
114
117
 
115
118
  config[:thinkingLevel] = thinking.effort.to_s if thinking.effort
116
119
  config[:thinkingBudget] = thinking.budget if thinking.budget.is_a?(Integer)
@@ -119,7 +122,15 @@ module RubyLLM
119
122
  config
120
123
  end
121
124
 
122
- def supports_provider_file_references?
125
+ def include_thoughts?(display)
126
+ unless [nil, :summarized, :omitted].include?(display)
127
+ raise ArgumentError, 'Gemini thinking display must be summarized or omitted'
128
+ end
129
+
130
+ display != :omitted
131
+ end
132
+
133
+ def provider_file_references?
123
134
  true
124
135
  end
125
136
 
@@ -133,20 +144,20 @@ module RubyLLM
133
144
 
134
145
  private
135
146
 
136
- def format_system_instruction(messages)
147
+ def render_system_instruction(messages)
137
148
  parts = messages.select { |msg| msg.role == :system }.flat_map do |msg|
138
149
  text = msg.content.to_s
139
- Media.format_content(text.empty? ? nil : text, msg.attachments)
150
+ Media.render_content(text.empty? ? nil : text, msg.attachments)
140
151
  end
141
152
 
142
153
  { parts: parts } if parts.any?
143
154
  end
144
155
 
145
- def format_messages(messages)
156
+ def render_messages(messages)
146
157
  MessageFormatter.new(self, messages).format
147
158
  end
148
159
 
149
- def format_role(role)
160
+ def render_role(role)
150
161
  case role
151
162
  when :assistant then 'model'
152
163
  when :system, :tool then 'user'
@@ -154,20 +165,21 @@ module RubyLLM
154
165
  end
155
166
  end
156
167
 
157
- def format_parts(msg)
168
+ def render_parts(msg)
158
169
  if msg.role == :assistant && msg.raw_content.is_a?(Array)
159
170
  msg.raw_content
160
171
  elsif msg.tool_call?
161
- format_tool_call(msg)
172
+ render_tool_call(msg)
162
173
  elsif msg.tool_result?
163
- format_tool_result(msg)
174
+ render_tool_result(msg)
164
175
  else
165
- format_message_parts(msg)
176
+ render_message_parts(msg)
166
177
  end
167
178
  end
168
179
 
169
- def format_message_parts(msg)
170
- parts = Media.format_content(msg.content, msg.attachments)
180
+ def render_message_parts(msg)
181
+ content = msg.content unless msg.role == :assistant && msg.content.to_s.empty?
182
+ parts = Media.render_content(content, msg.attachments)
171
183
  thinking = msg.thinking if msg.role == :assistant
172
184
  return parts unless thinking
173
185
 
@@ -179,27 +191,29 @@ module RubyLLM
179
191
  # Gemini signs the last part of an answer. An answer without parts gets
180
192
  # the empty text part a stream ends with: Gemini refuses a part without data.
181
193
  def sign_last_part(parts, signature)
182
- parts = [Media.format_text('')] if parts.empty?
194
+ parts = [Media.render_text('')] if parts.empty?
183
195
  [*parts[...-1], parts.last.merge(thoughtSignature: signature)]
184
196
  end
185
197
 
186
198
  def parse_completion_body(data, raw:)
187
199
  parts = data.dig('candidates', 0, 'content', 'parts') || []
188
- tool_calls = extract_tool_calls(data)
200
+ tool_calls = parse_tool_calls(data)
189
201
  content, attachments = parse_content(data)
202
+ continuation = parse_continuation(data)
190
203
 
191
204
  Message.new(
192
205
  role: :assistant,
193
206
  content: content,
194
207
  attachments: attachments,
195
- citations: extract_citations(data, content),
208
+ citations: parse_citations(data, parts),
196
209
  thinking: Thinking.build(
197
- text: extract_thought_parts(parts),
198
- signature: extract_thought_signature(parts)
210
+ text: parse_thought_parts(parts),
211
+ signature: parse_thought_signature(parts)
199
212
  ),
200
213
  tool_calls: tool_calls,
201
- server_tool_calls: extract_server_tool_calls(data, parts),
202
- raw_content: parts.any? { |part| server_tool_part?(part) } ? without_search_suggestions(parts) : nil,
214
+ server_tool_calls: parse_server_tool_calls(data, parts),
215
+ raw_content: (without_search_suggestions(parts) if continuation || parts.any? { |p| server_tool_part?(p) }),
216
+ continuation: continuation,
203
217
  input_tokens: input_tokens(data),
204
218
  output_tokens: calculate_output_tokens(data),
205
219
  cache_read_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
@@ -213,6 +227,14 @@ module RubyLLM
213
227
  )
214
228
  end
215
229
 
230
+ # Long decode hands back a token whenever it pauses, including at
231
+ # stream checkpoints and on MAX_TOKENS; only CONTINUATION leaves the
232
+ # response unfinished.
233
+ def parse_continuation(data)
234
+ candidate = data.dig('candidates', 0) || {}
235
+ candidate['continuationToken'] if candidate['finishReason'] == 'CONTINUATION'
236
+ end
237
+
216
238
  # Gemini 3 bills each distinct, non-empty query a grounding ran.
217
239
  def parse_server_tool_use(data)
218
240
  searches = Array(data['candidates']).sum do |candidate|
@@ -221,11 +243,15 @@ module RubyLLM
221
243
  { 'web_search_requests' => searches }
222
244
  end
223
245
 
246
+ # Gemini reports what a server-side tool loaded for the model, such as
247
+ # the frames of an agentic video, apart from the prompt, and bills it
248
+ # as input.
224
249
  def input_tokens(data)
225
- prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
250
+ usage = data['usageMetadata'] || {}
251
+ prompt_tokens = usage['promptTokenCount']
226
252
  return unless prompt_tokens
227
253
 
228
- [prompt_tokens.to_i - data.dig('usageMetadata', 'cachedContentTokenCount').to_i, 0].max
254
+ [prompt_tokens.to_i + usage['toolUsePromptTokenCount'].to_i - usage['cachedContentTokenCount'].to_i, 0].max
229
255
  end
230
256
 
231
257
  def parse_content(data)
@@ -260,7 +286,7 @@ module RubyLLM
260
286
  end
261
287
  end
262
288
 
263
- def extract_server_tool_calls(data, parts)
289
+ def parse_server_tool_calls(data, parts)
264
290
  calls = parts.select { |part| server_tool_part?(part) }.map do |part|
265
291
  ServerToolCall.new(
266
292
  type: part.key?('executableCode') ? 'executable_code' : 'code_execution_result',
@@ -280,6 +306,7 @@ module RubyLLM
280
306
  queries = candidate.dig('groundingMetadata', 'webSearchQueries')
281
307
  if queries&.any?
282
308
  calls << ServerToolCall.new(type: 'google_search', input: { 'queries' => queries },
309
+ result: web_grounding_chunks(candidate),
283
310
  raw: { 'webSearchQueries' => queries },
284
311
  search_suggestions: candidate.dig('groundingMetadata', 'searchEntryPoint',
285
312
  'renderedContent'))
@@ -290,8 +317,15 @@ module RubyLLM
290
317
  calls
291
318
  end
292
319
 
320
+ # Grounding on a store of documents reports retrievedContext chunks,
321
+ # which are not web pages.
322
+ def web_grounding_chunks(candidate)
323
+ chunks = Array(candidate.dig('groundingMetadata', 'groundingChunks')).select { |chunk| chunk['web'] }
324
+ chunks unless chunks.empty?
325
+ end
326
+
293
327
  # Normalizes grounding metadata (Google Search grounding) into citations.
294
- def extract_citations(data, content)
328
+ def parse_citations(data, parts)
295
329
  metadata = data.dig('candidates', 0, 'groundingMetadata')
296
330
  return [] unless metadata
297
331
 
@@ -299,13 +333,12 @@ module RubyLLM
299
333
  supports = metadata['groundingSupports'] || []
300
334
  return chunk_citations(chunks) if supports.empty?
301
335
 
302
- supports.flat_map { |support| support_citations(support, chunks, content) }
336
+ supports.flat_map { |support| support_citations(support, chunks, parts) }
303
337
  end
304
338
 
305
- def support_citations(support, chunks, content)
339
+ def support_citations(support, chunks, parts)
306
340
  segment = support['segment'] || {}
307
- end_index = segment['endIndex']
308
- start_index = segment['startIndex'] || (0 if end_index)
341
+ start_index, end_index = citation_indices(parts, segment)
309
342
 
310
343
  Array(support['groundingChunkIndices']).filter_map do |index|
311
344
  source = chunk_source(chunks[index])
@@ -315,13 +348,39 @@ module RubyLLM
315
348
  url: source['uri'],
316
349
  title: source['title'],
317
350
  text: segment['text'],
318
- start_index: byte_to_char_index(content, start_index),
319
- end_index: byte_to_char_index(content, end_index),
351
+ start_index: start_index,
352
+ end_index: end_index,
320
353
  source_index: index
321
354
  )
322
355
  end
323
356
  end
324
357
 
358
+ def citation_indices(parts, segment)
359
+ indices = [segment.fetch('startIndex', 0), segment['endIndex']].map do |byte_index|
360
+ citation_offset(parts, segment, byte_index)
361
+ end
362
+ indices.all? && indices.first <= indices.last ? indices : [nil, nil]
363
+ end
364
+
365
+ # Without a partIndex, offsets count across the visible text, which
366
+ # keeps thinking parts ahead of the answer from absorbing the span.
367
+ def citation_offset(parts, segment, byte_index)
368
+ return byte_to_char_index(visible_text(parts), byte_index) unless segment.key?('partIndex')
369
+
370
+ part_index = segment['partIndex']
371
+ return unless part_index.is_a?(Integer) && part_index.between?(0, parts.length - 1)
372
+
373
+ part = parts[part_index]
374
+ return if part['thought']
375
+
376
+ index = byte_to_char_index(part['text'], byte_index)
377
+ visible_text(parts.take(part_index)).length + index if index
378
+ end
379
+
380
+ def visible_text(parts)
381
+ parts.reject { |part| part['thought'] }.sum('') { |part| part['text'].to_s }
382
+ end
383
+
325
384
  def chunk_citations(chunks)
326
385
  chunks.each_with_index.filter_map do |chunk, index|
327
386
  source = chunk_source(chunk)
@@ -337,21 +396,27 @@ module RubyLLM
337
396
  chunk['web'] || chunk['retrievedContext']
338
397
  end
339
398
 
340
- # Grounding segment indices are byte offsets into the UTF-8 response text.
399
+ # Grounding segment indices are byte offsets within the selected UTF-8 part.
341
400
  def byte_to_char_index(content, byte_index)
342
- return nil unless content.is_a?(String) && byte_index
401
+ return unless content.is_a?(String) && byte_index.is_a?(Integer) &&
402
+ byte_index.between?(0, content.bytesize)
343
403
 
344
- content.byteslice(0, byte_index)&.length
404
+ prefix = content.byteslice(0, byte_index)
405
+ prefix.length if prefix.valid_encoding?
345
406
  end
346
407
 
347
- def extract_thought_parts(parts)
408
+ def parse_thought_parts(parts)
348
409
  thought_parts = parts.select { |p| p['thought'] }
349
410
  thoughts = thought_parts.filter_map { |p| p['text'] }.join
350
411
  thoughts.empty? ? nil : thoughts
351
412
  end
352
413
 
353
- def extract_thought_signature(parts)
414
+ # Agentic video navigation steps carry signatures of their own, which
415
+ # Gemini refuses on the answer they precede.
416
+ def parse_thought_signature(parts)
354
417
  parts.each do |part|
418
+ next if part.key?('toolCall') || part.key?('toolResponse')
419
+
355
420
  signature = part['thoughtSignature'] ||
356
421
  part['thought_signature'] ||
357
422
  part.dig('functionCall', 'thoughtSignature') ||
@@ -363,16 +428,15 @@ module RubyLLM
363
428
  end
364
429
 
365
430
  def calculate_output_tokens(data)
366
- candidates = data.dig('usageMetadata', 'candidatesTokenCount') || 0
367
- thoughts = data.dig('usageMetadata', 'thoughtsTokenCount') || 0
368
- candidates + thoughts
431
+ counts = (data['usageMetadata'] || {}).values_at('candidatesTokenCount', 'thoughtsTokenCount').compact
432
+ counts.sum unless counts.empty?
369
433
  end
370
434
 
371
435
  def build_json_schema(schema)
372
436
  normalized = RubyLLM::Support::Utils.deep_dup(schema[:schema])
373
437
  normalized.delete(:strict)
374
438
  normalized.delete('strict')
375
- RubyLLM::Support::Utils.deep_stringify_keys(normalized)
439
+ Schemas.render_schema(RubyLLM::Support::Utils.deep_stringify_keys(normalized))
376
440
  end
377
441
 
378
442
  def structured_output_config(schema)
@@ -402,7 +466,10 @@ module RubyLLM
402
466
  @index = next_index
403
467
  else
404
468
  remember_tool_calls if current_message.tool_call?
405
- formatted << build_standard_message(current_message)
469
+ turn = build_standard_message(current_message)
470
+ # Vertex AI refuses a turn without parts, which is what an empty
471
+ # answer is once another provider's signature is left out.
472
+ formatted << turn if turn[:parts].any? || current_message.role != :assistant
406
473
  @index += 1
407
474
  end
408
475
  end
@@ -430,7 +497,7 @@ module RubyLLM
430
497
  end
431
498
 
432
499
  parts = in_call_order(results).flat_map do |tool_message|
433
- format_tool_result(tool_message, @tool_call_names.delete(tool_message.tool_call_id))
500
+ render_tool_result(tool_message, @tool_call_names.delete(tool_message.tool_call_id))
434
501
  end
435
502
 
436
503
  [parts, index]
@@ -451,17 +518,17 @@ module RubyLLM
451
518
  end
452
519
 
453
520
  def remember_tool_calls
454
- current_message.tool_calls.each do |tool_call_id, tool_call|
521
+ current_message.tool_calls.to_h.each do |tool_call_id, tool_call|
455
522
  @tool_call_names[tool_call_id] = tool_call.name
456
523
  end
457
524
  end
458
525
 
459
526
  def build_standard_message(message)
460
- parts = @provider.send(:format_parts, message)
527
+ parts = @provider.send(:render_parts, message)
461
528
  parts = @provider.send(:sign_step, parts) if message.tool_call? && current_turn?
462
529
 
463
530
  {
464
- role: @provider.send(:format_role, message.role),
531
+ role: @provider.send(:render_role, message.role),
465
532
  parts: parts
466
533
  }
467
534
  end
@@ -472,8 +539,8 @@ module RubyLLM
472
539
  @turn_start.nil? || @index > @turn_start
473
540
  end
474
541
 
475
- def format_tool_result(message, tool_name)
476
- @provider.send(:format_tool_result, message, tool_name)
542
+ def render_tool_result(message, tool_name)
543
+ @provider.send(:render_tool_result, message, tool_name)
477
544
  end
478
545
  end
479
546
  end
@@ -44,11 +44,11 @@ module RubyLLM
44
44
  end
45
45
 
46
46
  def parse_embedding_batch_results(responses)
47
- groups = responses.each_with_index.group_by do |inline, index|
48
- inline.dig('metadata', 'custom_id') || index.to_s
49
- end
50
- groups.filter_map do |key, indexed|
51
- parse_embedding_batch_group(key, indexed.map(&:first))
47
+ identified, unidentified = responses.partition { |inline| inline.dig('metadata', 'custom_id') }
48
+ RubyLLM.logger.warn 'Gemini returned batch results without metadata' if unidentified.any?
49
+
50
+ identified.group_by { |inline| inline.dig('metadata', 'custom_id') }.filter_map do |key, group|
51
+ parse_embedding_batch_group(key, group)
52
52
  end
53
53
  end
54
54
 
@@ -60,6 +60,10 @@ module RubyLLM
60
60
  metadata = responses.first.fetch('metadata')
61
61
  return if responses.size < metadata.fetch('embedding_count')
62
62
 
63
+ parse_embedding_batch_slot(index, key, responses, metadata)
64
+ end
65
+
66
+ def parse_embedding_batch_slot(index, key, responses, metadata)
63
67
  positions = responses.map { |inline| inline.dig('metadata', 'embedding_index') }
64
68
  unless positions.sort == (0...responses.size).to_a
65
69
  return [index, nil, batch_failure(key, 'Invalid or duplicate embedding record positions')]
@@ -26,7 +26,7 @@ module RubyLLM
26
26
  Support::Utils.deep_merge({ requests: requests }, provider_options)
27
27
  end
28
28
 
29
- def supports_embedding_media?
29
+ def embedding_media?
30
30
  true
31
31
  end
32
32
 
@@ -42,7 +42,7 @@ module RubyLLM
42
42
  vectors = response.body['embeddings']&.map { |e| e['values'] }
43
43
  vectors = vectors.first if vectors&.length == 1 && !text.is_a?(Array)
44
44
 
45
- Embedding.new(vectors:, model:)
45
+ Embedding.new(vectors:, model:, input_tokens: response.body.dig('usageMetadata', 'promptTokenCount'))
46
46
  end
47
47
 
48
48
  private
@@ -52,7 +52,7 @@ module RubyLLM
52
52
  end
53
53
 
54
54
  def media_embedding_payload(text, attachments, model:, dimensions:, task_type: nil, title: nil)
55
- embedding_payload(Media.format_content(text, attachments), model:, dimensions:, task_type:, title:)
55
+ embedding_payload(Media.render_content(text, attachments), model:, dimensions:, task_type:, title:)
56
56
  end
57
57
 
58
58
  def embedding_payload(parts, model:, dimensions:, task_type:, title:)
@@ -4,6 +4,10 @@ module RubyLLM
4
4
  module Protocols
5
5
  class Gemini
6
6
  module FileTranscription # :nodoc: all
7
+ def rejected_options(operation)
8
+ operation == :transcribe ? %i[named_speakers] : super
9
+ end
10
+
7
11
  def transcribe(audio_file, model:, language:, format: nil, speaker_names: nil,
8
12
  speaker_references: nil, provider_options: {}, prompt: nil, temperature: nil)
9
13
  raise_transcription_streaming_unsupported if block_given?
@@ -11,9 +11,10 @@ module RubyLLM
11
11
  # rubocop:disable-next Lint/UnusedMethodArgument
12
12
  def upload(file, filename: nil, purpose: nil, expires_in: nil, uri: nil, content_type: nil,
13
13
  provider_options: {})
14
+ @content_type = content_type
14
15
  attachment = file_attachment(file, filename:)
15
- display_name = provider_options[:display_name] || attachment.filename
16
- upload_url = start_resumable_upload(attachment, display_name:)
16
+ resource = { display_name: attachment.filename }.merge(provider_options.transform_keys(&:to_sym))
17
+ upload_url = start_resumable_upload(attachment, resource:)
17
18
  response = upload_file_bytes(upload_url, attachment)
18
19
  await_active(parse_file_response(response.body.fetch('file')))
19
20
  end
@@ -51,7 +52,7 @@ module RubyLLM
51
52
  gemini_file_name(file_id)
52
53
  end
53
54
 
54
- def start_resumable_upload(attachment, display_name:)
55
+ def start_resumable_upload(attachment, resource:)
55
56
  response = gemini_connection(request_json: true, response_json: true).post(gemini_upload_url) do |request|
56
57
  request.headers.merge!(@provider.headers)
57
58
  request.headers['X-Goog-Upload-Protocol'] = 'resumable'
@@ -59,7 +60,7 @@ module RubyLLM
59
60
  request.headers['X-Goog-Upload-Header-Content-Length'] = attachment.content.bytesize.to_s
60
61
  request.headers['X-Goog-Upload-Header-Content-Type'] = file_content_type(attachment)
61
62
  request.headers['Content-Type'] = 'application/json'
62
- request.body = { file: { display_name: display_name } }
63
+ request.body = { file: resource }
63
64
  end
64
65
 
65
66
  response.headers['x-goog-upload-url'] || response.headers['X-Goog-Upload-URL'] ||
@@ -9,13 +9,13 @@ module RubyLLM
9
9
  ASPECT_RATIO = /\A\d+:\d+\z/
10
10
  PIXEL_DIMENSIONS = /\A(\d+)\s*[x×]\s*(\d+)\z/i
11
11
 
12
- def images_url(with: nil, mask: nil) # rubocop:disable Lint/UnusedMethodArgument
12
+ def images_url(**)
13
13
  id = model_id(@model)
14
14
 
15
15
  "models/#{id}:#{image_endpoint_action(id)}"
16
16
  end
17
17
 
18
- def render_image_payload(prompt, model:, size:, count: nil, with: nil, mask: nil, provider_options: {}) # rubocop:disable Lint/UnusedMethodArgument
18
+ def render_image_payload(prompt, model:, size:, count: nil, with: nil, provider_options: {}, **)
19
19
  @model = model
20
20
  payload = if gemini_image_model?(model)
21
21
  render_gemini_image_payload(prompt, with:, count:, size:)
@@ -52,7 +52,8 @@ module RubyLLM
52
52
  end
53
53
 
54
54
  def render_imagen_payload(prompt, count: nil, size: nil)
55
- RubyLLM.logger.debug { "Ignoring size #{size}. Imagen sizing is not supported." } if size
55
+ image_config = build_image_config(size) || {}
56
+ raise ArgumentError, "Imagen takes an aspect ratio, got size #{size.inspect}" if image_config.key?(:imageSize)
56
57
 
57
58
  {
58
59
  instances: [
@@ -62,7 +63,7 @@ module RubyLLM
62
63
  ],
63
64
  parameters: {
64
65
  sampleCount: count || 1
65
- }
66
+ }.merge(image_config)
66
67
  }
67
68
  end
68
69
 
@@ -76,7 +77,7 @@ module RubyLLM
76
77
  contents: [
77
78
  {
78
79
  role: 'user',
79
- parts: Media.format_content(prompt, image_attachments(with))
80
+ parts: Media.render_content(prompt, image_attachments(with))
80
81
  }
81
82
  ],
82
83
  generationConfig: generation_config
@@ -142,9 +143,9 @@ module RubyLLM
142
143
  end
143
144
 
144
145
  def gemini_image_parts(data)
145
- Array(data['candidates']).filter_map do |candidate|
146
+ Array(data['candidates']).flat_map do |candidate|
146
147
  parts = candidate.dig('content', 'parts') || []
147
- parts.filter_map { |part| part['inlineData'] }.find do |inline_data|
148
+ parts.reject { |part| part['thought'] }.filter_map { |part| part['inlineData'] }.select do |inline_data|
148
149
  image_mime_type?(inline_data['mimeType']) && inline_data['data']
149
150
  end
150
151
  end
@@ -157,7 +158,8 @@ module RubyLLM
157
158
  def gemini_image_usage(data)
158
159
  {
159
160
  'input_tokens' => input_tokens(data),
160
- 'output_tokens' => calculate_output_tokens(data)
161
+ 'output_tokens' => calculate_output_tokens(data),
162
+ 'cache_read_tokens' => data.dig('usageMetadata', 'cachedContentTokenCount')
161
163
  }.compact
162
164
  end
163
165
 
@@ -169,7 +171,7 @@ module RubyLLM
169
171
 
170
172
  def gemini_image_model?(model)
171
173
  id = model_id(model).downcase
172
- return true if id.start_with?('nano-banana', 'nanobanana')
174
+ return true if id.start_with?('nano-banana', 'nanobanana', 'gemini-nano-banana')
173
175
 
174
176
  id.start_with?('gemini-') && id.include?('-image')
175
177
  end
@@ -92,7 +92,7 @@ module RubyLLM
92
92
  socket.close if event.dig('serverContent', 'generationComplete')
93
93
  end
94
94
  end
95
- unless events.last&.dig('serverContent', 'generationComplete')
95
+ unless events.any? { |event| event.dig('serverContent', 'generationComplete') }
96
96
  raise Error, 'Google Live transcription ended before generation completed'
97
97
  end
98
98