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
@@ -15,6 +15,10 @@ module RubyLLM
15
15
  class Tokens
16
16
  include Support::Inspectable
17
17
 
18
+ READERS = %i[
19
+ input output cache_read cache_write thinking cache_write_by_ttl server_tool_use reported_cost
20
+ ].freeze # :nodoc:
21
+
18
22
  # The number of standard (non-cached) input tokens, or +nil+ if the
19
23
  # provider did not report it.
20
24
  attr_reader :input
@@ -32,14 +36,30 @@ module RubyLLM
32
36
  # +nil+ if the provider did not report it.
33
37
  attr_reader :cache_write
34
38
 
39
+ # The tokens in #cache_write split by cache lifetime, as a Hash of the
40
+ # TTL Strings that <tt>with_caching(ttl:)</tt> accepts and Integer
41
+ # counts, or +nil+ if the provider did not report a split. Lifetimes
42
+ # with no writes are left out. Providers that price a longer lifetime
43
+ # higher, such as Anthropic's one-hour cache, use it to price #cache_write.
44
+ #
45
+ # response.tokens.cache_write_by_ttl # => {"5m" => 1024, "1h" => 2048}
46
+ #
47
+ attr_reader :cache_write_by_ttl
48
+
35
49
  # The number of thinking (reasoning) tokens, or +nil+ if the provider
36
50
  # does not report them.
37
51
  attr_reader :thinking
38
52
 
39
- # The provider's server-tool usage counters as a Hash, such as
40
- # <tt>{"web_search_requests" => 2}</tt>, or +nil+ if the provider did
41
- # not report any. Counters are provider-shaped and billed per use, not
42
- # in tokens.
53
+ # How many times each provider-executed tool ran, as a Hash of String
54
+ # keys and Integer counts, or +nil+ if the provider reported none.
55
+ # Web searches are <tt>"web_search_requests"</tt> on every provider
56
+ # that reports them. Other tools follow the same form, such as
57
+ # <tt>"web_fetch_requests"</tt> and <tt>"code_execution_requests"</tt>.
58
+ # Tools that did not run are left out. Providers bill these per use,
59
+ # not in tokens.
60
+ #
61
+ # response.tokens.server_tool_use # => {"web_search_requests" => 2}
62
+ #
43
63
  attr_reader :server_tool_use
44
64
 
45
65
  # The exact cost of the call in US dollars as reported by the
@@ -54,16 +74,20 @@ module RubyLLM
54
74
  # tokens = RubyLLM::Tokens.new(input: 100, output: 20)
55
75
  # RubyLLM.models.find("gpt-5.6").cost_for(tokens).total
56
76
  #
57
- # +server_tool_use:+ holds provider tool-usage counters;
77
+ # +cache_write_by_ttl:+ splits the cache writes by lifetime, keyed as
78
+ # #cache_write_by_ttl describes;
79
+ # +server_tool_use:+ counts the provider-executed tools that ran, keyed
80
+ # as #server_tool_use describes;
58
81
  # +reported_cost:+ holds the provider's total price in US dollars.
59
82
  def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, thinking: nil,
60
- server_tool_use: nil, reported_cost: nil)
83
+ cache_write_by_ttl: nil, server_tool_use: nil, reported_cost: nil)
61
84
  @input = input
62
85
  @output = output
63
86
  @cache_read = cache_read
64
87
  @cache_write = cache_write
65
88
  @thinking = thinking
66
- @server_tool_use = server_tool_use
89
+ @cache_write_by_ttl = positive_counts(cache_write_by_ttl)
90
+ @server_tool_use = positive_counts(server_tool_use)
67
91
  @reported_cost = reported_cost
68
92
  end
69
93
 
@@ -78,23 +102,33 @@ module RubyLLM
78
102
  reported = tokens.filter_map { |usage| usage.public_send(component) }
79
103
  [component, reported.empty? ? nil : reported.sum]
80
104
  end
81
- new(**values, server_tool_use: aggregate_server_tool_use(tokens))
105
+ new(**values, cache_write_by_ttl: aggregate_counts(tokens, :cache_write_by_ttl),
106
+ server_tool_use: aggregate_counts(tokens, :server_tool_use))
82
107
  end
83
108
 
