ruby_llm 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Evaluation
5
+ # The recorded outcome of one case and repetition, including failed attempts.
6
+ class Trial
7
+ include Support::Inspectable
8
+ include Accounting::Usage::Result
9
+
10
+ # Returns the case whose inputs were executed.
11
+ attr_reader :test_case
12
+ # Returns the one-based repetition number.
13
+ attr_reader :repetition
14
+ # Returns the original value returned by perform, when it completed.
15
+ attr_reader :result
16
+ # Returns the primary output extracted from the returned value.
17
+ attr_reader :output
18
+ # Returns the frozen evidence supplied to evaluators.
19
+ attr_reader :evidence
20
+ # Returns each criterion's Evaluation::Result.
21
+ attr_reader :evaluations
22
+ # Returns the number of Ruby assertions attempted.
23
+ attr_reader :assertion_count
24
+ # Returns an assertion failure, or nil.
25
+ attr_reader :assertion_failure
26
+ # Returns an execution or evidence-conversion error, or nil.
27
+ attr_reader :error
28
+ # Returns elapsed wall-clock seconds, including evaluators.
29
+ attr_reader :duration
30
+
31
+ def initialize(test_case:, repetition:, task_usage: [], evaluator_usage: [], **attributes) # :nodoc:
32
+ @test_case = test_case
33
+ @repetition = repetition
34
+ @task_usage = task_usage.freeze
35
+ @evaluator_usage = evaluator_usage.freeze
36
+ @ruby_llm_usage_entries = (task_usage + evaluator_usage).freeze
37
+ attributes.each { |key, value| instance_variable_set("@#{key}", value) }
38
+ @evaluations = Array(@evaluations).freeze
39
+ freeze
40
+ end
41
+
42
+ # Returns a Tokens aggregate for every provider attempt made during this trial.
43
+ def tokens
44
+ ruby_llm_usage_tokens
45
+ end
46
+
47
+ # Returns a Cost aggregate for this trial, including task and evaluator requests.
48
+ def cost
49
+ ruby_llm_usage_cost
50
+ end
51
+
52
+ # Returns the task's tokens, including setup, assertions, and teardown requests.
53
+ def task_tokens
54
+ Tokens.aggregate(@task_usage.map(&:tokens))
55
+ end
56
+
57
+ # Returns the tokens used by the evaluators and their tools.
58
+ def evaluator_tokens
59
+ Tokens.aggregate(@evaluator_usage.map(&:tokens))
60
+ end
61
+
62
+ # Returns the task's cost, including setup, assertions, and teardown requests.
63
+ def task_cost
64
+ Cost.aggregate(@task_usage.map(&:cost), complete: @task_usage.all?(&:cost_available?))
65
+ end
66
+
67
+ # Returns the cost of the evaluators and their tools.
68
+ def evaluator_cost
69
+ Cost.aggregate(@evaluator_usage.map(&:cost), complete: @evaluator_usage.all?(&:cost_available?))
70
+ end
71
+
72
+ # Returns :passed, :failed, :measured, :unassessed, or :error. Empty trials are never passes.
73
+ def status
74
+ statuses = evaluations.map(&:status)
75
+ statuses << :error if error
76
+ statuses << :failed if assertion_failure
77
+ statuses << :measured if evaluations.empty? && assertion_count.to_i.zero?
78
+ %i[error failed unassessed measured].find { |status| statuses.include?(status) } || :passed
79
+ end
80
+
81
+ # Returns whether every assessment passed and no error occurred.
82
+ def passed?
83
+ status == :passed
84
+ end
85
+
86
+ # Returns the portable trial record, excluding the live application object.
87
+ def to_h
88
+ { case: test_case.to_h, repetition:, status:, evidence:, evaluations: evaluations.map(&:to_h),
89
+ assertion_count:, assertion_failure: assertion_failure&.message, duration:,
90
+ error: error && { class: error.class.name, message: error.message },
91
+ tokens: tokens.to_h, cost: cost.to_h,
92
+ task_tokens: task_tokens.to_h, evaluator_tokens: evaluator_tokens.to_h,
93
+ task_cost: task_cost.to_h, evaluator_cost: evaluator_cost.to_h }
94
+ end
95
+
96
+ def inspect_attributes # :nodoc:
97
+ { case: test_case.name, repetition:, status: }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Defines reusable evaluations with a dataset, semantic criteria, and Ruby assertions.
5
+ # Datasets default to app/evals/<class_name>.yml, .yaml, .json, or .jsonl.
6
+ # Without declared criteria, evaluates correctness against each case's expected_output.
7
+ #
8
+ # class SupportEvaluation < RubyLLM::Evaluation
9
+ # def perform(input)
10
+ # SupportAgent.new.ask(input)
11
+ # end
12
+ # end
13
+ #
14
+ # SupportEvaluation.run.save("tmp/support.json")
15
+ class Evaluation
16
+ CORRECTNESS = 'The answer agrees with the expected output'
17
+ private_constant :CORRECTNESS
18
+
19
+ class << self
20
+ def inherited(subclass) # :nodoc:
21
+ super
22
+ subclass.instance_variable_set(:@dataset, @dataset)
23
+ subclass.instance_variable_set(:@evaluator, @evaluator)
24
+ subclass.instance_variable_set(:@definitions, definitions.dup)
25
+ subclass.instance_variable_set(:@adapters, adapters.dup)
26
+ end
27
+
28
+ # Sets the dataset path, enumerable of cases, or block returning cases.
29
+ # With no argument, returns the configured source. The default is discovered by class name.
30
+ def dataset(source = nil, &block)
31
+ return @dataset if source.nil? && !block
32
+
33
+ raise ArgumentError, 'Pass a dataset or a block, not both' if source && block
34
+
35
+ @dataset = block || source
36
+ end
37
+
38
+ # Sets the default evaluator: a model, Agent class, or Judge class.
39
+ # Model keywords accept provider, protocol, and context as Chat does.
40
+ # Without a declaration, uses the default chat model and built-in reviewer.
41
+ # Pass false to disable model grading and run only Ruby assertions.
42
+ def evaluator(target = nil, **options)
43
+ if target.nil? && options.empty?
44
+ @evaluator = Evaluator.new if @evaluator.nil?
45
+ return @evaluator
46
+ end
47
+ raise ArgumentError, 'A disabled evaluator cannot have model options' if target == false && options.any?
48
+
49
+ @evaluator = target == false ? false : Evaluator.new(target, **options)
50
+ end
51
+
52
+ # Declares a semantic criterion. A minimum applies to a native probability or score;
53
+ # without one, numeric decisions are measured but do not count as passes.
54
+ # Omit instructions to set a minimum on a question already defined by a Judge.
55
+ # Declared criteria replace implicit correctness. Use evaluation :correctness
56
+ # without instructions to include the built-in reference comparison explicitly.
57
+ # Supply evaluator to override the class evaluator for this criterion.
58
+ def evaluation(name, instructions = nil, minimum: nil, evaluator: nil)
59
+ validate_criterion(name, minimum)
60
+ name = name.to_sym
61
+ @declared_names ||= []
62
+ raise ArgumentError, "Duplicate evaluation: #{name}" if @declared_names.include?(name)
63
+
64
+ @declared_names << name
65
+ backend = Evaluator.new(evaluator) if evaluator
66
+ definitions[name] = { name:, instructions: instructions&.dup&.freeze, minimum:, evaluator: backend }.freeze
67
+ end
68
+
69
+ # Converts a custom result into evaluation evidence. The original stays available as result.
70
+ #
71
+ # adapt Invoice do |invoice|
72
+ # invoice.attributes.slice("total", "currency")
73
+ # end
74
+ def adapt(type, &block)
75
+ raise ArgumentError, 'An adapter needs a class and a block' unless type.is_a?(Module) && block
76
+
77
+ adapters[type] = block
78
+ end
79
+
80
+ # Returns the dataset cases without running the application or its evaluators.
81
+ # Supply only to select one or more case names, raising when any name is missing.
82
+ def cases(dataset: nil, only: nil)
83
+ loaded = Dataset.load(dataset || self.dataset, name: name)
84
+ return loaded if only.nil?
85
+
86
+ names = Array(only).map(&:to_s)
87
+ missing = names - loaded.map(&:name)
88
+ raise ArgumentError, "Unknown evaluation cases: #{missing.join(', ')}" if missing.any? || names.empty?
89
+
90
+ loaded.select { |test_case| names.include?(test_case.name) }
91
+ end
92
+
93
+ # Executes fresh instances for every case and repetition and returns an Evaluation::Report.
94
+ # A supplied dataset overrides discovery for this run. Configuration errors raise;
95
+ # task, assertion, and evaluator failures are recorded per case.
96
+ # Yields each completed Trial before starting the next. Exceptions from the block propagate.
97
+ # Supply id to correlate reports and instrumentation with an application record or job.
98
+ def run(dataset: nil, only: nil, repetitions: 1, id: nil)
99
+ validate_run(repetitions)
100
+ id = (id || SecureRandom.uuid).to_s
101
+ raise ArgumentError, 'An evaluation run id cannot be empty' if id.empty?
102
+
103
+ cases = self.cases(dataset:, only:)
104
+ groups = evaluation_groups(cases)
105
+ definitions = Judge::Data.copy(groups.flat_map do |backend, criteria|
106
+ criteria.map { |criterion| criterion.merge(evaluator: backend.description) }
107
+ end)
108
+ started_at = Time.now.utc
109
+ name = self.name || 'Anonymous evaluation'
110
+ payload = { evaluation_id: id, evaluation_name: name,
111
+ total: cases.size * repetitions, completed: 0, started_at: }
112
+ RubyLLM.instrument('evaluation.ruby_llm', payload) do |event|
113
+ trials = cases.flat_map do |test_case|
114
+ Array.new(repetitions) do |index|
115
+ trial = run_trial(test_case, index + 1, groups, event)
116
+ yield trial if block_given?
117
+ trial
118
+ end
119
+ end
120
+ event[:report] = Report.new(name:, trials:, id:, started_at:, definitions:)
121
+ end
122
+ end
123
+
124
+ def definitions # :nodoc:
125
+ @definitions ||= {}
126
+ end
127
+
128
+ def adapters # :nodoc:
129
+ @adapters ||= {}
130
+ end
131
+
132
+ private
133
+
134
+ def run_trial(test_case, repetition, groups, run)
135
+ payload = run.slice(:evaluation_id, :evaluation_name, :total).merge(case: test_case.name, repetition:)
136
+ RubyLLM.instrument('evaluation_trial.ruby_llm', payload) do |event|
137
+ trial = new.run_case(test_case, repetition, groups, run_id: run[:evaluation_id])
138
+ event[:completed] = run[:completed] += 1
139
+ event[:trial] = trial
140
+ end
141
+ end
142
+
143
+ def validate_run(repetitions)
144
+ raise ArgumentError, 'Define perform(input) in your evaluation' if instance_method(:perform).owner == Evaluation
145
+ if evaluator == false && definitions.any?
146
+ raise ArgumentError, 'Cannot declare semantic evaluations with evaluator false'
147
+ end
148
+ return if repetitions.is_a?(Integer) && repetitions.positive?
149
+
150
+ raise ArgumentError, 'Repetitions must be a positive Integer'
151
+ end
152
+
153
+ def validate_criterion(name, minimum)
154
+ raise ArgumentError, 'An evaluation name cannot be empty' if name.to_s.empty?
155
+ return if minimum.nil? || (minimum.is_a?(Numeric) && minimum.finite?)
156
+
157
+ raise ArgumentError, 'Minimum must be a finite number'
158
+ end
159
+
160
+ def evaluation_groups(cases)
161
+ return {} unless evaluator
162
+
163
+ all = evaluator.question_names.to_h do |name|
164
+ [name, { name:, instructions: nil, minimum: nil }]
165
+ end.merge(definitions)
166
+ all[:correctness] = { name: :correctness, instructions: nil, minimum: nil } if all.empty?
167
+ groups = all.values.group_by { |definition| definition[:evaluator] || evaluator }
168
+ groups.to_h do |backend, criteria|
169
+ [backend, criteria.map { |criterion| prepare_criterion(backend, criterion, cases) }]
170
+ end
171
+ end
172
+
173
+ def prepare_criterion(backend, criterion, cases)
174
+ existing = backend.question_names.include?(criterion[:name])
175
+ raise ArgumentError, "Duplicate Judge question: #{criterion[:name]}" if existing && criterion[:instructions]
176
+ return criterion if existing || !criterion[:instructions].to_s.empty?
177
+
178
+ if criterion[:name] == :correctness && criterion[:instructions].nil?
179
+ return criterion.merge(instructions: correctness_instructions(cases))
180
+ end
181
+
182
+ raise ArgumentError, "Missing instructions: #{criterion[:name]}"
183
+ end
184
+
185
+ def correctness_instructions(cases)
186
+ missing = cases.reject(&:expected_output?).map(&:name)
187
+ raise ArgumentError, "Correctness requires expected_output for cases: #{missing.join(', ')}" if missing.any?
188
+
189
+ CORRECTNESS
190
+ end
191
+ end
192
+
193
+ # Returns the current case's input, reference output, and metadata, respectively.
194
+ attr_reader :input, :expected_output, :metadata
195
+ # Returns the original value returned by perform.
196
+ attr_reader :result
197
+
198
+ # Runs the application under evaluation. Override in your evaluation class.
199
+ def perform(_input)
200
+ raise NotImplementedError, 'Define perform(input) in your evaluation'
201
+ end
202
+
203
+ # Runs Ruby assertions after perform. Override to use assert, refute, and the Minitest assertion family.
204
+ def assertions; end
205
+
206
+ # Runs before perform on each fresh case instance. Override for application fixtures.
207
+ def setup; end
208
+
209
+ # Runs after each case, even when setup, perform, or an assertion fails.
210
+ def teardown; end
211
+
212
+ # Returns the primary answer or value extracted from result.
213
+ def output
214
+ @evidence.output
215
+ end
216
+
217
+ # Returns retained messages from a Chat, Agent, or Message result.
218
+ def messages
219
+ @evidence.messages
220
+ end
221
+
222
+ # Returns tool calls recorded in the returned conversation or message.
223
+ def tool_calls
224
+ @evidence.tool_calls
225
+ end
226
+
227
+ # :method: assert
228
+ # :call-seq:
229
+ # assert(condition, message = nil)
230
+ #
231
+ # Asserts that condition is truthy. Delegates to Minitest::Assertions and
232
+ # records its assertion count. Requires the minitest gem, which Rails
233
+ # applications already include. The other Minitest assert_* and refute_*
234
+ # methods use the same contract. Call these from #assertions.
235
+
236
+ # :method: refute
237
+ # :call-seq:
238
+ # refute(condition, message = nil)
239
+ #
240
+ # Asserts that condition is false or nil. See #assert.
241
+
242
+ # :method: assert_equal
243
+ # :call-seq:
244
+ # assert_equal(expected, actual, message = nil)
245
+ #
246
+ # Asserts equality through Minitest::Assertions. See #assert.
247
+
248
+ # :method: assert_includes
249
+ # :call-seq:
250
+ # assert_includes(collection, value, message = nil)
251
+ #
252
+ # Asserts that the collection includes value. See #assert.
253
+
254
+ # :stopdoc:
255
+ def method_missing(name, ...)
256
+ return super unless Assertions::METHOD.match?(name) && assertion_context.respond_to?(name)
257
+
258
+ assertion_context.public_send(name, ...)
259
+ end
260
+
261
+ def respond_to_missing?(name, include_private = nil)
262
+ Assertions.method?(name) || super
263
+ end
264
+
265
+ def run_case(test_case, repetition, groups, run_id:) # :nodoc:
266
+ Runner.new(self, test_case, repetition, groups, run_id:).run
267
+ end
268
+
269
+ def prepare(test_case) # :nodoc:
270
+ @input = JSON.parse(JSON.generate(test_case.inputs))
271
+ @expected_output = test_case.expected_output
272
+ @metadata = test_case.metadata
273
+ end
274
+
275
+ def execute # :nodoc:
276
+ @result = perform(input)
277
+ @evidence = Evidence.new(result, adapters: self.class.adapters)
278
+ end
279
+
280
+ def assertion_context # :nodoc:
281
+ @assertion_context ||= Assertions.new
282
+ end
283
+
284
+ def assertion_count # :nodoc:
285
+ @assertion_context&.assertions.to_i
286
+ end
287
+ # :startdoc:
288
+ end
289
+ end
@@ -43,6 +43,8 @@ module RubyLLM
43
43
  # request vocabulary and merges them into the request as-is.
