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
@@ -37,6 +37,7 @@ module RubyLLM
37
37
  class Protocol
38
38
  include Streaming
39
39
  include BinaryStreaming
40
+ include RequestShapes
40
41
 
41
42
  # The Provider this protocol talks through.
42
43
  attr_reader :provider
@@ -77,6 +78,7 @@ module RubyLLM
77
78
  abstract :render_transcription_payload, :transcription_url, :parse_transcription_response
78
79
  abstract :render_ocr_payload, :ocr_url, :parse_ocr_response
79
80
  abstract :render_rerank_payload, :rerank_url, :parse_rerank_response
81
+ abstract :render_judgment_payload, :judgment_url, :parse_judgment_response
80
82
  abstract :render_count_tokens_payload, :count_tokens_url, :parse_count_tokens_response
81
83
  abstract :render_tokenization_payload, :tokenization_url, :parse_tokenization_response
82
84
  abstract :render_compaction_payload, :compaction_url, :parse_compaction_response
@@ -92,6 +94,8 @@ module RubyLLM
92
94
  def tool_approval_response(tool_call, approved:)
93
95
  Message.new(role: :tool, content: approved ? 'Approved' : 'Denied', tool_call_id: tool_call.id,
94
96
  raw_content: render_tool_approval_response(tool_call, approved:))
97
+ rescue NotImplementedError
98
+ raise Error, "#{@provider.name} doesn't support remote tool approvals"
95
99
  end
96
100
 
97
101
  def complete(messages, tools:, temperature:, provider_options: {}, headers: {}, schema: nil, thinking: nil,
@@ -106,11 +110,15 @@ module RubyLLM
106
110
  stream: block_given?
107
111
  )
108
112
 
113
+ # The caller's block can raise the error of another operation it ran.
114
+ yielding = false
109
115
  track_usage(:chat, on_finish: usage_recorder) do
110
116
  if block_given?
111
117
  stream_response(payload, headers) do |chunk|
112
118
  @usage_tracker.observe(chunk)
119
+ yielding = true
113
120
  yield chunk
121
+ yielding = false
114
122
  end
115
123
  else
116
124
  sync_response payload, headers
@@ -118,6 +126,9 @@ module RubyLLM
118
126
  end
119
127
  rescue NotImplementedError
120
128
  raise Error, "#{@provider.name} doesn't support chat"
129
+ rescue Error => e
130
+ claim_error(e, payload) unless yielding
131
+ raise
121
132
  end
122
133
 
