llm-evals 0.4.0 → 0.5.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +163 -87
  4. data/exe/llm-evals +60 -5
  5. data/lib/.DS_Store +0 -0
  6. data/lib/llm/.DS_Store +0 -0
  7. data/lib/llm/evals/actions/adapter.rb +50 -0
  8. data/lib/llm/evals/actions/error_budget.rb +63 -0
  9. data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
  10. data/lib/llm/evals/actions/error_streak.rb +83 -0
  11. data/lib/llm/evals/actions/experiment.rb +251 -0
  12. data/lib/llm/evals/actions/verify.rb +61 -0
  13. data/lib/llm/evals/actions.rb +9 -0
  14. data/lib/llm/evals/cli/cases.rb +10 -0
  15. data/lib/llm/evals/cli/command.rb +32 -99
  16. data/lib/llm/evals/cli/compare.rb +0 -20
  17. data/lib/llm/evals/cli/contender_selector.rb +61 -0
  18. data/lib/llm/evals/cli/error_pause.rb +81 -0
  19. data/lib/llm/evals/cli/experiment_pool.rb +97 -0
  20. data/lib/llm/evals/cli/formatting.rb +8 -0
  21. data/lib/llm/evals/cli/leaderboard.rb +66 -12
  22. data/lib/llm/evals/cli/model_colors.rb +3 -0
  23. data/lib/llm/evals/cli/model_command.rb +152 -315
  24. data/lib/llm/evals/cli/model_options.rb +80 -0
  25. data/lib/llm/evals/cli/option_groups.rb +32 -0
  26. data/lib/llm/evals/cli/progress_bars.rb +92 -5
  27. data/lib/llm/evals/cli/render.rb +6 -6
  28. data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
  29. data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
  30. data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
  31. data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
  32. data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
  33. data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
  34. data/lib/llm/evals/cli/run.rb +13 -22
  35. data/lib/llm/evals/cli/run_planner.rb +97 -0
  36. data/lib/llm/evals/cli/run_record.rb +68 -0
  37. data/lib/llm/evals/cli/standings.rb +39 -17
  38. data/lib/llm/evals/cli/status_box.rb +51 -0
  39. data/lib/llm/evals/cli/table.rb +103 -0
  40. data/lib/llm/evals/cli/text.rb +22 -0
  41. data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
  42. data/lib/llm/evals/cli/upgrade_config.rb +60 -0
  43. data/lib/llm/evals/cli/verify.rb +5 -1
  44. data/lib/llm/evals/cli/version.rb +4 -0
  45. data/lib/llm/evals/cli/weights.rb +48 -0
  46. data/lib/llm/evals/cli.rb +39 -6
  47. data/lib/llm/evals/config/contender_builder.rb +30 -0
  48. data/lib/llm/evals/config/error_limits.rb +41 -0
  49. data/lib/llm/evals/config/listing.rb +153 -0
  50. data/lib/llm/evals/config/results.rb +42 -0
  51. data/lib/llm/evals/config/score_weights.rb +25 -0
  52. data/lib/llm/evals/config/upgrade.rb +107 -0
  53. data/lib/llm/evals/config.rb +90 -79
  54. data/lib/llm/evals/datasets/case.rb +46 -0
  55. data/lib/llm/evals/datasets/discovery.rb +143 -0
  56. data/lib/llm/evals/datasets.rb +9 -0
  57. data/lib/llm/evals/launcher.rb +57 -3
  58. data/lib/llm/evals/models/contender.rb +51 -0
  59. data/lib/llm/evals/models/model.rb +87 -0
  60. data/lib/llm/evals/models/throttle.rb +170 -0
  61. data/lib/llm/evals/models.rb +9 -0
  62. data/lib/llm/evals/providers/base.rb +61 -16
  63. data/lib/llm/evals/providers/catalog.rb +5 -2
  64. data/lib/llm/evals/providers/open_router.rb +54 -7
  65. data/lib/llm/evals/providers/registry.rb +49 -16
  66. data/lib/llm/evals/providers/type_safe/client.rb +67 -0
  67. data/lib/llm/evals/providers/type_safe.rb +113 -12
  68. data/lib/llm/evals/providers.rb +9 -0
  69. data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
  70. data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
  71. data/lib/llm/evals/render/reports/html/format.rb +38 -0
  72. data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
  73. data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
  74. data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
  75. data/lib/llm/evals/render/reports/html.rb +60 -181
  76. data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
  77. data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
  78. data/lib/llm/evals/render/reports/json.rb +38 -141
  79. data/lib/llm/evals/render/reports/pipeline.rb +55 -20
  80. data/lib/llm/evals/render/reports/terminal.rb +8 -1
  81. data/lib/llm/evals/render.rb +9 -0
  82. data/lib/llm/evals/results/provider_response.rb +25 -0
  83. data/lib/llm/evals/results/reply.rb +38 -0
  84. data/lib/llm/evals/results/run_outcome.rb +15 -0
  85. data/lib/llm/evals/results/run_result.rb +40 -0
  86. data/lib/llm/evals/results/score.rb +45 -0
  87. data/lib/llm/evals/results/warnings.rb +149 -0
  88. data/lib/llm/evals/results.rb +9 -0
  89. data/lib/llm/evals/runtime/configuration.rb +295 -0
  90. data/lib/llm/evals/runtime/environment.rb +90 -0
  91. data/lib/llm/evals/runtime/helper.rb +153 -0
  92. data/lib/llm/evals/runtime.rb +2 -67
  93. data/lib/llm/evals/scoring/metric.rb +47 -0
  94. data/lib/llm/evals/scoring/schema_validator.rb +102 -0
  95. data/lib/llm/evals/scoring/scorer.rb +49 -0
  96. data/lib/llm/evals/scoring/weighted_score.rb +148 -0
  97. data/lib/llm/evals/scoring.rb +9 -0
  98. data/lib/llm/evals/stats/call_stats.rb +91 -0
  99. data/lib/llm/evals/stats/usage.rb +46 -0
  100. data/lib/llm/evals/stats.rb +29 -0
  101. data/lib/llm/evals/testing/echo_adapter.rb +14 -9
  102. data/lib/llm/evals/testing/fake_model.rb +8 -6
  103. data/lib/llm/evals/testing/fake_provider.rb +39 -8
  104. data/lib/llm/evals/testing.rb +9 -0
  105. data/lib/llm/evals/trackers/base.rb +110 -0
  106. data/lib/llm/evals/trackers/braintrust.rb +40 -3
  107. data/lib/llm/evals/trackers/local.rb +38 -2
  108. data/lib/llm/evals/trackers/logfire.rb +56 -3
  109. data/lib/llm/evals/trackers/registry.rb +29 -7
  110. data/lib/llm/evals/trackers.rb +9 -0
  111. data/lib/llm/evals/version.rb +3 -1
  112. data/lib/llm/evals.rb +17 -7
  113. metadata +91 -24
  114. data/lib/llm/evals/adapter.rb +0 -47
  115. data/lib/llm/evals/case.rb +0 -36
  116. data/lib/llm/evals/completion.rb +0 -11
  117. data/lib/llm/evals/configuration.rb +0 -173
  118. data/lib/llm/evals/contender.rb +0 -31
  119. data/lib/llm/evals/discovery.rb +0 -95
  120. data/lib/llm/evals/experiment.rb +0 -158
  121. data/lib/llm/evals/helper.rb +0 -98
  122. data/lib/llm/evals/metric.rb +0 -26
  123. data/lib/llm/evals/model.rb +0 -55
  124. data/lib/llm/evals/reply.rb +0 -18
  125. data/lib/llm/evals/run_result.rb +0 -14
  126. data/lib/llm/evals/schema_validator.rb +0 -84
  127. data/lib/llm/evals/score.rb +0 -26
  128. data/lib/llm/evals/scorer.rb +0 -45
  129. data/lib/llm/evals/tracker.rb +0 -80
  130. data/lib/llm/evals/usage.rb +0 -33
  131. data/lib/llm/evals/verify.rb +0 -49
  132. data/lib/llm/evals/warnings.rb +0 -98
  133. data/lib/llm/evals/weighted_score.rb +0 -116
  134. /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
  135. /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
  136. /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "limiter"
