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
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # A RequestShape describes a conversation request as the provider received
5
+ # it, without its contents: each turn's role and parts with their sizes,
6
+ # the tools on offer, the thinking settings, and the problems RubyLLM finds
7
+ # in it, such as a part with no data or a tool call with no result. Read it
8
+ # from Error#request_shape when a provider rejects a request without saying
9
+ # what is wrong with it.
10
+ #
11
+ # begin
12
+ # chat.ask "What's due today?"
13
+ # rescue RubyLLM::BadRequestError => e
14
+ # Rails.logger.error("#{e.message}\n#{e.request_shape}")
15
+ # Rails.error.report(e, context: { request_shape: e.request_shape.to_h })
16
+ # raise
17
+ # end
18
+ #
19
+ # A shape holds no text, tool arguments, tool results, signatures, URLs,
20
+ # file data, or setting values beyond thinking settings, so you can log it
21
+ # and send it to an error tracker. A long conversation keeps its first 10
22
+ # and last 50 turns.
23
+ class RequestShape
24
+ include Support::Inspectable
25
+
26
+ # The provider's slug, such as <tt>"gemini"</tt>.
27
+ attr_reader :provider
28
+
29
+ # The id of the model the request went to.
30
+ attr_reader :model
31
+
32
+ # Which model call of the current turn the request makes, counting from
33
+ # 1, so +3+ follows two tool rounds. Returns +nil+ when the request holds
34
+ # no user turn to count from.
35
+ attr_reader :step
36
+
37
+ # The names of the payload's top-level fields, without their values.
38
+ attr_reader :payload_keys
39
+
40
+ # The thinking settings the request sends, such as an effort or a budget,
41
+ # as a Hash of the provider's setting names and values.
42
+ attr_reader :thinking_settings
43
+
44
+ # The Part objects of the instructions the request sends apart from its
45
+ # turns, or an empty Array when the provider takes instructions as a turn.
46
+ attr_reader :instructions
47
+
48
+ # The Turn objects of the conversation, in the order the request sends
49
+ # them. A long conversation keeps its first 10 and last 50 turns.
50
+ attr_reader :turns
51
+
52
+ # The number of turns the request sends, including omitted ones.
53
+ attr_reader :turn_count
54
+
55
+ # The number of turns left out of #turns.
56
+ attr_reader :omitted_turns
57
+
58
+ # The names of the tools the request offers, in order. Provider tools
59
+ # keep the name their provider's API gives them.
60
+ attr_reader :tool_names
61
+
62
+ # The ToolRound objects of the turns kept, one for each model turn that
63
+ # called tools.
64
+ attr_reader :tool_rounds
65
+
66
+ # The Problem objects RubyLLM finds in the request, across every turn.
67
+ attr_reader :problems
68
+
69
+ # Creates a shape. Protocols build shapes in
70
+ # Protocol#parse_request_shape; every attribute but +turns+ is optional.
71
+ def initialize(turns:, provider: nil, model: nil, step: nil, payload_keys: [], thinking_settings: {},
72
+ instructions: [], turn_count: nil, omitted_turns: 0, tool_names: [], tool_rounds: [], problems: [])
73
+ @turns = turns
74
+ @provider = provider
75
+ @model = model
76
+ @step = step
77
+ @payload_keys = payload_keys
78
+ @thinking_settings = thinking_settings
79
+ @instructions = instructions
80
+ @turn_count = turn_count || turns.size
81
+ @omitted_turns = omitted_turns
82
+ @tool_names = tool_names
83
+ @tool_rounds = tool_rounds
84
+ @problems = problems
85
+ end
86
+
87
+ # Returns the shape as text, one turn per line:
88
+ #
89
+ # gemini, gemini-2.5-flash, model call 1 of the turn
90
+ # payload keys: contents, generationConfig, tools
91
+ # thinking: includeThoughts true, thinkingBudget -1
92
+ # #0 user: text (17 chars)
93
+ # #1 model: call find_tasks (args 40 chars), signed
94
+ # #2 user: result find_tasks (6859 chars)
95
+ # #3 model: thinking (no text), signed, text (1075 chars)
96
+ # #4 user: text (31 chars)
97
+ # tools: find_tasks
98
+ # tool round at #1: 1 call, 1 result, paired
99
+ # problem at #3, part 0: thinking part carries no data
100
+ #
101
+ def to_s
102
+ [summary_line, list_line('payload keys', payload_keys), settings_line, list_line('instructions', instructions),
103
+ *turn_lines, list_line('tools', tool_names), *tool_rounds.map(&:to_s), *problem_lines].compact.join("\n")
104
+ end
105
+
106
+ # Returns the shape as a Hash with the same keys every time, for
107
+ # structured logs and error trackers.
108
+ def to_h
109
+ {
110
+ provider: provider, model: model, step: step, payload_keys: payload_keys,
111
+ thinking_settings: thinking_settings, instructions: instructions.map(&:to_h),
112
+ turn_count: turn_count, omitted_turns: omitted_turns, turns: turns.map(&:to_h),
113
+ tool_names: tool_names, tool_rounds: tool_rounds.map(&:to_h), problems: problems.map(&:to_h)
114
+ }
115
+ end
116
+
117
+ def inspect_attributes # :nodoc:
118
+ { provider: provider, model: model, turns: turn_count, problems: problems.size }
119
+ end
120
+
121
+ private
122
+
123
+ def summary_line
124
+ line = [provider, model, step_description].compact.join(', ')
125
+ line unless line.empty?
126
+ end
127
+
128
+ def step_description
129
+ return unless step
130
+
131
+ rounds = step - 1
132
+ description = "model call #{step} of the turn"
133
+ return description unless rounds.positive?
134
+
135
+ "#{description}, after #{rounds} tool #{rounds == 1 ? 'round' : 'rounds'}"
136
+ end
137
+
138
+ def list_line(label, items)
139
+ "#{label}: #{items.join(', ')}" if items.any?
140
+ end
141
+
142
+ def settings_line
143
+ "thinking: #{thinking_settings.map { |name, value| "#{name} #{value}" }.join(', ')}" if thinking_settings.any?
144
+ end
145
+
146
+ def turn_lines
147
+ turns.each_cons(2).flat_map { |turn, following| [turn.to_s, *gap_line(turn, following)] }
148
+ .push(*turns.last&.to_s)
149
+ end
150
+
151
+ def gap_line(turn, following)
152
+ gap = following.index - turn.index - 1
153
+ "(#{gap} #{gap == 1 ? 'turn' : 'turns'} omitted)" if gap.positive?
154
+ end
155
+
156
+ def problem_lines
157
+ problems.any? ? problems.map(&:to_s) : ['no problems found']
158
+ end
159
+ end
160
+ end
@@ -49,6 +49,8 @@ module RubyLLM
49
49
  Cost.new(tokens:, model: model_info, category: :embeddings)
