ruby_llm 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -16,15 +16,18 @@ module RubyLLM
16
16
  # Models.find delegate to the global registry.
17
17
  class Models
18
18
  include Enumerable
19
+ include Lookup
19
20
 
20
21
  MODELS_DEV_PROVIDER_MAP = { # :nodoc:
21
22
  'openai' => 'openai',
22
23
  'anthropic' => 'anthropic',
24
+ 'azure' => 'azure',
23
25
  'google' => 'gemini',
24
26
  'google-vertex' => 'vertexai',
25
27
  'amazon-bedrock' => 'bedrock',
26
28
  'cohere' => 'cohere',
27
29
  'deepseek' => 'deepseek',
30
+ 'hetzner' => 'hetzner',
28
31
  'mistral' => 'mistral',
29
32
  'ollama-cloud' => 'ollama_cloud',
30
33
  'openrouter' => 'openrouter',
@@ -33,24 +36,11 @@ module RubyLLM
33
36
  'xai' => 'xai'
34
37
  }.freeze
35
38
  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:
39
+ MODELS_DEV_OUTPUT_MODALITIES = %w[text image audio video embeddings moderation rerank judgment].freeze # :nodoc:
37
40
  # First-party providers outrank the aggregators that resell their models.
38
41
  PROVIDER_PREFERENCE = %w[
39
- openai
40
- anthropic
41
- gemini
42
- deepseek
43
- mistral
44
- cohere
45
- perplexity
46
- xai
47
- vertexai
48
- bedrock
49
- openrouter
50
- azure
51
- ollama_cloud
52
- ollama
53
- gpustack
42
+ openai anthropic gemini deepseek mistral cohere typesafe perplexity xai
43
+ vertexai bedrock openrouter azure hetzner ollama_cloud ollama gpustack
54
44
  ].freeze # :nodoc:
55
45
  INSTANCE_DELEGATES = (Enumerable.instance_methods(false) + %i[
56
46
  all
@@ -68,6 +58,8 @@ module RubyLLM
68
58
  load_from_store
69
59
  save_to_json
70
60
  ]).uniq.freeze # :nodoc:
61
+ INSTANCE_LOCK = Mutex.new
62
+ private_constant :INSTANCE_LOCK
71
63
 
72
64
  class << self
73
65
  # The providers whose model list could not be fetched during the last
@@ -82,7 +74,7 @@ module RubyLLM
82
74
  end
83
75
 
84
76
  def instance # :nodoc:
85
- @instance ||= new
77
+ @instance || INSTANCE_LOCK.synchronize { @instance ||= new }
86
78
  end
87
79
 
88
80
  def bundled_registry_file # :nodoc:
@@ -229,7 +221,7 @@ module RubyLLM
229
221
 
230
222
  connection = Transport::Connection.basic do |f|
231
223
  f.request :json
232
- f.response :json, parser_options: { symbolize_names: true }
224
+ f.use Transport::JsonResponse, parser_options: { symbolize_names: true }
233
225
  end
234
226
  { models: parse_models_dev_catalog(connection.get('https://models.dev/api.json').body), fetched: true }
235
227
  rescue StandardError => e
@@ -540,7 +532,7 @@ module RubyLLM
540
532
  # :startdoc:
541
533
 
542
534
  def initialize(models = nil) # :nodoc:
543
- @models = models || self.class.load_models
535
+ replace_models(models || self.class.load_models)
544
536
  end
545
537
 
546
538
  # Replaces the models in this registry with those read from the JSON
@@ -548,7 +540,7 @@ module RubyLLM
548
540
  # <tt>RubyLLM.config.model_registry_file</tt>. A missing or invalid
549
541
  # file falls back to the registry bundled with the gem.
550
542
  def load_from_json(file = RubyLLM.config.model_registry_file)
551
- @models = self.class.models_from_file(file) || self.class.models_from_bundle
543
+ replace_models(self.class.models_from_file(file) || self.class.models_from_bundle)
552
544
  self
553
545
  end
554
546
 
@@ -558,7 +550,7 @@ module RubyLLM
558
550
  store = RubyLLM.config.model_registry_store
559
551
  raise ModelRegistryError, 'No model registry store is configured' unless store
560
552
 
561
- @models = Array(store.read)
553
+ replace_models(Array(store.read))
562
554
  self
563
555
  end
564
556
 
@@ -680,14 +672,14 @@ module RubyLLM
680
672
  merged_models = self.class.merge_models(self.class.models_from_provider_gems, main_models)
681
673
  persisted_models = RubyLLM.config.model_registry_store ? merged_models : main_models