4
+
5
+ module LLM
6
+ module Evals
7
+ module Models
8
+ # Every model call passes through here, in one process, whatever the -j and
9
+ # -p. A call waits for each limit that matches it, narrowest first, so a
10
+ # slot on the shared limit is not spent while the model's own is still shut.
11
+ # Limits are keyed "openrouter", "openrouter/anthropic" and
12
+ # "openrouter/anthropic/claude-fable-5".
13
+ #
14
+ # RubyLLM's retries are off, since they neither pass through the limits nor
15
+ # tell other threads. A call the provider says may succeed later is retried
16
+ # here: it shuts its vendor for every thread, for as long as `Retry-After`
17
+ # says, or for a growing delay when it says nothing. The number of attempts
18
+ # is bounded, and so is the wait, so that a run ends with errors and not at all.
19
+ class Throttle
20
+ # One `rate_limit`: the first `burst` calls of the run go at once, then
21
+ # `per_minute`, evenly spaced. A burst is spent once and never refills, so
22
+ # this is a head start and not a token bucket.
23
+ class Limit
24
+ # @param burst [Integer] calls let through at once, before any spacing
25
+ # @param per_minute [Integer] the evenly spaced rate after that
26
+ def initialize(burst:, per_minute:)
27
+ @burst = burst
28
+ @queue = Limiter::RateQueue.new(per_minute, interval: 60, balanced: true)
29
+ @lock = Mutex.new
30
+ end
31
+
32
+ # Blocks until this call may go.
33
+ #
34
+ # @return [void]
35
+ def acquire
36
+ free = @lock.synchronize { (@burst -= 1) >= 0 }
37
+ @queue.shift unless free
38
+ end
39
+ end
40
+
41
+ # Tries of one call, the first included, before its error stands.
42
+ MAX_ATTEMPTS = 5
43
+ # The longest growing delay, in seconds, when a provider asks for none.
44
+ MAX_BACKOFF = 30
45
+
46
+ # A provider asking for longer than this has really said no.
47
+ MAX_PAUSE = 300
48
+
49
+ # The fiber-local key holding the seconds this thread has waited here.
50
+ WAITED = :llm_evals_throttle_waited
51
+
52
+ # Runs the block and says how long it spent waiting here, so that latency
53
+ # measures the model and not this lineup's limits. Waits are counted per
54
+ # thread, so those of threads an adapter starts itself are not seen.
55
+ #
56
+ # @yieldreturn [Object]
57
+ # @return [Array(Object, Float)] the block's value, and the seconds waited on this thread
58
+ def self.measure
59
+ before = Thread.current[WAITED].to_f
60
+ value = yield
61
+ [value, Thread.current[WAITED].to_f - before]
62
+ end
63
+
64
+ # @param limits [Hash{String => Limit}] by scope
65
+ # @param sleeper [#call] takes seconds
66
+ def initialize(limits = {}, sleeper: method(:sleep))
67
+ @limits = limits
68
+ @sleeper = sleeper
69
+ @paused_until = Hash.new(0.0)
70
+ @lock = Mutex.new
71
+ end
72
+
73
+ # @param model [Model]
74
+ # @yieldreturn [Object] the call itself, and nothing else: a retry runs the
75
+ # block again, so it must be safe to repeat
76
+ # @return [Object] the block's value
77
+ # @raise [StandardError] what the call raised, when it cannot or may not be retried
78
+ def call(model)
79
+ vendor = vendor_scope(model)
80
+ attempts = 1
81
+ begin
82
+ waiting { admit(model, vendor) }
83
+ yield
84
+ rescue StandardError => e
85
+ delay = delay(model.provider, e, attempts)
86
+ raise if delay.nil?
87
+
88
+ pause(vendor, delay)
89
+ attempts += 1
90
+ retry
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ # Waits out the vendor's pause, then every limit, narrowest first.
97
+ #
98
+ # @param model [Model] the model being called
99
+ # @param vendor [String] the scope a pause would shut
100
+ # @return [void]
101
+ def admit(model, vendor)
102
+ hold(vendor)
103
+ scopes(model).reverse_each { @limits[it]&.acquire }
104
+ end
105
+
106
+ # @param model [Model] the model being called
107
+ # @return [Array<String>] "p", "p/vendor", "p/vendor/model"; "p", "p/model" for a provider with no vendors
108
+ def scopes(model)
109
+ parts = model.name.split("/")
110
+ (0..parts.size).map { [model.provider.name, *parts.first(it)].join("/") }
111
+ end
112
+
113
+ # What a retryable failure shuts: the vendor, or the whole provider when it has no vendors.
114
+ #
115
+ # @param model [Model] the model being called
116
+ # @return [String] the scope
117
+ def vendor_scope(model) = scopes(model)[model.name.include?("/") ? 1 : 0]
118
+
119
+ # @param provider [Providers::Base] says whether the error is retryable, and how long it asked
120
+ # @param error [StandardError] what the call raised
121
+ # @param attempts [Integer] tries so far, this one included
122
+ # @return [Numeric, nil] seconds to wait before another attempt; nil to give up
123
+ def delay(provider, error, attempts)
124
+ return unless attempts < MAX_ATTEMPTS && provider.retryable?(error)
125
+
126
+ asked = provider.retry_after(error)
127
+ return [2**(attempts - 1), MAX_BACKOFF].min if asked.nil?
128
+
129
+ asked unless asked > MAX_PAUSE
130
+ end
131
+
132
+ # Shuts a scope for every thread, never shortening a pause already in force.
133
+ #
134
+ # @param scope [String] the scope to shut
135
+ # @param seconds [Numeric] how long from now
136
+ # @return [void]
137
+ def pause(scope, seconds)
138
+ @lock.synchronize { @paused_until[scope] = [@paused_until[scope], now + seconds].max }
139
+ end
140
+
141
+ # Sleeps until a scope is open, checking again in case another thread extended the pause.
142
+ #
143
+ # @param scope [String] the scope to wait on
144
+ # @return [void]
145
+ def hold(scope)
146
+ while (left = @lock.synchronize { @paused_until[scope] } - now).positive?
147
+ @sleeper.call(left)
148
+ end
149
+ end
150
+
151
+ # Adds the time the block takes to this thread's total, even when it raises.
152
+ #
153
+ # @yield the wait to measure
154
+ # @yieldreturn [Object]
155
+ # @return [Object] the block's value
156
+ def waiting
157
+ started = now
158
+ yield
159
+ ensure
160
+ Thread.current[WAITED] = Thread.current[WAITED].to_f + (now - started)
161
+ end
162
+
163
+ # A clock that wall-clock changes cannot move.
164
+ #
165
+ # @return [Float] monotonic seconds
166
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # What is being evaluated: a model at one setting, and how calls to it are limited.
6
+ module Models
7
+ end
8
+ end
9
+ end
@@ -3,7 +3,7 @@
3
3
  module LLM
