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,172 +1,237 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
4
+
3
5
  module RubyLLM
4
- # 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.
5
17
  class Models
6
18
  include Enumerable
7
19
 
8
- MODELS_DEV_PROVIDER_MAP = {
20
+ MODELS_DEV_PROVIDER_MAP = { # :nodoc:
9
21
  'openai' => 'openai',
10
22
  'anthropic' => 'anthropic',
11
23
  'google' => 'gemini',
12
24
  'google-vertex' => 'vertexai',
13
25
  'amazon-bedrock' => 'bedrock',
26
+ 'cohere' => 'cohere',
14
27
  'deepseek' => 'deepseek',
15
28
  'mistral' => 'mistral',
29
+ 'ollama-cloud' => 'ollama_cloud',
16
30
  'openrouter' => 'openrouter',
17
- 'perplexity' => 'perplexity'
31
+ 'perplexity' => 'perplexity',
32
+ 'perplexity-agent' => 'perplexity',
33
+ 'xai' => 'xai'
18
34
  }.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.
19
38
  PROVIDER_PREFERENCE = %w[
20
39
  openai
21
40
  anthropic
22
41
  gemini
23
- vertexai
24
- bedrock
25
- openrouter
26
42
  deepseek
27
43
  mistral
44
+ cohere
28
45
  perplexity
29
46
  xai
47
+ vertexai
48
+ bedrock
49
+ openrouter
30
50
  azure
51
+ ollama_cloud
31
52
  ollama
32
53
  gpustack
33
- ].freeze
54
+ ].freeze # :nodoc:
55
+ INSTANCE_DELEGATES = (Enumerable.instance_methods(false) + %i[
56
+ all
57
+ each
58
+ find
59
+ listed
60
+ unlisted
61
+ chat_models
62
+ embedding_models
63
+ audio_models
64
+ image_models
65
+ by_family
66
+ by_provider
67
+ load_from_json
68
+ load_from_store
69
+ save_to_json
70
+ ]).uniq.freeze # :nodoc:
34
71
 
35
72
  class << self
36
- def instance
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
+
78
+ INSTANCE_DELEGATES.each do |method_name|
79
+ define_method(method_name) do |*args, **kwargs, &block|
80
+ instance.public_send(method_name, *args, **kwargs, &block)
81
+ end
82
+ end
83
+
84
+ def instance # :nodoc:
37
85
  @instance ||= new
38
86
  end
39
87
 
40
- def schema_file
41
- File.expand_path('models_schema.json', __dir__)
88
+ def bundled_registry_file # :nodoc:
89
+ File.expand_path('models.json', __dir__)
90
+ end
91
+
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
99
+
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 }
103
+ end
104
+ end
105
+
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
42
114
  end
43
115
 
44
- def load_models(file = RubyLLM.config.model_registry_file)
45
- read_from_json(file)
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
46
124
  end
47
125
 
48
- def read_from_json(file = RubyLLM.config.model_registry_file)
49
- data = File.exist?(file) ? File.read(file) : '[]'
50
- models = JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
51
- filter_models(models)
52
- rescue JSON::ParserError
53
- []
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
54
134
  end
55
135
 
56
- def refresh!(remote_only: false)
57
- existing_models = load_existing_models
136
+ def fetch_published_registry(etag: nil) # :nodoc:
137
+ Registry::PublishedSource.new.fetch(etag:)
138
+ end
139
+
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:
58
152
 
59
- provider_fetch = fetch_provider_models(remote_only: remote_only)
60
- log_provider_fetch(provider_fetch)
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:
156
+ RubyLLM.instrument('models.refresh.ruby_llm', remote_only:) do |payload|
157
+ existing_models = read_existing_models
61
158
 
62
- models_dev_fetch = fetch_models_dev_models(existing_models)
63
- log_models_dev_fetch(models_dev_fetch)
159
+ provider_fetch = fetch_provider_models(remote_only: remote_only)
160
+ @last_provider_failures = provider_fetch[:failed]
161
+ log_provider_fetch(provider_fetch)
162
+ payload[:failed_providers] = provider_fetch[:failed].map { |failure| failure[:slug] }
64
163
 