44
44
  # +context:+ supplies a Context whose configuration replaces the
45
45
  # global one. +metadata:+ is included in the instrumentation payload.
46
+ # +owner:+ attributes the usage to a record, such as a user, and wins
47
+ # over RubyLLM.with_usage_owner.
46
48
  #
47
49
  # image = RubyLLM.paint("A small watercolor robot", model: "gpt-image-2")
48
50
  #
@@ -67,7 +69,8 @@ module RubyLLM
67
69
  with: nil,
68
70
  mask: nil,
69
71
  provider_options: {},
70
- metadata: nil)
72
+ metadata: nil,
73
+ owner: nil)
71
74
  config = context&.config || RubyLLM.config
72
75
  model ||= config.default_image_model
73
76
  model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
@@ -88,7 +91,9 @@ module RubyLLM
88
91
  }
89
92
 
90
93
  RubyLLM.instrument('image.ruby_llm', payload, config: config) do |event|
91
- result = provider_instance.paint(prompt, model:, size:, count:, with:, mask:, provider_options:)
94
+ result = Accounting::Usage.owned_by(owner) do
95
+ provider_instance.paint(prompt, model:, size:, count:, with:, mask:, provider_options:)
96
+ end
92
97
  images = Support::Utils.to_safe_array(result)
93
98
  event[:result] = result
