ruby_llm 1.15.0 → 2.0.0.rc1

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 (470) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +87 -33
  4. data/exe/ruby_llm +8 -0
  5. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +0 -1
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +3 -43
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +11 -3
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +4 -4
  10. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +1 -1
  11. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +1 -1
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +2 -2
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +1 -1
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +6 -4
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +1 -1
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +2 -2
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +5 -6
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +2 -2
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +5 -5
  23. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +1 -1
  24. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +1 -1
  25. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +1 -1
  26. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +2 -2
  27. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +1 -1
  28. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  29. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +6 -4
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +1 -1
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +4 -6
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +2 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +5 -6
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +2 -2
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -3
  37. data/lib/generators/ruby_llm/generator_helpers.rb +106 -62
  38. data/lib/generators/ruby_llm/install/install_generator.rb +3 -11
  39. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +2 -0
  40. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +14 -6
  41. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +117 -0
  42. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +0 -8
  43. data/lib/generators/ruby_llm/provider/cli.rb +175 -0
  44. data/lib/generators/ruby_llm/provider/scaffold.rb +323 -0
  45. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +37 -0
  46. data/lib/generators/ruby_llm/provider/templates/core/provider_spec.rb.erb +34 -0
  47. data/lib/generators/ruby_llm/provider/templates/gem/archspec.rb.erb +14 -0
  48. data/lib/generators/ruby_llm/provider/templates/gem/bin/console.erb +15 -0
  49. data/lib/generators/ruby_llm/provider/templates/gem/bin/setup.erb +5 -0
  50. data/lib/generators/ruby_llm/provider/templates/gem/chat_schema_spec.rb.erb +30 -0
  51. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +35 -0
  52. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +22 -0
  53. data/lib/generators/ruby_llm/provider/templates/gem/chat_tools_spec.rb.erb +30 -0
  54. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +32 -0
  55. data/lib/generators/ruby_llm/provider/templates/gem/embedding_spec.rb.erb +49 -0
  56. data/lib/generators/ruby_llm/provider/templates/gem/env.erb +2 -0
  57. data/lib/generators/ruby_llm/provider/templates/gem/fixtures_gitkeep.erb +1 -0
  58. data/lib/generators/ruby_llm/provider/templates/gem/flayignore.erb +1 -0
  59. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +25 -0
  60. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +28 -0
  61. data/lib/generators/ruby_llm/provider/templates/gem/gitignore.erb +7 -0
  62. data/lib/generators/ruby_llm/provider/templates/gem/gitleaks.yml.erb +22 -0
  63. data/lib/generators/ruby_llm/provider/templates/gem/image_spec.rb.erb +23 -0
  64. data/lib/generators/ruby_llm/provider/templates/gem/license.erb +21 -0
  65. data/lib/generators/ruby_llm/provider/templates/gem/models.rb.erb +19 -0
  66. data/lib/generators/ruby_llm/provider/templates/gem/models_spec.rb.erb +17 -0
  67. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +22 -0
  68. data/lib/generators/ruby_llm/provider/templates/gem/overcommit.yml.erb +31 -0
  69. data/lib/generators/ruby_llm/provider/templates/gem/provider.rb.erb +52 -0
  70. data/lib/generators/ruby_llm/provider/templates/gem/provider_spec.rb.erb +38 -0
  71. data/lib/generators/ruby_llm/provider/templates/gem/rakefile.erb +38 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/readme.md.erb +50 -0
  73. data/lib/generators/ruby_llm/provider/templates/gem/release.yml.erb +36 -0
  74. data/lib/generators/ruby_llm/provider/templates/gem/rerank_spec.rb.erb +23 -0
  75. data/lib/generators/ruby_llm/provider/templates/gem/rspec.erb +2 -0
  76. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +29 -0
  77. data/lib/generators/ruby_llm/provider/templates/gem/rubyllm_configuration.rb.erb +14 -0
  78. data/lib/generators/ruby_llm/provider/templates/gem/spec_helper.rb.erb +26 -0
  79. data/lib/generators/ruby_llm/provider/templates/gem/speech_spec.rb.erb +25 -0
  80. data/lib/generators/ruby_llm/provider/templates/gem/vcr_configuration.rb.erb +16 -0
  81. data/lib/generators/ruby_llm/provider/templates/gem/video_spec.rb.erb +27 -0
  82. data/lib/generators/ruby_llm/schema/schema_generator.rb +5 -1
  83. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +1 -1
  84. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +13 -0
  85. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +21 -0
  86. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +3 -3
  87. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +7 -12
  88. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +5 -2
  89. data/lib/generators/ruby_llm/tool/tool_generator.rb +25 -59
  90. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +461 -0
  91. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +100 -0
  92. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +215 -0
  93. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +660 -0
  94. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +222 -0
  95. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
  96. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +167 -0
  97. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +344 -0
  98. data/lib/ruby_llm/accounting/usage.rb +245 -0
  99. data/lib/ruby_llm/active_record/acts_as.rb +94 -136
  100. data/lib/ruby_llm/active_record/attachment_helpers.rb +180 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +823 -305
  103. data/lib/ruby_llm/active_record/message_methods.rb +119 -75
  104. data/lib/ruby_llm/active_record/model.rb +135 -0
  105. data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
  106. data/lib/ruby_llm/active_record/tool_call.rb +33 -0
  107. data/lib/ruby_llm/active_record/usage.rb +61 -0
  108. data/lib/ruby_llm/agent.rb +1065 -150
  109. data/lib/ruby_llm/aliases.json +338 -167
  110. data/lib/ruby_llm/attachment.rb +217 -61
  111. data/lib/ruby_llm/batch.rb +432 -0
  112. data/lib/ruby_llm/cached_content.rb +112 -0
  113. data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
  114. data/lib/ruby_llm/chat.rb +1208 -150
  115. data/lib/ruby_llm/chunk.rb +10 -0
  116. data/lib/ruby_llm/citation.rb +105 -0
  117. data/lib/ruby_llm/configuration.rb +274 -24
  118. data/lib/ruby_llm/context.rb +128 -6
  119. data/lib/ruby_llm/cost.rb +217 -80
  120. data/lib/ruby_llm/downloaded_file.rb +33 -0
  121. data/lib/ruby_llm/embedding.rb +141 -7
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +161 -89
  124. data/lib/ruby_llm/fallback.rb +133 -0
  125. data/lib/ruby_llm/files/mime_type.rb +97 -0
  126. data/lib/ruby_llm/image.rb +155 -32
  127. data/lib/ruby_llm/message.rb +233 -54
  128. data/lib/ruby_llm/model/modalities.rb +17 -4
  129. data/lib/ruby_llm/model/pricing.rb +43 -14
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +66 -15
  132. data/lib/ruby_llm/model.rb +244 -2
  133. data/lib/ruby_llm/models/aliases.rb +41 -0
  134. data/lib/ruby_llm/models/registry.rb +165 -0
  135. data/lib/ruby_llm/models/schema.rb +99 -0
  136. data/lib/ruby_llm/models.json +70380 -33380
  137. data/lib/ruby_llm/models.rb +528 -201
  138. data/lib/ruby_llm/moderation.rb +139 -26
  139. data/lib/ruby_llm/ocr.rb +112 -0
  140. data/lib/ruby_llm/prompt.rb +79 -0
  141. data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
  142. data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
  143. data/lib/ruby_llm/protocol/streaming.rb +230 -0
  144. data/lib/ruby_llm/protocol.rb +662 -0
  145. data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
  146. data/lib/ruby_llm/protocols/anthropic/chat.rb +540 -0
  147. data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
  148. data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
  149. data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
  150. data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
  151. data/lib/ruby_llm/protocols/anthropic/streaming.rb +166 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +43 -34
  153. data/lib/ruby_llm/protocols/anthropic.rb +100 -0
  154. data/lib/ruby_llm/protocols/azure/files.rb +16 -0
  155. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +109 -0
  156. data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
  157. data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
  158. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
  159. data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
  160. data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
  161. data/lib/ruby_llm/protocols/chat_completions/chat.rb +493 -0
  162. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
  163. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
  164. data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
  165. data/lib/ruby_llm/protocols/chat_completions/media.rb +121 -0
  166. data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
  167. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
  168. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +56 -0
  169. data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
  170. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
  171. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +17 -17
  172. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
  173. data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
  174. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
  175. data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
  176. data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
  177. data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
  178. data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
  179. data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
  180. data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
  181. data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
  182. data/lib/ruby_llm/protocols/cohere/rerank.rb +52 -0
  183. data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
  184. data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
  185. data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
  186. data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
  187. data/lib/ruby_llm/protocols/cohere.rb +21 -0
  188. data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
  189. data/lib/ruby_llm/protocols/converse/chat.rb +685 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +424 -0
  192. data/lib/ruby_llm/protocols/converse.rb +54 -0
  193. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  194. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  195. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  196. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  197. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  198. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  199. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  208. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  209. data/lib/ruby_llm/protocols/files.rb +119 -0
  210. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  211. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  212. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  213. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  214. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  215. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  216. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  217. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  218. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  219. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +33 -20
  220. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  221. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  222. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  223. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  224. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  225. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  226. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  227. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  228. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  229. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  230. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  231. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  232. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  233. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  234. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  235. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  236. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  237. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  238. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  239. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  240. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  242. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  243. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  244. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  248. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  249. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  250. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  251. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  252. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  253. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  254. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  255. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  256. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  257. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  258. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  259. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  261. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  262. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  263. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  264. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  265. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  266. data/lib/ruby_llm/protocols/responses.rb +35 -0
  267. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  268. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  271. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  272. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  273. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  274. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  275. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  276. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  277. data/lib/ruby_llm/provider.rb +565 -124
  278. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  279. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  280. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  281. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  282. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  283. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  284. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  285. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  286. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  287. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  288. data/lib/ruby_llm/providers/azure/media.rb +6 -15
  289. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  290. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  291. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  292. data/lib/ruby_llm/providers/azure.rb +77 -78
  293. data/lib/ruby_llm/providers/bedrock/auth.rb +61 -41
  294. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  295. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  300. data/lib/ruby_llm/providers/bedrock/models.rb +194 -42
  301. data/lib/ruby_llm/providers/bedrock.rb +217 -46
  302. data/lib/ruby_llm/providers/cohere.rb +31 -0
  303. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  304. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -8
  305. data/lib/ruby_llm/providers/deepseek/chat.rb +56 -0
  306. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  307. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  308. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  309. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  310. data/lib/ruby_llm/providers/gemini.rb +15 -8
  311. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -9
  312. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  313. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  314. data/lib/ruby_llm/providers/gpustack/models.rb +72 -60
  315. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  316. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  317. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  318. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  319. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  320. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  321. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  322. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  323. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  324. data/lib/ruby_llm/providers/mistral/media.rb +43 -0
  325. data/lib/ruby_llm/providers/mistral/models.rb +57 -21
  326. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  327. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  328. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  329. data/lib/ruby_llm/providers/mistral.rb +18 -6
  330. data/lib/ruby_llm/providers/ollama/chat.rb +9 -8
  331. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  332. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  333. data/lib/ruby_llm/providers/ollama.rb +9 -8
  334. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  335. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  336. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  337. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  338. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  339. data/lib/ruby_llm/providers/openai.rb +92 -11
  340. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -104
  341. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  342. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  343. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  344. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  345. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  346. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  347. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  348. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  349. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  350. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  351. data/lib/ruby_llm/providers/perplexity/media.rb +46 -0
  352. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  353. data/lib/ruby_llm/providers/perplexity.rb +29 -21
  354. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  355. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  356. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  357. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  359. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  360. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  361. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  362. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  363. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  364. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  365. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  366. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  367. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  368. data/lib/ruby_llm/providers/vertexai.rb +164 -17
  369. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  370. data/lib/ruby_llm/providers/xai/chat.rb +10 -0
  371. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  372. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  373. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  374. data/lib/ruby_llm/providers/xai/models.rb +32 -48
  375. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  376. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  377. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  378. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  379. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  380. data/lib/ruby_llm/providers/xai.rb +17 -7
  381. data/lib/ruby_llm/railtie.rb +11 -16
  382. data/lib/ruby_llm/rerank.rb +105 -0
  383. data/lib/ruby_llm/research_job.rb +241 -0
  384. data/lib/ruby_llm/search_results.rb +68 -0
  385. data/lib/ruby_llm/server_tool_call.rb +73 -0
  386. data/lib/ruby_llm/speech.rb +159 -0
  387. data/lib/ruby_llm/speech_chunk.rb +33 -0
  388. data/lib/ruby_llm/support/deprecator.rb +22 -0
  389. data/lib/ruby_llm/support/inspectable.rb +49 -0
  390. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  391. data/lib/ruby_llm/support/utils.rb +147 -0
  392. data/lib/ruby_llm/thinking.rb +127 -20
  393. data/lib/ruby_llm/tokenization.rb +59 -0
  394. data/lib/ruby_llm/tokens.rb +103 -33
  395. data/lib/ruby_llm/tool.rb +266 -91
  396. data/lib/ruby_llm/tool_call.rb +36 -3
  397. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  398. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  399. data/lib/ruby_llm/transcription.rb +139 -13
  400. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  401. data/lib/ruby_llm/transport/connection.rb +193 -0
  402. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  403. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  404. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  405. data/lib/ruby_llm/uploaded_file.rb +144 -0
  406. data/lib/ruby_llm/version.rb +2 -1
  407. data/lib/ruby_llm/video.rb +136 -0
  408. data/lib/ruby_llm/video_job.rb +150 -0
  409. data/lib/ruby_llm/workflow.rb +91 -0
  410. data/lib/ruby_llm.rb +385 -4
  411. data/lib/tasks/ruby_llm.rake +21 -16
  412. data/skills/rubyllm/SKILL.md +81 -0
  413. data/skills/rubyllm/agents/openai.yaml +4 -0
  414. metadata +340 -92
  415. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  416. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  417. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  418. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  419. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  422. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  424. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  426. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  428. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -530
  429. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  430. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  431. data/lib/ruby_llm/aliases.rb +0 -38
  432. data/lib/ruby_llm/connection.rb +0 -130
  433. data/lib/ruby_llm/content.rb +0 -77
  434. data/lib/ruby_llm/mime_type.rb +0 -71
  435. data/lib/ruby_llm/model/info.rb +0 -130
  436. data/lib/ruby_llm/models_schema.json +0 -171
  437. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -257
  438. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  439. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  440. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  441. data/lib/ruby_llm/providers/anthropic/models.rb +0 -57
  442. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -69
  443. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -403
  444. data/lib/ruby_llm/providers/bedrock/media.rb +0 -90
  445. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -322
  446. data/lib/ruby_llm/providers/gemini/chat.rb +0 -543
  447. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  448. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  449. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  450. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -96
  451. data/lib/ruby_llm/providers/gemini/tools.rb +0 -232
  452. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  453. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  454. data/lib/ruby_llm/providers/openai/chat.rb +0 -221
  455. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  456. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  457. data/lib/ruby_llm/providers/openai/media.rb +0 -84
  458. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  459. data/lib/ruby_llm/providers/openai/streaming.rb +0 -53
  460. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  461. data/lib/ruby_llm/providers/openai/transcription.rb +0 -70
  462. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  463. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  464. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  465. data/lib/ruby_llm/stream_accumulator.rb +0 -203
  466. data/lib/ruby_llm/streaming.rb +0 -175
  467. data/lib/ruby_llm/utils.rb +0 -91
  468. data/lib/tasks/models.rake +0 -565
  469. data/lib/tasks/release.rake +0 -67
  470. data/lib/tasks/vcr.rake +0 -124
