llm-evals 0.4.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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Finds the cases under a root with a glob and decodes each path into a
6
+ # {Case}. The default layout is
7
+ #
8
+ # datasets/<dataset>/cases/<variant>/[NNN/]input.txt
9
+ # datasets/<dataset>/cases/<variant>/[NNN/]expected.json
10
+ #
11
+ # The folder after `datasets/` is the dataset, the folder holding the
12
+ # input is the variant, unless it is a number, in which case it is the
13
+ # case number and its parent is the variant.
14
+ class Discovery
15
+ EXPECTED = "expected.json"
16
+ METADATA = "metadata.json"
17
+
18
+ attr_reader :root, :glob
19
+
20
+ # @param root [String] an absolute directory
21
+ # @param glob [String] relative to root
22
+ def initialize(root:, glob: Configuration::DEFAULT_GLOB)
23
+ @root = root
24
+ @glob = glob
25
+ end
26
+
27
+ # @return [Array<String>] every dataset that has at least one case, sorted
28
+ def datasets = all.map(&:dataset).uniq.sort
29
+
30
+ # @param dataset [String, nil] one dataset; nil for all of them
31
+ # @param only [Array<String>] id prefixes to keep ("clear", "clear/00"); empty keeps all
32
+ # @return [Array<Case>] sorted by dataset, variant and number
33
+ # @raise [Error] when the dataset does not exist
34
+ def cases(dataset: nil, only: [])
35
+ found = all
36
+ if dataset
37
+ raise Error, "no dataset #{dataset.inspect}; have #{datasets.join(", ")}" unless datasets.include?(dataset)
38
+
39
+ found = found.select { it.dataset == dataset }
40
+ end
41
+ only = only.flat_map { it.split(",") }.map(&:strip).reject(&:empty?)
42
+ found = found.select { |kase| only.any? { kase.id.start_with?(it) || kase.key.start_with?(it) } } unless only.empty?
43
+ found
44
+ end
45
+
46
+ # @return [Array<Case>]
47
+ def all
48
+ @all ||= Dir[File.join(root, glob)].map { decode(it) }
49
+ end
50
+
51
+ # Builds the case at one input file.
52
+ #
53
+ # @param input_path [String]
54
+ # @return [Case]
55
+ def decode(input_path)
56
+ dir = File.dirname(input_path)
57
+ parts = Pathname(dir).relative_path_from(Pathname(root)).each_filename.to_a
58
+ number = parts.last.match?(/\A\d+\z/) ? parts.last : nil
59
+ variant = number ? parts[-2] : parts.last
60
+ expected, problem = read_expected(File.join(dir, EXPECTED))
61
+ Case.new(
62
+ id: [variant, number].compact.join("/"),
63
+ dataset: dataset_of(parts),
64
+ variant: variant,
65
+ input: File.read(input_path).strip,
66
+ expected: expected,
67
+ path: dir,
68
+ metadata: metadata(dir, number, problem)
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ # The folder after `datasets/`, else the first folder, so a glob that
75
+ # does not say `datasets` still groups by its top level.
76
+ def dataset_of(parts)
77
+ index = parts.index("datasets")
78
+ index && parts[index + 1] ? parts[index + 1] : parts.first
79
+ end
80
+
81
+ def read_expected(path)
82
+ return [nil, "#{EXPECTED} is missing"] unless File.exist?(path)
83
+
84
+ [JSON.parse(File.read(path)), nil]
85
+ rescue JSON::ParserError => e
86
+ [nil, "#{EXPECTED} is not valid JSON: #{e.message.lines.first&.strip}"]
87
+ end
88
+
89
+ def metadata(dir, number, problem)
90
+ extra = File.exist?(File.join(dir, METADATA)) ? JSON.parse(File.read(File.join(dir, METADATA))).transform_keys(&:to_sym) : {}
91
+ { number: number&.to_i, difficulty: number.to_i, expected_error: problem }.compact.merge(extra)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # One model at one setting over every case of one dataset, run on a
6
+ # thread pool the gem owns, plus the per-case detail the terminal report
7
+ # needs. Trackers only watch: {Trackers::Local} always, and at most one
8
+ # remote tracker, each told when the experiment starts, when a case
9
+ # finishes, and what the summary was.
10
+ class Experiment
11
+ # What one finished experiment reports back. `means` holds one entry
12
+ # per metric plus "score", the mean of each case's overall value;
13
+ # `usage` is summed over the cases; `permalink` is the remote tracker's
14
+ # page, when it has one.
15
+ Summary = Data.define(:model, :dataset, :name, :means, :results, :errors, :duration, :permalink, :usage) do
16
+ # @return [Float, nil] the mean overall score
17
+ def score = means["score"]
18
+
19
+ # @param metric [Metric, String]
20
+ # @return [Float, nil]
21
+ def mean(metric) = means[metric.respond_to?(:name) ? metric.name : metric.to_s]
22
+
23
+ # The same experiment over one variant's cases. Its duration is the sum
24
+ # of those cases' own, since they overlapped with the others.
25
+ #
26
+ # @param variant [String]
27
+ # @param metrics [Array<Metric>] the scorer's
28
+ # @return [Summary]
29
+ def for_variant(variant, metrics)
30
+ chosen = results.select { it.case.variant == variant }
31
+ with(means: Experiment.means(metrics, chosen),
32
+ results: chosen,
33
+ errors: chosen.select(&:failed?).map { "#{it.case.key}: #{it.error}" },
34
+ duration: chosen.sum(&:duration),
35
+ usage: Experiment.usage(chosen))
36
+ end
37
+ end
38
+
39
+ # @param metrics [Array<Metric>]
40
+ # @param results [Array<RunResult>]
41
+ # @return [Hash{String => Float, nil}] one mean per metric, plus "score"
42
+ def self.means(metrics, results)
43
+ scored = results.filter_map(&:score)
44
+ means = metrics.to_h { |metric| [metric.name, metric.combine(scored.map { it.metrics[metric.name] })] }
45
+ means.merge("score" => scored.empty? ? nil : scored.sum(&:value).fdiv(scored.size))
46
+ end
47
+
48
+ # @param results [Array<RunResult>]
49
+ # @return [Usage] summed over the results
50
+ def self.usage(results) = results.filter_map(&:reply).map(&:usage).reduce(Usage.zero, :+)
51
+
52
+ attr_reader :model, :adapter, :scorer, :cases, :dataset, :parallelism, :trackers
53
+
54
+ # @param model [Model]
55
+ # @param cases [Array<Case>] all of one dataset
56
+ # @param adapter [Adapter]
57
+ # @param scorer [Scorer]
58
+ # @param parallelism [Integer] cases in flight at once
59
+ # @param trackers [Array<Tracker>] told about the run, in this order
60
+ def initialize(model:, cases:, adapter:, scorer:, parallelism: 4, trackers: [])
61
+ raise ArgumentError, "an experiment needs at least one case" if cases.empty?
62
+
63
+ @model = model
64
+ @cases = cases.sort_by { [it.variant, it.number || 0, it.id] }
65
+ @dataset = cases.first.dataset
66
+ @adapter = adapter
67
+ @scorer = scorer
68
+ @parallelism = [parallelism.to_i, 1].max
69
+ @trackers = trackers.select(&:enabled?)
70
+ @results = {}
71
+ @lock = Mutex.new
72
+ end
73
+
74
+ # @return [String] e.g. "anthropic/claude-haiku-5.5 effort=low t=0.0 [clear]"
75
+ def name = "#{model} [#{dataset}]"
76
+
77
+ # @param on_case [#call, nil] called once per finished case
78
+ # @return [Summary]
79
+ def run(on_case: nil)
80
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+ adapter.prepare(dataset)
82
+ trackers.each { it.start(self) }
83
+ errors = run_locally(on_case)
84
+ summary = summarize(errors, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
85
+ urls = trackers.map { it.finish(self, summary)&.fetch(:url, nil) }
86
+ summary.with(permalink: urls.compact.first)
87
+ end
88
+
89
+ # Runs one case: the adapter, then the scorer. A reply the model failed
90
+ # (`Reply#error`) scores 0 without troubling the scorer; an exception is
91
+ # the transport's and is left to the caller, which counts it as an error.
92
+ #
93
+ # @param kase [Case]
94
+ # @return [RunResult]
95
+ def evaluate(kase)
96
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
+ reply = adapter.call(kase, model: model)
98
+ raise Error, "#{adapter.class}#call returned #{reply.class}, not an LLM::Evals::Reply" unless reply.is_a?(Reply)
99
+
100
+ score = reply.error? ? Score.failed(scorer.metrics, reply.error) : scorer.call(kase, reply, context: adapter.context(kase))
101
+ RunResult.new(experiment: self,
102
+ case: kase,
103
+ reply: reply,
104
+ score: score,
105
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
106
+ end
107
+
108
+ private
109
+
110
+ # Remembers a result and tells the trackers, under one lock, so a
111
+ # tracker sees results one at a time and in the order they are kept.
112
+ def remember(result)
113
+ @lock.synchronize do
114
+ @results[result.case.key] = result
115
+ trackers.each { it.record(result) }
116
+ end
117
+ result
118
+ end
119
+
120
+ # A fixed pool of threads over a queue: `parallelism` cases in flight.
121
+ def run_locally(on_case)
122
+ queue = Queue.new
123
+ cases.each { queue << it }
124
+ queue.close
125
+ errors = Queue.new
126
+ Array.new([parallelism, cases.size].min) do
127
+ Thread.new do
128
+ while (kase = queue.pop)
129
+ begin
130
+ remember evaluate(kase)
131
+ rescue StandardError => e
132
+ remember RunResult.new(experiment: self, case: kase, error: "#{e.class}: #{e.message}")
133
+ errors << "#{kase.key}: #{e.class}: #{e.message}"
134
+ end
135
+ on_case&.call
136
+ end
137
+ end
138
+ end.each(&:join)
139
+ Array.new(errors.size) { errors.pop }
140
+ end
141
+
142
+ def summarize(errors, permalink, duration)
143
+ results = @lock.synchronize { @results.values }.sort_by { cases.index(it.case) }
144
+ Summary.new(
145
+ model: model,
146
+ dataset: dataset,
147
+ name: name,
148
+ means: self.class.means(scorer.metrics, results),
149
+ results: results,
150
+ errors: Array(errors),
151
+ duration: duration,
152
+ permalink: permalink,
153
+ usage: self.class.usage(results)
154
+ )
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Finds and loads the suite: `evals/eval_helper.rb`, the file that calls
6
+ # {LLM::Evals.configure}, or, for a config-only suite, the directory
7
+ # holding `evals/.llm-evals.config.yml`. The executable looks before
8
+ # dispatching, the way rspec finds spec_helper, so the suite's own
9
+ # directory needs no wrapper script.
10
+ module Helper
11
+ FILE = File.join("evals", "eval_helper.rb")
12
+ ENV_VAR = "LLM_EVALS_HELPER"
13
+ FLAG = "--helper"
14
+
15
+ # What {.locate} found: a helper to load, a config file to read, or neither.
16
+ Found = Data.define(:helper, :config) do
17
+ def initialize(helper: nil, config: nil) = super
18
+
19
+ # @return [Boolean] whether there is anything to load
20
+ def any? = !helper.nil? || !config.nil?
21
+
22
+ # @return [Boolean]
23
+ def none? = !any?
24
+ end
25
+
26
+ module_function
27
+
28
+ # Pulls `--helper PATH` (or `--helper=PATH`) out of argv, so dry-cli
29
+ # never sees a flag no command declares.
30
+ #
31
+ # @param argv [Array<String>]
32
+ # @return [Array(Array<String>, String, nil)] the remaining arguments and the path, if given
33
+ def extract(argv)
34
+ rest = []
35
+ path = nil
36
+ skip = false
37
+ argv.each_with_index do |arg, index|
38
+ next skip = false if skip
39
+
40
+ if arg == FLAG
41
+ path = argv[index + 1]
42
+ skip = true
43
+ elsif arg.start_with?("#{FLAG}=")
44
+ path = arg.delete_prefix("#{FLAG}=")
45
+ else
46
+ rest << arg
47
+ end
48
+ end
49
+ [rest, path]
50
+ end
51
+
52
+ # The suite to load: the flag, then the variable, then, from the
53
+ # directory upward, the nearest `evals/eval_helper.rb` or, failing
54
+ # that, the nearest `evals/.llm-evals.config.yml` (also found from
55
+ # inside `evals/` itself).
56
+ #
57
+ # @param explicit [String, nil] what `--helper` was given
58
+ # @param from [String] the directory to search up from
59
+ # @param env [Hash] the environment
60
+ # @return [Found] absolute paths; empty when there is none
61
+ def locate(explicit = nil, from: Dir.pwd, env: ENV)
62
+ candidate = explicit || env[ENV_VAR]
63
+ return Found.new(helper: File.expand_path(candidate, from)) if candidate && !candidate.empty?
64
+
65
+ Pathname(from).expand_path.ascend do |dir|
66
+ helper = dir.join(FILE)
67
+ config = [dir.join("evals", Configuration::DEFAULT_CONFIG), dir.join(Configuration::DEFAULT_CONFIG)].find(&:file?)
68
+ return Found.new(helper: helper.to_s, config: config&.to_s) if helper.file?
69
+ return Found.new(config: config.to_s) if config
70
+ end
71
+ Found.new
72
+ end
73
+
74
+ # Loads the helper, once, or roots the configuration at a config-only
75
+ # suite's directory so {Configuration#resolve_from_config!} can read it.
76
+ # A helper is loaded first and wins; the config fills what it left unset.
77
+ #
78
+ # @param found [Found, String, nil] nothing is loaded for nil or an empty Found
79
+ # @param configuration [Configuration]
80
+ # @return [Boolean] whether anything was loaded
81
+ # @raise [Error] when the helper does not exist
82
+ def load!(found, configuration: LLM::Evals.configuration)
83
+ found = Found.new(helper: found) if found.is_a?(String)
84
+ return false if found.nil? || found.none?
85
+
86
+ if found.helper
87
+ raise Error, "no eval helper at #{found.helper}" unless File.file?(found.helper)
88
+
89
+ require File.expand_path(found.helper)
90
+ else
91
+ root = File.dirname(found.config)
92
+ configuration.suite(File.basename(File.dirname(root)), root: root) unless configuration.complete?
93
+ end
94
+ true
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Starts the CLI. The executable and the specs both build one, each with
6
+ # its own argv and streams, and it exits only through kernel.exit.
7
+ class Launcher
8
+ def initialize(argv, stdin = $stdin, stdout = $stdout, stderr = $stderr, kernel = Kernel)
9
+ @argv = argv
10
+ @stdin = stdin
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ @kernel = kernel
14
+ end
15
+
16
+ def execute!
17
+ @kernel.exit(run)
18
+ end
19
+
20
+ private
21
+
22
+ # dry-cli exits by itself after help or a usage error; its status is kept.
23
+ def run
24
+ argv, helper = Helper.extract(@argv)
25
+ Helper.load!(Helper.locate(helper))
26
+ Dry::CLI.new(CLI).call(arguments: argv, out: @stdout, err: @stderr)
27
+ 0
28
+ rescue SystemExit => e
29
+ e.status
30
+ rescue StandardError => e
31
+ @stderr.puts "error: #{e.message}"
32
+ @stderr.puts(e.backtrace) if @argv.intersect?(%w[-v --verbose])
33
+ 1
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # One number a {Scorer} reports per case. `aggregate` is how the run
6
+ # combines them (only :mean today), skipping nil, so a metric a case does
7
+ # not apply to never drags its mean down with a free 0 or up with a free 1.
8
+ # The `primary` metric ranks the leaderboard and picks the best value.
9
+ class Metric < Data.define(:name, :description, :aggregate, :primary)
10
+ AGGREGATES = %i[mean].freeze
11
+
12
+ def initialize(name:, description: "", aggregate: :mean, primary: false)
13
+ raise ArgumentError, "aggregate must be one of #{AGGREGATES.join(", ")}" unless AGGREGATES.include?(aggregate)
14
+
15
+ super(name: name.to_s, description: description, aggregate: aggregate, primary: primary)
16
+ end
17
+
18
+ # @param values [Array<Float, nil>] one per case
19
+ # @return [Float, nil] nil when no case reported the metric
20
+ def combine(values)
21
+ present = values.compact
22
+ present.sum.fdiv(present.size) unless present.empty?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # One model at one setting, reached through its provider. Adapters call
6
+ # {#complete}; the settings an experiment is named by are read here.
7
+ class Model
8
+ # The settings every provider understands; a provider may accept more.
9
+ SETTINGS = %i[effort temperature max_tokens].freeze
10
+
11
+ attr_reader :id, :provider, :settings
12
+
13
+ # @param id [String] the model id as the lineup names it, e.g. "anthropic/claude-haiku-5.5"
14
+ # @param provider [Providers::Base] what answers {#complete}
15
+ # @param settings [Hash{Symbol => Object}] effort, temperature, max_tokens; nil leaves the model's default
16
+ def initialize(id:, provider:, settings: {})
17
+ @id = id
18
+ @provider = provider
19
+ @settings = settings.transform_keys(&:to_sym).freeze
20
+ end
21
+
22
+ # @return [String, nil] reasoning effort; nil sends none
23
+ def effort = settings[:effort]
24
+
25
+ # @return [Float, nil] sampling temperature; nil sends none
26
+ def temperature = settings[:temperature]
27
+
28
+ # @return [Integer, nil] output token cap
29
+ def max_tokens = settings[:max_tokens]
30
+
31
+ # @return [String] the id without the provider's prefix, as the provider's API knows it
32
+ def name = provider.model_name(id)
33
+
34
+ # @return [String] "provider:id", unique where two providers render the same id
35
+ def key = "#{provider.name}:#{id}"
36
+
37
+ # One call. Transport failures raise; they are the run's errors, not the model's.
38
+ #
39
+ # @param system [String, nil]
40
+ # @param user [String]
41
+ # @param json [Boolean] ask the provider for a JSON object reply where it supports that
42
+ # @return [Completion]
43
+ def complete(user:, system: nil, json: true)
44
+ provider.complete(self, system: system, user: user, json: json)
45
+ end
46
+
47
+ # @return [String] e.g. "effort=low t=0.2"
48
+ def label
49
+ "effort=#{effort || "default"} t=#{temperature.nil? ? "default" : temperature}"
50
+ end
51
+
52
+ def to_s = "#{id} #{label}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Providers
6
+ # What answers a {Model}'s calls: one API, the ids it serves, the keys it
7
+ # needs and the models it lists. A suite with its own transport
8
+ # subclasses this and registers it through `c.provider`.
9
+ #
10
+ # @abstract Override {#serves?}, {#env_keys}, {#catalog} and {#complete}.
11
+ class Base
12
+ # The reasoning efforts this provider's API accepts.
13
+ EFFORTS = %w[none minimal low medium high xhigh max].freeze
14
+
15
+ # The kind of request this provider answers; see {Config::REQUEST_TYPES}.
16
+ # A suite admits a provider only when its config lists this type.
17
+ #
18
+ # @return [Symbol]
19
+ def request_type
20
+ raise NotImplementedError, "#{self.class}#request_type must be one of #{Config::REQUEST_TYPES.join(", ")}"
21
+ end
22
+
23
+ # One line on what the request type is, for the README and `--help`;
24
+ # a suite's config never says it, the provider owns it.
25
+ #
26
+ # @return [String]
27
+ def request_type_description = ""
28
+
29
+ # 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.
32
+ #
33
+ # @return [Boolean]
34
+ def direct? = false
35
+
36
+ # @return [String] short name, for messages and the leaderboard
37
+ def name = self.class.name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
38
+
39
+ # @param model_id [String] as the lineup names it
40
+ # @return [Boolean] whether this provider serves the model
41
+ def serves?(model_id) = model_id.start_with?(prefix)
42
+
43
+ # @return [String] the id prefix this provider claims; "" claims everything
44
+ def prefix = ""
45
+
46
+ # @param model_id [String]
47
+ # @return [String] the id as the provider's API knows it
48
+ def model_name(model_id) = model_id.delete_prefix(prefix)
49
+
50
+ # @return [Array<String>] environment variables a run through this provider needs
51
+ def env_keys = []
52
+
53
+ # @return [Array<String>] the efforts the `-e` flag accepts for this provider's models
54
+ def efforts = EFFORTS
55
+
56
+ # Lists the model ids the provider serves, from the network, so a
57
+ # misspelled id aborts the run before any experiment starts instead
58
+ # of failing every case and showing up as a row of errors.
59
+ #
60
+ # @return [Array<String>, nil] ids as the lineup names them; nil when the provider cannot list them
61
+ def catalog = nil
62
+
63
+ # Prepares the client once per process, after the env files loaded.
64
+ #
65
+ # @option options [String] :app_name what the client tells the API the caller is
66
+ # @return [void]
67
+ def setup!(**) = nil
68
+
69
+ # @param model [Model]
70
+ # @param system [String, nil]
71
+ # @param user [String]
72
+ # @param json [Boolean]
73
+ # @return [Completion]
74
+ def complete(model, system:, user:, json: true)
75
+ raise NotImplementedError, "#{self.class}#complete must return an LLM::Evals::Completion"
76
+ end
77
+
78
+ # @param contender [Contender]
79
+ # @return [Model]
80
+ def model_for(contender) = Model.new(id: contender.model, provider: self, settings: contender.settings)
81
+
82
+ # Every id in `model_ids` the provider does not list.
83
+ #
84
+ # @param model_ids [Array<String>]
85
+ # @return [Array<String>]
86
+ def unknown(model_ids)
87
+ listed = catalog
88
+ listed.nil? ? [] : model_ids - listed
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module LLM
6
+ module Evals
7
+ module Providers
8
+ # One GET of a provider's model list, parsed. Every failure becomes an
9
+ # {Error} that names the URL, so an offline run says so instead of
10
+ # printing a socket error from inside a provider.
11
+ module Catalog
12
+ module_function
13
+
14
+ # @param url [String]
15
+ # @param headers [Hash{String => String}]
16
+ # @return [Hash] the parsed JSON body
17
+ # @raise [Error] when the list cannot be read
18
+ def fetch(url, headers = {})
19
+ uri = URI(url)
20
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 20) do |http|
21
+ http.request(Net::HTTP::Get.new(uri, headers))
22
+ end
23
+ raise Error, "could not list models at #{url}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
24
+
25
+ JSON.parse(response.body)
26
+ rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, JSON::ParserError, KeyError => e
27
+ raise Error, "could not list models at #{url}: #{e.message}"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Providers
6
+ # Every model OpenRouter serves, through RubyLLM, because Braintrust
7
+ # traces RubyLLM calls (tokens, latency, cost) and not raw HTTP. Claims
8
+ # every id no other provider does, so it is always asked last.
9
+ class OpenRouter < Base
10
+ URL = "https://openrouter.ai/api/v1/models"
11
+ KEY = "OPENROUTER_API_KEY"
12
+
13
+ # OpenRouter's reasoning.effort values.
14
+ EFFORTS = %w[none minimal low medium high xhigh max].freeze
15
+
16
+ def name = "openrouter"
17
+ def request_type = :prompt
18
+ def request_type_description = "a chat completion: system and user text in, text out"
19
+ def env_keys = [KEY]
20
+ def efforts = EFFORTS
21
+
22
+ # @return [Array<String>] every id OpenRouter lists
23
+ def catalog = Catalog.fetch(URL).fetch("data").map { it.fetch("id") }
24
+
25
+ # Points RubyLLM at OpenRouter. Requires it here, not at load, so a
26
+ # suite that never reaches OpenRouter never needs the gem.
27
+ #
28
+ # @param app_name [String]
29
+ # @return [void]
30
+ def setup!(app_name:)
31
+ LLM::Evals.optional!("ruby_llm", needed_for: "calling models through OpenRouter")
32
+ RubyLLM.configure do |config|
33
+ config.openrouter_api_key = ENV.fetch(KEY, nil)
34
+ config.openrouter_app_name = app_name
35
+ end
36
+ end
37
+
38
+ # @param model [Model]
39
+ # @param system [String, nil]
40
+ # @param user [String]
41
+ # @param json [Boolean] asks for `response_format: json_object`
42
+ # @return [Completion] text, plus the tokens and the cost OpenRouter billed
43
+ def complete(model, system:, user:, json: true)
44
+ LLM::Evals.optional!("ruby_llm", needed_for: "calling models through OpenRouter")
45
+ chat = RubyLLM.chat(model: model.name, provider: :openrouter, assume_model_exists: true)
46
+ chat.with_instructions(system) if system
47
+ chat.with_temperature(model.temperature) unless model.temperature.nil?
48
+ chat.with_max_output_tokens(model.max_tokens) if model.max_tokens
49
+ chat.with_thinking(effort: model.effort.to_sym) if model.effort
50
+ chat.with_provider_options(response_format: { type: "json_object" }) if json
51
+
52
+ message = chat.ask(user)
53
+ Completion.new(
54
+ text: message.content.to_s,
55
+ usage: Usage.new(input_tokens: message.tokens.input.to_i,
56
+ output_tokens: message.tokens.output.to_i,
57
+ cost_usd: message.tokens.reported_cost.to_f),
58
+ raw: message
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end