94
99
  event[:response_model] = images.first&.model
@@ -174,8 +179,11 @@ module RubyLLM
174
179
  Cost.new(tokens:, model: model_info, category: :images, input_details: input_tokens_details)
175
180
  end
176
181
 
177
- # Returns the registry Model for #model, or +nil+ if the model id
178
- # is missing or not in the registry.
182
+ attr_writer :model_info # :nodoc:
183
+
184
+ # Returns the image's Model from its provider's registry, falling back
185
+ # to the requested model when the response ID is unknown. Images built
186
+ # without a request look up #model, or return +nil+ if unknown.
179
187
  def model_info
180
188
  return unless model
181
189
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Judge
5
+ class Builder # :nodoc:
6
+ def self.resolve(value, scope:)
7
+ return value unless value.is_a?(::Proc)
8
+
9
+ new(scope).evaluate(value)
10
+ end
11
+
12
+ def initialize(scope)
13
+ @scope = scope
14
+ @data = {}
15
+ end
16
+
17
+ def evaluate(block)
18
+ result = block.arity.zero? ? instance_exec(&block) : instance_exec(self, &block)
19
+ return result if @data.empty?
20
+
21
+ unless result.equal?(self) || result.nil?
22
+ ::Kernel.raise ::ArgumentError, 'A judgment block must declare fields or return a value, not both'
23
+ end
24
+
25
+ @data
26
+ end
27
+
28
+ def method_missing(name, *values, &block)
29
+ return @scope.public_send(name) if runtime_input?(name, values, block)
30
+
31
+ if values.size > 1 || (!values.empty? && block)
32
+ ::Kernel.raise ::ArgumentError, "#{name} accepts one value or a block"
33
+ end
34
+ ::Kernel.raise ::ArgumentError, "Duplicate judgment field: #{name}" if @data.key?(name)
35
+
36
+ @data[name] = block ? ::RubyLLM::Judge::Builder.resolve(block, scope: @scope) : values.first
37
+ self
38
+ end
39
+
40
+ def respond_to_missing?(_name, _include_private = nil)
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ def runtime_input?(name, values, block)
47
+ values.empty? && !block && @scope.respond_to?(name)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Judge
5
+ module Data # :nodoc:
6
+ module_function
7
+
8
+ def copy(value, &resolver)
9
+ case value
10
+ when Proc then resolver ? copy(resolver.call(value), &resolver) : value
11
+ when Hash then copy_hash(value, &resolver)
12
+ when Array then value.map { |item| copy(item, &resolver) }.freeze
13
+ when String then value.dup.freeze
14
+ when Symbol then value.to_s.freeze
15
+ when Float
16
+ raise ArgumentError, 'Judgment data must contain finite numbers' unless value.finite?
17
+
18
+ value
19
+ when Integer, TrueClass, FalseClass, NilClass then value
20
+ else raise ArgumentError, "Unsupported judgment data: #{value.class}; pass JSON-compatible values"
21
+ end
22
+ end
23
+
24
+ def copy_hash(value, &resolver)
25
+ seen = {}
26
+ value.each_with_object({}) do |(key, item), result|
27
+ name = key.to_s
28
+ unless key.is_a?(String) || key.is_a?(Symbol) || key == true || key == false
29
+ raise ArgumentError, 'Judgment data keys must be Strings, Symbols, or booleans'
30
+ end
31
+ raise ArgumentError, "Duplicate judgment key: #{name}" if seen[name]
32
+
33
+ seen[name] = true
34
+ result[key.is_a?(String) ? key.dup.freeze : key] = copy(item, &resolver)
35
+ end.freeze
36
+ end
37
+
38
+ def description?(value)
39
+ value.nil? || value.is_a?(String) || value.is_a?(Hash) || value.is_a?(Array)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Judge
5
+ class Question # :nodoc:
6
+ CRITERIA_KEYS = { probability: :criteria, choice: :options, score: :levels }.freeze
7
+
8
+ attr_reader :name, :type, :instructions, :criteria
9
+
10
+ def initialize(name, type:, instructions: nil, criteria: nil, &block)
11
+ validate_name!(name)
12
+ raise ArgumentError, "Unknown judgment type: #{type.inspect}" unless CRITERIA_KEYS.key?(type)
13
+ raise ArgumentError, 'Pass question criteria or a block, not both' if !criteria.nil? && block
14
+
15
+ @name = name.is_a?(String) ? name.dup.freeze : name
16
+ @type = type
17
+ @instructions = Data.copy(instructions)
18
+ @criteria = Data.copy(block || criteria)
19
+ freeze
20
+ end
21
+
22
+ def self.from_h(name, definition)
23
+ raise ArgumentError, 'Each question must be a Hash' unless definition.is_a?(Hash)
24
+
25
+ definition = definition.transform_keys(&:to_sym)
26
+ type = definition[:type]
27
+ type = type.to_sym if type.is_a?(String)
28
+ key = CRITERIA_KEYS.fetch(type) { raise ArgumentError, "Unknown judgment type: #{type.inspect}" }
29
+ extra = definition.keys - [:type, :instructions, key]
30
+ raise ArgumentError, "Unknown question options: #{extra.join(', ')}" unless extra.empty?
31
+
32
+ new(name, type:, instructions: definition[:instructions], criteria: definition[key])
33
+ end
34
+
35
+ def resolve(scope)
36
+ resolver = ->(value) { Builder.resolve(value, scope:) }
37
+ resolved = self.class.new(name, type:, instructions: Data.copy(instructions, &resolver),
38
+ criteria: Data.copy(criteria, &resolver))
39
+ resolved.validate!
40
+ resolved
41
+ end
42
+
43
+ def validate!
44
+ unless Data.description?(instructions)
45
+ raise ArgumentError,
46
+ 'Question instructions must be text, a Hash, an Array, or nil'
47
+ end
48
+
49
+ case type
50
+ when :probability then validate_probability!
51
+ when :choice then validate_choice!
52
+ when :score then validate_score!
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def validate_name!(name)
59
+ unless name.is_a?(String) || name.is_a?(Symbol)
60
+ raise ArgumentError, 'A question name must be a String or Symbol'
61
+ end
62
+ raise ArgumentError, 'A question name cannot be empty' if name.to_s.empty?
63
+ end
64
+
65
+ def validate_probability!
66
+ return if criteria.nil?
67
+
68
+ unless criteria.is_a?(Hash) && (criteria.keys.map(&:to_s) - %w[yes no true false]).empty?
69
+ raise ArgumentError, 'Probability criteria must describe yes and no'
70
+ end
71
+
72
+ positive = %w[yes true]
73
+ aliases = criteria.keys.map { |key| positive.include?(key.to_s) }
74
+ raise ArgumentError, 'Probability criteria contain duplicate outcomes' unless aliases.uniq.size == aliases.size
75
+
76
+ validate_descriptions!(criteria.values)
77
+ end
78
+
79
+ def validate_choice!
80
+ raise ArgumentError, 'A choice needs a nonempty Hash of options' unless criteria.is_a?(Hash) && !criteria.empty?
81
+ unless criteria.keys.all? { |key| (key.is_a?(String) || key.is_a?(Symbol)) && !key.to_s.empty? }
82
+ raise ArgumentError, 'Choice options must have nonempty String or Symbol names'
83
+ end
84
+
85
+ validate_descriptions!(criteria.values)
86
+ end
87
+
88
+ def validate_score!
89
+ raise ArgumentError, 'A score needs a nonempty Array of levels' unless criteria.is_a?(Array) && !criteria.empty?
90
+
91
+ validate_descriptions!(criteria)
92
+ end
93
+
94
+ def validate_descriptions!(values)
95
+ return if values.all? { |value| Data.description?(value) }
96
+
97
+ raise ArgumentError, 'Descriptions must be text, a Hash, an Array, or nil'
98
+ end
99
+ end
100
+ end
101
+ end