65
- merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
66
- @instance = new(merged_models)
164
+ models_dev_fetch = fetch_models_dev_models(existing_models)
165
+ log_models_dev_fetch(models_dev_fetch)
166
+
167
+ merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
168
+ payload[:model_count] = merged_models.size
169
+ merged_models
170
+ end
67
171
  end
68
172
 
69
- def fetch_provider_models(remote_only: true) # rubocop:disable Metrics/PerceivedComplexity
173
+ def fetch_provider_models(remote_only: true) # :nodoc:
70
174
  config = RubyLLM.config
71
- provider_classes = remote_only ? Provider.remote_providers.values : Provider.providers.values
72
- configured_classes = if remote_only
73
- Provider.configured_remote_providers(config)
74
- else
75
- Provider.configured_providers(config)
76
- end
77
- 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) }
78
177
  result = {
79
- models: [],
80
- fetched_providers: [],
81
- configured_names: configured.map(&:name),
82
- failed: []
178
+ models: [], fetched_providers: [], configured_names: providers.map(&:display_name), failed: [], empty: []
83
179
  }
84
180
 
85
- provider_classes.each do |provider_class|
86
- next if remote_only && provider_class.local?
87
- next unless provider_class.configured?(config)
88
-
89
- begin
90
- 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)
91
187
  result[:fetched_providers] << provider_class.slug
92
- rescue StandardError => e
93
- result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
94
188
  end
189
+ rescue StandardError => e
190
+ result[:failed] << { name: provider_class.display_name, slug: provider_class.slug, error: e }
95
191
  end
96
192
 
97
- result[:fetched_providers].uniq!
98
193
  result
99
194
  end
100
195
 
101
- # Backwards-compatible wrapper used by specs.
102
- def fetch_from_providers(remote_only: true)
103
- fetch_provider_models(remote_only: remote_only)[:models]
104
- end
105
-
106
- 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
107
198
  config ||= RubyLLM.config
108
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?
109
202
 
110
- if provider_class
111
- temp_instance = provider_class.new(config)
112
- assume_exists = true if temp_instance.local? || temp_instance.assume_models_exist?
203
+ return [nil, provider_class.new(config)]
113
204
  end
205
+ model_id ||= default_model
206
+ assume_model_exists = true if provider_class&.local? || provider_class&.assume_models_exist?
114
207
 
115
- if assume_exists
116
- raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
117
-
118
- provider_class ||= raise(Error, "Unknown provider: #{provider.to_sym}")
119
- provider_instance = provider_class.new(config)
208
+ if assume_model_exists
209
+ raise ArgumentError, 'Provider must be specified if assume_model_exists is true' unless provider
120
210
 
121
- model = if provider_instance.local?
122
- begin
123
- Models.find(model_id, provider)
124
- rescue ModelNotFoundError
125
- nil
126
- end
127
- end
211
+ provider_class ||= Provider.resolve!(provider)
128
212
 
129
- model ||= Model::Info.default(model_id, provider_instance.slug)
130
- else
131
- model = Models.find model_id, provider
132
- provider_class = Provider.providers[model.provider.to_sym] || raise(Error,
133
- "Unknown provider: #{model.provider}")
134
- provider_instance = provider_class.new(config)
135
- end
136
- [model, provider_instance]
137
- end
213
+ model = begin
214
+ Models.find(model_id, provider: provider, config: config)
215
+ rescue ModelNotFoundError
216
+ nil
217
+ end
138
218
 
139
- def method_missing(method, ...)
140
- if instance.respond_to?(method)
141
- instance.send(method, ...)
219
+ model ||= Model.default(model_id, provider_class.slug)
142
220
  else
143
- super
221
+ model = Models.find model_id, provider: provider, config: config
222
+ provider_class = Provider.resolve!(model.provider)
144
223
  end
224
+ [model, provider_class.new(config)]
145
225
  end
146
226
 
