ruby_llm 1.16.0 → 2.0.0.rc2

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 (475) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +85 -32
  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 -8
  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 +23 -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 +101 -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 -111
  100. data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
  103. data/lib/ruby_llm/active_record/message_methods.rb +113 -136
  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 -151
  109. data/lib/ruby_llm/aliases.json +268 -100
  110. data/lib/ruby_llm/attachment.rb +187 -48
  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 +1127 -198
  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 +261 -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 +121 -17
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +159 -23
  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 +153 -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 +24 -5
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +55 -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 +73261 -33733
  137. data/lib/ruby_llm/models.rb +477 -215
  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 +548 -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 +167 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  153. data/lib/ruby_llm/protocols/anthropic.rb +101 -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/{providers/openai → protocols/chat_completions}/media.rb +30 -17
  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 +15 -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 +695 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
  192. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -0
  193. data/lib/ruby_llm/protocols/converse.rb +54 -0
  194. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  195. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  196. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  197. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  198. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  199. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  208. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  209. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  210. data/lib/ruby_llm/protocols/files.rb +119 -0
  211. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  212. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  213. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  214. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  215. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  216. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  217. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  218. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  219. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  220. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  221. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  222. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  223. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  224. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  225. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  226. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  227. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  228. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  229. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  230. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  231. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  232. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  233. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  234. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  235. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  236. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  237. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  238. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  239. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  240. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  242. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  243. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  244. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  248. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  249. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  250. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  251. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  252. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  253. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  254. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  255. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  256. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  257. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  258. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  259. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  261. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  262. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  263. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  264. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  265. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  266. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  267. data/lib/ruby_llm/protocols/responses.rb +35 -0
  268. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  271. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  272. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  273. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  274. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  275. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  276. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  277. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  278. data/lib/ruby_llm/provider.rb +560 -128
  279. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  280. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  281. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  282. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  283. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  284. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  285. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  286. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  287. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  288. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  289. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  290. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  291. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  292. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  293. data/lib/ruby_llm/providers/azure.rb +77 -78
  294. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  295. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  300. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  301. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  302. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  303. data/lib/ruby_llm/providers/cohere.rb +31 -0
  304. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  305. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  306. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  307. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  308. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  309. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  310. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  311. data/lib/ruby_llm/providers/gemini.rb +15 -8
  312. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  313. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  314. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  315. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  316. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  317. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  318. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  319. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  320. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  321. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  322. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  323. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  324. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  325. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  326. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  327. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  328. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  329. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  330. data/lib/ruby_llm/providers/mistral.rb +16 -4
  331. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  332. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  333. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  334. data/lib/ruby_llm/providers/ollama.rb +9 -8
  335. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  336. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  337. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  338. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  339. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  340. data/lib/ruby_llm/providers/openai.rb +92 -11
  341. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -108
  342. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  343. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  344. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  345. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  346. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  347. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  348. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  349. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  350. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  351. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  352. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  353. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  354. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  355. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  356. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  357. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  359. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  360. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  361. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  362. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  363. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  364. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  365. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  366. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  367. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  368. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  369. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  370. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  371. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  372. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  373. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  374. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  375. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  376. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  377. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  378. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  379. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  380. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  381. data/lib/ruby_llm/providers/xai.rb +15 -5
  382. data/lib/ruby_llm/railtie.rb +7 -16
  383. data/lib/ruby_llm/rerank.rb +105 -0
  384. data/lib/ruby_llm/research_job.rb +241 -0
  385. data/lib/ruby_llm/search_results.rb +68 -0
  386. data/lib/ruby_llm/server_tool_call.rb +73 -0
  387. data/lib/ruby_llm/speech.rb +159 -0
  388. data/lib/ruby_llm/speech_chunk.rb +33 -0
  389. data/lib/ruby_llm/support/deprecator.rb +22 -0
  390. data/lib/ruby_llm/support/inspectable.rb +49 -0
  391. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  392. data/lib/ruby_llm/support/utils.rb +147 -0
  393. data/lib/ruby_llm/thinking.rb +127 -20
  394. data/lib/ruby_llm/tokenization.rb +59 -0
  395. data/lib/ruby_llm/tokens.rb +103 -33
  396. data/lib/ruby_llm/tool.rb +266 -91
  397. data/lib/ruby_llm/tool_call.rb +36 -3
  398. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  399. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  400. data/lib/ruby_llm/transcription.rb +138 -13
  401. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  402. data/lib/ruby_llm/transport/connection.rb +193 -0
  403. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  404. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  405. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  406. data/lib/ruby_llm/uploaded_file.rb +144 -0
  407. data/lib/ruby_llm/version.rb +2 -1
  408. data/lib/ruby_llm/video.rb +136 -0
  409. data/lib/ruby_llm/video_job.rb +150 -0
  410. data/lib/ruby_llm/workflow.rb +91 -0
  411. data/lib/ruby_llm.rb +380 -6
  412. data/lib/tasks/ruby_llm.rake +21 -16
  413. data/skills/rubyllm/SKILL.md +81 -0
  414. data/skills/rubyllm/agents/openai.yaml +4 -0
  415. metadata +339 -97
  416. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  417. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  418. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  419. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  422. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  424. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  426. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  428. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  429. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  430. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  431. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  432. data/lib/ruby_llm/aliases.rb +0 -41
  433. data/lib/ruby_llm/connection.rb +0 -159
  434. data/lib/ruby_llm/content.rb +0 -91
  435. data/lib/ruby_llm/deprecator.rb +0 -24
  436. data/lib/ruby_llm/error_middleware.rb +0 -81
  437. data/lib/ruby_llm/instrumentation.rb +0 -36
  438. data/lib/ruby_llm/mime_type.rb +0 -96
  439. data/lib/ruby_llm/model/info.rb +0 -164
  440. data/lib/ruby_llm/model_registry.rb +0 -39
  441. data/lib/ruby_llm/models_schema.json +0 -171
  442. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  443. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  444. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  445. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  446. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  447. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  448. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  449. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  450. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  451. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  452. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  453. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  454. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  455. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  456. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  457. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  458. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  459. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  460. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  461. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  462. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  463. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  464. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  465. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  466. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  467. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  468. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  469. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  470. data/lib/ruby_llm/streaming.rb +0 -179
  471. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  472. data/lib/ruby_llm/utils.rb +0 -130
  473. data/lib/tasks/models.rake +0 -593
  474. data/lib/tasks/release.rake +0 -94
  475. data/lib/tasks/vcr.rake +0 -124