84
- def self.aggregate_server_tool_use(tokens) # :nodoc:
85
- reported = tokens.filter_map(&:server_tool_use)
109
+ # Returns Tokens with each count +other+ reported replacing this one.
110
+ def merge(other) # :nodoc:
111
+ self.class.new(**READERS.to_h do |reader|
112
+ value = other.public_send(reader)
113
+ [reader, value.nil? ? public_send(reader) : value]
114
+ end)
115
+ end
116
+
117
+ def self.aggregate_counts(tokens, reader) # :nodoc:
118
+ reported = tokens.filter_map(&reader)
86
119
  return nil if reported.empty?
87
120
 
88
121
  reported.each_with_object({}) do |counters, total|
89
- counters.each { |tool, count| total[tool] = total.fetch(tool, 0) + count.to_i }
122
+ counters.each { |key, count| total[key] = total.fetch(key, 0) + count.to_i }
90
123
  end
91
124
  end
92
- private_class_method :aggregate_server_tool_use
125
+ private_class_method :aggregate_counts
93
126
 
94
127
  # Returns the counts as a hash with keys +:input_tokens+,
95
128
  # +:output_tokens+, +:cache_read_tokens+, +:cache_write_tokens+, and
96
- # +:thinking_tokens+, omitting +nil+ counts. Includes +:server_tool_use+
97
- # when reported. The provider's reported cost is available separately
129
+ # +:thinking_tokens+, omitting +nil+ counts. Includes
130
+ # +:cache_write_tokens_by_ttl+ and +:server_tool_use+ when reported.
131
+ # The provider's reported cost is available separately
98
132
  # through #reported_cost.
99
133
  #
100
134
  # response.tokens.to_h
@@ -107,6 +141,7 @@ module RubyLLM
107
141
  cache_read_tokens: cache_read,
108
142
  cache_write_tokens: cache_write,
109
143
  thinking_tokens: thinking,
144
+ cache_write_tokens_by_ttl: cache_write_by_ttl,
110
145
  server_tool_use: server_tool_use
111
146
  }.compact
112
147
  end
@@ -118,8 +153,16 @@ module RubyLLM
118
153
  cache_read: cache_read,
119
154
  cache_write: cache_write,
120
155
  thinking: thinking,
156
+ cache_write_by_ttl: cache_write_by_ttl,
121
157
  server_tool_use: server_tool_use
122
158
  }.compact
123
159
  end
160
+
161
+ private
162
+
163
+ def positive_counts(counts)
164
+ used = counts.to_h.to_h { |key, count| [key.to_s, count.to_i] }.select { |_, count| count.positive? }
165
+ used unless used.empty?
166
+ end
124
167
  end
125
168
  end
data/lib/ruby_llm/tool.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'delegate'
3
4
  require 'schematist'
4
5
 
5
6
  module RubyLLM
@@ -49,6 +50,7 @@ module RubyLLM
49
50
  @parameters_schema_definition
50
51
  @requires_approval
51
52
  @approval_resolver
53
+ @deferred
52
54
  ].freeze
53
55
  private_constant :DUPED_INHERITED_CONFIG, :COPIED_INHERITED_CONFIG
54
56
 
@@ -74,9 +76,13 @@ module RubyLLM
74
76
  # WeatherLookup.tool_name # => "weather_lookup"
75
77
  #
76
78
  def tool_name
79
+ return @tool_name if @tool_name
80
+
77
81
  normalized = name.to_s.dup.force_encoding('UTF-8').unicode_normalize(:nfkd)
78
82
  ascii_name = normalized.encode('ASCII', replace: '').gsub(/[^a-zA-Z0-9_-]/, '-')
79
- Support::Utils.underscore(ascii_name).delete_suffix('_tool')
83
+ derived = Support::Utils.underscore(ascii_name).delete_suffix('_tool')
84
+ @tool_name = derived if name
85
+ derived
80
86
  end
81
87
 
82
88
  # :call-seq:
@@ -107,8 +113,8 @@ module RubyLLM
107
113
  # parameter :units, type: :string, description: "metric or imperial", required: false
108
114
  # end
109
115
  #
110
- def parameter(name, **options)
111
- declared_parameters[name] = Parameter.new(name, **options)
116
+ def parameter(name, **)
117
+ declared_parameters[name] = Parameter.new(name, **)
112
118
  end
113
119
 
114
120
  def declared_parameters # :nodoc:
@@ -195,10 +201,29 @@ module RubyLLM
195
201
  self
196
202
  end
197
203
 
