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
@@ -6,6 +6,9 @@ require 'ruby_llm/error'
6
6
  module RubyLLM
7
7
  module Transport # :nodoc:
8
8
  class ErrorMiddleware < Faraday::Middleware # :nodoc: all
9
+ PROVIDER_KEY = :ruby_llm_provider
10
+ STREAM_RESET_KEY = :ruby_llm_stream_reset
11
+
9
12
  def initialize(app, options = {})
10
13
  super(app)
11
14
  @provider = options[:provider]
@@ -17,25 +20,37 @@ module RubyLLM
17
20
  def call(env)
18
21
  env[:streaming_error_response] = nil
19
22
  env[:streaming_state] = nil
23
+ context = env[:request]&.context
24
+ context&.[](STREAM_RESET_KEY)&.call
25
+ provider = context&.[](PROVIDER_KEY) || @provider
20
26
  @app.call(env).on_complete do |response|
21
- apply_retry_delay(response)
22
- self.class.parse_error(provider: @provider, response: streaming_error_response(response))
27
+ apply_retry_delay(response, provider)
28
+ self.class.parse_error(provider:, response: streaming_error_response(response))
23
29
  end
24
30
  end
25
31
 
26
32
  private
27
33
 
28
34
  # The retry middleware only reads the standard Retry-After header, so
29
- # provider-specific rate-limit headers are normalized into it here,
30
- # where the provider is known.
31
- def apply_retry_delay(response)
35
+ # other retry hints are normalized into seconds here.
36
+ def apply_retry_delay(response, provider)
32
37
  status = response.respond_to?(:status) ? response.status : response[:status]
33
- return unless status == 429
38
+ return unless status && status >= 400
34
39
 
35
40
  headers = response[:response_headers]
36
- if @provider && !headers['Retry-After'] && (delay = @provider.retry_delay(response))
37
- headers['Retry-After'] = delay.to_s
38
- end
41
+ return unless headers && !headers['Retry-After']
42
+
43
+ delay = millisecond_retry_delay(headers) || provider_retry_delay(provider, response, status)
44
+ headers['Retry-After'] = delay.to_s if delay
45
+ end
46
+
47
+ def provider_retry_delay(provider, response, status)
48
+ provider&.retry_delay(response) if status == 429
49
+ end
50
+
51
+ def millisecond_retry_delay(headers)
52
+ value = Float(headers['retry-after-ms'], exception: false)
53
+ value / 1000 if value&.finite? && value >= 0
39
54
  end
40
55
 
41
56
  def streaming_error_response(response)
@@ -56,7 +71,7 @@ module RubyLLM
56
71
  /context window/i,
57
72
  /exceeds?.*context size/i,
58
73
  /maximum context/i,
59
- /request too large/i,
74
+ /request (?:was )?too large/i,
60
75
  /too many tokens/i,
61
76
  /token count exceeds/i,
62
77
  /input[_\s-]?token/i,
@@ -70,25 +85,41 @@ module RubyLLM
70
85
  /rate limit/i,
71
86
  /per minute/i,
72
87
  /per hour/i,
73
- /per day/i
88
+ /per day/i,
89
+ /quota exceeded/i,
90
+ /wait before trying again/i
91
+ ].freeze
92
+
93
+ OVERLOAD_PATTERNS = [
94
+ /currently overloaded/i
95
+ ].freeze
96
+
97
+ PAYMENT_REQUIRED_PATTERNS = [
98
+ /credit balance is too low/i
99
+ ].freeze
100
+
101
+ AUTHENTICATION_PATTERNS = [
102
+ /api key not valid/i,
103
+ /incorrect api key/i,
104
+ /security token included in the request is (?:invalid|expired)/i
74
105
  ].freeze
75
106
 
76
107
  def parse_error(provider:, response:)
108
+ return if (200..399).cover?(response.status)
109
+
77
110
  message = provider&.parse_error(response)
78
111
 
79
112
  case response.status
80
- when 200..399
81
- message
82
113
  when 400
