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
@@ -34,11 +34,37 @@ module RubyLLM
34
34
 
35
35
  # The ID of the model that produced the message, +nil+ on user messages.
36
36
  attr_reader :model
37
+ alias model_id model
37
38
 
38
39
  attr_writer :model_info # :nodoc:
39
40
 
40
- # The tool calls the assistant requested, as a Hash of ToolCall objects
41
- # keyed by call ID, or +nil+.
41
+ # Returns the slug of the provider that produced the message, from its
42
+ # last successful attempt, or +nil+ when none is recorded, as on user
43
+ # messages. A reply from a model given to Chat#with_fallbacks names that
44
+ # model's provider.
45
+ def provider
46
+ @provider || ruby_llm_usage_entries.reverse.find(&:succeeded?)&.provider
47
+ end
48
+
49
+ # Marks the message as the work of a fallback model.
50
+ def stood_in(model:, provider:) # :nodoc:
51
+ @model ||= model
52
+ @provider = provider
53
+ @stood_in = true
54
+ self
55
+ end
56
+
57
+ def stood_in? # :nodoc:
58
+ @stood_in || false
59
+ end
60
+
61
+ # The tool calls the assistant requested, as an Enumerable of ToolCall
62
+ # objects in provider order, also readable by call ID. Empty when there
63
+ # are none.
64
+ #
65
+ # response.tool_calls.map(&:name) # => ["weather"]
66
+ # response.tool_calls["call_1"] # => #<RubyLLM::ToolCall ...>
67
+ #
42
68
  attr_reader :tool_calls
43
69
 
44
70
  # The ID of the tool call this message answers. Set only on tool result
@@ -59,9 +85,16 @@ module RubyLLM
59
85
 
60
86
  # Why the model stopped: +:stop+, +:max_tokens+, +:tool_calls+, or
61
87
  # +:content_filter+. Any other reason comes through as the provider
62
- # spelled it, such as Anthropic's +:pause_turn+.
88
+ # named it, as a lowercase Symbol, such as Anthropic's +:pause_turn+ or
89
+ # Gemini's +:malformed_function_call+.
63
90
  attr_reader :finish_reason
64
91
 
92
+ # The ContentFilter verdicts the provider reported on the request and
93
+ # this response, or an empty Array. Read them to see which filter ended
94
+ # a response that is #content_filtered?. Only the live response carries
95
+ # them: #to_h leaves them out, so a persisted chat never stores them.
96
+ attr_reader :content_filters
97
+
65
98
  # The provider-executed tool steps in this response, as an array of
66
99
  # ServerToolCall objects. Empty unless the chat enabled tools with
67
100
  # Chat#with_provider_tools and the model used one.
@@ -82,6 +115,16 @@ module RubyLLM
82
115
  # +nil+ when the provider returned none.
83
116
  attr_reader :raw_reasoning # :nodoc:
84
117
 
118
+ # The provider's label for this assistant text within its turn, such as
119
+ # OpenAI's +commentary+ or +final_answer+, kept so later requests can
120
+ # send it back. +nil+ when the provider gave none.
121
+ attr_reader :phase # :nodoc:
122
+
123
+ # The provider's opaque state for an assistant message it has not
124
+ # finished, which a later request sends back to continue it. +nil+ once
125
+ # the message is complete.
126
+ attr_reader :continuation # :nodoc:
127
+
85
128
  # The Chat this message belongs to, set when it is added to a
86
129
  # conversation. Backs #tool_results.
87
130
  attr_accessor :conversation # :nodoc:
@@ -93,8 +136,10 @@ module RubyLLM
93
136
  @config = options[:config]
94
137
  @attachments = Attachment.wrap(options[:attachments], config: @config)
95
138
  @model = options[:model]
139
+ @provider = options[:provider]
96
140
  @supplied_cost = coerce_value(options[:cost], Cost)
97
141
  @tool_call_id = options[:tool_call_id]