@@ -1,82 +1,205 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
4
+
3
5
  module RubyLLM
4
- # Represents a generated image from an AI model.
6
+ # An Image is a generated or edited image. Save it to a file with #save
7
+ # or read its bytes with #to_blob. Both handle hosted URLs and inline data.
8
+ #
9
+ # image = RubyLLM.paint("a sunset over mountains in watercolor style")
10
+ # image.save("sunset.png")
11
+ #
5
12
  class Image
6
- attr_reader :url, :data, :mime_type, :revised_prompt, :model_id, :usage
13
+ include Support::Inspectable
14
+ include Accounting::Usage::Result
15
+
16
+ # The URL of the hosted image, for providers that return one, or +nil+.
17
+ attr_reader :url
18
+
19
+ # The Base64-encoded image data, for providers that return the image
20
+ # inline, or +nil+.
21
+ attr_reader :data
22
+
23
+ # The MIME type of the image data, such as <tt>"image/png"</tt>.
24
+ attr_reader :mime_type
25
+
26
+ # The provider's rewritten version of the prompt, when reported.
27
+ attr_reader :revised_prompt
28
+
29
+ # The id of the model that generated the image.
30
+ attr_reader :model
31
+
32
+ # Generates an image from +prompt+ and returns an Image. Most code
33
+ # calls this through RubyLLM.paint.
34
+ #
35
+ # +model:+ selects the image model and defaults to the configured
36
+ # +default_image_model+. +provider:+ forces a specific provider, and
37
+ # +assume_model_exists:+ skips the registry lookup, which is useful
38
+ # for custom endpoints. +size:+ requests dimensions on models that
39
+ # support it. +count:+ asks for several images in one request, returning
40
+ # an Array of Images instead of one. +with:+ passes one or more source
41
+ # images for editing, and +mask:+ constrains which parts of the image
42
+ # may change. +provider_options:+ takes options in the provider's
43
+ # request vocabulary and merges them into the request as-is.
44
+ # +context:+ supplies a Context whose configuration replaces the
45
+ # global one. +metadata:+ is included in the instrumentation payload.
46
+ #
47
+ # image = RubyLLM.paint("A small watercolor robot", model: "gpt-image-2")
48
+ #
49
+ # images = RubyLLM.paint("A small watercolor robot", count: 4)
50
+ # images.each_with_index { |image, i| image.save("robot-#{i}.png") }
51
+ #
52
+ # RubyLLM.paint(
53
+ # "Turn the logo green and keep the background transparent",
54
+ # model: "gpt-image-2",
55
+ # with: "logo.png"
56
+ # )
57
+ #
58
+ # Providers that cannot generate several images in one request ignore
59
+ # +count:+ and return a single Image.
60
+ def self.paint(prompt,
61
+ model: nil,
62
+ provider: nil,
63
+ assume_model_exists: false,
64
+ size: nil,
65
+ count: nil,
66
+ context: nil,
67
+ with: nil,
68
+ mask: nil,
69
+ provider_options: {},
70
+ metadata: nil)
71
+ config = context&.config || RubyLLM.config
72
+ model ||= config.default_image_model
73
+ model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
74
+ config: config)
75
+ empty_tokens = Tokens.new
76
+ payload = {
77
+ provider: provider_instance.slug,
78
+ provider_class: provider_instance.class.display_name,
79
+ model: model.id,
80
+ model_info: model,
81
+ prompt: prompt,
82
+ size: size,
83
+ count: count,
84
+ provider_options: provider_options,
85
+ metadata: metadata,
86
+ tokens: empty_tokens,
87
+ cost: Cost.new(tokens: empty_tokens, model:, category: :images)
88
+ }
89
+
90
+ RubyLLM.instrument('image.ruby_llm', payload, config: config) do |event|
91
+ result = provider_instance.paint(prompt, model:, size:, count:, with:, mask:, provider_options:)
92
+ images = Support::Utils.to_safe_array(result)
93
+ event[:result] = result
94
+ event[:response_model] = images.first&.model
95
+ event[:tokens] = Tokens.aggregate(images.map(&:tokens))
96
+ event[:cost] = Cost.aggregate(images.map(&:cost))
97
+ result
98
+ end
99
+ end
7
100
 