123
134
  def render(messages, tools:, temperature:, provider_options: {}, schema: nil, thinking: nil,
@@ -140,6 +151,7 @@ module RubyLLM
140
151
  payload = apply_compaction(payload, compaction) if compaction
141
152
  payload = Support::Utils.deep_merge(payload, provider_options)
142
153
  payload = apply_provider_tools(payload, provider_tools)
154
+ payload = apply_tool_search(payload)
143
155
  apply_before_request_hooks(payload, before_request)
144
156
  rescue NotImplementedError
145
157
  raise Error, "#{@provider.name} doesn't support chat"
@@ -208,6 +220,9 @@ module RubyLLM
208
220
  parse_count_tokens_response post_count_tokens(payload)
209
221
  rescue NotImplementedError
210
222
  raise Error, "#{@provider.name} doesn't support token counting"
223
+ rescue Error => e
224
+ claim_error(e, payload)
225
+ raise
211
226
  end
212
227
 
213
228
  def list_models
@@ -233,6 +248,9 @@ module RubyLLM
233
248
  end
234
249
  rescue NotImplementedError
235
250
  raise Error, "#{@provider.name} doesn't support manual compaction"
251
+ rescue Error => e
252
+ claim_error(e, payload)
253
+ raise
236
254
  end
237
255
 
238
256
  def embed(text, model:, dimensions:, task_type: nil, title: nil, with: nil, provider_options: {})
@@ -310,11 +328,30 @@ module RubyLLM
310
328
  url = video_request_url(payload)
311
329
  end
312
330
  response = post_video(url, payload)
313
- parse_video_job(response, model:)
331
+ parse_video_job(response, model:, **parse_video_request(payload))
314
332
  rescue NotImplementedError
315
333
  raise Error, "#{@provider.name} doesn't support video generation"
316
334
  end
317
335
 
336
+ # Returns the duration in seconds and the resolution a rendered video
337
+ # request asked for, so the VideoJob keeps them. Protocols whose API
338
+ # names them differently override video_request_settings.
339
+ def parse_video_request(payload)
340
+ settings = video_request_settings(Support::Utils.deep_symbolize_keys(payload))
341
+ { duration: parse_video_seconds(settings[:duration]), resolution: settings[:resolution] }
342
+ end
343
+
344
+ def video_request_settings(request)
345
+ request.slice(:duration, :resolution)
346
+ end
347
+
348
+ def parse_video_seconds(value)
349
+ return value unless value.is_a?(String)
350
+
351
+ seconds = value.delete_suffix('s')
352
+ Integer(seconds, exception: false) || Float(seconds, exception: false)
353
+ end
354
+
318
355
  def post_video(url, payload)
319
356
  @connection.post url, payload, idempotent: false
320
357
  end
@@ -445,6 +482,16 @@ module RubyLLM
445
482
  raise Error, "#{@provider.name} doesn't support explicit content caching"
446
483
  end
447
484
 
485
+ def judge(input, questions:, model:, with: [], provider_options: {}) # :nodoc:
486
+ track_usage(:judgment) do
487
+ payload = render_judgment_payload(input, questions:, model:, with:, provider_options:)
488
+ response = @connection.post judgment_url, payload, usage: @usage_tracker
489
+ parse_judgment_response(response, questions:)
490
+ end
491
+ rescue NotImplementedError
492
+ raise Error, "#{@provider.name} doesn't support judgments"
493
+ end
494
+
448
495
  def find_cache(name)
449
496
  response = @connection.get cache_url(name)
450
497
  parse_cache_response(response.body)
@@ -470,32 +517,108 @@ module RubyLLM
470
517
  @provider.parse_error(response)
471
518
  end
472
519
 
473
- def preprocess_message(message)
474
- return message.without_thinking if foreign_thinking?(message)
475
- return message unless auto_upload_large_files?
476
- return message unless message.role == :user
520
+ # Returns a RequestShape describing +payload+, a conversation request
521
+ # this protocol rendered, as JSON parsed back with String keys.
522
+ # Error#request_shape asks for it when the request fails. The default
523
+ # returns +nil+; protocols that render conversations override it,
524
+ # naming each turn's role and measuring its parts:
525
+ #
526
+ # def parse_request_shape(payload)
527
+ # turns = payload['messages'].each_with_index.map do |message, index|
528
+ # text = RubyLLM::RequestShape::Part.new(kind: :text, size: message['content'].length)
529
+ # RubyLLM::RequestShape::Turn.new(index:, role: message['role'], parts: [text])
530
+ # end
531
+ # RubyLLM::RequestShape.new(turns:)
532
+ # end
533
+ #
534
+ def parse_request_shape(_payload)
535
+ nil
536
+ end
537
+
538
+ # A description must not raise inside the rescue that asks for it.
539
+ def request_shape(payload) # :nodoc:
540
+ payload = Support::Utils.deep_stringify_keys(payload)
541
+ parse_request_shape(payload) if payload.is_a?(Hash)
542
+ rescue StandardError => e
543
+ RubyLLM.logger.debug { "#{self.class} could not describe its request: #{e.class}" }
544
+ nil
545
+ end
546
+
547
+ def preprocess_message(message, unsupported_attachment: nil)
548
+ message = without_foreign_native_content(message) if foreign_native_content?(message)
477
549
  return message if message.attachments.empty?
478
550
 
479
- uploaded = message.attachments.map { |attachment| preprocess_attachment(attachment) }
480
- return message if uploaded == message.attachments
551
+ upload = message.role == :user && auto_upload_large_files?
552
+ prepared = message.attachments.map do |attachment|
553
+ replacement = replace_unsupported_attachment(attachment, unsupported_attachment, message)
554
+ upload ? preprocess_attachment(replacement) : replacement
555
+ end
556
+ return message if prepared == message.attachments
557
+
558
+ message.with_attachments(prepared)
559
+ end
560
+
561
+ def supported_attachment?(_attachment) # :nodoc:
562
+ true
563
+ end
481
564
 
482
- message.with_attachments(uploaded)
565
+ # A provider can delete a file RubyLLM uploaded for an attachment. When
566
+ # a request fails naming such a file, or with a 404 that names none, the
567
+ # attachments forget those uploads so the next request uploads them
568
+ # again. Returns the uploads it forgot.
569
+ def discard_missing_uploads(messages, error)
570
+ scope = provider_upload_scope
571
+ missing_uploads(messages, error, scope).map do |attachment, upload|
572
+ attachment.provider_uploads.delete(scope)
573
+ StoredUploads.new(@provider, attachment.provider_file_store).forget(upload)
574
+ upload
575
+ end
483
576
  end
484
577
 
485
578
  private
486
579
 
487
- # A thinking signature is opaque to every provider but the one that
488
- # issued it, so a message another provider produced replays without
489
- # its thinking. A message with no known producer replays as it is.
490
- def foreign_thinking?(message)
491
- return false unless message.role == :assistant && carries_thinking?(message)
580
+ # An error raised before the request went out answers no request.
581
+ def claim_error(error, payload)
582
+ return if error.request_protocol || error.response.nil? || payload.nil?
583
+
584
+ error.request_protocol = self
585
+ error.request_payload = payload
586
+ end
587
+
588
+ # A thinking signature or a provider-shaped content block belongs to the
589
+ # model that produced it, and one provider can serve models that do not
590
+ # read each other's, so a message another model produced replays
591
+ # without them. A message with no known producer replays as it is.
592
+ def foreign_native_content?(message)
593
+ return false unless message.role == :assistant && carries_native_content?(message)
594
+
595
+ producer = producing_entry(message)
596
+ !producer.nil? && (producer.provider != @provider.slug || !current_model?(producer.model))
597
+ end
598
+
599
+ # Usage records the id a chat resolved its model to, and a message built
600
+ # by hand may name the model by an alias of it.
601
+ def current_model?(model_id)
602
+ return true if @model.nil? || model_id == @model.id
603
+
604
+ RubyLLM.models.find(model_id, provider: @provider.slug, config: @config).id == @model.id
605
+ rescue ModelNotFoundError
606
+ false
607
+ end
492
608
 
493
- producer = producer_slug(message)
494
- !producer.nil? && producer != @provider.slug
609
+ # Another model of this provider gets the raw content any of its models
610
+ # reads, such as a tool search, and none of the producing model's own.
611
+ def without_foreign_native_content(message)
612
+ portable = portable_raw_content(message) if producer_slug(message) == @provider.slug
613
+ message.without_native_content(raw_content: portable)
495
614
  end
496
615
 
497
- def carries_thinking?(message)
498
- return true if message.thinking || message.raw_reasoning
616
+ def portable_raw_content(_message)
617
+ nil
618
+ end
619
+
620
+ def carries_native_content?(message)
621
+ return true if message.thinking || message.raw_reasoning || message.raw_content
499
622
 
500
623
  message.tool_call? && message.tool_calls.each_value.any?(&:thought_signature)
501
624
  end
@@ -503,7 +626,19 @@ module RubyLLM
503
626
  # Only a usage entry names the producer: a model id alone can belong
504
627
  # to several providers.
505
628
  def producer_slug(message)
506
- message.ruby_llm_usage_entries.reverse.find(&:succeeded?)&.provider
629
+ producing_entry(message)&.provider
630
+ end
631
+
632
+ def producing_entry(message)
633
+ message.ruby_llm_usage_entries.reverse.find(&:succeeded?)
634
+ end
635
+
636
+ # A thinking signature can be another provider's opaque blob, and a
637
+ # message restored without its usage could come from anyone, so a
638
+ # protocol that verifies signatures gets back only its own.
639
+ def own_signature(message)
640
+ signature = message.thinking&.signature
641
+ signature if signature && producer_slug(message) == @provider.slug
507
642
  end
508
643
 
509
644
  def resolve_provider_tools_for_request(entries)
@@ -534,11 +669,16 @@ module RubyLLM
534
669
  payload[:tools] = Array(payload[:tools]) + entries
535
670
  end
536
671
 
537
- def track_usage(operation, on_finish: nil)
672
+ def apply_tool_search(payload)
673
+ payload
674
+ end
675
+
676
+ def track_usage(operation, on_finish: nil, model_id: nil)
538
677
  @usage_tracker = Accounting::Usage::Tracker.new(
539
678
  operation:,
540
679
  provider: @provider,
541
680
  model: @model,
681
+ model_id:,
542
682
  config: @config,
543
683
  on_finish:
544
684
  )
@@ -569,8 +709,24 @@ module RubyLLM
569
709
  return attachment if attachment.provider_file?
570
710
  return attachment unless upload_large_attachment?(attachment)
571
711
 
572
- ensure_provider_file_size!(attachment)
573
- Attachment.new(provider_upload(attachment), config: @config)
712
+ Attachment.new(provider_upload(attachment), resolution: attachment.resolution, config: @config)
713
+ end
714
+
715
+ def supported_message_attachment?(_message, attachment)
716
+ supported_attachment?(attachment)
717
+ end
718
+
719
+ def replace_unsupported_attachment(attachment, handler, message)
720
+ return attachment if handler.nil? || supported_message_attachment?(message, attachment)
721
+
722
+ replacement = handler.call(attachment)
723
+ return attachment if replacement.nil?
724
+ unless replacement.is_a?(Attachment)
725
+ raise ArgumentError, 'convert_unsupported_attachments must return a RubyLLM::Attachment or nil'
726
+ end
727
+ raise UnsupportedAttachmentError, replacement.mime_type unless supported_message_attachment?(message, replacement)
728
+
729
+ replacement
574
730
  end
575
731
 
576
732
  # Uploads are memoized per provider on the attachment itself, so a chat
@@ -582,8 +738,9 @@ module RubyLLM
582
738
  upload = attachment.provider_uploads[scope]
583
739
  return upload if upload && !upload.expired?
584
740
 
585
- attachment.provider_uploads[scope] =
741
+ attachment.provider_uploads[scope] = StoredUploads.new(@provider, attachment.provider_file_store).fetch do
586
742
  @provider.upload_file(attachment, **provider_file_upload_options(attachment))
743
+ end
587
744
  end
588
745
 
589
746
  # A file id belongs to the account that uploaded it, so the memo is keyed
@@ -595,6 +752,19 @@ module RubyLLM
595
752
  "#{@provider.slug}:#{Digest::SHA256.hexdigest(credentials.join("\0"))}"
596
753
  end
597
754
 
755
+ def missing_uploads(messages, error, scope)
756
+ uploads = messages.flat_map(&:attachments).filter_map do |attachment|
757
+ upload = attachment.provider_uploads[scope]
758
+ [attachment, upload] if upload
759
+ end
760
+ named = uploads.select { |_, upload| names_file?(error, upload.id) }
761
+ named.empty? && error.response&.status == 404 ? uploads : named
762
+ end
763
+
764
+ def names_file?(error, id)
765
+ [error.message, error.response&.body].any? { |text| text.to_s.include?(id) }
766
+ end
767
+
598
768
  def upload_large_attachment?(attachment)
599
769
  size = attachment.byte_size
600
770
  size && size > default_large_file_upload_threshold && provider_file_attachable?(attachment)
@@ -604,10 +774,6 @@ module RubyLLM
604
774
  Float::INFINITY
605
775
  end
606
776
 
607
- def provider_file_upload_limit
608
- nil
609
- end
610
-
611
777
  def provider_file_attachable?(_attachment)
612
778
  false
613
779
  end
@@ -616,20 +782,6 @@ module RubyLLM
616
782
  {}
617
783
  end
618
784
 
619
- def ensure_provider_file_size!(attachment)
620
- limit = provider_file_upload_limit
621
- return unless limit && attachment.byte_size.to_i > limit
622
-
623
- raise Error, "#{@provider.name} file uploads support files up to #{format_bytes(limit)}; " \
624
- "#{attachment.filename} is #{format_bytes(attachment.byte_size)}"
625
- end
626
-
627
- def format_bytes(bytes)
628
- return 'unknown size' unless bytes
629
-
630
- "#{(bytes.to_f / (1024 * 1024)).round(1)} MB"
631
- end
632
-
633
785
  def validate_paint_inputs!(with:, mask:)
634
786
  return if with.nil? && mask.nil?
635
787
 
@@ -11,7 +11,6 @@ module RubyLLM
11
11
  }.freeze