147
- def respond_to_missing?(method, include_private = false)
148
- instance.respond_to?(method, include_private) || super
149
- end
150
-
151
- def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
227
+ def fetch_models_dev_models(existing_models) # :nodoc:
152
228
  RubyLLM.logger.info 'Fetching models from models.dev API...'
153
229
 
154
- connection = Connection.basic do |f|
230
+ connection = Transport::Connection.basic do |f|
155
231
  f.request :json
156
232
  f.response :json, parser_options: { symbolize_names: true }
157
233
  end
158
- response = connection.get 'https://models.dev/api.json'
159
- providers = response.body || {}
160
-
161
- models = providers.flat_map do |provider_key, provider_data|
162
- provider_slug = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
163
- next [] unless provider_slug
164
-
165
- (provider_data[:models] || {}).values.map do |model_data|
166
- Model::Info.new(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
167
- end
168
- end
169
- { 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 }
170
235
  rescue StandardError => e
171
236
  RubyLLM.logger.warn("Failed to fetch models.dev (#{e.class}: #{e.message}). Keeping existing.")
172
237
  {
@@ -175,13 +240,33 @@ module RubyLLM
175
240
  }
176
241
  end
177
242
 
178
- def load_existing_models
179
- existing_models = instance&.all
180
- existing_models = read_from_json if existing_models.nil? || existing_models.empty?
181
- 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
252
+ end
253
+
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
182
262
  end
183
263
 
184
- 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:
185
270
  RubyLLM.logger.info "Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
186
271
  provider_fetch[:failed].each do |failure|
187
272
  RubyLLM.logger.warn(
@@ -189,15 +274,18 @@ module RubyLLM
189
274
  'Keeping existing.'
190
275
  )
191
276
  end
277
+ Array(provider_fetch[:empty]).each do |provider|
278
+ RubyLLM.logger.warn("#{provider[:name]} listed no models. Keeping existing.")
279
+ end
192
280
  end
193
281
 
194
- def log_models_dev_fetch(models_dev_fetch)
282
+ def log_models_dev_fetch(models_dev_fetch) # :nodoc:
195
283
  return if models_dev_fetch[:fetched]
196
284
 
197
285
  RubyLLM.logger.warn('Using cached models.dev data due to fetch failure.')
198
286
  end
199
287
 
200
- def merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
288
+ def merge_with_existing(existing_models, provider_fetch, models_dev_fetch) # :nodoc:
201
289
  existing_by_provider = existing_models.group_by(&:provider)
202
290
  preserved_models = existing_by_provider
203
291
  .except(*provider_fetch[:fetched_providers])
@@ -214,70 +302,50 @@ module RubyLLM
214
302
  merge_models(provider_models, models_dev_models)
215
303
  end
216
304
 
217
- def merge_models(provider_models, models_dev_models)
305
+ def merge_models(provider_models, models_dev_models) # :nodoc:
218
306
  models_dev_by_key = index_by_key(models_dev_models)
219
307
  provider_by_key = index_by_key(provider_models)
308
+ provider_by_alias = index_provider_aliases(provider_models)
220
309
 
221
310
  all_keys = models_dev_by_key.keys | provider_by_key.keys
222
311
 
223
312
  models = all_keys.map do |key|
224
- models_dev_model = find_models_dev_model(key, models_dev_by_key)
225
- 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)
226
315
 
227
316
  if models_dev_model && provider_model
228
317
  add_provider_metadata(models_dev_model, provider_model)
229
318
  elsif models_dev_model
230
319
  models_dev_model
231
320
  else
232
- provider_model
321
+ augment_model_capabilities(provider_model)
233
322
  end
234
323
  end
235
324
 
236
- filter_models(models).sort_by { |m| [m.provider, m.id] }
325
+ models.sort_by { |m| [m.provider, m.id] }
237
326
  end
238
327
 
239
- def filter_models(models)
240
- models.reject do |model|
241
- model.provider.to_s == 'vertexai' && model.id.to_s.include?('/')
242
- end
243
- end
244
-
245
- def find_models_dev_model(key, models_dev_by_key)
246
- # Direct match
328
+ def find_models_dev_model(key, models_dev_by_key, provider_model = nil) # :nodoc:
247
329
  return models_dev_by_key[key] if models_dev_by_key[key]