4
4
  module Evals
5
5
  module Providers
6
- # What answers a {Model}'s calls: one API, the ids it serves, the keys it
6
+ # What answers a {Models::Model}'s calls: one API, the ids it serves, the keys it
7
7
  # needs and the models it lists. A suite with its own transport
8
8
  # subclasses this and registers it through `c.provider`.
9
9
  #
@@ -16,6 +16,7 @@ module LLM
16
16
  # A suite admits a provider only when its config lists this type.
17
17
  #
18
18
  # @return [Symbol]
19
+ # @raise [NotImplementedError] unless a subclass overrides it
19
20
  def request_type
20
21
  raise NotImplementedError, "#{self.class}#request_type must be one of #{Config::REQUEST_TYPES.join(", ")}"
21
22
  end
@@ -27,29 +28,42 @@ module LLM
27
28
  def request_type_description = ""
28
29
 
29
30
  # A direct provider serves a vendor's own API and is reached only
30
- # through a `direct:` entry that names it; it never claims an id from
31
- # `vendors:`, however the id is spelled.
31
+ # through its own section of the lineup; it never claims another's id,
32
+ # however the id is spelled.
32
33
  #
33
34
  # @return [Boolean]
34
35
  def direct? = false
35
36
 
37
+ # Dasherized from the class name; a subclass overrides it when the brand is
38
+ # spelled differently.
39
+ #
36
40
  # @return [String] short name, for messages and the leaderboard