83
- raise ContextLengthExceededError.new(message, response:) if context_length_exceeded?(message)
84
-
85
- raise BadRequestError.new(message, response:)
114
+ raise_bad_request(message, response)
86
115
  when 401
87
116
  raise UnauthorizedError.new(message, response:)
88
117
  when 402
89
118
  raise PaymentRequiredError.new(message, response:)
90
119
  when 403
91
- raise ForbiddenError.new(message, response:)
120
+ raise_forbidden(message, response)
121
+ when 413
122
+ raise_too_large(message, response)
92
123
  when 429
93
124
  raise RateLimitError.new(message, response:) if rate_limited?(message)
94
125
  raise ContextLengthExceededError.new(message, response:) if context_length_exceeded?(message)
@@ -107,21 +138,56 @@ module RubyLLM
107
138
 
108
139
  private
109
140
 
110
- # Providers hand back whatever their error body holds, which is not
111
- # always a String: bedrock-mantle nests code, message, and type in a
112
- # Hash. Match on the rendered text so any shape classifies.
141
+ def raise_bad_request(message, response)
142
+ raise UnauthorizedError.new(message, response:) if authentication_failed?(message)
143
+ raise ContextLengthExceededError.new(message, response:) if context_length_exceeded?(message)
144
+ raise RateLimitError.new(message, response:) if rate_limited?(message)
145
+ raise OverloadedError.new(message, response:) if overloaded?(message)
146
+ raise PaymentRequiredError.new(message, response:) if payment_required?(message)
147
+
148
+ raise BadRequestError.new(message, response:)
149
+ end
150
+
151
+ def raise_forbidden(message, response)
152
+ raise UnauthorizedError.new(message, response:) if authentication_failed?(message)
153
+
154
+ raise ForbiddenError.new(message, response:)
155
+ end
156
+
157
+ def raise_too_large(message, response)
158
+ raise ContextLengthExceededError.new(message, response:) if context_length_exceeded?(message)
159
+
160
+ raise Error.new(message, response:)
161
+ end
162
+
113
163
  def context_length_exceeded?(message)
114
- text = message.to_s
115
- return false if text.empty?
164
+ matches?(message, CONTEXT_LENGTH_PATTERNS)
165
+ end
166
+
167
+ def overloaded?(message)
168
+ matches?(message, OVERLOAD_PATTERNS)
169
+ end
116
170
 
117
- CONTEXT_LENGTH_PATTERNS.any? { |pattern| text.match?(pattern) }
171
+ def payment_required?(message)
172
+ matches?(message, PAYMENT_REQUIRED_PATTERNS)
118
173
  end
119
174
 
120
175
  def rate_limited?(message)
176
+ matches?(message, RATE_LIMIT_PATTERNS)
177
+ end
178
+
179
+ def authentication_failed?(message)
180
+ matches?(message, AUTHENTICATION_PATTERNS)
181
+ end
182
+
183
+ # Providers hand back whatever their error body holds, which is not
184
+ # always a String: bedrock-mantle nests code, message, and type in a
185
+ # Hash. Match on the rendered text so any shape classifies.
186
+ def matches?(message, patterns)
121
187
  text = message.to_s
122
188
  return false if text.empty?
123
189
 
124
- RATE_LIMIT_PATTERNS.any? { |pattern| text.match?(pattern) }
190
+ patterns.any? { |pattern| text.match?(pattern) }
125
191
  end
126
192
  end