204
+ # Keeps this tool's definition out of the model's context until the
205
+ # provider's tool search loads it. Pass +defer: false+ to
206
+ # Chat#with_tools to offer it up front in one chat. See
207
+ # Chat#deferred_tools.
208
+ #
209
+ # class DeepResearch < RubyLLM::Tool
210
+ # description "Runs a multi-step web investigation"
211
+ # defer
212
+ # end
213
+ #
214
+ def defer
215
+ @deferred = true
216
+ end
217
+
218
+ def deferred? # :nodoc:
219
+ @deferred || false
220
+ end
221
+
198
222
  def split_result(result) # :nodoc:
199
223
  case result
200
224
  when Attachment then ['', [result]]
201
225
  when Array then split_array_result(result)
226
+ when MCP::Result then split_result(result.content)
202
227
  else [result_content(result), []]
203
228
  end
204
229
  end
@@ -258,6 +283,9 @@ module RubyLLM
258
283
  self.class.provider_options
259
284
  end
260
285
 
286
+ class Deferred < SimpleDelegator # :nodoc:
287
+ end
288
+
261
289
  # Returns the JSON Schema for the tool's arguments, whether declared
262
290
  # explicitly or inferred from the +execute+ signature.
263
291
  def parameters_schema
@@ -313,6 +341,25 @@ module RubyLLM
313
341
  raise NotImplementedError, 'Subclasses must implement #execute'
314
342
  end
315
343
 
344
+ # Reports what the tool is doing while #execute runs, so an application
345
+ # can show it before the result arrives. Chat#after_tool_progress
346
+ # callbacks receive it as a Progress. Pass +value:+ and +total:+ when
347
+ # you can count the work. Outside a chat, the report goes nowhere.
348
+ #
349
+ # def execute(url:)
350
+ # progress "Downloading #{File.basename(url)}"
351
+ # pages = Scanner.pages(url)
352
+ # pages.each_with_index.map do |page, index|
353
+ # progress "Reading page #{index + 1} of #{pages.size}", value: index + 1, total: pages.size
354
+ # page.text
355
+ # end.join("\n")
356
+ # end
357
+ #
358
+ def progress(message = nil, value: nil, total: nil)
359
+ Support::ProgressReporter.report(Progress.new(value:, total:, message:))
360
+ nil
361
+ end
362
+
316
363
  protected
317
364
 
318
365
  def validate_keyword_arguments(arguments) # :nodoc:
@@ -21,7 +21,10 @@ module RubyLLM
21
21
  # The name of the tool the model wants to invoke.
22
22
  attr_reader :name
23
23
 
24
- # The arguments the model supplied for the invocation, as a Hash.
24
+ # The arguments the model supplied for the invocation, as a Hash. Keys
25
+ # are ordered the way PostgreSQL +jsonb+ and MySQL +json+ store them,
26
+ # shorter keys first, so a call reloaded from the database renders the
27
+ # same request bytes and keeps the provider's prompt cache.
25
28
  attr_reader :arguments
26
29
 
27
30
  # Returns +true+ if the provider executes this call after approval.
@@ -35,7 +38,7 @@ module RubyLLM
35
38
  def initialize(id:, name:, arguments: {}, thought_signature: nil, remote: false) # :nodoc:
36
39
  @id = id
37
40
  @name = name
38
- @arguments = arguments
41
+ @arguments = canonical_arguments(arguments)
39
42
  @thought_signature = thought_signature
40
43
  @remote = remote
41
44
  end
@@ -54,5 +57,17 @@ module RubyLLM
54
57
  thought_signature: @thought_signature
55
58
  }.compact
56
59
  end
60
+
61
+ private
62
+
63
+ def canonical_arguments(value)
64
+ case value
65
+ when Hash
66
+ value.sort_by { |key, _| [key.to_s.bytesize, key.to_s] }
67
+ .to_h { |key, nested| [key, canonical_arguments(nested)] }
68
+ when Array then value.map { |item| canonical_arguments(item) }
69
+ else value
70
+ end
71
+ end
57
72
  end
58
73
  end
@@ -61,6 +61,8 @@ module RubyLLM
61
61
  Cost.new(tokens:, model: model_info, category: :audio_tokens)
62
62
  end
63
63
 
64
+ attr_writer :model_info # :nodoc:
65
+
64
66
  def model_info # :nodoc:
65
67
  @model_info ||= RubyLLM.models.find(model)
66
68
  rescue ModelNotFoundError
@@ -96,6 +98,8 @@ module RubyLLM
96
98
  # +:segment+ or +:character+; unsupported granularities raise ArgumentError.