@@ -1,207 +1,237 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'date'
4
- require 'json'
5
4
 
6
5
  module RubyLLM
7
- # Registry of available AI models and their capabilities.
6
+ # A Models registry is the catalog of AI models RubyLLM knows about,
7
+ # including their capabilities, context windows, and pricing. The global
8
+ # registry is available through RubyLLM.models.
9
+ #
10
+ # RubyLLM.models.find 'claude-sonnet-5'
11
+ # RubyLLM.models.by_provider(:openai).chat_models
12
+ # RubyLLM.models.refresh
13
+ #
14
+ # Filter methods return new Models instances, so calls chain. Models is
15
+ # enumerable over its Model entries. Class-level calls such as
16
+ # Models.find delegate to the global registry.
8
17
  class Models
9
18
  include Enumerable
10
19
 
11
- MODELS_DEV_PROVIDER_MAP = {
20
+ MODELS_DEV_PROVIDER_MAP = { # :nodoc:
12
21
  'openai' => 'openai',
13
22
  'anthropic' => 'anthropic',
14
23
  'google' => 'gemini',
15
24
  'google-vertex' => 'vertexai',
16
25
  'amazon-bedrock' => 'bedrock',
26
+ 'cohere' => 'cohere',
17
27
  'deepseek' => 'deepseek',
18
28
  'mistral' => 'mistral',
29
+ 'ollama-cloud' => 'ollama_cloud',
19
30
  'openrouter' => 'openrouter',
20
31
  'perplexity' => 'perplexity',
32
+ 'perplexity-agent' => 'perplexity',
21
33
  'xai' => 'xai'
22
34
  }.freeze
23
- MODELS_DEV_INPUT_MODALITIES = %w[text image audio pdf video file].freeze
24
- MODELS_DEV_OUTPUT_MODALITIES = %w[text image audio video embeddings moderation].freeze
25
- MODELS_DEV_AUTHORITY_CAPABILITIES = %w[function_calling structured_output reasoning vision].freeze
35
+ MODELS_DEV_INPUT_MODALITIES = %w[text image audio pdf video file].freeze # :nodoc:
36
+ MODELS_DEV_OUTPUT_MODALITIES = %w[text image audio video embeddings moderation rerank].freeze # :nodoc:
37
+ # First-party providers outrank the aggregators that resell their models.
26
38
  PROVIDER_PREFERENCE = %w[
27
39
  openai
28
40
  anthropic
29
41
  gemini
30
- vertexai
31
- bedrock
32
- openrouter
33
42
  deepseek
34
43
  mistral
44
+ cohere
35
45
  perplexity
36
46
  xai
47
+ vertexai
48
+ bedrock
49
+ openrouter
37
50
  azure
51
+ ollama_cloud
38
52
  ollama
39
53
  gpustack
40
- ].freeze
54
+ ].freeze # :nodoc:
41
55
  INSTANCE_DELEGATES = (Enumerable.instance_methods(false) + %i[
42
56
  all
43
57
  each
44
58
  find
59
+ listed
60
+ unlisted
45
61
  chat_models
46
62
  embedding_models
47
63
  audio_models
48
64
  image_models
49
65
  by_family
50
66
  by_provider
51
- load_from_json!
52
- load_from_database!
67
+ load_from_json
68
+ load_from_store
53
69
  save_to_json
54
- ]).uniq.freeze
70
+ ]).uniq.freeze # :nodoc:
55
71
 
56
72
  class << self
73
+ # The providers whose model list could not be fetched during the last
74
+ # refresh, as hashes of +:name+, +:slug+, and +:error+. Their previous
75
+ # models are kept, so an unreported failure leaves stale entries behind.
76
+ attr_reader :last_provider_failures
77
+
57
78
  INSTANCE_DELEGATES.each do |method_name|
58
79
  define_method(method_name) do |*args, **kwargs, &block|
59
- if kwargs.empty?
60
- instance.public_send(method_name, *args, &block)
61
- else
62
- instance.public_send(method_name, *args, **kwargs, &block)
63
- end
80
+ instance.public_send(method_name, *args, **kwargs, &block)
64
81
  end
65
82
  end
66
83
 
67
- def instance
84
+ def instance # :nodoc:
68
85
  @instance ||= new
69
86
  end
70
87
 
71
- def schema_file
72
- File.expand_path('models_schema.json', __dir__)
88
+ def bundled_registry_file # :nodoc:
89
+ File.expand_path('models.json', __dir__)
73
90
  end
74
91
 
75
- def load_models(file = RubyLLM.config.model_registry_file)
76
- source = RubyLLM.config.model_registry_source
77
- if source && file == RubyLLM.config.model_registry_file
78
- models = source.read
79
- return models if models.any?
92
+ def load_models # :nodoc:
93
+ base = models_from_store(RubyLLM.config.model_registry_store) ||
94
+ models_from_file(RubyLLM.config.model_registry_file) ||
95
+ models_from_bundle
96
+
97
+ merge_models(models_from_provider_gems, base)
98
+ end
80
99
 
81
- RubyLLM.logger.debug { 'Model registry source is empty, falling back to JSON registry' }
100
+ def models_from_provider_gems # :nodoc:
101
+ Provider.model_registry_files.flat_map do |provider, file|
102
+ Array(models_from_file(file)).select { |model| model.provider == provider.to_s }
82
103
  end
104
+ end
83
105
 
84
- read_from_json(file)
106
+ def models_from_store(store) # :nodoc:
107
+ return unless store
108
+
109
+ models = Array(store.read)
110
+ return models unless models.empty?
111
+
112
+ RubyLLM.logger.debug { 'Model registry store is empty, falling back to the registry file' }
113
+ nil
85
114
  end
86
115
 
87
- def read_from_json(file = RubyLLM.config.model_registry_file)
88
- data = File.exist?(file) ? File.read(file) : '[]'
89
- models = JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
90
- filter_models(models)
91
- rescue JSON::ParserError
92
- []
116
+ def models_from_file(file) # :nodoc:
117
+ return unless file
118
+
119
+ models = Registry.read(file)
120
+ models unless models.nil? || models.empty?
121
+ rescue ModelRegistryError => e
122
+ RubyLLM.logger.warn("Ignoring invalid model registry file #{file}: #{e.message}")
123
+ nil
124
+ end
125
+
126
+ def models_from_bundle # :nodoc:
127
+ Registry.read(bundled_registry_file) || begin
128
+ RubyLLM.logger.warn(
129
+ "Bundled model registry is missing: #{bundled_registry_file}. " \
130
+ 'Refresh the registry to rebuild it.'
131
+ )
132
+ []
133
+ end
93
134
  end