12
12
 
13
13
  ANTHROPIC_INLINE_REQUEST_LIMIT = 24 * 1024 * 1024
14
- ANTHROPIC_FILE_UPLOAD_LIMIT = 500 * 1024 * 1024
15
14
  CACHE_CONTROL_TYPE = 'ephemeral'
16
15
  PROMPT_CACHE_OPTIONS = %i[ttl].freeze
17
16
  BETA_HEADER = 'anthropic-beta'
@@ -19,6 +18,7 @@ module RubyLLM
19
18
  COMPACTION_EDIT_TYPE = 'compact_20260112'
20
19
  COUNT_TOKENS_KEYS = %i[model messages system tools tool_choice thinking].freeze
21
20
  THINKING_BLOCK_TYPES = %w[thinking redacted_thinking].freeze
21
+ PORTABLE_BLOCK_TYPES = %w[text tool_use].freeze
22
22
 
23
23
  module_function
24
24
 
@@ -64,7 +64,7 @@ module RubyLLM
64
64
  thinking: thinking,
65
65
  citations: citations,
66
66
  caching: caching
67
- ).slice(*COUNT_TOKENS_KEYS)
67
+ ).then { |payload| Tools.apply_tool_search(payload) }.slice(*COUNT_TOKENS_KEYS)
68
68
  end