37
41
  def name = self.class.name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
38
42
 
43
+ # Matches on {#prefix}, so most subclasses override that instead of this.
44
+ #
39
45
  # @param model_id [String] as the lineup names it
40
46
  # @return [Boolean] whether this provider serves the model
41
47
  def serves?(model_id) = model_id.start_with?(prefix)
42
48
 
49
+ # Overridden by a subclass whose ids share a namespace.
50
+ #
43
51
  # @return [String] the id prefix this provider claims; "" claims everything
44
52
  def prefix = ""
45
53
 
54
+ # Strips {#prefix}, so the lineup's namespacing never reaches the API.
55
+ #
46
56
  # @param model_id [String]
47
57
  # @return [String] the id as the provider's API knows it
48
58
  def model_name(model_id) = model_id.delete_prefix(prefix)
49
59
 
60
+ # {Registry#keys_for} collects these across the lineup.
61
+ #
50
62
  # @return [Array<String>] environment variables a run through this provider needs
51
63
  def env_keys = []
52
64
 
65
+ # {EFFORTS} unless a subclass narrows or empties it.
66
+ #
53
67
  # @return [Array<String>] the efforts the `-e` flag accepts for this provider's models
54
68
  def efforts = EFFORTS
55
69
 
@@ -62,27 +76,58 @@ module LLM
62
76
 
63
77
  # Prepares the client once per process, after the env files loaded.
64
78
  #
65
- # @option options [String] :app_name what the client tells the API the caller is
66
- # @return [void]
79
+ # @overload setup!(app_name:)
80
+ # @param app_name [String] what the client tells the API the caller is
81
+ # @return [void]
67
82
  def setup!(**) = nil
68
83
 