682
674
  persist_registry!(persisted_models, published:)
683
- @models = stored_models || merged_models
675
+ replace_models(stored_models || merged_models)
684
676
  payload.merge!(model_count: all.size, not_modified: published.not_modified)
685
677
  end
686
678
  self
687
679
  end
688
680
 
689
681
  def refresh_from_providers(remote_only: false) # :nodoc:
690
- @models = self.class.fetch_merged_models(remote_only: remote_only)
682
+ replace_models(self.class.fetch_merged_models(remote_only: remote_only))
691
683
  self
692
684
  end
693
685
 
@@ -697,6 +689,11 @@ module RubyLLM
697
689
 
698
690
  private
699
691
 
692
+ def replace_models(models)
693
+ @models = models.dup
694
+ @model_index = nil
695
+ end
696
+
700
697
  # Filters keep the unlisted entries so #find and #unlisted still see them
701
698
  # after a chain such as by_provider(:openai).unlisted.
702
699
  def select_models(&)
@@ -792,51 +789,5 @@ module RubyLLM
792
789
 
793
790
  store.respond_to?(:description) ? store.description : store.class.name
794
791
  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)
815
- resolved_id = Aliases.resolve(model_id)
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
828
-
829
- def refresh_registry_guidance
830
- 'If the model exists at the provider, refresh the registry with `RubyLLM.models.refresh`.'
831
- end
832
-
833
- def preferred_match(candidates)
834
- return candidates.first if candidates.size == 1
835
-
836
- candidates.min_by do |model|
837
- index = PROVIDER_PREFERENCE.index(model.provider)
838
- [model.unlisted? ? 1 : 0, index || PROVIDER_PREFERENCE.length]
839
- end
840
- end
841
792
  end
842
793
  end
@@ -81,6 +81,8 @@ module RubyLLM
81
81
  # +model+ is not given. Pass +provider:+ and <tt>assume_model_exists: true</tt>
82
82
  # to use a model that is not in the registry. An explicitly selected
83
83
  # provider may instead use a configured resource without a model.
84
+ # +owner:+ attributes the usage to a record, such as a user, and wins
85
+ # over RubyLLM.with_usage_owner.
84
86
  #
85
87
  # RubyLLM.moderate("User message")
86
88
  # RubyLLM.moderate(["First comment", "Second comment"]).results
@@ -93,7 +95,8 @@ module RubyLLM
93
95
  assume_model_exists: false,
94
96
  context: nil,
95
97
  provider_options: {},
96
- metadata: nil)
98
+ metadata: nil,
99
+ owner: nil)
97
100
  attachments = Attachment.wrap(with)
98
101
  raise ArgumentError, 'must provide input text, image attachment, or both' if input.nil? && attachments.empty?
99
102
 
@@ -116,7 +119,9 @@ module RubyLLM
116
119
  }
117
120
 
118
121
  RubyLLM.instrument('moderation.ruby_llm', payload, config: config) do |event|
119
- result = provider_instance.moderate(input, model:, with: attachments, provider_options:)
122
+ result = Accounting::Usage.owned_by(owner) do
123
+ provider_instance.moderate(input, model:, with: attachments, provider_options:)
124
+ end
120
125
  event[:result] = result
121
126
  event[:flagged] = result.flagged?
122
127
  event[:tokens] = result.tokens
data/lib/ruby_llm/ocr.rb CHANGED
@@ -41,6 +41,8 @@ module RubyLLM
41
41
  # the given zero-based page indexes. +provider_options:+ merges options
42
42
  # into the request in the provider's own vocabulary, such as Mistral's
43
43
  # +include_image_base64:+ or +table_format:+.
44
+ # +owner:+ attributes the usage to a record, such as a user, and wins
45
+ # over RubyLLM.with_usage_owner.
44
46
  #
45
47
  # RubyLLM.ocr("report.pdf")
46
48
  # RubyLLM.ocr("https://example.com/scan.png")
@@ -55,7 +57,8 @@ module RubyLLM
55
57
  context: nil,
56
58
  pages: nil,
57
59
  provider_options: {},
58
- metadata: nil)
60
+ metadata: nil,
61
+ owner: nil)
59
62
  config = context&.config || RubyLLM.config
60
63
  model ||= config.default_ocr_model
61
64
  model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
@@ -71,7 +74,9 @@ module RubyLLM
71
74
  }
72
75
 
73
76
  RubyLLM.instrument('ocr.ruby_llm', payload, config: config) do |event|