69
69
 
70
70
  def parse_count_tokens_response(response)
@@ -90,7 +90,7 @@ module RubyLLM
90
90
  # own block in the resulting array.
91
91
  system_messages.flat_map do |msg|
92
92
  blocks = Media.format_content(msg.content, msg.attachments).dup
93
- cache_boundary?(msg, caching:) ? inject_cache_control(blocks, caching:) : blocks
93
+ cache_boundary?(msg, caching:) ? inject_cache_control(blocks, caching:, ttl: msg.cache_ttl) : blocks
94
94
  end
95
95
  end
96
96
 
@@ -100,7 +100,7 @@ module RubyLLM
100
100
  model: model.id,
101
101
  messages: format_messages(chat_messages, thinking:, citations:, caching:),
102
102
  stream: stream,
103
- max_tokens: max_output_tokens || model.max_output_tokens || 4096
103
+ max_tokens: max_output_tokens || model.max_output_tokens || DEFAULT_MAX_OUTPUT_TOKENS
104
104
  }
105
105
 
106
106
  add_thinking_fields(payload, thinking, model)
@@ -115,7 +115,7 @@ module RubyLLM
115
115
  messages.each do |msg|
116
116
  if msg.tool_result?
117
117
  tool_result_blocks << Tools.format_tool_result_block(msg)