248
330
 
249
331
  provider, model_id = key.split(':', 2)
250
- if provider == 'bedrock'
251
- normalized_id = model_id.sub(/^[a-z]{2}\./, '')
252
- context_override = nil
253
- normalized_id = normalized_id.gsub(/:(\d+)k\b/) do
254
- context_override = Regexp.last_match(1).to_i * 1000
255
- ''
256
- end
257
- bedrock_model = models_dev_by_key["bedrock:#{normalized_id}"]
258
- if bedrock_model
259
- data = bedrock_model.to_h.merge(id: model_id)
260
- data[:context_window] = context_override if context_override
261
- return Model::Info.new(data)
262
- end
263
- end
264
-
265
- # VertexAI uses same models as Gemini
266
- return unless provider == 'vertexai'
267
-
268
- gemini_model = models_dev_by_key["gemini:#{model_id}"]
269
- return unless gemini_model
270
-
271
- # Return Gemini's models.dev data but with VertexAI as provider
272
- 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)
273
333
  end
274
334
 
275
- def index_by_key(models)
335
+ def index_by_key(models) # :nodoc:
276
336
  models.to_h do |model|
277
337
  ["#{model.provider}:#{model.id}", model]
278
338
  end
279
339
  end
280
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
+
281
349
  def add_provider_metadata(models_dev_model, provider_model) # rubocop:disable Metrics/PerceivedComplexity
282
350
  data = models_dev_model.to_h
283
351
  data[:name] = provider_model.name if blank_value?(data[:name])
@@ -285,15 +353,51 @@ module RubyLLM
285
353
  data[:created_at] = provider_model.created_at if blank_value?(data[:created_at])
286
354
  data[:context_window] = provider_model.context_window if blank_value?(data[:context_window])
287
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])
288
357
  data[:modalities] = provider_model.modalities.to_h if blank_value?(data[:modalities])
289
- 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)
290
362
  data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
291
- data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
363
+ data[:capabilities] = merge_capabilities(models_dev_model, provider_model, data[:modalities])
292
364
  normalize_embedding_modalities(data)
293
- Model::Info.new(data)
365
+ Model.new(data)
366
+ end
367
+
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))
294
379
  end
295
380
 
296
- def normalize_embedding_modalities(data)
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:
297
401
  return unless data[:id].to_s.include?('embedding')
298
402
 
299
403
  modalities = data[:modalities].to_h
@@ -302,7 +406,7 @@ module RubyLLM
302
406
  data[:modalities] = modalities
303
407
  end
304
408
 
305
- def blank_value?(value)
409
+ def blank_value?(value) # :nodoc:
306
410
  return true if value.nil?
307
411
  return value.empty? if value.is_a?(String) || value.is_a?(Array)
308
412
 
@@ -315,19 +419,19 @@ module RubyLLM
315
419
  false
316
420
  end
317
421
 
318
- 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:
319
423
  modalities = normalize_models_dev_modalities(model_data[:modalities])
320
- capabilities = models_dev_capabilities(model_data, modalities)
424
+ capabilities = models_dev_capabilities(model_data, modalities, provider_slug)
321
425
 
322
426
  created_date = [model_data[:release_date], model_data[:last_updated]]
323
427
  .find { |value| !value.to_s.strip.empty? }
324
428
 
