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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +163 -87
- data/exe/llm-evals +60 -5
- data/lib/.DS_Store +0 -0
- data/lib/llm/.DS_Store +0 -0
- data/lib/llm/evals/actions/adapter.rb +50 -0
- data/lib/llm/evals/actions/error_budget.rb +63 -0
- data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
- data/lib/llm/evals/actions/error_streak.rb +83 -0
- data/lib/llm/evals/actions/experiment.rb +251 -0
- data/lib/llm/evals/actions/verify.rb +61 -0
- data/lib/llm/evals/actions.rb +9 -0
- data/lib/llm/evals/cli/cases.rb +10 -0
- data/lib/llm/evals/cli/command.rb +32 -99
- data/lib/llm/evals/cli/compare.rb +0 -20
- data/lib/llm/evals/cli/contender_selector.rb +61 -0
- data/lib/llm/evals/cli/error_pause.rb +81 -0
- data/lib/llm/evals/cli/experiment_pool.rb +97 -0
- data/lib/llm/evals/cli/formatting.rb +8 -0
- data/lib/llm/evals/cli/leaderboard.rb +66 -12
- data/lib/llm/evals/cli/model_colors.rb +3 -0
- data/lib/llm/evals/cli/model_command.rb +152 -315
- data/lib/llm/evals/cli/model_options.rb +80 -0
- data/lib/llm/evals/cli/option_groups.rb +32 -0
- data/lib/llm/evals/cli/progress_bars.rb +92 -5
- data/lib/llm/evals/cli/render.rb +6 -6
- data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
- data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
- data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
- data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
- data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
- data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
- data/lib/llm/evals/cli/run.rb +13 -22
- data/lib/llm/evals/cli/run_planner.rb +97 -0
- data/lib/llm/evals/cli/run_record.rb +68 -0
- data/lib/llm/evals/cli/standings.rb +39 -17
- data/lib/llm/evals/cli/status_box.rb +51 -0
- data/lib/llm/evals/cli/table.rb +103 -0
- data/lib/llm/evals/cli/text.rb +22 -0
- data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
- data/lib/llm/evals/cli/upgrade_config.rb +60 -0
- data/lib/llm/evals/cli/verify.rb +5 -1
- data/lib/llm/evals/cli/version.rb +4 -0
- data/lib/llm/evals/cli/weights.rb +48 -0
- data/lib/llm/evals/cli.rb +39 -6
- data/lib/llm/evals/config/contender_builder.rb +30 -0
- data/lib/llm/evals/config/error_limits.rb +41 -0
- data/lib/llm/evals/config/listing.rb +153 -0
- data/lib/llm/evals/config/results.rb +42 -0
- data/lib/llm/evals/config/score_weights.rb +25 -0
- data/lib/llm/evals/config/upgrade.rb +107 -0
- data/lib/llm/evals/config.rb +90 -79
- data/lib/llm/evals/datasets/case.rb +46 -0
- data/lib/llm/evals/datasets/discovery.rb +143 -0
- data/lib/llm/evals/datasets.rb +9 -0
- data/lib/llm/evals/launcher.rb +57 -3
- data/lib/llm/evals/models/contender.rb +51 -0
- data/lib/llm/evals/models/model.rb +87 -0
- data/lib/llm/evals/models/throttle.rb +170 -0
- data/lib/llm/evals/models.rb +9 -0
- data/lib/llm/evals/providers/base.rb +61 -16
- data/lib/llm/evals/providers/catalog.rb +5 -2
- data/lib/llm/evals/providers/open_router.rb +54 -7
- data/lib/llm/evals/providers/registry.rb +49 -16
- data/lib/llm/evals/providers/type_safe/client.rb +67 -0
- data/lib/llm/evals/providers/type_safe.rb +113 -12
- data/lib/llm/evals/providers.rb +9 -0
- data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
- data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
- data/lib/llm/evals/render/reports/html/format.rb +38 -0
- data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
- data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
- data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
- data/lib/llm/evals/render/reports/html.rb +60 -181
- data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
- data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
- data/lib/llm/evals/render/reports/json.rb +38 -141
- data/lib/llm/evals/render/reports/pipeline.rb +55 -20
- data/lib/llm/evals/render/reports/terminal.rb +8 -1
- data/lib/llm/evals/render.rb +9 -0
- data/lib/llm/evals/results/provider_response.rb +25 -0
- data/lib/llm/evals/results/reply.rb +38 -0
- data/lib/llm/evals/results/run_outcome.rb +15 -0
- data/lib/llm/evals/results/run_result.rb +40 -0
- data/lib/llm/evals/results/score.rb +45 -0
- data/lib/llm/evals/results/warnings.rb +149 -0
- data/lib/llm/evals/results.rb +9 -0
- data/lib/llm/evals/runtime/configuration.rb +295 -0
- data/lib/llm/evals/runtime/environment.rb +90 -0
- data/lib/llm/evals/runtime/helper.rb +153 -0
- data/lib/llm/evals/runtime.rb +2 -67
- data/lib/llm/evals/scoring/metric.rb +47 -0
- data/lib/llm/evals/scoring/schema_validator.rb +102 -0
- data/lib/llm/evals/scoring/scorer.rb +49 -0
- data/lib/llm/evals/scoring/weighted_score.rb +148 -0
- data/lib/llm/evals/scoring.rb +9 -0
- data/lib/llm/evals/stats/call_stats.rb +91 -0
- data/lib/llm/evals/stats/usage.rb +46 -0
- data/lib/llm/evals/stats.rb +29 -0
- data/lib/llm/evals/testing/echo_adapter.rb +14 -9
- data/lib/llm/evals/testing/fake_model.rb +8 -6
- data/lib/llm/evals/testing/fake_provider.rb +39 -8
- data/lib/llm/evals/testing.rb +9 -0
- data/lib/llm/evals/trackers/base.rb +110 -0
- data/lib/llm/evals/trackers/braintrust.rb +40 -3
- data/lib/llm/evals/trackers/local.rb +38 -2
- data/lib/llm/evals/trackers/logfire.rb +56 -3
- data/lib/llm/evals/trackers/registry.rb +29 -7
- data/lib/llm/evals/trackers.rb +9 -0
- data/lib/llm/evals/version.rb +3 -1
- data/lib/llm/evals.rb +17 -7
- metadata +91 -24
- data/lib/llm/evals/adapter.rb +0 -47
- data/lib/llm/evals/case.rb +0 -36
- data/lib/llm/evals/completion.rb +0 -11
- data/lib/llm/evals/configuration.rb +0 -173
- data/lib/llm/evals/contender.rb +0 -31
- data/lib/llm/evals/discovery.rb +0 -95
- data/lib/llm/evals/experiment.rb +0 -158
- data/lib/llm/evals/helper.rb +0 -98
- data/lib/llm/evals/metric.rb +0 -26
- data/lib/llm/evals/model.rb +0 -55
- data/lib/llm/evals/reply.rb +0 -18
- data/lib/llm/evals/run_result.rb +0 -14
- data/lib/llm/evals/schema_validator.rb +0 -84
- data/lib/llm/evals/score.rb +0 -26
- data/lib/llm/evals/scorer.rb +0 -45
- data/lib/llm/evals/tracker.rb +0 -80
- data/lib/llm/evals/usage.rb +0 -33
- data/lib/llm/evals/verify.rb +0 -49
- data/lib/llm/evals/warnings.rb +0 -98
- data/lib/llm/evals/weighted_score.rb +0 -116
- /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
- /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
|
|
@@ -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
|
|
31
|
-
#
|
|
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
|
-
# @
|
|
66
|
-
#
|
|
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
|
-
#
|
|
70
|
-
#
|
|
71
|
-
# @param
|
|
72
|
-
# @param
|
|
73
|
-
# @
|
|
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::
|
|
93
|
+
raise NotImplementedError, "#{self.class}#complete must return an LLM::Evals::Results::ProviderResponse"
|
|
76
94
|
end
|
|
77
95
|
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
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
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# @param
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
67
|
-
#
|
|
68
|
-
|
|
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
|