118
- inject_cache_control(tool_result_blocks, caching:) if cache_boundary?(msg, caching:)
118
+ inject_cache_control(tool_result_blocks, caching:, ttl: msg.cache_ttl) if cache_boundary?(msg, caching:)
119
119
  next
120
120
  end
121
121
 
@@ -196,10 +196,6 @@ module RubyLLM
196
196
  ANTHROPIC_INLINE_REQUEST_LIMIT
197
197
  end
198
198
 
199
- def provider_file_upload_limit
200
- ANTHROPIC_FILE_UPLOAD_LIMIT
201
- end
202
-
203
199
  def provider_file_attachable?(attachment)
204
200
  attachment.image? || attachment.pdf? || attachment.text?
205
201
  end
@@ -329,6 +325,7 @@ module RubyLLM
329
325
  output_tokens: usage['output_tokens'],
330
326
  cache_read_tokens: extract_cache_read_tokens(data),
331
327
  cache_write_tokens: extract_cache_write_tokens(data),
328
+ cache_write_tokens_by_ttl: extract_cache_write_by_ttl(data),
332
329
  thinking_tokens: thinking_tokens,
333
330
  server_tool_use: usage['server_tool_use'],
334
331
  finish_reason: normalize_finish_reason(data['stop_reason']),
@@ -352,12 +349,20 @@ module RubyLLM
352
349
  end