50
50
  end
51
51
 
52
+ attr_writer :model_info # :nodoc:
53
+
52
54
  def model_info # :nodoc:
53
55
  @model_info ||= RubyLLM.models.find(model)
54
56
  rescue ModelNotFoundError
@@ -64,6 +66,8 @@ module RubyLLM
64
66
  # provider-specific and have no cross-provider default. +top_n:+
65
67
  # limits how many results come back. +provider_options:+ merges
66
68
  # options into the request in the provider's vocabulary.
69
+ # +owner:+ attributes the usage to a record, such as a user, and wins
70
+ # over RubyLLM.with_usage_owner.
67
71
  #
68
72
  # RubyLLM.rerank "what is ruby", docs, model: "voyageai/rerank-2.5-lite", provider: :openrouter
69
73
  #
@@ -74,7 +78,8 @@ module RubyLLM
74
78
  context: nil,
75
79
  top_n: nil,
76
80
  provider_options: {},
77
- metadata: nil)
81
+ metadata: nil,
82
+ owner: nil)
78
83
  config = context&.config || RubyLLM.config
79
84
  model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
80
85
  config: config)
@@ -94,7 +99,9 @@ module RubyLLM
94
99
  }
95
100
 
96
101
  RubyLLM.instrument('rerank.ruby_llm', payload, config: config) do |event|