94
135
 
95
- def read_from_database
96
- ModelRegistry::ActiveRecordSource.new.read
136
+ def fetch_published_registry(etag: nil) # :nodoc:
137
+ Registry::PublishedSource.new.fetch(etag:)
97
138
  end
98
139
 
99
- def refresh!(remote_only: false)
100
- # Replaces the process-wide model registry. Call save_to_json when the
101
- # refreshed registry should also be persisted.
140
+ # Refreshes the global model registry from the published catalog and
141
+ # configured providers. Returns the global Models instance. See
142
+ # #refresh for the +remote_only:+ option.
143
+ def refresh(remote_only: false)
144
+ instance.refresh(remote_only: remote_only)
145
+ end
146
+
147
+ def refresh_from_providers(remote_only: false) # :nodoc:
148
+ instance.refresh_from_providers(remote_only: remote_only)
149
+ end
150
+
151
+ # :stopdoc:
152
+
153
+ # Fetches and merges models directly from upstream provider APIs and
154
+ # models.dev for the maintainer registry builder.
155
+ def fetch_merged_models(remote_only: false) # :nodoc:
102
156
  RubyLLM.instrument('models.refresh.ruby_llm', remote_only:) do |payload|
103
- existing_models = load_existing_models
157
+ existing_models = read_existing_models
104
158
 
105
159
  provider_fetch = fetch_provider_models(remote_only: remote_only)
160
+ @last_provider_failures = provider_fetch[:failed]
106
161
  log_provider_fetch(provider_fetch)
162
+ payload[:failed_providers] = provider_fetch[:failed].map { |failure| failure[:slug] }
107
163
 
108
164
  models_dev_fetch = fetch_models_dev_models(existing_models)
109
165
  log_models_dev_fetch(models_dev_fetch)
110
166
 
111
167
  merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
112
168
  payload[:model_count] = merged_models.size
113
- @instance = new(merged_models)
169
+ merged_models
114
170
  end
115
171
  end
116
172
 
117
- def fetch_provider_models(remote_only: true) # rubocop:disable Metrics/PerceivedComplexity
173
+ def fetch_provider_models(remote_only: true) # :nodoc:
118
174
  config = RubyLLM.config
119
- provider_classes = remote_only ? Provider.remote_providers.values : Provider.providers.values
120
- configured_classes = if remote_only
121
- Provider.configured_remote_providers(config)
122
- else
123
- Provider.configured_providers(config)
124
- end
125
- configured = configured_classes.select { |klass| provider_classes.include?(klass) }
175
+ providers = remote_only ? Provider.configured_remote_providers(config) : Provider.configured_providers(config)
176
+ providers = providers.reject { |provider| Provider.model_registry_files.key?(provider.slug.to_sym) }
126
177
  result = {
127
- models: [],
128
- fetched_providers: [],
129
- configured_names: configured.map(&:name),
130
- failed: []
178
+ models: [], fetched_providers: [], configured_names: providers.map(&:display_name), failed: [], empty: []
131
179
  }
132
180
 
133
- provider_classes.each do |provider_class|
134
- next if remote_only && provider_class.local?
135
- next unless provider_class.configured?(config)
136
-
137
- begin
138
- result[:models].concat(provider_class.new(config).list_models)
181
+ providers.each do |provider_class|
182
+ models = provider_class.new(config).list_models
183
+ if models.empty?
184
+ result[:empty] << { name: provider_class.display_name, slug: provider_class.slug }
185
+ else
186
+ result[:models].concat(models)
139
187
  result[:fetched_providers] << provider_class.slug
140
- rescue StandardError => e
141
- result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
142
188
  end
189
+ rescue StandardError => e
190
+ result[:failed] << { name: provider_class.display_name, slug: provider_class.slug, error: e }
143
191
  end
144
192
 
145
- result[:fetched_providers].uniq!
146
193
  result
147
194
  end
148
195
 
149
- # Backwards-compatible wrapper used by specs.
150
- def fetch_from_providers(remote_only: true)
151
- fetch_provider_models(remote_only: remote_only)[:models]
152
- end
153
-
154
- def resolve(model_id, provider: nil, assume_exists: false, config: nil) # rubocop:disable Metrics/PerceivedComplexity
196
+ def resolve(model_id, provider: nil, assume_model_exists: false, config: nil,
197
+ operation: nil, default_model: nil) # rubocop:disable Metrics/PerceivedComplexity
155
198
  config ||= RubyLLM.config
156
199
  provider_class = provider ? Provider.providers[provider.to_sym] : nil
200
+ if operation && provider_class && !provider_class.model_required?(operation:)
201
+ raise ArgumentError, "#{operation} does not accept a model" unless model_id.nil?
157
202
 
158
- if provider_class
159
- temp_instance = provider_class.new(config)
160
- assume_exists = true if temp_instance.local? || temp_instance.assume_models_exist?
203
+ return [nil, provider_class.new(config)]
161
204
  end
205
+ model_id ||= default_model
206
+ assume_model_exists = true if provider_class&.local? || provider_class&.assume_models_exist?
162
207
 
163
- if assume_exists
164
- raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
208
+ if assume_model_exists
209
+ raise ArgumentError, 'Provider must be specified if assume_model_exists is true' unless provider
165
210
 
166
- provider_class ||= raise_unknown_provider(provider)
167
- provider_instance = provider_class.new(config)
211
+ provider_class ||= Provider.resolve!(provider)
168
212
 
169
- model = if provider_instance.local?
170
- begin
171
- Models.find(model_id, provider)
172
- rescue ModelNotFoundError
173
- nil
174
- end
175
- end
213
+ model = begin
214
+ Models.find(model_id, provider: provider, config: config)
215
+ rescue ModelNotFoundError
216
+ nil
217
+ end
176
218
 
177
- model ||= Model::Info.default(model_id, provider_instance.slug)
219
+ model ||= Model.default(model_id, provider_class.slug)
178
220
  else
179
- model = Models.find model_id, provider
180
- provider_class = Provider.providers[model.provider.to_sym] || raise_unknown_provider(model.provider)
181
- provider_instance = provider_class.new(config)
221
+ model = Models.find model_id, provider: provider, config: config
222
+ provider_class = Provider.resolve!(model.provider)
182
223
  end
183
- [model, provider_instance]
224
+ [model, provider_class.new(config)]
184
225
  end