353
350
  end
354
351
 
352
+ def portable_raw_content(message)
353
+ blocks = message.raw_content
354
+ return unless blocks.is_a?(Array) && blocks.any? { |block| Tools.tool_search_block?(block) }
355
+
356
+ kept = blocks.reject { |block| THINKING_BLOCK_TYPES.include?(block['type']) }
357
+ kept if kept.all? { |block| PORTABLE_BLOCK_TYPES.include?(block['type']) || Tools.tool_search_block?(block) }
358
+ end
359
+
355
360
  # Turns that used server tools replay their provider-shaped blocks
356
361
  # verbatim: the API requires the tool_use/result blocks and their
357
362
  # citations back exactly as returned.
358
363
  def format_raw_assistant_message(msg, caching: nil)
359
364
  blocks = msg.raw_content.dup
360
- inject_cache_control(blocks, caching:) if cache_boundary?(msg, caching:)
365
+ inject_cache_control(blocks, caching:, ttl: msg.cache_ttl) if cache_boundary?(msg, caching:)
361
366
 
362
367
  { role: 'assistant', content: blocks }
363
368
  end
@@ -366,7 +371,7 @@ module RubyLLM
366
371
  content_blocks = msg.role == :assistant ? format_thinking_blocks(msg) : []
367
372
 
368
373
  append_formatted_content(content_blocks, msg, citations: citations)
369
- inject_cache_control(content_blocks, caching:) if cache_boundary?(msg, caching:)
374
+ inject_cache_control(content_blocks, caching:, ttl: msg.cache_ttl) if cache_boundary?(msg, caching:)
370
375
 
371
376
  {
372
377
  role: convert_role(msg.role),
@@ -386,7 +391,7 @@ module RubyLLM
386
391
  input: tool_call.arguments
387
392
  }
388
393
  end
389
- inject_cache_control(content_blocks, caching:) if cache_boundary?(msg, caching:)
394
+ inject_cache_control(content_blocks, caching:, ttl: msg.cache_ttl) if cache_boundary?(msg, caching:)
390
395
 