69
- # @param model [Model]
70
- # @param system [String, nil]
71
- # @param user [String]
72
- # @param json [Boolean]
73
- # @return [Completion]
84
+ # One chat completion, for a provider of request type `prompt`.
85
+ #
86
+ # @param model [Models::Model] the model and settings to call
87
+ # @param system [String, nil] the system prompt, if any
88
+ # @param user [String] the user prompt
89
+ # @param json [Boolean] whether to ask for a JSON object reply
90
+ # @return [Results::ProviderResponse]
91
+ # @raise [NotImplementedError] unless a subclass overrides it
74
92
  def complete(model, system:, user:, json: true)
75
- raise NotImplementedError, "#{self.class}#complete must return an LLM::Evals::Completion"
93
+ raise NotImplementedError, "#{self.class}#complete must return an LLM::Evals::Results::ProviderResponse"
76
94
  end
77
95
 
78
- # @param contender [Contender]
79
- # @return [Model]
80
- def model_for(contender) = Model.new(id: contender.model, provider: self, settings: contender.settings)
96
+ # One request of typed questions, for a provider whose API answers them.
97
+ #
98
+ # @param model [Models::Model] the model to ask
99
+ # @param state [Object] what the questions are about; any JSON-encodable value
100
+ # @param questions [Hash{String => Hash}] by name
101
+ # @return [Results::ProviderResponse]
102
+ # @raise [NotImplementedError] unless a subclass overrides it
103
+ def ask(model, state:, questions:)
104
+ raise NotImplementedError, "#{self.class} does not answer typed questions"
105
+ end
106
+
107
+ # {Models::Throttle} asks this after a failed call; false by default, so a
108
+ # provider that says nothing is never retried.
109
+ #
110
+ # @param _error [StandardError] what a call raised
111
+ # @return [Boolean] whether the same call may work later
112
+ def retryable?(_error) = false
113
+
114
+ # {Models::Throttle} uses this instead of its own backoff when it is not nil.
115
+ #
116
+ # @param _error [StandardError] what a call raised
117
+ # @return [Numeric, nil] the seconds the API asked to wait, if it did
118
+ def retry_after(_error) = nil
119
+
120
+ # Builds the callable model for a lineup entry, on this provider.
121
+ #
122
+ # @param contender [Models::Contender] the lineup entry
123
+ # @param throttle [Models::Throttle] what the model's calls pass through
124
+ # @return [Models::Model]
125
+ def model_for(contender, throttle: Models::Throttle.new) = Models::Model.new(id: contender.model, provider: self, settings: contender.settings, throttle:)
81
126
 
82
127
  # Every id in `model_ids` the provider does not list.
83
128
  #
84
- # @param model_ids [Array<String>]
85
- # @return [Array<String>]
129
+ # @param model_ids [Array<String>] ids as the lineup names them
130
+ # @return [Array<String>] none when the provider cannot list its models
86
131
  def unknown(model_ids)
87
132
  listed = catalog
88
133
  listed.nil? ? [] : model_ids - listed
@@ -11,8 +11,11 @@ module LLM
11
11
  module Catalog
12
12
  module_function
13
13
 
14
- # @param url [String]
15
- # @param headers [Hash{String => String}]
14
+ # Performs the GET. A status other than 2xx, a socket failure, a timeout and
15
+ # an unparsable body all come out as the same {Error}.
16
+ #
17
+ # @param url [String] the list's address
18
+ # @param headers [Hash{String => String}] request headers, such as credentials
16
19
  # @return [Hash] the parsed JSON body
17
20
  # @raise [Error] when the list cannot be read
18
21
  def fetch(url, headers = {})
@@ -7,39 +7,86 @@ module LLM
7
7
  # traces RubyLLM calls (tokens, latency, cost) and not raw HTTP. Claims
8
8
  # every id no other provider does, so it is always asked last.
9
9
  class OpenRouter < Base
10
+ # The model list, which {#catalog} reads.
10
11
  URL = "https://openrouter.ai/api/v1/models"
12
+ # The environment variable holding the API key.
11
13
  KEY = "OPENROUTER_API_KEY"
12
14
 
13
15
  # OpenRouter's reasoning.effort values.
14
16
  EFFORTS = %w[none minimal low medium high xhigh max].freeze
15
17
 
18
+ # Overrides {Base#name}, whose dasherizing would say "open-router".
19
+ #
20
+ # @return [String] "openrouter"
16
21
  def name = "openrouter"
22
+ # Overrides {Base#request_type}: chat completions.
23
+ #
24
+ # @return [Symbol] `:prompt`
17
25
  def request_type = :prompt
26
+ # Overrides {Base#request_type_description}.
27
+ #
28
+ # @return [String]
18
29
  def request_type_description = "a chat completion: system and user text in, text out"