97
- result = provider_instance.rerank(query, documents, model:, top_n:, provider_options:)
102
+ result = Accounting::Usage.owned_by(owner) do
103
+ provider_instance.rerank(query, documents, model:, top_n:, provider_options:)
104
+ end
98
105
  event[:result] = result
99
106
  event[:tokens] = result.tokens
100
107
  event[:cost] = result.cost
@@ -68,14 +68,18 @@ module RubyLLM
68
68
  # Submits one research task without waiting. +provider:+ and +agent:+
69
69
  # are required; +with:+ attaches documents or images where supported.
70
70
  # +provider_tools:+ accepts an array of aliases or a Hash of aliases
71
- # and their options, as on Chat#with_provider_tools.
71
+ # and their options, as on Chat#with_provider_tools. The job records
72
+ # its usage, attributed to +owner:+ or the owner of
73
+ # RubyLLM.with_usage_owner, when it finishes.
72
74
  def self.research_later(prompt, provider:, agent:, with: nil, provider_tools: nil,
73
- context: nil, provider_options: {}, metadata: nil)
75
+ context: nil, provider_options: {}, metadata: nil, owner: nil)
74
76
  config = context&.config || RubyLLM.config
75
77
  instance = Provider.resolve!(provider).new(config)
76
78
  payload = { provider: instance.slug, agent:, prompt:, metadata: }
77
79
  RubyLLM.instrument('research_job.ruby_llm', payload, config:) do |event|
78
- job = instance.research_later(prompt, agent:, with:, provider_tools:, provider_options:)
80
+ job = Accounting::Usage.owned_by(owner) do
81
+ instance.research_later(prompt, agent:, with:, provider_tools:, provider_options:)
82
+ end
79
83
  event[:job_id] = job.id
80
84
  event[:status] = job.status
81
85
  job
@@ -85,9 +89,9 @@ module RubyLLM
85
89
  # Runs a research task and returns its Message. On a timeout or
86
90
  # interrupt, attempts to cancel the remote task before raising an
87
91
  # error retaining the job. +timeout:+ and +interval:+ are seconds.
88
- def self.research(prompt, timeout: 600, interval: 5, **options)
92
+ def self.research(prompt, timeout: 600, interval: 5, **)
89
93
  validate_polling_options(timeout, interval)
90
- job = research_later(prompt, **options)
94
+ job = research_later(prompt, **)
91
95
  job.wait(timeout:, interval:).message
92
96
  rescue Interrupt => e
93
97
  raise unless job
@@ -123,6 +127,7 @@ module RubyLLM
123
127
  @provider = provider.to_sym
124
128
  @agent = agent
125
129
  @protocol = protocol
130
+ @usage_owner = Accounting::Usage.owner
126
131
  apply_state(state)
127
132
  end
128
133
 
@@ -216,12 +221,23 @@ module RubyLLM
216
221
  raise Error.new("Research request failed: #{e.message} (job #{id})", job: self, response:), cause: e
217
222
  end
218
223
 
224
+ # A job found by ID may have been submitted and recorded elsewhere, so
225
+ # only a finish this object observes records usage.
219
226
  def apply_state(state)
227
+ observed_finish = pending? && state.fetch(:status) != :pending
220
228
  @status = state.fetch(:status)
221
229
  @raw = state[:raw]
222
230
  @error = state[:error]
223
231
  @message = state[:message]
224
232
  @tokens = state[:tokens] || @message&.tokens || Tokens.new
233
+ record_usage if observed_finish
234
+ end
235
+
236
+ def record_usage
237
+ status = { completed: :succeeded, incomplete: :succeeded, cancelled: :cancelled }.fetch(@status, :failed)
238
+ entry = Accounting::Usage::Entry.new(operation: :research, provider:, model: agent, status:, tokens:, cost:,
239
+ owner: @usage_owner)
240
+ Accounting::Usage.report(entry, config: @protocol.config)
225
241
  end
226
242
 