325
429
  data = {
326
- id: model_data[:id],
430
+ id: models_dev_model_id(model_data[:id], provider_slug),
327
431
  name: model_data[:name] || model_data[:id],
328
432
  provider: provider_slug,
329
433
  family: model_data[:family],
330
- created_at: created_date ? "#{created_date} 00:00:00 UTC" : nil,
434
+ created_at: Support::Utils.iso_date_prefix_to_utc_midnight_string(created_date),
331
435
  context_window: model_data.dig(:limit, :context),
332
436
  max_output_tokens: model_data.dig(:limit, :output),
333
437
  knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
@@ -341,16 +445,22 @@ module RubyLLM
341
445
  data
342
446
  end
343
447
 
344
- 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:
345
454
  capabilities = []
346
455
  capabilities << 'function_calling' if model_data[:tool_call]
347
456
  capabilities << 'structured_output' if model_data[:structured_output]
348
- capabilities << 'reasoning' if model_data[:reasoning]
457
+ capabilities << 'reasoning' if model_data[:reasoning] || model_data[:reasoning_options]
349
458
  capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
350
- capabilities.uniq
459
+ capabilities << 'video' if modalities[:input].include?('video')
460
+ augment_capabilities(provider_slug, capabilities.uniq, model_data[:id], modalities)
351
461
  end
352
462
 
353
- def models_dev_pricing(cost)
463
+ def models_dev_pricing(cost) # :nodoc:
354
464
  return {} unless cost
355
465
 
356
466
  text_standard = {
@@ -367,12 +477,27 @@ module RubyLLM
367
477
  }.compact
368
478
 
369
479
  pricing = {}
370
- 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
371
482
  pricing[:audio_tokens] = { standard: audio_standard } if audio_standard.any?
372
483
  pricing
373
484
  end
374
485
 
375
- 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:
376
501
  metadata = {
377
502
  source: 'models.dev',
378
503
  provider_id: provider_key,
@@ -382,6 +507,10 @@ module RubyLLM
382
507
  last_updated: model_data[:last_updated],
383
508
  status: model_data[:status],
384
509
  interleaved: model_data[:interleaved],
510
+ tool_call: model_data[:tool_call],
511
+ structured_output: model_data[:structured_output],
512
+ reasoning: model_data[:reasoning],
513
+ reasoning_options: model_data[:reasoning_options],
385
514
  cost: model_data[:cost],
386
515
  limit: model_data[:limit],
387
516
  knowledge: model_data[:knowledge]
@@ -389,16 +518,16 @@ module RubyLLM
389
518
  metadata.compact
390
519
  end
391
520
 
392
- def normalize_models_dev_modalities(modalities)
521
+ def normalize_models_dev_modalities(modalities) # :nodoc:
393
522
  normalized = { input: [], output: [] }
394
523
  return normalized unless modalities
395
524
 
396
- normalized[:input] = Array(modalities[:input]).compact
397
- normalized[:output] = Array(modalities[:output]).compact
525
+ normalized[:input] = Array(modalities[:input]).compact & MODELS_DEV_INPUT_MODALITIES
526
+ normalized[:output] = Array(modalities[:output]).compact & MODELS_DEV_OUTPUT_MODALITIES
398
527
  normalized
399
528
  end
400
529
 
401
- def normalize_models_dev_knowledge(value)
530
+ def normalize_models_dev_knowledge(value) # :nodoc:
402
531
  return if value.nil?
403
532
  return value if value.is_a?(Date)
404
533
 
@@ -408,99 +537,297 @@ module RubyLLM
408
537
  end
409
538
  end
410
539
 
411
- def initialize(models = nil)
412
- @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
413
544
  end
414
545
 
415
- def load_from_json!(file = RubyLLM.config.model_registry_file)
416
- @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
417
553
  end
418
554
 
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
563
+ end
564
+
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
+ #
419
571
  def save_to_json(file = RubyLLM.config.model_registry_file)
420
- File.write(file, JSON.pretty_generate(all.map(&:to_h)))
572
+ Registry::FileStore.new(file).write(all)
573
+ self
421
574
  end
422
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.
423
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:
424
598
  @models
425
599
  end
426
600
 
601
+ # Yields each Model in the registry.
602
+ #
603
+ # RubyLLM.models.each { |model| puts model.id }
604
+ #
427
605
  def each(&)
428
606
  all.each(&)
429
607
  end
430
608
 
431
- 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)
432
618
  if provider
433
- find_with_provider(model_id, provider)
619
+ find_with_provider(model_id, provider, config)
434
620
  else
435
621
  find_without_provider(model_id)
436
622
  end
437
623
  end
438
624
 
625
+ # Returns a new Models registry containing only chat models.
439
626
  def chat_models
440
- self.class.new(all.select { |m| m.type == 'chat' })
627
+ select_models { |m| m.type == :chat }
441
628
  end
442
629
 
630
+ # Returns a new Models registry containing only embedding models.
443
631
  def embedding_models
444
- 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') }
445
633
  end
446
634
 
635
+ # Returns a new Models registry containing only models with audio
636
+ # output.
447
637
  def audio_models
448
- 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') }
449
639
  end
450
640
 
641
+ # Returns a new Models registry containing only models with image
642
+ # output.
451
643
  def image_models
452
- 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') }
453
645
  end
454
646
 
647
+ # Returns a new Models registry containing only models in +family+.
648
+ #
649
+ # RubyLLM.models.by_family('claude3_sonnet')
650
+ #
455
651
  def by_family(family)
456
- self.class.new(all.select { |m| m.family == family.to_s })
652
+ select_models { |m| m.family == family.to_s }
457
653
  end
458
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
+ #
459
660
  def by_provider(provider)
460
- self.class.new(all.select { |m| m.provider == provider.to_s })
661
+ select_models { |m| m.provider == provider.to_s }
461
662
  end
462
663
 
463
- def refresh!(remote_only: false)
464
- self.class.refresh!(remote_only: remote_only)
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
687
+ end
688
+
689
+ def refresh_from_providers(remote_only: false) # :nodoc:
690
+ @models = self.class.fetch_merged_models(remote_only: remote_only)
691
+ self
465
692
  end
466
693
 
467
- def resolve(model_id, provider: nil, assume_exists: false, config: nil)
468
- 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)
469
696
  end