30
+ # Overrides {Base#env_keys}.
31
+ #
32
+ # @return [Array<String>] just {KEY}
19
33
  def env_keys = [KEY]
34
+ # Overrides {Base#efforts} with {EFFORTS}, OpenRouter's own list.
35
+ #
36
+ # @return [Array<String>]
20
37
  def efforts = EFFORTS
21
38
 
39
+ # Reads the list without credentials.
40
+ #
22
41
  # @return [Array<String>] every id OpenRouter lists
42
+ # @raise [Error] when the list cannot be read
43
+ # @raise [KeyError] when the body has no `data`, or an entry no `id`
23
44
  def catalog = Catalog.fetch(URL).fetch("data").map { it.fetch("id") }
24
45
 
25
46
  # Points RubyLLM at OpenRouter. Requires it here, not at load, so a
26
47
  # suite that never reaches OpenRouter never needs the gem.
27
48
  #
28
- # @param app_name [String]
49
+ # @param app_name [String] what RubyLLM tells OpenRouter the caller is
29
50
  # @return [void]
51
+ # @raise [MissingDependency] when the ruby_llm gem is absent
30
52
  def setup!(app_name:)
31
53
  LLM::Evals.optional!("ruby_llm", needed_for: "calling models through OpenRouter")
32
54
  RubyLLM.configure do |config|
33
55
  config.openrouter_api_key = ENV.fetch(KEY, nil)
34
56
  config.openrouter_app_name = app_name
57
+ config.max_retries = 0 # {Throttle} retries, in step with the limits
58
+ end
59
+ end
60
+
61
+ # Only classifies the errors RubyLLM and Faraday raise; anything else is final.
62
+ #
63
+ # @param error [StandardError] what a call raised
64
+ # @return [Boolean] rate limits, overloads and dropped connections
65
+ def retryable?(error)
66
+ case error
67
+ when RubyLLM::RateLimitError, RubyLLM::ServerError, RubyLLM::ServiceUnavailableError, RubyLLM::OverloadedError,
68
+ Faraday::TimeoutError, Faraday::ConnectionFailed then true
69
+ else false
35
70
  end
36
71
  end
37
72
 
38
- # @param model [Model]
39
- # @param system [String, nil]
40
- # @param user [String]
73
+ # OpenRouter sends the header only when every upstream hinted at it.
74
+ #
75
+ # @param error [StandardError] what a call raised
76
+ # @return [Numeric, nil] the seconds to wait, when the error carries a usable header
77
+ def retry_after(error)
78
+ value = error.respond_to?(:response) && error.response&.response_headers&.[]("retry-after")
79
+ Float(value, exception: false) if value
80
+ end
81
+
82
+ # Sends one chat through RubyLLM, passing on only the settings the model has.
83
+ #
84
+ # @param model [Models::Model] the model and settings to call
85
+ # @param system [String, nil] the system prompt, if any
86
+ # @param user [String] the user prompt
41
87
  # @param json [Boolean] asks for `response_format: json_object`
42
- # @return [Completion] text, plus the tokens and the cost OpenRouter billed
88
+ # @return [Results::ProviderResponse] text, plus the tokens and the cost OpenRouter billed
89
+ # @raise [MissingDependency] when the ruby_llm gem is absent
43
90
  def complete(model, system:, user:, json: true)
44
91
  LLM::Evals.optional!("ruby_llm", needed_for: "calling models through OpenRouter")
45
92
  chat = RubyLLM.chat(model: model.name, provider: :openrouter, assume_model_exists: true)
@@ -50,9 +97,9 @@ module LLM
50
97
  chat.with_provider_options(response_format: { type: "json_object" }) if json
51
98
 
52
99
  message = chat.ask(user)