227
243
  def attempt_cancellation
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # A probability-weighted position on a judgment's ordered levels.
5
+ class Score
6
+ include Support::Inspectable
7
+
8
+ # Returns the weighted score, which can fall between level indexes.
9
+ attr_reader :score
10
+
11
+ # Returns the ordered descriptions, preserving structured Hashes and Arrays.
12
+ attr_reader :levels
13
+
14
+ # Returns each zero-based Integer level index's probability.
15
+ attr_reader :probabilities
16
+
17
+ # Returns the provider's confidence in the distribution, between 0 and 1.
18
+ attr_reader :confidence
19
+
20
+ def initialize(score:, levels:, probabilities:, confidence:) # :nodoc:
21
+ @score = score
22
+ @levels = Judge::Data.copy(levels)
23
+ @probabilities = probabilities.dup.freeze
24
+ @confidence = confidence
25
+ freeze
26
+ end
27
+
28
+ # Returns :score.
29
+ def type
30
+ :score
31
+ end
32
+
33
+ # Returns the answer as a Hash.
34
+ def to_h
35
+ { type:, score:, levels:, probabilities:, confidence: }
36
+ end
37
+
38
+ def inspect_attributes # :nodoc:
39
+ { score:, confidence: }
40
+ end
41
+ end
42
+ end
@@ -52,8 +52,8 @@ module RubyLLM
52
52
  { KEY => results }
53
53
  end
54
54
 
55
- def to_json(*args) # :nodoc:
56
- JSON.generate(to_h, *args)
55
+ def to_json(*) # :nodoc:
56
+ JSON.generate(to_h, *)
57
57
  end
58
58
 
59
59
  private
@@ -39,22 +39,30 @@ module RubyLLM
39
39
  # conversation is sent back to the provider.
40
40
  attr_reader :raw
41
41
 
42
+ # The HTML of the search suggestions the provider requires you to show
43
+ # with a grounded answer, such as Google's for Google Search grounding,
44
+ # or +nil+. Only the live response carries them: #to_h leaves them out,
45
+ # so a persisted chat never stores them.
46
+ attr_reader :search_suggestions
47
+
42
48
  def self.from_h(data) # :nodoc:
43
49
  data = Support::Utils.deep_symbolize_keys(data)
44
50
  new(type: data[:type], name: data[:name], id: data[:id], input: data[:input],
45
51
  result: data[:result], raw: data[:raw])
46
52
  end
47
53
 
48
- def initialize(type:, raw:, name: nil, id: nil, input: nil, result: nil) # :nodoc:
54
+ def initialize(type:, raw:, name: nil, id: nil, input: nil, result: nil, search_suggestions: nil) # :nodoc:
49
55
  @type = type
50
56
  @name = name
51
57
  @id = id
52
58
  @input = input
53
59
  @result = result
54
60
  @raw = raw
61
+ @search_suggestions = search_suggestions
55
62
  end
56
63
 
57
- # Returns the call's attributes as a Hash, omitting +nil+ values.
64
+ # Returns the call's attributes as a Hash, omitting +nil+ values and
65
+ # #search_suggestions.
58
66
  def to_h
59
67
  {
60
68
  type: type,
@@ -54,6 +54,8 @@ module RubyLLM
54
54
  # that is not in the registry. +provider_options:+ takes options in the
55
55
  # provider's request vocabulary, such as +instructions:+ and +speed:+
56
56
  # for OpenAI, and merges them into the request as-is.
57
+ # +owner:+ attributes the usage to a record, such as a user, and wins
58
+ # over RubyLLM.with_usage_owner.
57
59
  #
58
60
  # speech = RubyLLM.speak "Hello, welcome to RubyLLM!"
59
61
  # speech.save "welcome.mp3"
@@ -82,6 +84,7 @@ module RubyLLM
82
84
  context: nil,
83
85
  provider_options: {},
84
86
  metadata: nil,
87
+ owner: nil,
85
88
  &block)
86
89
  config = context&.config || RubyLLM.config
87
90
  model ||= config.default_speech_model