127
193
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Transport
5
+ # Interprets a text/event-stream as the HTML standard describes, fed in
6
+ # whatever pieces the network delivers. Each dispatched event is yielded
7
+ # as its type, data, and last event ID. The last event ID and the
8
+ # reconnection time the server asked for stay readable between events.
9
+ class EventStreamParser # :nodoc:
10
+ BOM = "\xEF\xBB\xBF".b.freeze
11
+ LINE_BREAK = /[\r\n]/
12
+ DIGITS = /\A[0-9]+\z/
13
+ CR = 13
14
+ LF = 10
15
+ SPACE = 32
16
+
17
+ attr_reader :last_event_id, :reconnection_time
18
+
19
+ def initialize
20
+ @buffer = String.new(encoding: Encoding::BINARY)
21
+ @position = 0
22
+ @searched = 0
23
+ @data = +''
24
+ @event_type = +''
25
+ @id_buffer = +''
26
+ @last_event_id = +''
27
+ @reconnection_time = nil
28
+ @started = false
29
+ @after_cr = false
30
+ end
31
+
32
+ def feed(chunk, &)
33
+ compact
34
+ @buffer << (chunk.encoding == Encoding::BINARY ? chunk : chunk.b)
35
+ return unless start
36
+
37
+ skip_line_feed_after_carriage_return
38
+ while (index = @buffer.index(LINE_BREAK, @searched))
39
+ line = @buffer.byteslice(@position, index - @position)
40
+ @position = @searched = line_end(index)
41
+ process_line(line, &)
42
+ end
43
+ @searched = @buffer.bytesize
44
+ end
45
+
46
+ private
47
+
48
+ def compact
49
+ return if @position.zero?
50
+
51
+ @buffer = @buffer.byteslice(@position, @buffer.bytesize - @position)
52
+ @searched -= @position
53
+ @position = 0
54
+ end
55
+
56
+ # The stream decodes as UTF-8, which drops one byte order mark at its
57
+ # very start, so the first bytes wait until they can be told apart.
58
+ def start
59
+ return true if @started
60
+ return false if @buffer.bytesize < BOM.bytesize && BOM.start_with?(@buffer)
61
+
62
+ @position = @searched = BOM.bytesize if @buffer.start_with?(BOM)
63
+ @started = true
64
+ end
65
+
66
+ # A carriage return that ended the previous piece may be the first
67
+ # half of a CRLF pair.
68
+ def skip_line_feed_after_carriage_return
69
+ return unless @after_cr && @buffer.bytesize > @position
70
+
71
+ @after_cr = false
72
+ @position = @searched = @position + 1 if @buffer.getbyte(@position) == LF
73
+ end
74
+
75
+ def line_end(index)
76
+ return index + 1 unless @buffer.getbyte(index) == CR
77
+ return index + 2 if @buffer.getbyte(index + 1) == LF
78
+
79
+ @after_cr = index + 1 == @buffer.bytesize
80
+ index + 1
81
+ end
82
+
83
+ def process_line(line, &)
84
+ return dispatch(&) if line.empty?
85
+ return if line.start_with?(':')
86
+
87
+ colon = line.index(':')
88
+ return process_field(line, +'') unless colon
89
+
90
+ offset = line.getbyte(colon + 1) == SPACE ? colon + 2 : colon + 1
91
+ process_field(line.byteslice(0, colon), line.byteslice(offset, line.bytesize - offset))
92
+ end
93
+
94
+ def process_field(field, value)
95
+ case field
96
+ when 'event' then @event_type = decode(value)
97
+ when 'data' then @data << decode(value) << "\n"
98
+ when 'id' then @id_buffer = decode(value) unless value.include?("\0")
99
+ when 'retry' then @reconnection_time = value.to_i if value.match?(DIGITS)
100
+ end
101
+ end
102
+
103
+ def dispatch
104
+ @last_event_id = @id_buffer
105
+ data = @data
106
+ type = @event_type
107
+ @data = +''
108
+ @event_type = +''
109
+ return if data.empty?
110
+
111
+ data.delete_suffix!("\n")
112
+ yield type.empty? ? 'message' : type, data, @last_event_id
113
+ end
114
+
115
+ def decode(bytes)
116
+ text = bytes.force_encoding(Encoding::UTF_8)
117
+ text.valid_encoding? ? text : text.scrub
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module RubyLLM
7
+ module Transport
8
+ class JsonResponse < Faraday::Middleware # :nodoc:
9
+ def initialize(app, parser_options: {}, preserve_raw: false)
10
+ super(app)
11
+ @parser_options = parser_options || {}
12
+ @preserve_raw = preserve_raw
13
+ end
14
+
15
+ def on_complete(env)
16
+ body = env[:body]
17
+ return unless body.respond_to?(:to_str) && json_response?(env)
18
+
19
+ env[:raw_body] = body if @preserve_raw
20
+ env[:body] = body.strip.empty? ? nil : JSON.parse(body, **@parser_options)
21
+ rescue StandardError, SyntaxError => e
22
+ raise Faraday::ParsingError.new(e, env[:response])
23
+ end
24
+
25
+ private
26
+
27
+ def json_response?(env)
28
+ content_type = env[:response_headers]['content-type'].to_s.split(';', 2).first
29
+ content_type&.match?(/\bjson$/)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module RubyLLM
6
+ module Transport # :nodoc:
7
+ # Sits inside Faraday retry middleware so every transport attempt is
8
+ # signed when it is sent, over the body it sends. A signature computed
9
+ # once before the first attempt can expire before a retry goes out.
10
+ class SigningMiddleware < Faraday::Middleware # :nodoc: all
11
+ CONTEXT_KEY = :ruby_llm_signer
12
+
13
+ def call(env)
14
+ signer = env.request.context&.[](CONTEXT_KEY)
15
+ sign(env.request_headers, signer.call(env)) if signer
16
+ @app.call(env)
17
+ end
18
+
19
+ private
20
+
21
+ # A nil value removes a header an earlier attempt was signed with.
22
+ def sign(headers, signed)
23
+ signed.each { |name, value| value.nil? ? headers.delete(name) : headers[name] = value }
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ Faraday::Middleware.register_middleware(llm_signing: RubyLLM::Transport::SigningMiddleware)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module RubyLLM
4
4
  # The version of the ruby_llm gem, as a string.