470
697
 
471
698
  private
472
699
 
473
- def find_with_provider(model_id, provider)
474
- resolved_id = Aliases.resolve(model_id, provider)
475
- resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
476
- all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
477
- all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
478
- raise(ModelNotFoundError, "Unknown model: #{model_id} for 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(&))
479
704
  end
480
705
 
481
- def resolve_bedrock_region_id(model_id)
482
- region = RubyLLM.config.bedrock_region.to_s
483
- 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
484
709
 
485
- candidate_id = Providers::Bedrock::Models.with_region_prefix(model_id, region)
486
- return model_id if candidate_id == model_id
710
+ Registry::FileStore.new(RubyLLM.config.model_registry_file)
711
+ end
487
712
 
488
- candidate = all.find { |m| m.provider == 'bedrock' && m.id == candidate_id }
489
- 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
490
719
 
491
- inference_types = Array(candidate.metadata[:inference_types] || candidate.metadata['inference_types'])
492
- 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
493
725
  end
494
726
 
495
- def find_without_provider(model_id)
496
- exact_matches = all.select { |m| m.id == model_id }
497
- 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)
498
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
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)
499
815
  resolved_id = Aliases.resolve(model_id)
500
- alias_matches = all.select { |m| m.id == resolved_id }
501
- 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 }
818
+
819
+ preferred_match(matches) || raise_model_not_found(model_id)
820
+ end
821
+
822
+ def raise_model_not_found(model_id, provider: nil)
823
+ message = "Unknown model: #{model_id.inspect}"
824
+ message = "#{message} for provider: #{provider.inspect}" if provider
825
+
826
+ raise ModelNotFoundError, "#{message}. #{refresh_registry_guidance}"
827
+ end
502
828
 
503
- raise(ModelNotFoundError, "Unknown model: #{model_id}")
829
+ def refresh_registry_guidance
830
+ 'If the model exists at the provider, refresh the registry with `RubyLLM.models.refresh`.'
504
831
  end
505
832
 
506
833
  def preferred_match(candidates)
@@ -508,7 +835,7 @@ module RubyLLM
508
835
 
509
836
  candidates.min_by do |model|
510
837
  index = PROVIDER_PREFERENCE.index(model.provider)
511
- index || PROVIDER_PREFERENCE.length
838
+ [model.unlisted? ? 1 : 0, index || PROVIDER_PREFERENCE.length]
512
839
  end
513
840
  end
514
841
  end