97
99
  # +provider_options:+ takes options in the provider's request vocabulary
98
100
  # and merges them into the rendered request as-is.
101
+ # +owner:+ attributes the usage to a record, such as a user, and wins
102
+ # over RubyLLM.with_usage_owner.
99
103
  #
100
104
  # Given a block, the transcript streams: each TranscriptionChunk is
101
105
  # yielded as it arrives and the completed Transcription is still
@@ -124,6 +128,7 @@ module RubyLLM
124
128
  speaker_references: nil,
125
129
  provider_options: {},
126
130
  metadata: nil,
131
+ owner: nil,
127
132
  &block)
128
133
  config = context&.config || RubyLLM.config
129
134
  model ||= config.default_transcription_model
@@ -143,9 +148,11 @@ module RubyLLM
143
148
  }
144
149
 
145
150
  RubyLLM.instrument('transcription.ruby_llm', payload, config: config) do |event|
146
- result = provider_instance.transcribe(audio_file, model:, language:, format:, timestamps:, speaker_names:,
147
- speaker_references:, provider_options:, prompt:,
148
- temperature:, &block)
151
+ result = Accounting::Usage.owned_by(owner) do
152
+ provider_instance.transcribe(audio_file, model:, language:, format:, timestamps:, speaker_names:,
153
+ speaker_references:, provider_options:, prompt:,
154
+ temperature:, &block)
155
+ end
149
156
  event[:result] = result
150
157
  event[:response_model] = result.model
151
158
  event[:tokens] = result.tokens
@@ -4,6 +4,7 @@ require 'faraday'
4
4
  require 'faraday/multipart'
5
5
  require 'faraday/retry'
6
6
  require 'ruby_llm/transport/error_middleware'
7
+ require 'ruby_llm/transport/signing_middleware'
7
8
  require 'ruby_llm/transport/usage_middleware'
8
9
  require 'timeout'
9
10
 
@@ -15,7 +16,17 @@ module RubyLLM
15
16
  IDEMPOTENT_KEY = :ruby_llm_idempotent
16
17
  STREAM_PROGRESS_KEY = :ruby_llm_stream_progress
17
18
 
18
- attr_reader :provider, :connection, :config
19
+ # The key a shared Faraday connection is cached under: the provider,
20
+ # whether it streams, and everything ::build reads.
21
+ Settings = Struct.new(
22
+ :provider, :stream, :api_base, :adapter, :timeout, :proxy, :logger, :log_bodies, :log_regexp_timeout,
23
+ :max_retries, :retry_interval, :retry_max_interval, :retry_interval_randomness, :retry_backoff_factor,
24
+ keyword_init: true
25
+ )
26
+
27
+ CACHE = Support::ProcessCache.new
28
+
29
+ attr_reader :provider, :config
19
30
 
20
31
  def self.basic(config = RubyLLM.config, &)
21
32
  Faraday.new do |f|
@@ -32,34 +43,61 @@ module RubyLLM
32
43
  end
33
44
  end
34
45
 
35
- def initialize(provider, config, api_base: nil)
46
+ def self.cache
47
+ CACHE
48
+ end
49
+
50
+ # The result is shared across threads and contexts, so the middleware
51
+ # stack is built now rather than lazily, without a lock, on the first
52
+ # request, and the defaults every request copies are frozen.
53
+ def self.build(settings)
54
+ connection = Faraday.new(settings.api_base) do |faraday|
55
+ setup_timeout(faraday, settings)
56
+ setup_logging(faraday, settings)
57
+ setup_retry(faraday, settings)
58
+ setup_middleware(faraday, settings)
59
+ setup_http_proxy(faraday, settings)
60
+ end
61
+ [connection.headers, connection.params, connection.options].each(&:freeze)
62
+ connection.app
63
+ connection
64
+ end
65
+
66
+ def initialize(provider, config, api_base: nil, headers: {}, signer: nil)
36
67
  @provider = provider
37
68
  @config = config
69
+ @headers = headers
70
+ @signer = signer
71
+ @settings = settings_for(api_base || provider.api_base)
72
+ @stream_settings = @settings.dup.tap { |settings| settings.stream = true }.freeze
73
+ connection
74
+ end
38
75
 