5
- VERSION = '2.0.0'
5
+ VERSION = '2.1.0'
6
6
  end
@@ -49,7 +49,8 @@ module RubyLLM
49
49
  # vocabulary, such as durations and resolutions, and merges them into
50
50
  # the request as-is. +context:+ supplies a Context whose configuration
51
51
  # replaces the global one. +metadata:+ is included in the
52
- # instrumentation payload.
52
+ # instrumentation payload. +owner:+ attributes the usage to a record,
53
+ # such as a user, and wins over RubyLLM.with_usage_owner.
53
54
  #
54
55
  # video = RubyLLM.animate("a hummingbird in slow motion", model: "veo-3.1-fast-generate-preview")
55
56
  #
@@ -68,13 +69,14 @@ module RubyLLM
68
69
  with: nil,
69
70
  extend: nil,
70
71
  provider_options: {},
71
- metadata: nil)
72
+ metadata: nil,
73
+ owner: nil)
72
74
  config = context&.config || RubyLLM.config
73
75
  payload = { model:, prompt:, provider_options:, metadata: }
74
76
 
75
77
  RubyLLM.instrument('video.ruby_llm', payload, config: config) do |event|
76
78
  job = VideoJob.animate_later(prompt, model:, provider:, assume_model_exists:,
77
- context:, with:, extend:, provider_options:, metadata:)
79
+ context:, with:, extend:, provider_options:, metadata:, owner:)
78
80
  event[:model] = job.model
79
81
  event[:job_id] = job.id
80
82
  job.wait
@@ -27,9 +27,20 @@ module RubyLLM
27
27
  # The provider's raw response from the last submit or poll.
28
28
  attr_reader :raw
29
29
 
30
+ # The clip length in seconds the request asked for, or +nil+ when it
31
+ # left the length to the provider's default.
32
+ attr_reader :duration
33
+
34
+ # The resolution the request asked for, as the provider spells it,
35
+ # such as <tt>"720p"</tt> or <tt>"1280x720"</tt>, or +nil+ when it left
36
+ # the resolution to the provider's default.
37
+ attr_reader :resolution
38
+
30
39
  # Submits a video generation job and returns a VideoJob without
31
40
  # waiting for the result. Most code calls this through
32
41
  # RubyLLM.animate_later. Takes the same arguments as Video.animate.