53
- Completion.new(
100
+ Results::ProviderResponse.new(
54
101
  text: message.content.to_s,
55
- usage: Usage.new(input_tokens: message.tokens.input.to_i,
102
+ usage: Stats::Usage.new(input_tokens: message.tokens.input.to_i,
56
103
  output_tokens: message.tokens.output.to_i,
57
104
  cost_usd: message.tokens.reported_cost.to_f),
58
105
  raw: message
@@ -3,17 +3,21 @@
3
3
  module LLM
4
4
  module Evals
5
5
  module Providers
6
- # The providers in use. A `vendors:` contender goes to the first
6
+ # The providers in use. A contender outside a direct provider's section goes to the first
7
7
  # provider that serves its id, asked last-registered first, so a
8
8
  # suite's own transport beats the defaults and OpenRouter, which claims
9
- # everything, is always last. A `direct:` contender goes to the
9
+ # everything, is always last. A direct contender goes to the
10
10
  # provider it names, and a direct provider is never asked about an id.
11
11
  class Registry
12
12
  include Enumerable
13
13
 
14
+ # OpenRouter comes first in the list because the last is asked first.
15
+ #
14
16
  # @return [Registry] TypeSafe, then OpenRouter for everything else
15
17
  def self.default = new([OpenRouter.new, TypeSafe.new])
16
18
 
19
+ # The array is copied, so registering later does not touch the caller's.
20
+ #
17
21
  # @param providers [Array<Base>] the last one is asked first
18
22
  def initialize(providers = [])
19
23
  @providers = providers.dup
@@ -23,7 +27,7 @@ module LLM
23
27
  # helper and a required file that both register the same provider
24
28
  # leave one, not two that would be asked twice.
25
29
  #
26
- # @param provider [Base]
30
+ # @param provider [Base] the provider to add
27
31
  # @return [Base] the provider
28
32
  def register(provider)
29
33
  @providers.reject! { it.name == provider.name }
@@ -31,20 +35,29 @@ module LLM
31
35
  provider
32
36
  end
33
37
 
38
+ # Walks the providers in asking order, last-registered first, which is what
39
+ # every Enumerable lookup in this class relies on.
40
+ #
41
+ # @yieldparam provider [Base] each provider
42
+ # @return [Array<Base>, Enumerator] the reversed list; an Enumerator without a block
34
43
  def each(&) = @providers.reverse.each(&)
35
44
 
36
- # @param target [Contender, String] a contender, or a `vendors:` model id
45
+ # Picks the provider for a lineup entry or a bare id, by the rules above.
46
+ #
47
+ # @param target [Models::Contender, String] a contender, or a model id
37
48
  # @return [Base]
38
49
  # @raise [Error] when no provider serves it
39
50
  def for(target)
40
- return direct(target.provider) if target.is_a?(Contender) && target.direct?
51
+ return direct(target.provider) if target.is_a?(Models::Contender) && target.direct?
41
52
 
42
- model_id = target.is_a?(Contender) ? target.model : target
53
+ model_id = target.is_a?(Models::Contender) ? target.model : target
43
54
  find { !it.direct? && it.serves?(model_id) } ||
44
55
  raise(Error, "no provider serves #{model_id.inspect}; have #{reject(&:direct?).map(&:name).join(", ")}")
45
56
  end
46
57
 
47
- # @param name [Symbol, String] a key under `direct:`
58
+ # Finds a direct provider by its lineup section name.
59
+ #
60
+ # @param name [Symbol, String] a direct provider's section
48
61
  # @return [Base]
49
62
  # @raise [Error] when no direct provider has the name
50
63
  def direct(name)
@@ -52,23 +65,40 @@ module LLM
52
65
  raise(Error, "no direct provider #{name.to_s.inspect}; have #{direct_names.join(", ")}")
53
66
  end
54
67
 
55
- # @return [Array<String>] the names `direct:` may use
68
+ # For {#direct}'s error message.
69
+ #
70
+ # @return [Array<String>] the direct providers' names
56
71
  def direct_names = select(&:direct?).map(&:name)
57
72
 
58
- # @param contenders [Array<Contender>]
73
+ # Deduplicated, so each provider's setup runs once.
74
+ #
75
+ # @param contenders [Array<Models::Contender>] the lineup
59
76
  # @return [Array<Base>] each provider the contenders use, once
60
77
  def used_by(contenders) = contenders.map { self.for(it) }.uniq
61
78
 
62
- # @param contenders [Array<Contender>]
79
+ # Deduplicated across providers that share a key.
80
+ #
81
+ # @param contenders [Array<Models::Contender>] the lineup
63
82
  # @return [Array<String>] the API keys the contenders' providers need
64
83
  def keys_for(contenders) = used_by(contenders).flat_map(&:env_keys).uniq
65
84
 
66
- # @param contender [Contender]
67
- # @return [Model]
68
- def model_for(contender) = self.for(contender).model_for(contender)
85
+ # Routes the contender with {#for}, then lets its provider build the model.
86
+ #
87
+ # @param contender [Models::Contender] the lineup entry
88
+ # @param throttle [Models::Throttle] what the model's calls pass through
89
+ # @return [Models::Model]
90
+ def model_for(contender, throttle: Models::Throttle.new) = self.for(contender).model_for(contender, throttle:)
69
91
 
70
92
  # One refused contender: who, and why.
93
+ #
94
+ # @!attribute [r] contender
95
+ # @return [Models::Contender] the contender left out of the run
96
+ # @!attribute [r] reason
97
+ # @return [String] the request type it needs, against what the suite produces
71
98
  Refusal = Data.define(:contender, :reason) do
99
+ # The line a warning prints.
100
+ #
101
+ # @return [String]
72
102
  def to_s = "#{contender.model} refused: #{reason}"
73
103
  end
74
104
 
@@ -77,9 +107,10 @@ module LLM
77
107
  # is named, never run, and never scored 0: a model the suite cannot
78
108
  # even ask is not a model that answered wrongly.
79
109
  #
80
- # @param contenders [Array<Contender>]
110
+ # @param contenders [Array<Models::Contender>] the lineup
81
111
  # @param request_types [Array<Symbol>] what the suite's config declares
82
- # @return [Array(Array<Contender>, Array<Refusal>)] admitted, refused
112
+ # @return [Array(Array<Models::Contender>, Array<Refusal>)] admitted, refused
113
+ # @raise [Error] when no provider serves a contender
83
114
  def admit(contenders, request_types:)
84
115
  admitted, refused = contenders.partition { request_types.include?(self.for(it).request_type) }
85
116
  refusals = refused.map do |contender|
@@ -89,13 +120,15 @@ module LLM
89
120
  [admitted, refusals]
90
121
  end
91
122
 
123
+ # Providers disagree on efforts, so this is the union of them.
124
+ #
92
125
  # @return [Array<String>] every effort any provider accepts, for the flag's help
93
126
  def efforts = flat_map(&:efforts).uniq
94
127
 
95
128
  # Asks each provider in use for its catalog, and names every model it
96
129
  # does not list.
97
130
  #
98
- # @param contenders [Array<Contender>]
131
+ # @param contenders [Array<Models::Contender>] the lineup
99
132
  # @return [void]
100
133
  # @raise [Error] naming every unknown model
101
134
  def verify!(contenders)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+
6
+ module LLM
7
+ module Evals
8
+ module Providers
9
+ class TypeSafe < Base
10
+ # The HTTP side of typed questions: credentials, transport, and turning
11
+ # whatever came back into a parsed body or a gem error.
12
+ class Client
13
+ # Where typed questions are posted.
14
+ URL = URI("https://api.typesafe.ai/v1/systemone")
15
+ # Seconds to wait for a connection.
16
+ OPEN_TIMEOUT = 10
17
+ # Seconds to wait for a reply.
18
+ READ_TIMEOUT = 120
19
+
20
+ # Sends one request, reading the key on each call rather than at load.
21
+ #
22
+ # @param payload [Hash{Symbol => Object}] `model:`, `state:` and `questions:`
23
+ # @return [Hash{String => Object}] the reply, which has `answers`
24
+ # @raise [Error] on a missing key, or a 2xx that is not a usable answer
25
+ # @raise [HTTPError] on any status but 2xx
26
+ def ask(payload)
27
+ key = ENV.fetch(KEY, "")
28
+ raise Error, "#{KEY} is not set" if key.empty?
29
+
30
+ response = post(payload, key)
31
+ raise HTTPError, response unless response.is_a?(Net::HTTPSuccess)
32
+
33
+ parsed(response.body, payload[:model])
34
+ end
35
+
36
+ private
37
+
38
+ # Performs the HTTP call; transport errors pass through for {TypeSafe#retryable?} to judge.
39
+ #
40
+ # @param payload [Hash{Symbol => Object}] the JSON body
41
+ # @param key [String] the API key
42
+ # @return [Net::HTTPResponse]
43
+ def post(payload, key)
44
+ request = Net::HTTP::Post.new(URL, "Authorization" => "Bearer #{key}", "Content-Type" => "application/json")
45
+ request.body = JSON.generate(payload)
46
+ Net::HTTP.start(URL.host, URL.port, use_ssl: true, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) { it.request(request) }
47
+ end
48
+
49
+ # Accepts only a JSON object whose `answers` is an object.
50
+ #
51
+ # @param text [String] the response body
52
+ # @param model [String] the model name, for the error message
53
+ # @return [Hash{String => Object}] the parsed reply
54
+ # @raise [Error] when the body is not JSON or has no answers
55
+ def parsed(text, model)
56
+ body = JSON.parse(text)
57
+ return body if body.is_a?(Hash) && body["answers"].is_a?(Hash)
58
+
59
+ raise Error, "#{model}: unexpected TypeSafe answer, no answers: #{text.to_s[0, 500]}"
60
+ rescue JSON::ParserError
61
+ raise Error, "#{model}: unexpected TypeSafe answer, not JSON: #{text.to_s[0, 500]}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end