39
- @connection = Faraday.new(api_base || provider.api_base) do |faraday|
40
- setup_timeout(faraday)
41
- setup_logging(faraday)
42
- setup_retry(faraday)
43
- setup_middleware(faraday)
44
- setup_http_proxy(faraday)
45
- end
76
+ # The Faraday connection shared by every request with these settings in
77
+ # this process. Looked up per request, so an object that outlives a fork
78
+ # never reaches the parent's sockets. Streaming requests get their own:
79
+ # some adapters, such as httpx, only stream if their first request did.
80
+ def connection(stream: false)
81
+ settings = stream ? @stream_settings : @settings
82
+ CACHE.fetch(settings) { self.class.build(settings) }
46
83
  end
47
84
 
48
- def post(url, payload, usage: nil, idempotent: true, &)
85
+ def post(url, payload, usage: nil, idempotent: true, stream: false, &)
49
86
  instrument_request(:post, url) do
50
- @connection.post url, payload do |req|
51
- req.headers.merge! @provider.headers
87
+ response = connection(stream:).post url, payload do |req|
88
+ prepare(req)
52
89
  set_usage_tracker(req, usage) if usage
53
90
  mark_non_idempotent(req) unless idempotent
54
91
  yield req if block_given?
55
92
  end
93
+ release_request(response)
56
94
  end
57
95
  end
58
96
 
59
97
  def get(url, &)
60
98
  instrument_request(:get, url) do
61
- @connection.get url do |req|
62
- req.headers.merge! @provider.headers
99
+ connection.get url do |req|
100
+ prepare(req)
63
101
  yield req if block_given?
64
102
  end
65
103
  end
@@ -67,17 +105,18 @@ module RubyLLM
67
105
 
68
106
  def patch(url, payload, &)
69
107
  instrument_request(:patch, url) do
70
- @connection.patch url, payload do |req|
71
- req.headers.merge! @provider.headers
108
+ response = connection.patch url, payload do |req|
109
+ prepare(req)
72
110
  yield req if block_given?
73
111
  end
112
+ release_request(response)
74
113
  end
75
114
  end
76
115
 
77
116
  def delete(url, &)
78
117
  instrument_request(:delete, url) do
79
- @connection.delete url do |req|
80
- req.headers.merge! @provider.headers
118
+ connection.delete url do |req|
119
+ prepare(req)
81
120
  yield req if block_given?
82
121
  end
83
122
  end
@@ -99,35 +138,74 @@ module RubyLLM
99
138
  end
100
139
  end
101
140
 
102
- def setup_timeout(faraday)
103
- faraday.options.timeout = @config.request_timeout
141
+ # The response outlives the call as Message#raw and other results. A chat
142
+ # request body is the whole serialized conversation, and a streaming
143
+ # callback closes over the payload it was built from.
144
+ def release_request(response)
145
+ response.env.request_body = nil
146
+ response.env.request.on_data = nil
147
+ response.env.request.context&.delete(ErrorMiddleware::STREAM_RESET_KEY)
148
+ response
104
149
  end
105
150
 
106
- def setup_logging(faraday)
151
+ def settings_for(api_base)
152
+ Settings.new(
153
+ provider: @provider.class,
154
+ stream: false,
155
+ api_base: api_base,
156
+ adapter: @config.faraday_adapter,
157
+ timeout: @config.request_timeout,
158
+ proxy: @config.http_proxy,
159
+ logger: RubyLLM.logger,
160
+ log_bodies: RubyLLM.logger.debug?,
161
+ log_regexp_timeout: @config.log_regexp_timeout,
162
+ max_retries: @config.max_retries,
163
+ retry_interval: @config.retry_interval,
164
+ retry_max_interval: @config.retry_max_interval,
165
+ retry_interval_randomness: @config.retry_interval_randomness,
166
+ retry_backoff_factor: @config.retry_backoff_factor
167
+ ).freeze
168
+ end
169
+
170
+ # Credentials, the signer, and the provider that parses errors travel
171
+ # with each request, because the Faraday connection is shared across
172
+ # contexts.
173
+ def prepare(request)
174
+ request.headers.merge!(@headers).merge!(@provider.headers)
175
+ context = request.options.context ||= {}
176
+ context[ErrorMiddleware::PROVIDER_KEY] = @provider
177
+ context[SigningMiddleware::CONTEXT_KEY] = @signer if @signer
178
+ end
179
+
180
+ def self.setup_timeout(faraday, settings)
181
+ faraday.options.timeout = settings.timeout
182
+ end
183
+
184
+ def self.setup_logging(faraday, settings)
107
185
  faraday.response :logger,