42
+ # The job records its usage, attributed to +owner:+ or the owner of
43
+ # RubyLLM.with_usage_owner, once it finishes.
33
44
  #
34
45
  # job = RubyLLM.animate_later("a hummingbird in slow motion")
35
46
  # job.id # => "0eb6910f-a353-4699-9d1e-6a4f7a5b39e2"
@@ -43,7 +54,8 @@ module RubyLLM
43
54
  with: nil,
44
55
  extend: nil,
45
56
  provider_options: {},
46
- metadata: nil)
57
+ metadata: nil,
58
+ owner: nil)
47
59
  config = context&.config || RubyLLM.config
48
60
  model ||= config.default_video_model
49
61
  model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
@@ -59,19 +71,27 @@ module RubyLLM
59
71
  }
60
72
 
61
73
  RubyLLM.instrument('video_job.ruby_llm', payload, config: config) do |event|
62
- job = provider_instance.animate_later(prompt, model:, with:, extend:, provider_options:)
74
+ job = Accounting::Usage.owned_by(owner) do
75
+ provider_instance.animate_later(prompt, model:, with:, extend:, provider_options:)
76
+ end
63
77
  event[:job_id] = job.id
64
78
  job
65
79
  end
66
80
  end
67
81
 
68
- def initialize(id:, protocol:, model: nil, status: :pending, raw: nil, error: nil) # :nodoc:
82
+ def initialize(id:, protocol:, model: nil, status: :pending, raw: nil, error: nil, # :nodoc:
83
+ reported_cost: nil, duration: nil, resolution: nil)
69
84
  @id = id
70
85
  @protocol = protocol
71
86
  @model = model
72
87
  @status = status
73
88
  @raw = raw
74
89
  @error = error
90
+ @reported_cost = reported_cost
91
+ @duration = duration
92
+ @resolution = resolution
93
+ @usage_owner = Accounting::Usage.owner
94
+ record_usage
75
95
  end
76
96
 
77
97
  # Returns +true+ while the provider is still rendering the video.
@@ -104,9 +124,22 @@ module RubyLLM
104
124
  @status = state.fetch(:status)
105
125
  @raw = state[:raw]
106
126
  @error = state[:error]
127
+ @reported_cost = state[:reported_cost]
128
+ record_usage
107
129
  self
108
130
  end
109
131
 
132
+ # Returns the Cost the provider reported for the job. Its total is
133
+ # +nil+ while the job is pending or when the provider reports no
134
+ # price.
135
+ #
136
+ # job.wait
137
+ # job.cost.total # => 0.05
138
+ #
139
+ def cost
140
+ Cost.new(tokens: Tokens.new(reported_cost: @reported_cost))
141
+ end
142
+
110
143
  # Polls the job until it finishes, then returns self. Raises Error
111
144
  # when the job fails or +timeout+ elapses first. +timeout+ and
112
145
  # +interval+ are seconds and default to the configured
@@ -117,9 +150,10 @@ module RubyLLM
117
150
  deadline = monotonic_time + timeout
118
151
 
119
152
  until done?
120
- raise Error, "Video generation timed out after #{timeout} seconds" if monotonic_time > deadline
153
+ remaining = deadline - monotonic_time
154
+ raise Error, "Video generation timed out after #{timeout} seconds" unless remaining.positive?
121
155
 
122
- sleep interval
156
+ sleep [interval, remaining].min
123
157
  refresh
124
158
  end
125
159
  raise Error, "Video generation failed: #{error}" if failed?
@@ -139,6 +173,15 @@ module RubyLLM
139
173
 
140
174
  private
141
175
 
176
+ def record_usage
177
+ return if pending? || @usage_recorded
178
+
179
+ @usage_recorded = true
180
+ entry = Accounting::Usage::Entry.new(operation: :video, provider: @protocol.provider.slug, model:,
181
+ status: completed? ? :succeeded : :failed, cost:, owner: @usage_owner)
182
+ Accounting::Usage.report(entry, config: @protocol.config)
183
+ end
184
+
142
185
  def monotonic_time
143
186
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
187
  end