8
- def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil, usage: {}) # rubocop:disable Metrics/ParameterLists
101
+ # :stopdoc:
102
+
103
+ # Set by the protocol that generated the image, so a Context's
104
+ # connection settings reach #to_blob.
105
+ attr_writer :config
106
+
107
+ def config
108
+ @config || RubyLLM.config
109
+ end
110
+
111
+ def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model: nil, usage: {})
9
112
  @url = url
10
113
  @data = data
11
114
  @mime_type = mime_type
12
115
  @revised_prompt = revised_prompt
13
- @model_id = model_id
14
- @usage = usage
116
+ @model = model
117
+ @raw_usage = usage
15
118
  end
119
+ # :startdoc:
16
120
 
121
+ # Returns +true+ if the image holds inline Base64 data, +false+ otherwise.
17
122
  def base64?
18
123
  !@data.nil?
19
124
  end
20
125
 
126
+ # Returns the raw binary image bytes, decoding #data when present or
127
+ # downloading from #url otherwise.
128
+ #
129
+ # image_bytes = image.to_blob
130
+ #
21
131
  def to_blob
22
132
  if base64?
23
133
  Base64.decode64 @data
24
134
  else
25
- response = Connection.basic.get @url
135
+ response = Transport::Connection.basic(config).get @url
26
136
  response.body