142
+ @provider_call_id = options[:provider_call_id] || options[:provider_tool_call_id]
98
143
  @tokens = options[:tokens] || Tokens.new(
99
144
  input: options[:input_tokens],
100
145
  output: options[:output_tokens],
@@ -111,8 +156,11 @@ module RubyLLM
111
156
  @server_tool_calls = Array(options[:server_tool_calls]).map { |call| coerce_value(call, ServerToolCall) }
112
157
  @raw_content = options[:raw_content]
113
158
  @raw_reasoning = options[:raw_reasoning]
159
+ @phase = options[:phase]
160
+ @continuation = options[:continuation]
114
161
  @mcp_result = coerce_mcp_result(options[:mcp_result])
115
162
  @finish_reason = options[:finish_reason]&.to_sym
163
+ @content_filters = coerce_list(options[:content_filters], ContentFilter)
116
164
  self.ruby_llm_usage_entries = options[:usage_entries] if options[:usage_entries]
117
165
  @cache_until_here = options.fetch(:cache_until_here, false)
118
166
  @cache_ttl = options[:cache_ttl] if @cache_until_here
@@ -121,15 +169,99 @@ module RubyLLM
121
169
  end
122
170
 
123
171
  # Returns #content parsed as JSON, memoized after the first call.
124
- # Useful for reading structured output responses.
172
+ # Useful for reading structured output responses. When the provider
173
+ # marks part of the turn as commentary before the final answer, only
174
+ # the final answer is parsed.
125
175
  #
126
176
  # response = chat.with_schema(PersonSchema).ask "Generate a person"
127
177
  # response.parsed # => {"name" => "Alice", "age" => 30}
128
178
  #
129
179
  def parsed
130
- return if content.nil? || content.empty?
180
+ text = answer_text
181
+ return if text.nil? || text.empty?
182
+
183
+ @parsed ||= JSON.parse(text)
184
+ end
185
+
186
+ # Returns the pages a hosted web search returned for this message, as an
187
+ # array of WebSource objects with each address once. Reads what the message
188
+ # keeps, so a restored message answers the same. Empty when the message
189
+ # ran no search or its provider reports no sources.
190
+ #
191
+ # response = chat.with_provider_tools(:web_search).ask "What changed in Ruby 4.0?"
192
+ # response.web_sources.map(&:url)
193
+ #
194
+ # Sources come from the outside world and are not a safe allowlist on their
195
+ # own: a result can be attacker-controlled, and OpenAI can list a page the
196
+ # model chose to open beside what its search found.
197
+ #
198
+ def web_sources
199
+ return [] if server_tool_calls.empty? || provider.nil?
200
+
201
+ Array(Provider.resolve(provider)&.web_sources(self)).uniq(&:url)
202
+ end
203
+
204
+ # Returns a copy of the message with the given fields changed, like
205
+ # Data#with. The message itself is left alone. Change +content+ or
206
+ # +attachments+:
207
+ #
208
+ # message.with(content: "[left out]")
209
+ #
210
+ # New content also drops what holds the old text: on an assistant
211
+ # message its thinking, citations, server tool calls, raw response, and
212
+ # native response blocks, and on a tool result its MCP result. Raises
213
+ # ArgumentError for another field.
214
+ def with(**changes)
215
+ unknown = changes.keys - %i[content attachments]
216
+ raise ArgumentError, "Message#with cannot change #{unknown.join(', ')}" if unknown.any?
217
+
218
+ changes.reduce(self) do |message, (field, value)|
219
+ field == :content ? message.with_content(value) : message.with_attachments(value)
220
+ end
221
+ end
222
+
223
+ def with_content(content) # :nodoc:
224
+ (role == :assistant ? without_native_content : dup).tap do |message|
225
+ message.instance_variable_set(:@content, normalize_content(content))
226
+ message.instance_variable_set(:@mcp_result, nil)
227
+ if role == :assistant
228
+ message.instance_variable_set(:@citations, [])
229
+ message.instance_variable_set(:@server_tool_calls, [])
230
+ message.instance_variable_set(:@raw, nil)
231
+ end
232
+ message.remove_instance_variable(:@parsed) if message.instance_variable_defined?(:@parsed)
233
+ end
234
+ end
235
+
236
+ # Returns whether a tool ended the turn with this result, with
237
+ # Tool#halt. Its finish reason is +:halted+.
238
+ def halted?
239
+ finish_reason == :halted
240
+ end
131
241
 
132
- @parsed ||= JSON.parse(content)
242
+ # Returns whether this is the part of a streamed reply the chat kept
243
+ # when it was cancelled (see Chat#cancel), or the result that closed a
244
+ # tool call it left unfinished. Its finish reason is +:cancelled+.
245
+ def cancelled?
246
+ finish_reason == :cancelled
247
+ end
248
+
249
+ # Returns the path, in the chat's files, of the whole of a tool result
250
+ # too long to send whole, or +nil+ when the result went whole. A chat
251
+ # with a file store keeps such a result in a file and sends the model
252
+ # its start and this path (see Chat#with_file_store).
253
+ #
254
+ # if (path = message.spilled_path)
255
+ # chat.file_store.read(path)
256
+ # end
257
+ #
258
+ def spilled_path
259
+ Chat::FileReferences.spilled_path(content) if tool_result?
260
+ end
261
+
262
+ def mark_cancelled # :nodoc:
263
+ @finish_reason = :cancelled
264
+ self
133
265
  end
134
266
 
135
267
  def with_attachments(attachments) # :nodoc:
@@ -137,19 +269,29 @@ module RubyLLM
137
269
  dup.tap { |message| message.instance_variable_set(:@attachments, wrapped) }
138
270
  end
139
271
 
272
+ def with_tool_calls(tool_calls) # :nodoc:
273
+ dup.tap { |message| message.instance_variable_set(:@tool_calls, coerce_tool_calls(tool_calls)) }
274
+ end
275
+
276
+ def with_tool_call_id(tool_call_id) # :nodoc:
277
+ dup.tap { |message| message.instance_variable_set(:@tool_call_id, tool_call_id) }
278
+ end
279
+
140
280
  def without_native_content(raw_content: nil) # :nodoc:
141
281
  dup.tap do |message|
142
282
  message.instance_variable_set(:@thinking, nil)
143
283
  message.instance_variable_set(:@raw_reasoning, nil)
284
+ message.instance_variable_set(:@phase, nil)
285
+ message.instance_variable_set(:@continuation, nil)
144
286
  message.instance_variable_set(:@raw_content, raw_content)
145
- message.instance_variable_set(:@tool_calls, tool_calls_without_thought_signatures)
287
+ message.instance_variable_set(:@tool_calls, coerce_tool_calls(tool_calls_without_thought_signatures))
146
288
  end
147
289
  end
148
290
 
149
291
  # Returns +true+ if the assistant requested one or more tool calls,
150
292
  # +false+ otherwise.
151
293
  def tool_call?
152
- !tool_calls.nil? && !tool_calls.empty?
294
+ !tool_calls.empty?
153
295
  end
154
296
 
155
297
  # Returns +true+ if the message carries the result of a tool call,
@@ -158,6 +300,17 @@ module RubyLLM
158
300
  !tool_call_id.nil? && !tool_call_id.empty?
159
301
  end
160
302
 
303
+ def provider_call_id # :nodoc:
304
+ return @provider_call_id if @provider_call_id
305
+ return tool_call_id unless tool_result? && conversation
306
+
307
+ conversation.messages.reverse_each do |message|
308
+ call = message.tool_calls[tool_call_id]
309
+ return call.provider_call_id if call
310
+ end
311
+ tool_call_id
312
+ end
313
+
161
314
  # Returns the tool result messages answering this message's tool calls,
162
315
  # or an empty array when it made none. Mirrors the +tool_results+
163
316
  # association on acts_as_message records.
@@ -176,11 +329,17 @@ module RubyLLM
176
329
  finish_reason == :stop && !tool_call?
177
330
  end
178
331
 
332
+ # Returns the error text of a failed tool result, or +nil+.
333
+ def tool_error_message
334
+ tool_result_payload&.[]('error')
335
+ end
336
+
179
337
  # Returns +true+ if the response was cut off by a token limit,
180
338
  # +false+ otherwise.
181
339
  def max_tokens?
182
340
  finish_reason == :max_tokens
183
341
  end
342
+ alias truncated? max_tokens?
184
343
 
185
344
  # Returns +true+ if the model stopped to request tool calls,
186
345
  # +false+ otherwise.
@@ -213,18 +372,21 @@ module RubyLLM
213
372
  return ruby_llm_usage_cost if model.nil? && ruby_llm_usage_entries.any?
214
373
  return @supplied_cost if model.nil? && @supplied_cost
215
374
 
216
- Cost.new(tokens:, model: model || model_info)
375
+ Cost.new(tokens: model ? tokens.without_reported_cost : tokens, model: model || model_info)
217
376
  end
218
377
 
219
378
  # Marks this message as an explicit prompt cache boundary. Providers
220
379
  # with boundary controls use the conversation up to and including this
221
- # message as the cacheable prefix. Pass +ttl:+ to give this boundary its
222
- # own cache lifetime instead of the +ttl:+ from Chat#with_caching, on
380
+ # message as the cacheable prefix. Pass +ttl:+, in any form
381
+ # Chat#with_caching takes, to give this boundary its own cache lifetime
382
+ # instead of the +ttl:+ from Chat#with_caching, on
223
383
  # providers that set a lifetime per boundary. Returns +self+.
224
384
  #
225
385
  # chat.add_message(role: :user, content: long_context).cache_until_here
226
386
  # chat.add_message(role: :user, content: handbook).cache_until_here(ttl: "1h")
227
387
  #
388
+ # To mark system instructions, pass +cache_until_here:+ to
389
+ # Chat#with_instructions.
228
390
  def cache_until_here(ttl: nil)
229
391
  @cache_until_here = true
230
392
  @cache_ttl = ttl
@@ -261,16 +423,20 @@ module RubyLLM
261
423
  content: content,
262
424
  attachments: list_to_h(attachments),
263
425
  model: model,
426
+ provider: provider,
264
427
  cost: @supplied_cost && cost.to_h,
265
428
  reported_cost: tokens.reported_cost,
266
- tool_calls: tool_calls&.transform_values(&:to_h),
429
+ tool_calls: tool_calls.empty? ? nil : tool_calls.transform_values(&:to_h),
267
430
  tool_call_id: tool_call_id,
431
+ provider_call_id: @provider_call_id,
268
432
  thinking: thinking&.text,
269
433
  thinking_signature: thinking&.signature,
270
434
  citations: list_to_h(citations),
271
435
  server_tool_calls: list_to_h(server_tool_calls),
272
436
  raw_content: raw_content,
273
437
  raw_reasoning: raw_reasoning,
438
+ phase: phase,
439
+ continuation: continuation,
274
440
  mcp_result: mcp_result&.dump,
275
441
  finish_reason: finish_reason,
276
442
  cache_until_here: cache_until_here? || nil,
@@ -297,25 +463,49 @@ module RubyLLM
297
463
 
298
464
  private
299
465
 
466
+ def answer_text
467
+ slug = ruby_llm_usage_entries.reverse.find(&:succeeded?)&.provider if raw_content || phase
468
+ (Provider.resolve(slug)&.answer_text(self) if slug) || content
469
+ end
470
+
471
+ def tool_result_payload
472
+ return unless tool_result? && content
473
+
474
+ payload = JSON.parse(content)
475
+ payload if payload.is_a?(Hash)
476
+ rescue JSON::ParserError
477
+ nil
478
+ end
479
+
300
480
  def list_to_h(list)
301
481
  list.empty? ? nil : list.map(&:to_h)
302
482
  end
303
483
 
304
484
  def coerce_tool_calls(tool_calls)
305
- return tool_calls unless tool_calls.is_a?(Hash)
485
+ return tool_calls if tool_calls.is_a?(ToolCall::Collection)
486
+ if tool_calls.is_a?(Array) && tool_calls.all?(ToolCall)
487
+ return ToolCall::Collection.new(ToolCall.index(tool_calls))
488
+ end
306
489
 
307
- tool_calls.to_h do |id, call|
308
- next [id, call] unless call.is_a?(Hash)
490
+ ToolCall::Collection.new(
491
+ tool_calls.to_h do |id, call|
492
+ [id, call.is_a?(Hash) ? tool_call_from_h(id, call) : call]
493
+ end
494
+ )
495
+ end
309
496
 
310
- attributes = call.transform_keys(&:to_sym)
311
- [id, ToolCall.new(id: attributes[:id] || id, name: attributes[:name],
312
- arguments: attributes[:arguments] || {},
313
- thought_signature: attributes[:thought_signature], remote: attributes.fetch(:remote, false))]
314
- end
497
+ def tool_call_from_h(id, call)
498
+ attributes = call.transform_keys(&:to_sym)
499
+ ToolCall.new(id: attributes[:id] || id, name: attributes[:name],
500
+ provider_call_id: attributes[:provider_call_id] || attributes[:provider_id],
501
+ arguments: attributes[:arguments] || {},
502
+ malformed_arguments: attributes[:malformed_arguments],
503
+ namespace: attributes[:namespace],
504
+ thought_signature: attributes[:thought_signature], remote: attributes.fetch(:remote, false))
315
505
  end
316
506
 
317
507
  def tool_calls_without_thought_signatures
318
- tool_calls&.transform_values do |call|
508
+ tool_calls.transform_values do |call|
319
509
  call.dup.tap { |copy| copy.thought_signature = nil }
320
510
  end
321
511
  end
@@ -333,12 +523,16 @@ module RubyLLM
333
523
  value.is_a?(Hash) ? klass.from_h(value) : value
334
524
  end
335
525
 
526
+ def coerce_list(values, klass)
527
+ Array(values).map { |value| coerce_value(value, klass) }
528
+ end
529
+
336
530
  def coerce_mcp_result(value)
337
531
  value.is_a?(Hash) ? MCP::Result.load(value.transform_keys(&:to_s)) : value
338
532
  end
339
533
 
340
534
  def normalize_content(content)
341
- return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
535
+ return '' if role == :assistant && content.nil? && tool_call?
342
536
  return content if content.nil? || content.is_a?(String)
343
537
 
344
538
  raise ArgumentError,
@@ -354,7 +548,7 @@ module RubyLLM
354
548
  {
355
549
  role: role,
356
550
  content: content,
357
- tool_calls: tool_calls&.values&.map(&:name),
551
+ tool_calls: tool_calls.map(&:name),
358
552
  tool_call_id: tool_call_id,
359
553
  model: model,
360
554
  finish_reason: finish_reason
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  # A Model::Modalities lists the kinds of content a model accepts and
6
6
  # produces, as arrays of Strings. Instances come from Model#modalities.
7
7
  #
8
- # model = RubyLLM.models.find('gpt-5.6')
8
+ # model = RubyLLM.models.find('gpt-6.1-sol')
9
9
  # model.modalities.input # => ["text", "image", "pdf"]
10
10
  # model.modalities.output # => ["text"]
11
11
  #
@@ -3,9 +3,10 @@
3
3
  module RubyLLM
4
4
  class Model
5
5
  # A Pricing groups a model's prices by usage category: text tokens,
6
- # images, audio tokens, and embeddings. Each category is a
7
- # PricingCategory. Prices are in USD per million tokens. Instances come
8
- # from Model#pricing.
6
+ # images, audio tokens, and embeddings, plus the characters, audio
7
+ # seconds, and search units some operations bill instead of tokens.
8
+ # Each category is a PricingCategory. Prices are in USD per million
9
+ # units of the category. Instances come from Model#pricing.
9
10
  #
10
11
  # model = RubyLLM.models.find "claude-sonnet-5"
11
12
  # model.pricing.text_tokens.input # => 3
@@ -13,7 +14,7 @@ module RubyLLM
13
14
  #
14
15
  class Pricing
15
16
  # The pricing categories a model may define.
16
- CATEGORIES = %i[text_tokens images audio_tokens embeddings].freeze
17
+ CATEGORIES = %i[text_tokens images audio_tokens embeddings characters audio_seconds search_units].freeze
17
18
 
18
19
  def initialize(data) # :nodoc:
19
20
  @data = {}
@@ -47,6 +48,27 @@ module RubyLLM
47
48
  category(:embeddings)
48
49
  end
49
50
 
51
+ # Returns the PricingCategory for prices per million input
52
+ # characters, such as a text-to-speech model's, or an empty category
53
+ # if the model has none.
54
+ def characters
55
+ category(:characters)
56
+ end
57
+
58
+ # Returns the PricingCategory for prices per million seconds of
59
+ # input audio, such as a transcription model's, or an empty category
60
+ # if the model has none.
61
+ def audio_seconds
62
+ category(:audio_seconds)
63
+ end
64
+
65
+ # Returns the PricingCategory for prices per million search units,
66
+ # such as a rerank model's, or an empty category if the model has
67
+ # none.
68
+ def search_units
69
+ category(:search_units)
70
+ end
71
+
50
72
  # Returns the pricing data as a nested Hash keyed by category.
51
73
  # Categories without prices are omitted.
52
74
  def to_h
@@ -5,7 +5,9 @@ module RubyLLM
5
5
  # A PricingCategory holds the standard, batch, and long-context pricing
6
6
  # tiers for one kind of model usage, such as text tokens or images.
7
7
  # Model#pricing returns a Pricing collection whose categories are
8
- # PricingCategory instances. Prices are in USD per million tokens.
8
+ # PricingCategory instances. Prices are in USD per million tokens, or
9
+ # per million units for categories billed in characters, audio seconds,
10
+ # or search units.
9
11
  #
10
12
  # model = RubyLLM.models.find "claude-sonnet-5"
11
13
  # category = model.pricing.text_tokens
@@ -39,6 +41,7 @@ module RubyLLM
39
41
  @batch = tier_from(data[:batch])
40
42
  @long_context = tier_from(data[:long_context])
41
43
  @long_context_threshold = Integer(data[:long_context_threshold], exception: false)
44
+ @context_tiers = context_tiers_from(data[:context_tiers])
42
45
  end
43
46
 
44
47
  # Returns the standard-tier input price in USD per million tokens,
@@ -72,15 +75,11 @@ module RubyLLM
72
75
  end
73
76
 
74
77
  # Returns the PricingTier that applies for a prompt of the given size.
75
- # Uses #long_context when that tier exists and +prompt_tokens+ is
76
- # greater than #long_context_threshold; otherwise returns #standard.
78
+ # Uses the largest context tier +prompt_tokens+ is greater than, such
79
+ # as #long_context above #long_context_threshold; otherwise returns
80
+ # #standard.
77
81
  def tier_for(prompt_tokens)
78
- if long_context && long_context_threshold &&
79
- prompt_tokens.to_i > long_context_threshold
80
- long_context
81
- else
82
- standard
83
- end
82
+ @context_tiers.find { |size, _| prompt_tokens.to_i > size }&.last || standard
84
83
  end
85
84
 
86
85
  # Returns a Hash with present tier hashes and optional
@@ -105,20 +104,38 @@ module RubyLLM
105
104
  entry, threshold = context_cost(cost)
106
105
  return [nil, nil] unless entry
107
106
 
108
- rates = {
107
+ rates = context_rates(entry)
108
+ rates.empty? ? [nil, nil] : [rates, threshold]
109
+ end
110
+
111
+ # Returns every context tier in +cost+ as <tt>[size, rates]</tt>. The
112
+ # registry schema holds one long-context tier, so a model with more
113
+ # keeps the rest in its metadata.
114
+ def self.context_tiers_from_cost(cost) # :nodoc:
115
+ cost = RubyLLM::Support::Utils.deep_symbolize_keys(cost || {})
116
+ context_entries(cost).filter_map do |entry|
117
+ size = Integer(entry.dig(:tier, :size), exception: false)
118
+ rates = context_rates(entry)
119
+ [size, rates] if size && rates.any?
120
+ end
121
+ end
122
+
123
+ def self.context_rates(entry) # :nodoc:
124
+ {
109
125
  input_per_million: entry[:input],
110
126
  output_per_million: entry[:output],
111
127
  cache_read_input_per_million: entry[:cache_read],
112
128
  cache_write_input_per_million: entry[:cache_write],
113
129
  reasoning_output_per_million: entry[:reasoning]
114
130
  }.compact
115
- rates.empty? ? [nil, nil] : [rates, threshold]
131
+ end
132
+
133
+ def self.context_entries(cost) # :nodoc:
134
+ Array(cost[:tiers]).select { |entry| entry.is_a?(Hash) && entry.dig(:tier, :type).to_s == 'context' }
116
135
  end
117
136
 
118
137
  def self.context_cost(cost) # :nodoc:
119
- context_tier = Array(cost[:tiers]).find do |entry|
120
- entry.is_a?(Hash) && entry.dig(:tier, :type).to_s == 'context'
121
- end
138
+ context_tier = context_entries(cost).first
122
139
  if context_tier
123
140
  [context_tier, Integer(context_tier.dig(:tier, :size), exception: false)]
124
141
  elsif cost[:context_over_200k].is_a?(Hash)
@@ -126,10 +143,16 @@ module RubyLLM
126
143
  end
127
144
  end
128
145
 
129
- private_class_method :context_cost
146
+ private_class_method :context_cost, :context_rates, :context_entries
130
147
 
131
148
  private
132
149
 
150
+ def context_tiers_from(tiers)
151
+ tiers = Array(tiers).to_h { |size, rates| [size, tier_from(rates)] }
152
+ tiers[long_context_threshold] = long_context if long_context && long_context_threshold
153
+ tiers.sort_by { |size, _| -size }
154
+ end
155
+
133
156
  def tier_from(tier_data)
134
157
  return nil if empty_tier?(tier_data)
135
158
 
@@ -4,8 +4,9 @@ module RubyLLM
4
4
  class Model
5
5
  # A PricingTier holds the prices a model charges within one billing tier,
6
6
  # such as standard, batch, or long_context. Each price is in USD per one
7
- # million tokens. Prices missing from the registry read as +nil+. A zero
8
- # price means the usage is free.
7
+ # million tokens, or per million units of a category billed in other
8
+ # units, such as Pricing#characters. Prices missing from the registry
9
+ # read as +nil+. A zero price means the usage is free.
9
10
  #
10
11
  # Instances come from PricingCategory#standard, #batch, and #long_context:
11
12
  #
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  # capabilities, modalities, pricing, and provider metadata. Instances come
6
6
  # from the registry through RubyLLM.models and from Chat#model.
7
7
  #
8
- # model = RubyLLM.models.find('gpt-5.6')
8
+ # model = RubyLLM.models.find('gpt-6.1-sol')
9
9
  # model.name # => "GPT-5.6"
10
10
  # model.provider # => "openai"
11
11
  # model.context_window # => 1050000
@@ -14,7 +14,7 @@ module RubyLLM
14
14
  class Model
15
15
  include Support::Inspectable
16
16
 
17
- # The provider's identifier for the model, e.g. <tt>"gpt-5.6"</tt>.
17
+ # The provider's identifier for the model, e.g. <tt>"gpt-6.1-sol"</tt>.
18
18
  attr_reader :id
19
19
 
20
20
  # The human-readable model name, e.g. <tt>"GPT-5.6"</tt>.
@@ -150,16 +150,24 @@ module RubyLLM
150
150
  end
151
151
 
152
152
  # Builds a Cost for +tokens+ (a Tokens object, or anything responding
153
- # to +tokens+) using this model's pricing.
153
+ # to +tokens+) using this model's pricing. A Chat or Message is priced
154
+ # one provider request at a time, so each request pays the context
155
+ # tier its own prompt reached.
154
156
  #
155
157
  # cost = model.cost_for(response.tokens)
156
158
  # puts cost.total
157
159
  #
158
160
  # Pass <tt>tier: :batch</tt> to use the model's explicit batch rates.
161
+ # Amounts the model has no batch price for are +nil+.
159
162
  #
160
163
  def cost_for(tokens, tier: :standard)
161
- tokens = tokens.tokens if tokens.respond_to?(:tokens)
164
+ requests = usage_entries_of(tokens)
165
+ if requests.any?
166
+ complete = requests.all? { |entry| entry.usage_available? || entry.tokens.reported_cost }
167
+ return Cost.aggregate(requests.map { |entry| cost_for(entry.tokens, tier:) }, complete:)
168
+ end
162
169
 
170
+ tokens = tokens.tokens if tokens.respond_to?(:tokens)
163
171
  Cost.new(tokens:, model: self, tier:)
164
172
  end
165
173
 
@@ -207,15 +215,25 @@ module RubyLLM
207
215
 
208
216
  private
209
217
 
218
+ def usage_entries_of(source)
219
+ return source.usage_entries if source.respond_to?(:usage_entries)
220
+ return source.ruby_llm_usage_entries if source.respond_to?(:ruby_llm_usage_entries)
221
+
222
+ []
223
+ end
224
+
210
225
  def pricing_data_with_long_context(pricing)
211
226
  pricing = RubyLLM::Support::Utils.deep_symbolize_keys(RubyLLM::Support::Utils.deep_dup(pricing))
227
+ cost = metadata[:cost] || metadata['cost']
212
228
  text = pricing[:text_tokens] || {}
229
+ tiers = PricingCategory.context_tiers_from_cost(cost)
230
+ pricing[:text_tokens] = text.merge(context_tiers: tiers) if tiers.size > 1
213
231
  return pricing if text[:long_context]
214
232
 
215
- rates, threshold = PricingCategory.long_context_from_cost(metadata[:cost] || metadata['cost'])
233
+ rates, threshold = PricingCategory.long_context_from_cost(cost)
216
234
  return pricing unless rates
217
235
 
218
- pricing[:text_tokens] = text.merge(long_context: rates)
236
+ pricing[:text_tokens] = (pricing[:text_tokens] || {}).merge(long_context: rates)
219
237
  pricing[:text_tokens][:long_context_threshold] = threshold if threshold
220
238
  pricing
221
239
  end