185
226
 
186
- def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
227
+ def fetch_models_dev_models(existing_models) # :nodoc:
187
228
  RubyLLM.logger.info 'Fetching models from models.dev API...'
188
229
 
189
- connection = Connection.basic do |f|
230
+ connection = Transport::Connection.basic do |f|
190
231
  f.request :json
191
232
  f.response :json, parser_options: { symbolize_names: true }
192
233
  end
193
- response = connection.get 'https://models.dev/api.json'
194
- providers = response.body || {}
195
-
196
- models = providers.flat_map do |provider_key, provider_data|
197
- provider_slug = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
198
- next [] unless provider_slug
199
-
200
- (provider_data[:models] || {}).values.map do |model_data|
201
- Model::Info.new(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
202
- end
203
- end
204
- { models: models.reject { |model| model.provider.nil? || model.id.nil? }, fetched: true }
234
+ { models: parse_models_dev_catalog(connection.get('https://models.dev/api.json').body), fetched: true }
205
235
  rescue StandardError => e
206
236
  RubyLLM.logger.warn("Failed to fetch models.dev (#{e.class}: #{e.message}). Keeping existing.")
207
237
  {
@@ -210,18 +240,33 @@ module RubyLLM
210
240
  }
211
241
  end
212
242
 
213
- def load_existing_models
214
- existing_models = instance&.all
215
- existing_models = read_from_json if existing_models.nil? || existing_models.empty?
216
- existing_models
243
+ # An answer RubyLLM cannot read a single model out of is no answer:
244
+ # only a catalog carrying models may overrule what the registry holds.
245
+ def parse_models_dev_catalog(body) # :nodoc:
246
+ raise ModelRegistryError, "models.dev returned #{body.class} instead of a catalog" unless body.is_a?(Hash)
247
+
248
+ models = body.flat_map { |provider_key, data| models_dev_provider_models(provider_key, data) }
249
+ raise ModelRegistryError, 'models.dev returned no models RubyLLM knows a provider for' if models.empty?
250
+
251
+ models
217
252
  end
218
253
 
219
- def raise_unknown_provider(provider)
220
- available = Provider.providers.keys.join(', ')
221
- raise Error, "Unknown provider: #{provider.inspect}. Available providers: #{available}"
254
+ def models_dev_provider_models(provider_key, provider_data) # :nodoc:
255
+ provider_slug = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
256
+ return [] unless provider_slug
257
+
258
+ (provider_data[:models] || {}).values.filter_map do |model_data|
259
+ model = Model.new(models_dev_model_attributes(model_data, provider_slug, provider_key.to_s))
260
+ model unless model.provider.nil? || model.id.nil?
261
+ end
222
262
  end
223
263
 
224
- def log_provider_fetch(provider_fetch)
264
+ def read_existing_models # :nodoc:
265
+ existing_models = instance.all
266
+ existing_models.empty? ? load_models : existing_models
267
+ end
268
+
269
+ def log_provider_fetch(provider_fetch) # :nodoc:
225
270
  RubyLLM.logger.info "Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
226
271
  provider_fetch[:failed].each do |failure|
227
272
  RubyLLM.logger.warn(
@@ -229,15 +274,18 @@ module RubyLLM
229
274
  'Keeping existing.'
230
275
  )
231
276
  end
277
+ Array(provider_fetch[:empty]).each do |provider|
278
+ RubyLLM.logger.warn("#{provider[:name]} listed no models. Keeping existing.")
279
+ end
232
280
  end
233
281
 
234
- def log_models_dev_fetch(models_dev_fetch)
282
+ def log_models_dev_fetch(models_dev_fetch) # :nodoc:
235
283
  return if models_dev_fetch[:fetched]
236
284
 
237
285
  RubyLLM.logger.warn('Using cached models.dev data due to fetch failure.')
238
286
  end
239
287
 
240
- def merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
288
+ def merge_with_existing(existing_models, provider_fetch, models_dev_fetch) # :nodoc:
241
289
  existing_by_provider = existing_models.group_by(&:provider)
242
290
  preserved_models = existing_by_provider
243
291
  .except(*provider_fetch[:fetched_providers])
@@ -254,70 +302,50 @@ module RubyLLM
254
302
  merge_models(provider_models, models_dev_models)
255
303
  end
256
304
 
257
- def merge_models(provider_models, models_dev_models)
305
+ def merge_models(provider_models, models_dev_models) # :nodoc:
258
306
  models_dev_by_key = index_by_key(models_dev_models)
259
307
  provider_by_key = index_by_key(provider_models)
308
+ provider_by_alias = index_provider_aliases(provider_models)
260
309
 
261
310
  all_keys = models_dev_by_key.keys | provider_by_key.keys
262
311
 
263
312
  models = all_keys.map do |key|
264
- models_dev_model = find_models_dev_model(key, models_dev_by_key)
265
- provider_model = provider_by_key[key]
313
+ provider_model = provider_by_key[key] || provider_by_alias[key]
314
+ models_dev_model = find_models_dev_model(key, models_dev_by_key, provider_model)
266
315
 
267
316
  if models_dev_model && provider_model
268
317
  add_provider_metadata(models_dev_model, provider_model)
269
318
  elsif models_dev_model
270
319
  models_dev_model
271
320
  else
272
- provider_model
321
+ augment_model_capabilities(provider_model)
273
322
  end
274
323
  end
275
324
 
276
- filter_models(models).sort_by { |m| [m.provider, m.id] }
325
+ models.sort_by { |m| [m.provider, m.id] }
277
326
  end
278
327
 
279
- def filter_models(models)
280
- models.reject do |model|
281
- model.provider.to_s == 'vertexai' && model.id.to_s.include?('/')
282
- end
283
- end
284
-
285
- def find_models_dev_model(key, models_dev_by_key)
286
- # Direct match
328
+ def find_models_dev_model(key, models_dev_by_key, provider_model = nil) # :nodoc:
287
329
  return models_dev_by_key[key] if models_dev_by_key[key]
288
330
 
289
331
  provider, model_id = key.split(':', 2)
290
- if provider == 'bedrock'
291
- normalized_id = model_id.sub(/^[a-z]{2}\./, '')
292
- context_override = nil
293
- normalized_id = normalized_id.gsub(/:(\d+)k\b/) do
294
- context_override = Regexp.last_match(1).to_i * 1000
295
- ''
296
- end
297
- bedrock_model = models_dev_by_key["bedrock:#{normalized_id}"]
298
- if bedrock_model
299
- data = bedrock_model.to_h.merge(id: model_id)
300
- data[:context_window] = context_override if context_override
301
- return Model::Info.new(data)
302
- end
303
- end
304
-
305
- # VertexAI uses same models as Gemini
306
- return unless provider == 'vertexai'
307
-
308
- gemini_model = models_dev_by_key["gemini:#{model_id}"]
309
- return unless gemini_model
310
-
311
- # Return Gemini's models.dev data but with VertexAI as provider
312
- Model::Info.new(gemini_model.to_h.merge(provider: 'vertexai'))
332
+ Provider.resolve(provider)&.models_dev_alias(model_id, models_dev_by_key, provider_model)
313
333
  end
314
334
 
315
- def index_by_key(models)
335
+ def index_by_key(models) # :nodoc:
316
336
  models.to_h do |model|
317
337
  ["#{model.provider}:#{model.id}", model]
318
338
  end
319
339
  end
320
340
 
341
+ def index_provider_aliases(models) # :nodoc:
342
+ models.each_with_object({}) do |model, aliases|
343
+ Array(model.metadata[:aliases]).each do |alias_id|
344
+ aliases["#{model.provider}:#{alias_id}"] ||= model
345
+ end
346
+ end
347
+ end
348
+
321
349
  def add_provider_metadata(models_dev_model, provider_model) # rubocop:disable Metrics/PerceivedComplexity
322
350
  data = models_dev_model.to_h
323
351
  data[:name] = provider_model.name if blank_value?(data[:name])
@@ -325,16 +353,51 @@ module RubyLLM
325
353
  data[:created_at] = provider_model.created_at if blank_value?(data[:created_at])
326
354
  data[:context_window] = provider_model.context_window if blank_value?(data[:context_window])
327
355
  data[:max_output_tokens] = provider_model.max_output_tokens if blank_value?(data[:max_output_tokens])
356
+ data[:knowledge_cutoff] = provider_model.knowledge_cutoff if blank_value?(data[:knowledge_cutoff])
328
357
  data[:modalities] = provider_model.modalities.to_h if blank_value?(data[:modalities])
329
- data[:pricing] = provider_model.pricing.to_h if blank_value?(data[:pricing])
358
+ if models_dev_model.type == :chat && provider_model.type != :chat
359
+ data[:modalities] = provider_model.modalities.to_h
360
+ end
361
+ data[:pricing] = Support::Utils.deep_merge(provider_model.pricing.to_h, data[:pricing].to_h)
330
362
  data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
331
- provider_capabilities = provider_model.capabilities - MODELS_DEV_AUTHORITY_CAPABILITIES
332
- data[:capabilities] = (models_dev_model.capabilities + provider_capabilities).uniq
363
+ data[:capabilities] = merge_capabilities(models_dev_model, provider_model, data[:modalities])
333
364
  normalize_embedding_modalities(data)
334
- Model::Info.new(data)
365
+ Model.new(data)
335
366
  end
336
367
 
337
- def normalize_embedding_modalities(data)
368
+ def merge_capabilities(models_dev_model, provider_model, modalities) # :nodoc:
369
+ denied = models_dev_reported_capabilities(models_dev_model) - models_dev_model.capabilities
370
+ reported = (models_dev_model.capabilities + provider_model.capabilities).uniq - denied
371
+ augment_capabilities(provider_model.provider, reported, provider_model.id, modalities)
372
+ end
373
+
374
+ def augment_model_capabilities(model) # :nodoc:
375
+ capabilities = augment_capabilities(model.provider, model.capabilities, model.id, model.modalities.to_h)
376
+ return model if capabilities == model.capabilities
377
+
378
+ Model.new(model.to_h.merge(capabilities: capabilities))
379
+ end
380
+
381
+ def augment_capabilities(provider_slug, capabilities, model_id, modalities) # :nodoc:
382
+ augmenter = Provider.resolve(provider_slug)&.capabilities
383
+ return capabilities unless augmenter
384
+
385
+ augmenter.augment(capabilities, model_id: model_id, modalities: modalities.to_h)
386
+ end
387
+
388
+ # models.dev leaves a field out where it has no opinion, so only the
389
+ # capabilities it reports on can overrule what a provider claims.
390
+ def models_dev_reported_capabilities(models_dev_model) # :nodoc:
391
+ metadata = models_dev_model.metadata
392
+ reported = []
393
+ reported << 'function_calling' unless metadata[:tool_call].nil?
394
+ reported << 'structured_output' unless metadata[:structured_output].nil?
395
+ reported << 'reasoning' unless metadata[:reasoning].nil? && metadata[:reasoning_options].nil?
396
+ reported << 'vision' unless models_dev_model.modalities.input.empty?
397
+ reported
398
+ end
399
+
400
+ def normalize_embedding_modalities(data) # :nodoc:
338
401
  return unless data[:id].to_s.include?('embedding')
339
402
 
340
403
  modalities = data[:modalities].to_h
@@ -343,7 +406,7 @@ module RubyLLM
343
406
  data[:modalities] = modalities
344
407
  end
345
408
 
346
- def blank_value?(value)
409
+ def blank_value?(value) # :nodoc:
347
410
  return true if value.nil?
348
411
  return value.empty? if value.is_a?(String) || value.is_a?(Array)
349
412
 
@@ -356,19 +419,19 @@ module RubyLLM
356
419
  false
357
420
  end
358
421
 
359
- def models_dev_model_to_info(model_data, provider_slug, provider_key)
422
+ def models_dev_model_attributes(model_data, provider_slug, provider_key) # :nodoc:
360
423
  modalities = normalize_models_dev_modalities(model_data[:modalities])
361
- capabilities = models_dev_capabilities(model_data, modalities)
424
+ capabilities = models_dev_capabilities(model_data, modalities, provider_slug)
362
425
 
363
426
  created_date = [model_data[:release_date], model_data[:last_updated]]
364
427
  .find { |value| !value.to_s.strip.empty? }
365
428
 
366
429
  data = {
367
- id: model_data[:id],
430
+ id: models_dev_model_id(model_data[:id], provider_slug),
368
431
  name: model_data[:name] || model_data[:id],
369
432
  provider: provider_slug,
370
433
  family: model_data[:family],
371
- created_at: Utils.iso_date_prefix_to_utc_midnight_string(created_date),
434
+ created_at: Support::Utils.iso_date_prefix_to_utc_midnight_string(created_date),
372
435
  context_window: model_data.dig(:limit, :context),
373
436
  max_output_tokens: model_data.dig(:limit, :output),
374
437
  knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
@@ -382,16 +445,22 @@ module RubyLLM
382
445
  data
383
446
  end
384
447
 
385
- def models_dev_capabilities(model_data, modalities)
448
+ def models_dev_model_id(id, provider_slug) # :nodoc:
449
+ provider = Provider.resolve(provider_slug)
450
+ provider ? provider.models_dev_model_id(id) : id
451
+ end
452
+
453
+ def models_dev_capabilities(model_data, modalities, provider_slug) # :nodoc:
386
454
  capabilities = []
387
455
  capabilities << 'function_calling' if model_data[:tool_call]
388
456
  capabilities << 'structured_output' if model_data[:structured_output]
389
457
  capabilities << 'reasoning' if model_data[:reasoning] || model_data[:reasoning_options]
390
458
  capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
391
- capabilities.uniq
459
+ capabilities << 'video' if modalities[:input].include?('video')
460
+ augment_capabilities(provider_slug, capabilities.uniq, model_data[:id], modalities)
392
461
  end
393
462
 
394
- def models_dev_pricing(cost)
463
+ def models_dev_pricing(cost) # :nodoc:
395
464
  return {} unless cost
396
465
 
397
466
  text_standard = {
@@ -408,12 +477,27 @@ module RubyLLM
408
477
  }.compact
409
478
 
410
479
  pricing = {}
411
- pricing[:text_tokens] = { standard: text_standard } if text_standard.any?
480
+ text_tokens = models_dev_text_tokens_pricing(text_standard, cost)
481
+ pricing[:text_tokens] = text_tokens if text_tokens
412
482
  pricing[:audio_tokens] = { standard: audio_standard } if audio_standard.any?
413
483
  pricing
414
484
  end
415
485
 
416
- def models_dev_metadata(model_data, provider_key)
486
+ def models_dev_text_tokens_pricing(text_standard, cost) # :nodoc:
487
+ long_context, threshold = Model::PricingCategory.long_context_from_cost(cost)
488
+
489
+ return nil if text_standard.empty? && long_context.nil?
490
+
491
+ text_tokens = {}
492
+ text_tokens[:standard] = text_standard if text_standard.any?
493
+ if long_context
494
+ text_tokens[:long_context] = long_context
495
+ text_tokens[:long_context_threshold] = threshold if threshold
496
+ end
497
+ text_tokens
498
+ end
499
+
500
+ def models_dev_metadata(model_data, provider_key) # :nodoc:
417
501
  metadata = {
418
502
  source: 'models.dev',
419
503
  provider_id: provider_key,
@@ -423,6 +507,9 @@ module RubyLLM
423
507
  last_updated: model_data[:last_updated],
424
508
  status: model_data[:status],
425
509
  interleaved: model_data[:interleaved],
510
+ tool_call: model_data[:tool_call],
511
+ structured_output: model_data[:structured_output],
512
+ reasoning: model_data[:reasoning],
426
513
  reasoning_options: model_data[:reasoning_options],
427
514
  cost: model_data[:cost],
428
515
  limit: model_data[:limit],
@@ -431,7 +518,7 @@ module RubyLLM
431
518
  metadata.compact
432
519
  end
433
520
 
434
- def normalize_models_dev_modalities(modalities)
521
+ def normalize_models_dev_modalities(modalities) # :nodoc:
435
522
  normalized = { input: [], output: [] }
436
523
  return normalized unless modalities
437
524
 
@@ -440,7 +527,7 @@ module RubyLLM
440
527
  normalized
441
528
  end
442
529
 
443
- def normalize_models_dev_knowledge(value)
530
+ def normalize_models_dev_knowledge(value) # :nodoc:
444
531
  return if value.nil?
445
532
  return value if value.is_a?(Date)
446
533
 
@@ -450,108 +537,286 @@ module RubyLLM
450
537
  end
451
538
  end
452
539
 
453
- def initialize(models = nil)
454
- @models = self.class.filter_models(models || self.class.load_models)
540
+ # :startdoc:
541
+
542
+ def initialize(models = nil) # :nodoc:
543
+ @models = models || self.class.load_models
455
544
  end
456
545
 
457
- # Replaces this registry instance with models loaded from JSON.
458
- def load_from_json!(file = RubyLLM.config.model_registry_file)
459
- @models = self.class.read_from_json(file)
546
+ # Replaces the models in this registry with those read from the JSON
547
+ # +file+. The default is the configured
548
+ # <tt>RubyLLM.config.model_registry_file</tt>. A missing or invalid
549
+ # file falls back to the registry bundled with the gem.
550
+ def load_from_json(file = RubyLLM.config.model_registry_file)
551
+ @models = self.class.models_from_file(file) || self.class.models_from_bundle
552
+ self
460
553
  end
461
554
 
462
- # Replaces this registry instance with models loaded from the configured
463
- # ActiveRecord model class.
464
- def load_from_database!
465
- @models = self.class.read_from_database
555
+ # Replaces the models in this registry with entries from the configured
556
+ # model-registry store.
557
+ def load_from_store
558
+ store = RubyLLM.config.model_registry_store
559
+ raise ModelRegistryError, 'No model registry store is configured' unless store
560
+
561
+ @models = Array(store.read)
562
+ self
466
563
  end
467
564
 
468
- # Persists this registry instance to JSON without changing the global
469
- # RubyLLM.models instance.
565
+ # Exports this registry to +file+ as pretty-printed JSON. The default is
566
+ # the configured <tt>RubyLLM.config.model_registry_file</tt>. A regular
567
+ # #refresh already persists to the active registry store.
568
+ #
569
+ # RubyLLM.models.save_to_json('/tmp/models.json')
570
+ #
470
571
  def save_to_json(file = RubyLLM.config.model_registry_file)
471
- File.write(file, JSON.pretty_generate(all.map(&:to_h)))
572
+ Registry::FileStore.new(file).write(all)
573
+ self
472
574
  end
473
575
 
576
+ # Returns an array of the Model entries the configured provider still
577
+ # lists. Models it has stopped listing are left out; #find still resolves
578
+ # them, and #unlisted reports them.
474
579
  def all
580
+ all_including_unlisted.reject(&:unlisted?)
581
+ end
582
+
583
+ # Returns an array of the Model entries the configured provider has
584
+ # stopped listing. Only a store that keeps them, such as the Rails model
585
+ # table, ever reports one.
586
+ #
587
+ # RubyLLM.models.unlisted.map(&:id)
588
+ #
589
+ def unlisted
590
+ all_including_unlisted.select(&:unlisted?)
591
+ end
592
+
593
+ # Returns an array of the Model entries the configured provider still
594
+ # lists. Reads the same as #all, which already excludes the rest.
595
+ alias listed all
596
+
597
+ def all_including_unlisted # :nodoc:
475
598
  @models
476
599
  end
477
600
 
601
+ # Yields each Model in the registry.
602
+ #
603
+ # RubyLLM.models.each { |model| puts model.id }
604
+ #
478
605
  def each(&)
479
606
  all.each(&)
480
607
  end
481
608
 
482
- def find(model_id, provider = nil)
609
+ # Returns the Model matching +model_id+, resolving aliases along the
610
+ # way. Without +provider+, picks the preferred provider that carries
611
+ # the model, first-party providers before aggregators. Raises
612
+ # RubyLLM::ModelNotFoundError if no model matches.
613
+ #
614
+ # RubyLLM.models.find 'gpt-5.6'
615
+ # RubyLLM.models.find 'claude-sonnet-5', provider: :bedrock
616
+ #
617
+ def find(model_id, provider: nil, config: nil)
483
618
  if provider
484
- find_with_provider(model_id, provider)
619
+ find_with_provider(model_id, provider, config)
485
620
  else
486
621
  find_without_provider(model_id)
487
622
  end
488
623
  end
489
624
 
625
+ # Returns a new Models registry containing only chat models.
490
626
  def chat_models
491
- self.class.new(all.select { |m| m.type == 'chat' })
627
+ select_models { |m| m.type == :chat }
492
628
  end
493
629
 
630
+ # Returns a new Models registry containing only embedding models.
494
631
  def embedding_models
495
- self.class.new(all.select { |m| m.type == 'embedding' || m.modalities.output.include?('embeddings') })
632
+ select_models { |m| m.type == :embedding || m.modalities.output.include?('embeddings') }
496
633
  end
497
634
 
635
+ # Returns a new Models registry containing only models with audio
636
+ # output.
498
637
  def audio_models
499
- self.class.new(all.select { |m| m.type == 'audio' || m.modalities.output.include?('audio') })
638
+ select_models { |m| m.type == :audio || m.modalities.output.include?('audio') }
500
639
  end
501
640
 
641
+ # Returns a new Models registry containing only models with image
642
+ # output.
502
643
  def image_models
503
- self.class.new(all.select { |m| m.type == 'image' || m.modalities.output.include?('image') })
644
+ select_models { |m| m.type == :image || m.modalities.output.include?('image') }
504
645
  end
505
646
 
647
+ # Returns a new Models registry containing only models in +family+.
648
+ #
649
+ # RubyLLM.models.by_family('claude3_sonnet')
650
+ #
506
651
  def by_family(family)
507
- self.class.new(all.select { |m| m.family == family.to_s })
652
+ select_models { |m| m.family == family.to_s }
508
653
  end
509
654
 
655
+ # Returns a new Models registry containing only models from +provider+.
656
+ # Accepts a symbol or a string.
657
+ #
658
+ # RubyLLM.models.by_provider(:openai).select { |model| model.supports?(:vision) }
659
+ #
510
660
  def by_provider(provider)
511
- self.class.new(all.select { |m| m.provider == provider.to_s })
661
+ select_models { |m| m.provider == provider.to_s }
662
+ end
663
+
664
+ # Replaces the registry with the latest published RubyLLM catalog,
665
+ # merged with models discovered from configured providers. The result is
666
+ # saved to the platform cache, or to the database in Rails applications.
667
+ # Pass +remote_only:+ +true+ to skip local providers such as Ollama and
668
+ # GPUStack. Returns +self+.
669
+ #
670
+ # Raises ModelRegistryError when the catalog cannot be fetched or the
671
+ # result cannot be persisted, leaving the current registry unchanged.
672
+ #
673
+ # RubyLLM.models.refresh
674
+ # RubyLLM.models.refresh(remote_only: true).chat_models
675
+ #
676
+ def refresh(remote_only: false)
677
+ RubyLLM.instrument('models.refresh.ruby_llm', remote_only:) do |payload|
678
+ published = fetch_published_models
679
+ main_models = merge_discovered_models(published.models, remote_only:)
680
+ merged_models = self.class.merge_models(self.class.models_from_provider_gems, main_models)
681
+ persisted_models = RubyLLM.config.model_registry_store ? merged_models : main_models
682
+ persist_registry!(persisted_models, published:)
683
+ @models = stored_models || merged_models
684
+ payload.merge!(model_count: all.size, not_modified: published.not_modified)
685
+ end
686
+ self
512
687
  end
513
688
 
514
- def refresh!(remote_only: false)
515
- self.class.refresh!(remote_only: remote_only)
689
+ def refresh_from_providers(remote_only: false) # :nodoc:
690
+ @models = self.class.fetch_merged_models(remote_only: remote_only)
691
+ self
516
692
  end
517
693
 
518
- def resolve(model_id, provider: nil, assume_exists: false, config: nil)
519
- self.class.resolve(model_id, provider: provider, assume_exists: assume_exists, config: config)
694
+ def resolve(model_id, provider: nil, assume_model_exists: false, config: nil) # :nodoc:
695
+ self.class.resolve(model_id, provider: provider, assume_model_exists: assume_model_exists, config: config)
520
696
  end
521
697
 
522
698
  private
523
699
 
524
- def find_with_provider(model_id, provider)
525
- resolved_id = Aliases.resolve(model_id, provider)
526
- resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
527
- all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
528
- all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
529
- raise_model_not_found(model_id, provider: provider)
700
+ # Filters keep the unlisted entries so #find and #unlisted still see them
701
+ # after a chain such as by_provider(:openai).unlisted.
702
+ def select_models(&)
703
+ self.class.new(all_including_unlisted.select(&))
530
704
  end
531
705
 
532
- def resolve_bedrock_region_id(model_id)
533
- region = RubyLLM.config.bedrock_region.to_s
534
- return model_id if region.empty?
706
+ def file_store
707
+ return if RubyLLM.config.model_registry_store
708
+ return unless RubyLLM.config.model_registry_file
535
709
 
536
- candidate_id = Providers::Bedrock::Models.with_region_prefix(model_id, region)
537
- return model_id if candidate_id == model_id
710
+ Registry::FileStore.new(RubyLLM.config.model_registry_file)
711
+ end
538
712
 
539
- candidate = all.find { |m| m.provider == 'bedrock' && m.id == candidate_id }
540
- return model_id unless candidate
713
+ # The ETag identifies the published catalog, not the merged registry, so
714
+ # it may only be sent while the catalog it stands for is still on disk.
715
+ def published_store
716
+ file = file_store
717
+ Registry::FileStore.new("#{file.path}.published.json") if file
718
+ end
541
719
 
542
- inference_types = Array(candidate.metadata[:inference_types] || candidate.metadata['inference_types'])
543
- Providers::Bedrock::Models.normalize_inference_profile_id(model_id, inference_types, region)
720
+ def fetch_published_models
721
+ cached = published_catalog
722
+ result = self.class.fetch_published_registry(etag: (file_store.etag if cached))
723
+ result.models ||= cached
724
+ result.models ? result : self.class.fetch_published_registry
544
725
  end
545
726
 
546
- def find_without_provider(model_id)
547
- exact_matches = all.select { |m| m.id == model_id }
548
- return preferred_match(exact_matches) if exact_matches.any?
727
+ def published_catalog
728
+ models = published_store&.read
729
+ models unless models.nil? || models.empty?
730
+ rescue ModelRegistryError
731
+ nil
732
+ end
733
+
734
+ def merge_discovered_models(published, remote_only:)
735
+ provider_fetch = self.class.fetch_provider_models(remote_only: remote_only)
736
+ self.class.log_provider_fetch(provider_fetch)
737
+ preserved = preserved_providers(provider_fetch, published)
738
+ preserved_models = all.select { |model| preserved.include?(model.provider) }
739
+ self.class.merge_models(provider_fetch[:models] + preserved_models, published)
740
+ end
741
+
742
+ # A provider that answered replaces its own models and the published
743
+ # catalog replaces what it covers. Everything else survives the refresh,
744
+ # including the local providers a remote_only run never asks.
745
+ def preserved_providers(provider_fetch, published)
746
+ failed = provider_fetch[:failed].map { |failure| failure[:slug] }
747
+ covered = provider_fetch[:fetched_providers] + published.map(&:provider)
748
+ provider_gems = Provider.model_registry_files.keys.map(&:to_s)
749
+ failed | (all.map(&:provider).uniq - covered - provider_gems)
750
+ end
751
+
752
+ def persist_registry!(models, published:)
753
+ store = RubyLLM.config.model_registry_store
754
+ if store
755
+ raise ModelRegistryError, "Model registry store #{store.class} is read-only" unless store.respond_to?(:write)
756
+
757
+ store.write(self.class.new(models))
758
+ return
759
+ end
760
+
761
+ file = file_store
762
+ raise ModelRegistryError, 'No writable model registry store is configured' unless file
763
+
764
+ write_published_catalog(published)
765
+ file.write(models, etag: published.etag)
766
+ rescue ModelRegistryError
767
+ raise
768
+ rescue StandardError => e
769
+ destination = store_description(store) || file&.path || 'the configured store'
770
+ raise ModelRegistryError, "Could not save the model registry to #{destination}: #{e.message}"
771
+ end
772
+
773
+ # A store keeps entries the merge dropped, such as the unlisted rows a
774
+ # Rails application still references, so its answer wins over the merge.
775
+ def stored_models
776
+ store = RubyLLM.config.model_registry_store
777
+ return unless store.respond_to?(:read)
778
+
779
+ models = Array(store.read)
780
+ models unless models.empty?
781
+ rescue StandardError => e
782
+ RubyLLM.logger.debug { "Could not re-read the model registry store: #{e.message}" }
783
+ nil
784
+ end
785
+
786
+ def write_published_catalog(published)
787
+ published_store.write(published.models) unless published.not_modified
788
+ end
789
+
790
+ def store_description(store)
791
+ return unless store
792
+
793
+ store.respond_to?(:description) ? store.description : store.class.name
794
+ end
795
+
796
+ def find_with_provider(model_id, provider, config = nil)
797
+ resolved_id = Aliases.resolve(model_id, provider)
798
+ resolved_id = resolve_provider_registry_id(resolved_id, provider, config)
799
+ all_including_unlisted.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
800
+ all_including_unlisted.find { |m| m.id == model_id && m.provider == provider.to_s } ||
801
+ raise_model_not_found(model_id, provider: provider)
802
+ end
803
+
804
+ def resolve_provider_registry_id(model_id, provider, config = nil)
805
+ provider_class = Provider.resolve(provider)
806
+ return model_id unless provider_class
549
807
 
808
+ provider_class.resolve_registry_id(model_id, self, config || RubyLLM.config)
809
+ end
810
+
811
+ # A name can be one provider's exact id and another's alias:
812
+ # claude-opus-4 is exact on vertexai, an alias on anthropic.
813
+ # Provider preference settles it, not the kind of match.
814
+ def find_without_provider(model_id)
550
815
  resolved_id = Aliases.resolve(model_id)
551
- alias_matches = all.select { |m| m.id == resolved_id }
552
- return preferred_match(alias_matches) if alias_matches.any?
816
+ matches = all_including_unlisted.select { |m| [model_id, resolved_id].include?(m.id) }
817
+ .sort_by { |m| m.id == model_id ? 0 : 1 }
553
818
 
554
- raise_model_not_found(model_id)
819
+ preferred_match(matches) || raise_model_not_found(model_id)
555
820
  end
556
821
 
557
822
  def raise_model_not_found(model_id, provider: nil)
@@ -562,10 +827,7 @@ module RubyLLM
562
827
  end
563
828
 
564
829
  def refresh_registry_guidance
565
- rails_model = RubyLLM.config.model_registry_class
566
- 'If the model exists at the provider, refresh the registry with `RubyLLM.models.refresh!` ' \
567
- 'and persist it with `RubyLLM.models.save_to_json`. ' \
568
- "Rails model registries can call `#{rails_model}.refresh!` instead."
830
+ 'If the model exists at the provider, refresh the registry with `RubyLLM.models.refresh`.'
569
831
  end
570
832
 
571
833
  def preferred_match(candidates)
@@ -573,7 +835,7 @@ module RubyLLM
573
835
 
574
836
  candidates.min_by do |model|
575
837
  index = PROVIDER_PREFERENCE.index(model.provider)
576
- index || PROVIDER_PREFERENCE.length
838
+ [model.unlisted? ? 1 : 0, index || PROVIDER_PREFERENCE.length]
577
839
  end
578
840
  end
579
841
  end