74
- result = provider_instance.ocr(file, model:, pages:, provider_options:)
77
+ result = Accounting::Usage.owned_by(owner) do
78
+ provider_instance.ocr(file, model:, pages:, provider_options:)
79
+ end
75
80
  event[:result] = result
76
81
  event[:response_model] = result.model
77
82
  result
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class OpenTelemetry
5
+ module Attributes # :nodoc:
6
+ OPERATIONS = {
7
+ 'chat.ruby_llm' => 'chat',
8
+ 'embedding.ruby_llm' => 'embeddings',
9
+ 'image.ruby_llm' => 'generate_content',
10
+ 'speech.ruby_llm' => 'generate_content',
11
+ 'transcription.ruby_llm' => 'transcription',
12
+ 'ocr.ruby_llm' => 'ocr',
13
+ 'rerank.ruby_llm' => 'rerank',
14
+ 'moderation.ruby_llm' => 'moderation',
15
+ 'judgment.ruby_llm' => 'judgment',
16
+ 'tool_call.ruby_llm' => 'execute_tool',
17
+ 'workflow.ruby_llm' => 'invoke_workflow',
18
+ 'workflow_step.ruby_llm' => 'ruby_llm.workflow_step'
19
+ }.freeze
20
+ PROVIDERS = {
21
+ 'bedrock' => 'aws.bedrock',
22
+ 'azure' => 'azure.ai.openai',
23
+ 'gemini' => 'gcp.gen_ai',
24
+ 'vertexai' => 'gcp.vertex_ai',
25
+ 'mistral' => 'mistral_ai',
26
+ 'xai' => 'x_ai'
27
+ }.freeze
28
+ INTERNAL_OPERATIONS = %w[execute_tool invoke_workflow ruby_llm.workflow_step].freeze
29
+
30
+ module_function
31
+
32
+ def span_name(operation, payload)
33
+ target = case operation
34
+ when 'execute_tool' then payload[:tool_name]
35
+ when 'invoke_workflow' then payload[:workflow_name]
36
+ when 'ruby_llm.workflow_step' then payload[:workflow_step_name]
37
+ else payload[:model]
38
+ end
39
+ [operation, target].compact.join(' ')
40
+ end
41
+
42
+ def span_kind(operation)
43
+ INTERNAL_OPERATIONS.include?(operation) ? :internal : :client
44
+ end
45
+
46
+ def request(operation, payload, event:)
47
+ return workflow(operation, payload) if %w[invoke_workflow ruby_llm.workflow_step].include?(operation)
48
+ return tool(payload) if operation == 'execute_tool'
49
+
50
+ {
51
+ 'gen_ai.operation.name' => operation,
52
+ 'gen_ai.provider.name' => PROVIDERS.fetch(payload[:provider], payload[:provider]),
53
+ 'gen_ai.request.model' => payload[:model],
54
+ 'gen_ai.request.temperature' => payload[:temperature],
55
+ 'gen_ai.request.max_tokens' => payload[:max_output_tokens],
56
+ 'gen_ai.request.stream' => (true if payload[:streaming]),
57
+ 'gen_ai.embeddings.dimension.count' => payload[:dimensions],
58
+ 'gen_ai.output.type' => output_type(event, payload)
59
+ }.compact
60
+ end
61
+
62
+ def output_type(event, payload)
63
+ return 'json' if payload[:schema]
64
+ return 'image' if event == 'image.ruby_llm'
65
+ return 'speech' if event == 'speech.ruby_llm'
66
+
67
+ nil
68
+ end
69
+
70
+ def response(payload)
71
+ response = payload[:response]
72
+ attributes = {
73
+ 'gen_ai.response.model' => payload[:response_model],
74
+ 'gen_ai.response.finish_reasons' => ([response.finish_reason.to_s] if response&.finish_reason)
75
+ }.compact
76
+ attributes.merge!(tokens(payload[:response_tokens] || payload[:tokens])) if payload[:tokens]
77
+ attributes
78
+ end
79
+
80
+ def tokens(tokens)
81
+ input = [tokens.input, tokens.cache_read, tokens.cache_write].compact
82
+ {
83
+ 'gen_ai.usage.input_tokens' => (input.sum unless input.empty?),
84
+ 'gen_ai.usage.output_tokens' => tokens.output,
85
+ 'gen_ai.usage.cache_read.input_tokens' => tokens.cache_read,
86
+ 'gen_ai.usage.cache_write.input_tokens' => tokens.cache_write,
87
+ 'gen_ai.usage.reasoning.output_tokens' => tokens.thinking
88
+ }.compact
89
+ end
90
+
91
+ def tool(payload)
92
+ {
93
+ 'gen_ai.operation.name' => 'execute_tool',
94
+ 'gen_ai.tool.name' => payload[:tool_name],
95
+ 'gen_ai.tool.call.id' => payload[:tool_call_id],
96
+ 'gen_ai.tool.type' => 'function'
97
+ }.compact
98
+ end
99
+
100
+ def workflow(operation, payload)
101
+ {
102
+ 'gen_ai.operation.name' => (operation unless operation == 'ruby_llm.workflow_step'),
103
+ 'gen_ai.workflow.name' => payload[:workflow_name],
104
+ 'ruby_llm.workflow.id' => payload[:workflow_id],
105
+ 'ruby_llm.workflow.step.name' => payload[:workflow_step_name],
106
+ 'ruby_llm.workflow.step.id' => payload[:workflow_step_id]
107
+ }.compact
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Optional OpenTelemetry tracing through RubyLLM's instrumentation events.
5
+ # Configure your application's SDK and exporters before calling .enable.
6
+ class OpenTelemetry
7
+ @mutex = Mutex.new
8
+
9
+ class << self
10
+ # Enables tracing without replacing the configured instrumenter.
11
+ # Requires the optional opentelemetry-api gem. Repeated calls are harmless.
12
+ # The application owns the tracer provider, SDK, and exporters.
13
+ def enable
14
+ require 'opentelemetry-api'
15
+ @mutex.synchronize do
16
+ @subscriber ||= new
17
+ Support::Instrumentation.subscribe(@subscriber)
18
+ end
19
+ nil
20
+ rescue LoadError
21
+ raise LoadError, "OpenTelemetry tracing requires the 'opentelemetry-api' gem. Add it to your Gemfile."
22
+ end
23
+
24
+ # Disables tracing for new operations without shutting down the SDK.
25
+ # Operations already running finish their spans normally.
26
+ def disable
27
+ @mutex.synchronize { Support::Instrumentation.unsubscribe(@subscriber) if @subscriber }
28
+ nil
29
+ end
30
+ end
31
+
32
+ def instrument(name, payload) # :nodoc:
33
+ operation = Attributes::OPERATIONS[name]
34
+ return yield unless operation
35
+
36
+ span = safely do
37
+ tracer.start_span(Attributes.span_name(operation, payload),
38
+ kind: Attributes.span_kind(operation),
39
+ attributes: Attributes.request(operation, payload, event: name))
40
+ end
41
+ return yield unless span
42
+
43
+ ::OpenTelemetry::Trace.with_span(span) do
44
+ yield
45
+ rescue StandardError => e
46
+ record_error(span, e)
47
+ raise
48
+ ensure
49
+ safely { span.add_attributes(Attributes.response(payload)) }
50
+ safely { span.finish }
51
+ end
52
+ end
53
+
54
+ def capture_context # :nodoc:
55
+ ::OpenTelemetry::Context.current
56
+ end
57
+
58
+ def with_context(context, &) # :nodoc:
59
+ ::OpenTelemetry::Context.with_current(context, &)
60
+ end
61
+
62
+ private
63
+
64
+ def record_error(span, error)
65
+ safely do
66
+ span.set_attribute('error.type', error.class.name)
67
+ span.status = ::OpenTelemetry::Trace::Status.error
68
+ end
69
+ end
70
+
71
+ def tracer
72
+ ::OpenTelemetry.tracer_provider.tracer('ruby_llm', RubyLLM::VERSION)
73
+ end
74
+
75
+ def safely
76
+ yield
77
+ rescue StandardError => e
78
+ RubyLLM.logger.warn("OpenTelemetry instrumentation failed (#{e.class})")
79
+ nil
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # The probability that a judgment's yes/no question is true.
5
+ class Probability
6
+ include Support::Inspectable
7
+
8
+ # Returns the probability of yes, between 0 and 1.
9
+ attr_reader :probability
10
+
11
+ def initialize(probability:) # :nodoc:
12
+ @probability = probability
13
+ freeze
14
+ end
15
+
16
+ # Returns :probability.
17
+ def type
18
+ :probability
19
+ end
20
+
21
+ # Returns the answer as a Hash.
22
+ def to_h
23
+ { type:, probability: }
24
+ end
25
+
26
+ def inspect_attributes # :nodoc:
27
+ { probability: }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Progress a tool or an MCP server reports while it works. A Tool reports
5
+ # it with Tool#progress; Chat#after_tool_progress and MCP.after_progress
6
+ # callbacks receive it.
7
+ #
8
+ # chat.after_tool_progress do |tool_call, progress|
9
+ # puts "#{tool_call.name}: #{progress.message} #{progress.value}/#{progress.total}"
10
+ # end
11
+ #
12
+ class Progress
13
+ include Support::Inspectable
14
+
15
+ # How much work is done, or +nil+ when only a message was reported. It
16
+ # only grows, but its unit is the reporter's.
17
+ attr_reader :value
18
+
19
+ # How much work there is in total, or +nil+ when the reporter does not
20
+ # know.
21
+ attr_reader :total
22
+
23
+ # What the tool or server is doing, or +nil+.
24
+ attr_reader :message
25
+
26
+ def initialize(value: nil, total: nil, message: nil) # :nodoc:
27
+ @value = value
28
+ @total = total
29
+ @message = message
30
+ end
31
+
32
+ # Returns the share of work done, from 0.0 to 1.0, or +nil+ without a
33
+ # value and a total.
34
+ def fraction
35
+ value.to_f / total if value && total&.positive?
36
+ end
37
+
38
+ private
39
+
40
+ def inspect_attributes
41
+ { value:, total:, message: }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The object a prompt template is evaluated in. Locals become local
4
+ # variables of the template, and +render+ inserts a partial.
5
+ #
6
+ # The class is declared with its full name so the template binding has no
7
+ # RubyLLM lexical scope: +Chat+ in a template is the application's model,
8
+ # not RubyLLM::Chat.
9
+ class RubyLLM::Prompt::Context # rubocop:disable Style/ClassAndModuleChildren
10
+ LOCAL_VARIABLE_NAME = /\A(?![A-Z0-9])(?:[[:alnum:]_]|[^\0-\177])+\z/
11
+ private_constant :LOCAL_VARIABLE_NAME
12
+
13
+ # Returns the locals passed to the current prompt or partial. Use it to
14
+ # read an optional local, or a local whose name is not a valid Ruby
15
+ # variable name:
16
+ #
17
+ # <% if local_assigns[:product_name] %>
18
+ #
19
+ attr_reader :local_assigns
20
+
21
+ def initialize(prompt, local_assigns) # :nodoc:
22
+ @prompt = prompt
23
+ @local_assigns = local_assigns
24
+ end
25
+
26
+ # Renders a partial with locals. A bare name is looked up next to the
27
+ # current prompt only. A name with a path is looked up in the prompt roots.
28
+ #
29
+ # <%= render "tone" %>
30
+ # <%= render "shared/safety", product_name: product_name %>
31
+ # <%= render partial: "shared/safety", locals: { product_name: product_name } %>
32
+ #
33
+ def render(options = {}, locals = {})
34
+ return partial(options).render(**locals) unless options.is_a?(Hash)
35
+
36
+ partial(options.fetch(:partial)).render(**(options[:locals] || {}))
37
+ end
38
+
39
+ def scope # :nodoc:
40
+ @local_assigns.each_with_object(binding) do |(name, value), scope|
41
+ scope.local_variable_set(name, value) if name.to_s.match?(LOCAL_VARIABLE_NAME)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def partial(name)
48
+ name = name.to_s
49
+ partial = name.sub(%r{([^/]+)\z}, '_\\1')
50
+ directory = File.dirname(@prompt.name)
51
+ return RubyLLM::Prompt.new(partial) if name.include?('/') || directory == '.'
52
+
53
+ RubyLLM::Prompt.new("#{directory}/#{partial}")
54
+ end
55
+ end
@@ -50,7 +50,7 @@ module RubyLLM
50
50
  def render(**locals) # :nodoc:
51
51
  raise PromptNotFoundError, "Prompt file not found: #{path}" unless File.exist?(path)
52
52
 
53
- ERB.new(File.read(path)).result_with_hash(locals)
53
+ ERB.new(File.read(path)).result(Context.new(self, locals).scope)
54
54
  end
55
55
 
56
56
  def self.render(name, **locals) # :nodoc:
@@ -8,7 +8,7 @@ module RubyLLM
8
8
  fallback = Streaming::StreamState.new
9
9
  on_data = binary_on_data(fallback, progress, &)
10
10
 
11
- response = @connection.post(url, payload, usage: @usage_tracker) do |request|
11
+ response = @connection.post(url, payload, usage: @usage_tracker, stream: true) do |request|
12
12
  (request.options.context ||= {})[Transport::Connection::STREAM_PROGRESS_KEY] = progress
13
13
  if faraday_1?
14
14
  request.options[:on_data] = on_data