27
137
  end
28
138
  end
29
139
 
140
+ # Writes the binary image to +path+, expanding it first. Returns
141
+ # +path+ as given.
142
+ #
143
+ # image.save("steampunk_owl.png")
144
+ #
30
145
  def save(path)
31
146
  File.binwrite(File.expand_path(path), to_blob)
32
147
  path
33
148
  end
34
149
 
35
- def self.paint(prompt, # rubocop:disable Metrics/ParameterLists
36
- model: nil,
37
- provider: nil,
38
- assume_model_exists: false,
39
- size: '1024x1024',
40
- context: nil,
41
- with: nil,
42
- mask: nil,
43
- params: {})
44
- config = context&.config || RubyLLM.config
45
- model ||= config.default_image_model
46
- model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
47
- config: config)
48
- model_id = model.id
49
-
50
- provider_instance.paint(prompt, model: model_id, size:, with:, mask:, params:)
51
- end
52
-
150
+ # Returns a Tokens with usage across every provider attempt.
151
+ # Its fields are +nil+ when none were reported.
152
+ #
153
+ # image.tokens.input
154
+ # image.tokens.output
155
+ #
53
156
  def tokens
54
- @tokens ||= Tokens.build(
55
- input: usage_value('input_tokens'),
56
- output: usage_value('output_tokens')
157
+ return ruby_llm_usage_tokens unless ruby_llm_usage_entries.empty?
158
+
159
+ @tokens ||= Tokens.new(
160
+ input: raw_usage['input_tokens'],
161
+ output: raw_usage['output_tokens'],
162
+ reported_cost: raw_usage['cost']
57
163
  )
58
164
  end
59
165
 
166
+ # Returns a Cost across every provider attempt, using reported prices
167
+ # when available and registry pricing otherwise.
168
+ #
169
+ # image.cost.total
170
+ #
60
171
  def cost
172
+ return ruby_llm_usage_cost unless ruby_llm_usage_entries.empty?
173
+
61
174
  Cost.new(tokens:, model: model_info, category: :images, input_details: input_tokens_details)
62
175
  end
63
176
 
177
+ # Returns the registry Model for #model, or +nil+ if the model id
178
+ # is missing or not in the registry.
64
179
  def model_info
65
- return unless model_id
180
+ return unless model
66
181
 
67
- @model_info ||= RubyLLM.models.find(model_id)
182
+ @model_info ||= RubyLLM.models.find(model)
68
183
  rescue ModelNotFoundError
69
184
  nil
70
185
  end
71
186
 
72
187
  private
73
188
 
189
+ attr_reader :raw_usage
190
+
74
191
  def input_tokens_details
75
- usage_value('input_tokens_details')
192
+ raw_usage['input_tokens_details']
76
193
  end
77
194
 
78
- def usage_value(key)
79
- usage[key] || usage[key.to_sym]
195
+ def inspect_attributes # :nodoc:
196
+ {
197
+ model: model,
198
+ mime_type: mime_type,
199
+ url: url,
200
+ data: data && "#{data.bytesize} bytes",
201
+ revised_prompt: revised_prompt
202
+ }
80
203
  end
81
204
  end
82
205
  end
@@ -1,127 +1,306 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- # A single message in a chat conversation.
4
+ # A Message is a single entry in a chat conversation: a user prompt, an
5
+ # assistant reply, a system instruction, or a tool result. Chat#ask
6
+ # returns the model's reply as a Message, and Chat#messages holds the
7
+ # transcript as an array of them.
8
+ #
9
+ # response = chat.ask "What is the capital of France?"
10
+ # response.role # => :assistant
11
+ # response.content # => "The capital of France is Paris."
12
+ # response.finish_reason # => :stop
13
+ #
14
+ # A Message also carries everything else the provider returned: token
15
+ # usage (#tokens), reasoning output (#thinking), source citations
16
+ # (#citations), and requested tool calls (#tool_calls).
5
17
  class Message
18
+ include Support::Inspectable
19
+ include Accounting::Usage::Result
20
+
21
+ # The valid message roles: +:system+, +:user+, +:assistant+, and +:tool+.
6
22
  ROLES = %i[system user assistant tool].freeze
7
23
 
8
- attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :thinking, :tokens
9
- attr_writer :content
24
+ # The role of the message: +:system+, +:user+, +:assistant+, or +:tool+.
25
+ attr_reader :role
26
+
27
+ # The message text as a String. Empty for assistant messages that only
28
+ # request tool calls.
29
+ attr_reader :content
30
+
31
+ # The files sent or returned with the message, as an array of
32
+ # Attachment objects.
33
+ attr_reader :attachments
34
+
35
+ # The ID of the model that produced the message, +nil+ on user messages.
36
+ attr_reader :model
37
+
38
+ # The tool calls the assistant requested, as a Hash of ToolCall objects
39
+ # keyed by call ID, or +nil+.
40
+ attr_reader :tool_calls
41
+
42
+ # The ID of the tool call this message answers. Set only on tool result
43
+ # messages.
44
+ attr_reader :tool_call_id
45
+
46
+ # The raw provider response: a Faraday::Response, or the result body
47
+ # Hash for messages retrieved from a Batch.
48
+ attr_reader :raw
49
+
50
+ # The model's reasoning output as a Thinking object, or +nil+ when the
51
+ # provider returned none.
52
+ attr_reader :thinking
53
+
54
+ # The source citations as an array of Citation objects, normalized
55
+ # across providers.
56
+ attr_reader :citations
57
+
58
+ # Why the model stopped: +:stop+, +:max_tokens+, +:tool_calls+, or
59
+ # +:content_filter+. Any other reason comes through as the provider
60
+ # spelled it, such as Anthropic's +:pause_turn+.
61
+ attr_reader :finish_reason
62
+
63
+ # The provider-executed tool steps in this response, as an array of
64
+ # ServerToolCall objects. Empty unless the chat enabled tools with
65
+ # Chat#with_server_tools and the model used one.
66
+ attr_reader :server_tool_calls
67
+
68
+ # The provider-shaped content blocks of this assistant message, kept
69
+ # verbatim when the response used server tools so later requests can
70
+ # replay the turn exactly. +nil+ otherwise.
71
+ attr_reader :raw_content # :nodoc:
10
72
 
11
- def initialize(options = {})
73
+ # The provider-shaped reasoning payload of this assistant message, kept
74
+ # verbatim so later requests can replay the model's reasoning exactly.
75
+ # +nil+ when the provider returned none.
76
+ attr_reader :raw_reasoning # :nodoc:
77
+
78
+ # The Chat this message belongs to, set when it is added to a
79
+ # conversation. Backs #tool_results.
80
+ attr_accessor :conversation # :nodoc:
81
+
82
+ def initialize(options = {}) # :nodoc:
12
83
  @role = options.fetch(:role).to_sym
13
- @tool_calls = options[:tool_calls]
14
- @content = normalize_content(options.fetch(:content), role: @role, tool_calls: @tool_calls)
15
- @model_id = options[:model_id]
84
+ @tool_calls = coerce_tool_calls(options[:tool_calls])
85
+ @content = normalize_content(options.fetch(:content))
86
+ @config = options[:config]
87
+ @attachments = Attachment.wrap(options[:attachments], config: @config)
88
+ @model = options[:model]
89
+ @supplied_cost = coerce_value(options[:cost], Cost)
16
90
  @tool_call_id = options[:tool_call_id]
17
- @tokens = options[:tokens] || Tokens.build(
91
+ @tokens = options[:tokens] || Tokens.new(
18
92
  input: options[:input_tokens],
19
93
  output: options[:output_tokens],
20
- cached: options[:cached_tokens],
21
- cache_creation: options[:cache_creation_tokens],
94
+ cache_read: options[:cache_read_tokens],
95
+ cache_write: options[:cache_write_tokens],
22
96
  thinking: options[:thinking_tokens],
23
- reasoning: options[:reasoning_tokens]
97
+ server_tool_use: options[:server_tool_use],
98
+ reported_cost: options[:reported_cost]
24
99
  )
25
100
  @raw = options[:raw]
26
- @thinking = options[:thinking]
101
+ @thinking = coerce_thinking(options[:thinking], options[:thinking_signature])
102
+ @citations = Array(options[:citations]).map { |citation| coerce_value(citation, Citation) }
103
+ @server_tool_calls = Array(options[:server_tool_calls]).map { |call| coerce_value(call, ServerToolCall) }
104
+ @raw_content = options[:raw_content]
105
+ @raw_reasoning = options[:raw_reasoning]
106
+ @finish_reason = options[:finish_reason]&.to_sym
107
+ self.ruby_llm_usage_entries = options[:usage_entries] if options[:usage_entries]
108
+ @cache_until_here = options.fetch(:cache_until_here, false)
27
109
 
28
110
  ensure_valid_role
29
111
  end
30
112
 
31
- def content
32
- if @content.is_a?(Content) && @content.text && @content.attachments.empty?
33
- @content.text
34
- else
35
- @content
36
- end
113
+ # Returns #content parsed as JSON, memoized after the first call.
114
+ # Useful for reading structured output responses.
115
+ #
116
+ # response = chat.with_schema(PersonSchema).ask "Generate a person"
117
+ # response.parsed # => {"name" => "Alice", "age" => 30}
118
+ #
119
+ def parsed
120
+ return if content.nil? || content.empty?
121
+
122
+ @parsed ||= JSON.parse(content)
123
+ end
124
+
125
+ def with_attachments(attachments) # :nodoc:
126
+ wrapped = Attachment.wrap(attachments, config: @config)
127
+ dup.tap { |message| message.instance_variable_set(:@attachments, wrapped) }
37
128
  end
38
129
 
130
+ # Returns +true+ if the assistant requested one or more tool calls,
131
+ # +false+ otherwise.
39
132
  def tool_call?
40
133
  !tool_calls.nil? && !tool_calls.empty?
41
134
  end
42
135
 
136
+ # Returns +true+ if the message carries the result of a tool call,
137
+ # +false+ otherwise.
43
138
  def tool_result?
44
139
  !tool_call_id.nil? && !tool_call_id.empty?
45
140
  end
46
141
 
142
+ # Returns the tool result messages answering this message's tool calls,
143
+ # or an empty array when it made none. Mirrors the +tool_results+
144
+ # association on acts_as_message records.
47
145
  def tool_results
48
- content if tool_result?
49
- end
146
+ return [] unless tool_call? && conversation
50
147
 
51
- def input_tokens
52
- tokens&.input
148
+ conversation.messages.select do |message|
149
+ message.tool_result? && tool_calls.key?(message.tool_call_id)
150
+ end
53
151
  end
54
152
 
55
- def output_tokens
56
- tokens&.output
153
+ # Returns +true+ if #finish_reason indicates the model finished
154
+ # normally, +false+ otherwise. A turn that stopped to call tools is
155
+ # reported by #tool_call_stop? instead, whatever the provider named it.
156
+ def stopped?
157
+ finish_reason == :stop && !tool_call?
57
158
  end
58
159
 
59
- def cached_tokens
60
- tokens&.cached
160
+ # Returns +true+ if the response was cut off by a token limit,
161
+ # +false+ otherwise.
162
+ def max_tokens?
163
+ finish_reason == :max_tokens
61
164
  end
62
165
 
63
- def cache_creation_tokens
64
- tokens&.cache_creation
166
+ # Returns +true+ if the model stopped to request tool calls,
167
+ # +false+ otherwise.
168
+ def tool_call_stop?
169
+ finish_reason == :tool_calls || (tool_call? && finish_reason == :stop)
65
170
  end
66
171
 
67
- def cache_read_tokens
68
- tokens&.cache_read
172
+ # Returns +true+ if a provider safety filter stopped the response,
173
+ # +false+ otherwise.
174
+ def content_filtered?
175
+ finish_reason == :content_filter
69
176
  end
70
177
 
71
- def cache_write_tokens
72
- tokens&.cache_write
178
+ # Returns usage aggregated across every provider attempt that produced this
179
+ # message. Messages constructed by hand report the token counts they were
180
+ # built with.
181
+ def tokens
182
+ return @tokens if ruby_llm_usage_entries.empty?
183
+
184
+ ruby_llm_usage_tokens
73
185
  end
74
186
 
75
- def thinking_tokens
76
- tokens&.thinking
187
+ # Returns a Cost pricing this message's token usage in US dollars.
188
+ # Uses recorded attempt costs, an explicitly supplied +cost:+, or pricing
189
+ # from #model_info. An explicit +model:+ overrides those costs for repricing.
190
+ #
191
+ # response.cost.total
192
+ #
193
+ def cost(model: nil)
194
+ return ruby_llm_usage_cost if model.nil? && ruby_llm_usage_entries.any?
195
+ return @supplied_cost if model.nil? && @supplied_cost
196
+
197
+ Cost.new(tokens:, model: model || model_info)
77
198
  end
78
199
 
79
- def reasoning_tokens
80
- tokens&.thinking
200
+ # Marks this message as an explicit prompt cache boundary. Providers
201
+ # with boundary controls use the conversation up to and including this
202
+ # message as the cacheable prefix. Returns +self+.
203
+ #
204
+ # chat.add_message(role: :user, content: long_context).cache_until_here
205
+ #
206
+ def cache_until_here
207
+ @cache_until_here = true
208
+ self
81
209
  end
82
210
 
83
- def cost(model: nil)
84
- Cost.new(tokens:, model: model || model_info)
211
+ # Returns +true+ if the message carries an explicit prompt cache
212
+ # boundary, +false+ otherwise.
213
+ def cache_until_here?
214
+ @cache_until_here
85
215
  end
86
216
 
217
+ # Returns a Hash of the message's attributes, with token counts merged
218
+ # in as +:input_tokens+, +:output_tokens+, and related keys. Omits
219
+ # +nil+ values and empty attachment and citation lists. Includes +:cost+
220
+ # only when supplied explicitly, preserving unknown costs on round-trip.
87
221
  def to_h
88
222
  {
89
223
  role: role,
90
224
  content: content,
91
- model_id: model_id,
92
- tool_calls: tool_calls,
225
+ attachments: list_to_h(attachments),
226
+ model: model,
227
+ cost: @supplied_cost && cost.to_h,
228
+ tool_calls: tool_calls&.transform_values(&:to_h),
93
229
  tool_call_id: tool_call_id,
94
230
  thinking: thinking&.text,
95
- thinking_signature: thinking&.signature
96
- }.merge(tokens ? tokens.to_h : {}).compact
97
- end
98
-
99
- def instance_variables
100
- super - [:@raw]
231
+ thinking_signature: thinking&.signature,
232
+ citations: list_to_h(citations),
233
+ server_tool_calls: list_to_h(server_tool_calls),
234
+ raw_content: raw_content,
235
+ raw_reasoning: raw_reasoning,
236
+ finish_reason: finish_reason,
237
+ cache_until_here: cache_until_here? || nil
238
+ }.merge(tokens.to_h).compact
101
239
  end
102
240
 
241
+ # Returns the Model record for #model from the model registry, or
242
+ # +nil+ when the message has no model or the model is unknown.
103
243
  def model_info
104
- return unless model_id
244
+ return unless model
105
245
 
106
- @model_info ||= RubyLLM.models.find(model_id)
246
+ @model_info ||= RubyLLM.models.find(model)
107
247
  rescue ModelNotFoundError
108
248
  nil
109
249
  end
110
250
 
111
251
  private
112
252
 
113
- def normalize_content(content, role:, tool_calls:)
114
- return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
253
+ def list_to_h(list)
254
+ list.empty? ? nil : list.map(&:to_h)
255
+ end
115
256
 
116
- case content
117
- when String then Content.new(content)
118
- when Hash then Content.new(content[:text], content)
119
- else content
257
+ def coerce_tool_calls(tool_calls)
258
+ return tool_calls unless tool_calls.is_a?(Hash)
259
+
260
+ tool_calls.to_h do |id, call|
261
+ next [id, call] unless call.is_a?(Hash)
262
+
263
+ attributes = call.transform_keys(&:to_sym)
264
+ [id, ToolCall.new(id: attributes[:id] || id, name: attributes[:name],
265
+ arguments: attributes[:arguments] || {},
266
+ thought_signature: attributes[:thought_signature], remote: attributes.fetch(:remote, false))]
267
+ end
268
+ end
269
+
270
+ def coerce_thinking(thinking, signature)
271
+ case thinking
272
+ when nil, Thinking then thinking
273
+ when Hash then Thinking.build(**thinking.transform_keys(&:to_sym).slice(:text, :signature))
274
+ else Thinking.build(text: thinking.to_s, signature: signature)
120
275
  end
121
276
  end
122
277
 
278
+ def coerce_value(value, klass)
279
+ value.is_a?(Hash) ? klass.from_h(value) : value
280
+ end
281
+
282
+ def normalize_content(content)
283
+ return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
284
+ return content if content.nil? || content.is_a?(String)
285
+
286
+ raise ArgumentError,
287
+ "Message content must be a String, got #{content.class}. " \
288
+ 'Pass files via attachments: and structured data as JSON.'
289
+ end
290
+
123
291
  def ensure_valid_role
124
292
  raise InvalidRoleError, "Expected role to be one of: #{ROLES.join(', ')}" unless ROLES.include?(role)
125
293
  end
294
+
295
+ def inspect_attributes # :nodoc:
296
+ {
297
+ role: role,
298
+ content: content,
299
+ tool_calls: tool_calls&.values&.map(&:name),
300
+ tool_call_id: tool_call_id,
301
+ model: model,
302
+ finish_reason: finish_reason
303
+ }
304
+ end
126
305
  end
127
306
  end
@@ -1,16 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- module Model
5
- # Holds and manages input and output modalities for a language model
4
+ class Model
5
+ # A Model::Modalities lists the kinds of content a model accepts and
6
+ # produces, as arrays of Strings. Instances come from Model#modalities.
7
+ #
8
+ # model = RubyLLM.models.find('gpt-5.6')
9
+ # model.modalities.input # => ["text", "image", "pdf"]
10
+ # model.modalities.output # => ["text"]
11
+ #
6
12
  class Modalities
7
- attr_reader :input, :output
13
+ # The input modalities as an array of Strings,
14
+ # e.g. <tt>["text", "image", "pdf"]</tt>.
15
+ attr_reader :input
8
16
 
9
- def initialize(data)
17
+ # The output modalities as an array of Strings,
18
+ # e.g. <tt>["text"]</tt> or <tt>["embeddings"]</tt>.
19
+ attr_reader :output
20
+
21
+ def initialize(data) # :nodoc:
10
22
  @input = Array(data[:input]).map(&:to_s)
11
23
  @output = Array(data[:output]).map(&:to_s)
12
24
  end
13
25
 
26
+ # Returns the modalities as a Hash with +:input+ and +:output+ keys.
14
27
  def to_h
15
28
  {
16
29
  input: input,