@@ -105,7 +108,9 @@ module RubyLLM
105
108
  }
106
109
 
107
110
  RubyLLM.instrument('speech.ruby_llm', payload, config: config) do |event|
108
- result = provider_instance.speak(input, model:, voice:, format:, provider_options:, &block)
111
+ result = Accounting::Usage.owned_by(owner) do
112
+ provider_instance.speak(input, model:, voice:, format:, provider_options:, &block)
113
+ end
109
114
  event[:result] = result
110
115
  event[:response_model] = result.model
111
116
  event[:voice] = result.voice
@@ -137,6 +142,8 @@ module RubyLLM
137
142
  Cost.new(tokens:, model: model_info, category: :audio_tokens)
138
143
  end
139
144
 
145
+ attr_writer :model_info # :nodoc:
146
+
140
147
  def model_info # :nodoc:
141
148
  @model_info ||= RubyLLM.models.find(model)
142
149
  rescue ModelNotFoundError
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Support
5
+ # Lets work that runs inside a chat operation, such as a tool waiting on
6
+ # a remote server, stop when the chat is cancelled. The chat installs a
7
+ # checkpoint for the current fiber; #check raises CancelledError once
8
+ # the chat has been cancelled.
9
+ module Cancellation # :nodoc:
10
+ KEY = :ruby_llm_cancellation_checkpoint
11
+ CHECK_INTERVAL = 0.1
12
+
13
+ module_function
14
+
15
+ def watch(checkpoint)
16
+ previous = Thread.current[KEY]
17
+ Thread.current[KEY] = checkpoint
18
+ yield
19
+ ensure
20
+ Thread.current[KEY] = previous
21
+ end
22
+
23
+ def check
24
+ Thread.current[KEY]&.call
25
+ end
26
+
27
+ def pause(seconds)
28
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
29
+ loop do
30
+ check
31
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ break unless remaining.positive?
33
+
34
+ sleep [remaining, CHECK_INTERVAL].min
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -17,9 +17,10 @@ module RubyLLM
17
17
  attributes = inspect_attributes
18
18
  .reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
19
19
  .map { |name, value| "#{name}: #{format_for_inspect(value)}" }
20
- return "#<#{self.class.name}>" if attributes.empty?
20
+ class_name = self.class.ancestors.find(&:name).name
21
+ return "#<#{class_name}>" if attributes.empty?
21
22
 
22
- "#<#{self.class.name} #{attributes.join(', ')}>"
23
+ "#<#{class_name} #{attributes.join(', ')}>"
23
24
  end
24
25
 
25
26
  def pretty_print(printer) # :nodoc:
@@ -5,15 +5,51 @@ module RubyLLM
5
5
  module Instrumentation # :nodoc:
6
6
  WORKFLOW_CONTEXT_KEY = :ruby_llm_workflow_context
7
7
 
8
+ @subscribers = [].freeze
9
+ @mutex = Mutex.new
10
+
8
11
  module_function
9
12
 
10
- def instrument(name, payload = nil, config: nil, **attributes) # rubocop:disable Metrics/PerceivedComplexity
13
+ def instrument(name, payload = nil, config: nil, **attributes, &block)
11
14
  payload ||= {}
12
15
  payload = payload.merge(attributes) unless attributes.empty?
13
16
  workflow_context = current_workflow
14
17
  payload = payload.merge(workflow_context) if workflow_context
15
18
  instrumenter = (config || RubyLLM.config).instrumenter
19
+ subscribers = @subscribers
20
+ return dispatch(instrumenter, name, payload, &block) if subscribers.empty?
21
+
22
+ dispatch = -> { dispatch(instrumenter, name, payload, &block) }
23
+ subscribers.reverse_each do |subscriber|
24
+ inner = dispatch
25
+ dispatch = -> { subscriber.instrument(name, payload, &inner) }
26
+ end
27
+ dispatch.call
28
+ end
29
+
30
+ def subscribe(subscriber)
31
+ @mutex.synchronize { @subscribers = (@subscribers | [subscriber]).freeze }
32
+ end
33
+
34
+ def unsubscribe(subscriber)
35
+ @mutex.synchronize { @subscribers = (@subscribers - [subscriber]).freeze }
36
+ end
37
+
38
+ def capture_context
39
+ @subscribers.filter_map do |subscriber|
40
+ [subscriber, subscriber.capture_context] if subscriber.respond_to?(:capture_context)
41
+ end
42
+ end
43
+
44
+ def with_context(context, &block)
45
+ context.reverse_each do |subscriber, value|
46
+ inner = block
47
+ block = -> { subscriber.with_context(value, &inner) }
48
+ end
49
+ block.call
50
+ end
16
51
 