391
396
  {
392
397
  role: 'assistant',
@@ -404,24 +409,16 @@ module RubyLLM
404
409
  blocks = msg.raw_reasoning['anthropic'] if msg.raw_reasoning.is_a?(Hash)
405
410
  return Support::Utils.deep_dup(blocks) if blocks
406
411
 
407
- [build_thinking_block(msg.thinking)].compact
412
+ [build_thinking_block(msg)].compact
408
413
  end
409
414
 
410
- def build_thinking_block(thinking)
411
- return nil unless thinking
415
+ # Claude takes a thinking block back only with the signature it issued.
416
+ def build_thinking_block(msg)
417
+ signature = own_signature(msg) if msg.thinking
418
+ return unless signature
412
419
 
413
- if thinking.text
414
- {
415
- type: 'thinking',
416
- thinking: thinking.text,
417
- signature: thinking.signature
418
- }.compact
419
- elsif thinking.signature
420
- {
421
- type: 'redacted_thinking',
422
- data: thinking.signature
423
- }
424
- end
420
+ text = msg.thinking.text
421
+ text ? { type: 'thinking', thinking: text, signature: } : { type: 'redacted_thinking', data: signature }
425
422
  end
426
423
 
427
424
  def append_formatted_content(content_blocks, msg, citations: false)
@@ -432,22 +429,23 @@ module RubyLLM
432
429
  caching != false && message.cache_until_here?
433
430
  end
434
431
 
435
- def inject_cache_control(blocks, caching: nil)
432
+ def inject_cache_control(blocks, caching: nil, ttl: nil)
436
433
  return blocks if blocks.empty?
437
434
 
438
435
  last = blocks.last
439
436
  return blocks if last.is_a?(Hash) && (last[:cache_control] || last['cache_control'])
440
437
  return blocks unless last.is_a?(Hash)
441
438
 
442
- blocks[-1] = last.merge(cache_control: prompt_cache_control(caching))
439
+ blocks[-1] = last.merge(cache_control: prompt_cache_control(caching, ttl:))
443
440
  blocks
444
441
  end
445
442
 
446
- def prompt_cache_control(caching = nil)
443
+ def prompt_cache_control(caching = nil, ttl: nil)
447
444
  options = prompt_cache_options(caching)
445
+ ttl ||= options[:ttl]
448
446
 
449
447
  { type: CACHE_CONTROL_TYPE }.tap do |control|
450
- control[:ttl] = options[:ttl] if options[:ttl]
448
+ control[:ttl] = ttl if ttl
451
449
  end
452
450
  end
453
451
 
@@ -485,7 +483,8 @@ module RubyLLM
485
483
 
486
484
  def build_thinking_payload(thinking, model, max_tokens)
487
485
  return nil unless thinking&.enabled?
488
- return { thinking: { type: 'disabled' } } if thinking.enabled == false
486
+
487
+ return { thinking: { type: thinking_off_type(model) } } if thinking.enabled == false
489
488
 
490
489
  effort = resolve_effort(thinking)
491
490
  return nil if effort == 'none'
@@ -533,6 +532,14 @@ module RubyLLM
533
532
  budget.clamp(minimum, [max_tokens - 1, minimum].max)
534
533
  end
535
534
 
535
+ def thinking_off_type(model)
536
+ between_tools_off?(model.id) ? 'between_tools' : 'disabled'
537
+ end
538
+
539
+ def between_tools_off?(model_id)
540
+ @provider.class.respond_to?(:between_tools_off?) && @provider.class.between_tools_off?(model_id)
541
+ end
542
+
536
543
  def resolve_effort(thinking)
537
544
  effort = thinking.effort.to_s
538
545
  effort.empty? ? nil : effort
@@ -7,11 +7,17 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
+ def supported_attachment?(attachment)
11
+ attachment.provider_file? || %i[image pdf text].include?(attachment.type)
12
+ end
13
+
10
14
  def format_content(content, attachments = [], citations: false)
11
15
  parts = []
12
16
  parts << format_text(content) unless content.nil? || content.empty?
13
17
 
14
18
  attachments.each do |attachment|
19
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
20
+
15
21
  if attachment.provider_file?
16
22
  parts << format_provider_file(attachment, citations: citations)
17
23
  next
@@ -24,8 +30,6 @@ module RubyLLM
24
30
  parts << format_pdf(attachment, citations: citations)
25
31
  when :text
26
32
  parts << (citations ? format_text_document(attachment) : format_text_file(attachment))
27
- else
28
- raise UnsupportedAttachmentError, attachment.mime_type
29
33
  end
30
34
  end
31
35
 
@@ -102,6 +102,16 @@ module RubyLLM
102
102
  breakdown.values.compact.sum
103
103
  end
104
104
 
105
+ def extract_cache_write_by_ttl(data)
106
+ breakdown = message_usage(data)['cache_creation'] || delta_usage(data)['cache_creation']
107
+ return unless breakdown.is_a?(Hash)
108
+
109
+ breakdown.filter_map do |key, count|
110
+ ttl = key[/\Aephemeral_(.+)_input_tokens\z/, 1]
111
+ [ttl, count] if ttl
112
+ end.to_h
113
+ end
114
+
105
115
  def message_usage(data)
106
116
  aggregate_usage(data.dig('message', 'usage'))
107
117
  end
@@ -121,7 +131,12 @@ module RubyLLM
121
131
 
122
132
  usage.merge(ITERATION_TOKEN_KEYS.to_h do |key|
123
133
  [key, iterations.sum { |iteration| iteration[key].to_i }]
124
- end)
134
+ end, { 'cache_creation' => iteration_cache_creation(iterations) }.compact)
135
+ end
136
+
137
+ def iteration_cache_creation(iterations)
138
+ breakdowns = iterations.map { |iteration| iteration['cache_creation'] }.grep(Hash)
139
+ breakdowns.reduce { |total, breakdown| total.merge(breakdown) { |_, sum, count| sum.to_i + count.to_i } }
125
140
  end
126
141
  end
127
142
  end