108
- RubyLLM.logger,
109
- bodies: RubyLLM.logger.debug?,
186
+ settings.logger,
187
+ bodies: settings.log_bodies,
110
188
  errors: true,
111
189
  headers: false,
112
190
  log_level: :debug do |logger|
113
- logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}'), '[BASE64 DATA]')
114
- logger.filter(logging_regexp('[-\\d.e,\\s]{100,}'), '[EMBEDDINGS ARRAY]')
191
+ logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}', settings), '[BASE64 DATA]')
192
+ logger.filter(logging_regexp('[-\\d.e,\\s]{100,}', settings), '[EMBEDDINGS ARRAY]')
115
193
  end
116
194
  end
117
195
 
118
- def logging_regexp(pattern)
119
- return Regexp.new(pattern) if @config.log_regexp_timeout.nil? || !Regexp.respond_to?(:timeout)
196
+ def self.logging_regexp(pattern, settings)
197
+ return Regexp.new(pattern) if settings.log_regexp_timeout.nil?
120
198
 
121
- Regexp.new(pattern, timeout: @config.log_regexp_timeout)
199
+ Regexp.new(pattern, timeout: settings.log_regexp_timeout)
122
200
  end
123
201
 
124
- def setup_retry(faraday)
202
+ def self.setup_retry(faraday, settings)
125
203
  faraday.request :retry, {
126
- max: @config.max_retries,
127
- interval: @config.retry_interval,
128
- max_interval: @config.retry_max_interval,
129
- interval_randomness: @config.retry_interval_randomness,
130
- backoff_factor: @config.retry_backoff_factor,
204
+ max: settings.max_retries,
205
+ interval: settings.retry_interval,
206
+ max_interval: settings.retry_max_interval,
207
+ interval_randomness: settings.retry_interval_randomness,
208
+ backoff_factor: settings.retry_backoff_factor,
131
209
  methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS,
132
210
  retry_if: lambda { |env, _exception|
133
211
  env[:method] == :post && idempotent?(env) && !stream_delivered?(env)
@@ -137,34 +215,36 @@ module RubyLLM
137
215
  faraday.use :llm_usage
138
216
  end
139
217
 
140
- def stream_delivered?(env)
218
+ def self.stream_delivered?(env)
141
219
  env[:request]&.context&.dig(STREAM_PROGRESS_KEY, :started)
142
220
  end
143
221
 
144
- def idempotent?(env)
222
+ def self.idempotent?(env)
145
223
  env[:request]&.context&.dig(IDEMPOTENT_KEY) != false
146
224
  end
147
225
 
148
- def setup_middleware(faraday)
226
+ def self.setup_middleware(faraday, settings)
149
227
  faraday.request :multipart
150
228
  faraday.request :json
151
- faraday.response :json
152
- faraday.adapter(@config.faraday_adapter)
153
- faraday.use :llm_errors, provider: @provider
229
+ faraday.use :llm_signing
230
+ faraday.use JsonResponse
231
+ faraday.adapter(settings.adapter)
232
+ faraday.use :llm_errors
154
233
  end
155
234
 
156
- def setup_http_proxy(faraday)
157
- return unless @config.http_proxy
235
+ def self.setup_http_proxy(faraday, settings)
236
+ return unless settings.proxy
158
237
 
159
- faraday.proxy = @config.http_proxy
238
+ faraday.proxy = settings.proxy
160
239
  end
161
240
 
162
- def retry_exceptions
241
+ def self.retry_exceptions
163
242
  [
164
243
  Errno::ETIMEDOUT,
165
244
  Timeout::Error,
166
245
  Faraday::TimeoutError,
167
246
  Faraday::ConnectionFailed,
247
+ Faraday::SSLError,
168
248
  Faraday::RetriableResponse,
169
249
  RubyLLM::RateLimitError,
170
250
  RubyLLM::ServerError,
@@ -173,6 +253,9 @@ module RubyLLM
173
253
  ]
174
254
  end
175
255
 
256
+ private_class_method :setup_timeout, :setup_logging, :logging_regexp, :setup_retry, :stream_delivered?,
257
+ :idempotent?, :setup_middleware, :setup_http_proxy, :retry_exceptions
258
+
176
259
  def set_usage_tracker(request, tracker)
177
260
  context = request.options.context ||= {}
178
261
  context[UsageMiddleware::CONTEXT_KEY] = tracker