52
+ def dispatch(instrumenter, name, payload)
17
53
  if instrumenter.respond_to?(:instrument)
18
54
  if block_given?
19
55
  instrumenter.instrument(name, payload) { yield(payload) }
@@ -24,6 +60,7 @@ module RubyLLM
24
60
  yield(payload)
25
61
  end
26
62
  end
63
+ private_class_method :dispatch
27
64
 
28
65
  def current_workflow
29
66
  Thread.current[WORKFLOW_CONTEXT_KEY]
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Support # :nodoc:
5
+ class ProcessCache # :nodoc:
6
+ LIMIT = 64
7
+
8
+ def initialize(limit: LIMIT)
9
+ @limit = limit
10
+ @lock = Mutex.new
11
+ @pid = Process.pid
12
+ @entries = {}
13
+ end
14
+
15
+ # Builds outside the lock, so a slow build never stalls other threads or
16
+ # fibers. When two callers race, the first value stored wins.
17
+ def fetch(key)
18
+ @lock.synchronize { touch(key) } || store(key, yield)
19
+ end
20
+
21
+ def clear
22
+ @lock.synchronize { @entries.clear }
23
+ end
24
+
25
+ def delete(key)
26
+ @lock.synchronize do
27
+ forget_inherited_entries
28
+ @entries.delete(key)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def touch(key)
35
+ forget_inherited_entries
36
+ value = @entries.delete(key)
37
+ @entries[key] = value if value
38
+ end
39
+
40
+ def store(key, value)
41
+ @lock.synchronize do
42
+ value = touch(key) || value
43
+ @entries[key] = value
44
+ @entries.shift while @entries.size > @limit
45
+ value
46
+ end
47
+ end
48
+
49
+ # A forked child shares its parent's sockets and credentials. Closing
50
+ # them here would tear down the parent's connections, so the child only
51
+ # forgets them.
52
+ def forget_inherited_entries
53
+ return if @pid == Process.pid
54
+
55
+ @pid = Process.pid
56
+ @entries = {}
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Support
5
+ # Carries the progress of work that runs inside a chat operation, such
6
+ # as a tool call, to whoever listens for it. The chat installs a
7
+ # listener for the current execution context; #report hands it a
8
+ # Progress. Each concurrent tool call installs its own listener, so its
9
+ # reports reach only its own callback. The listener lives in fiber
10
+ # storage, which fibers and threads the tool starts inherit.
11
+ module ProgressReporter # :nodoc:
12
+ KEY = :ruby_llm_progress_listener
13
+
14
+ module_function
15
+
16
+ def listen(listener)
17
+ previous = Fiber[KEY]
18
+ Fiber[KEY] = listener
19
+ yield
20
+ ensure
21
+ Fiber[KEY] = previous
22
+ end
23
+
24
+ def listener
25
+ Fiber[KEY]
26
+ end
27
+
28
+ def report(progress)
29
+ listener&.call(progress)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -69,8 +69,9 @@ module RubyLLM
69
69
 
70
70
  budget = model.reasoning_option(:budget_tokens)
71
71
  return { budget: 0 } if budget && budget[:min].is_a?(Numeric) && budget[:min] <= 0
72
+ return { enabled: false } if model.reasoning_option(:toggle) || budget
72
73
 
73
- { enabled: false } if model.reasoning_option(:toggle) || budget
74
+ model.provider_class&.thinking_off_control(model.id)
74
75
  end
75
76
 
76
77
  private