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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +764 -0
- data/exe/evals +9 -0
- data/exe/llm-evals +14 -0
- data/lib/llm/evals/adapter.rb +47 -0
- data/lib/llm/evals/case.rb +36 -0
- data/lib/llm/evals/cli/cases.rb +48 -0
- data/lib/llm/evals/cli/command.rb +163 -0
- data/lib/llm/evals/cli/compare.rb +40 -0
- data/lib/llm/evals/cli/formatting.rb +16 -0
- data/lib/llm/evals/cli/leaderboard.rb +183 -0
- data/lib/llm/evals/cli/model_colors.rb +23 -0
- data/lib/llm/evals/cli/model_command.rb +437 -0
- data/lib/llm/evals/cli/progress_bars.rb +147 -0
- data/lib/llm/evals/cli/render.rb +37 -0
- data/lib/llm/evals/cli/run.rb +69 -0
- data/lib/llm/evals/cli/standings.rb +114 -0
- data/lib/llm/evals/cli/verify.rb +24 -0
- data/lib/llm/evals/cli/version.rb +15 -0
- data/lib/llm/evals/cli.rb +43 -0
- data/lib/llm/evals/completion.rb +11 -0
- data/lib/llm/evals/config.rb +198 -0
- data/lib/llm/evals/configuration.rb +173 -0
- data/lib/llm/evals/contender.rb +31 -0
- data/lib/llm/evals/discovery.rb +95 -0
- data/lib/llm/evals/experiment.rb +158 -0
- data/lib/llm/evals/helper.rb +98 -0
- data/lib/llm/evals/launcher.rb +37 -0
- data/lib/llm/evals/metric.rb +26 -0
- data/lib/llm/evals/model.rb +55 -0
- data/lib/llm/evals/providers/base.rb +93 -0
- data/lib/llm/evals/providers/catalog.rb +32 -0
- data/lib/llm/evals/providers/open_router.rb +64 -0
- data/lib/llm/evals/providers/registry.rb +112 -0
- data/lib/llm/evals/providers/type_safe.rb +53 -0
- data/lib/llm/evals/render/reports/html.rb +266 -0
- data/lib/llm/evals/render/reports/json.rb +172 -0
- data/lib/llm/evals/render/reports/pipeline.rb +153 -0
- data/lib/llm/evals/render/reports/terminal.rb +46 -0
- data/lib/llm/evals/reply.rb +18 -0
- data/lib/llm/evals/run_result.rb +14 -0
- data/lib/llm/evals/runtime.rb +74 -0
- data/lib/llm/evals/schema_validator.rb +84 -0
- data/lib/llm/evals/score.rb +26 -0
- data/lib/llm/evals/scorer.rb +45 -0
- data/lib/llm/evals/templates/html_report.css +206 -0
- data/lib/llm/evals/templates/html_report.html.erb +184 -0
- data/lib/llm/evals/templates/html_report.js +48 -0
- data/lib/llm/evals/testing/echo_adapter.rb +29 -0
- data/lib/llm/evals/testing/fake_model.rb +18 -0
- data/lib/llm/evals/testing/fake_provider.rb +67 -0
- data/lib/llm/evals/tracker.rb +80 -0
- data/lib/llm/evals/trackers/braintrust.rb +91 -0
- data/lib/llm/evals/trackers/local.rb +78 -0
- data/lib/llm/evals/trackers/logfire.rb +143 -0
- data/lib/llm/evals/trackers/registry.rb +82 -0
- data/lib/llm/evals/usage.rb +33 -0
- data/lib/llm/evals/verify.rb +49 -0
- data/lib/llm/evals/version.rb +7 -0
- data/lib/llm/evals/warnings.rb +98 -0
- data/lib/llm/evals/weighted_score.rb +116 -0
- data/lib/llm/evals.rb +83 -0
- 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
|