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
data/lib/llm/evals/cli.rb
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module LLM
|
|
4
2
|
module Evals
|
|
5
3
|
# Every command the executable answers to. The help's title and
|
|
@@ -8,17 +6,50 @@ module LLM
|
|
|
8
6
|
module CLI
|
|
9
7
|
extend Dry::CLI::Registry
|
|
10
8
|
|
|
9
|
+
class << self
|
|
10
|
+
# @return [String] the help's title
|
|
11
|
+
def header
|
|
12
|
+
p = Pastel.new
|
|
13
|
+
p.green.inverse("\n #{Runtime::Configuration::DEFAULT_NAME} ") +
|
|
14
|
+
p.yellow.inverse(" v#{VERSION} ") +
|
|
15
|
+
p.cyan.inverse.bold(" © 2026 Konstantin Gredeskoul, MIT License ") +
|
|
16
|
+
p.cyan("") + p.reset("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Confirms which evals are running: a green box naming the suite and its
|
|
20
|
+
# file, or a yellow one saying there is no file.
|
|
21
|
+
#
|
|
22
|
+
# @param configuration [LLM::Evals::Runtime::Configuration]
|
|
23
|
+
# @param color [Boolean]
|
|
24
|
+
# @param width [Integer] the box's whole width; a console reporting no size, as CI does, gets the narrowest
|
|
25
|
+
# @return [String]
|
|
26
|
+
def dataset_conf_label(configuration:, color: false, width: IO.console&.winsize&.last.to_i.clamp(60..))
|
|
27
|
+
box = StatusBox.new(width:, color:)
|
|
28
|
+
return box.render(:warning, configuration.config_missing_message) unless configuration.config_found?
|
|
29
|
+
|
|
30
|
+
box.render(:found, "Evals configuration: #{configuration.name}\nFile: #{configuration.relative_config}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
11
34
|
# What users type, so what the help and shell completion are written for.
|
|
12
35
|
# exe/llm-evals exists only for a system where `evals` is taken.
|
|
13
|
-
PROGRAM = "evals"
|
|
36
|
+
PROGRAM = "evals".freeze
|
|
37
|
+
|
|
38
|
+
Dry::CLI::Help::Screens::Command.prepend(OptionGroups)
|
|
14
39
|
|
|
15
40
|
Dry::CLI::Help.configure do
|
|
16
41
|
configuration = LLM::Evals.configuration
|
|
17
|
-
|
|
18
|
-
|
|
42
|
+
|
|
43
|
+
title ::LLM::Evals::CLI.header
|
|
44
|
+
|
|
45
|
+
types = configuration.providers.uniq(&:request_type).map do |provider|
|
|
46
|
+
"#{provider.request_type} (#{provider.request_type_description})"
|
|
47
|
+
end
|
|
48
|
+
|
|
19
49
|
description "#{configuration.description} Request types: #{types.join("; ")}."
|
|
20
50
|
|
|
21
|
-
columns = IO.console&.winsize&.last || 80
|
|
51
|
+
columns = (IO.console&.winsize&.last || 80).clamp(60, 90)
|
|
52
|
+
|
|
22
53
|
width columns.clamp(60, 120)
|
|
23
54
|
exit_code_without_arguments 0
|
|
24
55
|
|
|
@@ -36,6 +67,8 @@ module LLM
|
|
|
36
67
|
register "render", Render
|
|
37
68
|
register "cases", Cases
|
|
38
69
|
register "verify", Verify
|
|
70
|
+
register "upgrade-config", UpgradeConfig
|
|
71
|
+
register "weights", Weights
|
|
39
72
|
register "version", Version, aliases: ["-V", "--version"]
|
|
40
73
|
register "completion", Dry::CLI::Autocomplete::Command[self, program_name: PROGRAM]
|
|
41
74
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
class Config
|
|
6
|
+
# One contender per effort and temperature a model's settings list.
|
|
7
|
+
class ContenderBuilder
|
|
8
|
+
# @param defaults [Hash{String => Object}] under the entry's own settings
|
|
9
|
+
# @param overrides [Hash{String => Object}] over them; nil entries are ignored
|
|
10
|
+
def initialize(defaults:, overrides:)
|
|
11
|
+
@defaults = defaults
|
|
12
|
+
@overrides = overrides.compact
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Expands one model's lists of efforts and temperatures into their product.
|
|
16
|
+
#
|
|
17
|
+
# @param model [String] the model id
|
|
18
|
+
# @param entry [Hash{String => Object}] the model's settings in the file
|
|
19
|
+
# @param provider [Symbol, nil] a direct provider's name; nil when the id picks the provider
|
|
20
|
+
# @return [Array<Models::Contender>]
|
|
21
|
+
def call(model, entry = {}, provider: nil)
|
|
22
|
+
settings = @defaults.merge(entry).merge(@overrides)
|
|
23
|
+
Array(settings["effort"]).product(Array(settings["temperature"])).map do |effort, temperature|
|
|
24
|
+
Models::Contender.new(model:, effort: effort.to_s, temperature:, max_tokens: Integer(settings["max_tokens"]), provider:)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
class Config
|
|
6
|
+
# The `errors:` section: when to give up on a run. Both limits must be met,
|
|
7
|
+
# and `percent` needs `total`, since one failed first call is already 100%.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] total
|
|
10
|
+
# @return [Integer] errors before the run may abort
|
|
11
|
+
# @!attribute [r] percent
|
|
12
|
+
# @return [Numeric, nil] the share of finished calls that must have failed
|
|
13
|
+
ErrorLimits = Data.define(:total, :percent) do
|
|
14
|
+
# Checks the section as the file wrote it.
|
|
15
|
+
#
|
|
16
|
+
# @param section [Object] the section as the file has it, not nil
|
|
17
|
+
# @param path [String] names the file in an error
|
|
18
|
+
# @return [ErrorLimits]
|
|
19
|
+
# @raise [Error] on a limit that is missing or out of range
|
|
20
|
+
def self.from_hash(section, path:)
|
|
21
|
+
limits = section["abort_after"] if section.is_a?(Hash)
|
|
22
|
+
raise Error, "#{path}: errors needs an abort_after: mapping of total and percent" unless limits.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
total = limits["total"]
|
|
25
|
+
percent = limits["percent"]
|
|
26
|
+
raise Error, "#{path}: errors.abort_after.total must be a whole number above 0" unless total.is_a?(Integer) && total.positive?
|
|
27
|
+
unless percent.nil? || (percent.is_a?(Numeric) && percent.positive? && percent <= 100)
|
|
28
|
+
raise Error, "#{path}: errors.abort_after.percent must be above 0 and at most 100"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
new(total:, percent:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A new one each time, since a budget counts one run's calls.
|
|
35
|
+
#
|
|
36
|
+
# @return [Actions::ErrorBudget]
|
|
37
|
+
def budget = Actions::ErrorBudget.new(total:, percent:)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
class Config
|
|
6
|
+
# What a lineup's provider sections list: the models and the `rate_limit`s,
|
|
7
|
+
# checked and read once, so that a file wrong in either way is refused
|
|
8
|
+
# whichever of the two a command asks for first.
|
|
9
|
+
class Listing
|
|
10
|
+
# The keys a provider section may have.
|
|
11
|
+
SECTION_KEYS = %w[rate_limit vendors models].freeze
|
|
12
|
+
# The keys an entry under `vendors:` may have.
|
|
13
|
+
VENDOR_KEYS = %w[rate_limit models].freeze
|
|
14
|
+
# The keys one line under `models:` may have.
|
|
15
|
+
ENTRY_KEYS = %w[model effort temperature max_tokens rate_limit].freeze
|
|
16
|
+
|
|
17
|
+
# What marks a mapping as meant for a provider, under any name. Other
|
|
18
|
+
# mappings belong to trackers and suites, so they are left alone.
|
|
19
|
+
MARKERS = %w[vendors models model rate_limit].freeze
|
|
20
|
+
|
|
21
|
+
# One line under `models:`.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] provider
|
|
24
|
+
# @return [Providers::Base] the section it was listed under
|
|
25
|
+
# @!attribute [r] id
|
|
26
|
+
# @return [String] the id a provider recognises
|
|
27
|
+
# @!attribute [r] settings
|
|
28
|
+
# @return [Hash{String => Object}] the line as the file has it, with `model` as a key
|
|
29
|
+
Entry = Data.define(:provider, :id, :settings)
|
|
30
|
+
|
|
31
|
+
# @return [Array<Entry>] in the file's order
|
|
32
|
+
attr_reader :entries
|
|
33
|
+
|
|
34
|
+
# @return [Hash{String => Models::Throttle::Limit}] by scope
|
|
35
|
+
attr_reader :limits
|
|
36
|
+
|
|
37
|
+
# @param data [Hash] the whole file
|
|
38
|
+
# @param path [String] named in every error
|
|
39
|
+
# @param providers [Providers::Registry] whose names the sections may use
|
|
40
|
+
# @raise [Error] on a section, entry or limit the file format does not have
|
|
41
|
+
def initialize(data, path:, providers:)
|
|
42
|
+
@path = path
|
|
43
|
+
@providers = providers
|
|
44
|
+
@entries = []
|
|
45
|
+
@limits = {}
|
|
46
|
+
sections(data).each { |provider, section| read(provider, section) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :path, :providers
|
|
52
|
+
|
|
53
|
+
# @!attribute [r] path
|
|
54
|
+
# The lineup file, named in every error.
|
|
55
|
+
# @return [String]
|
|
56
|
+
|
|
57
|
+
# @!attribute [r] providers
|
|
58
|
+
# Whose names the sections may use.
|
|
59
|
+
# @return [Providers::Registry]
|
|
60
|
+
|
|
61
|
+
# @param data [Hash] the whole file
|
|
62
|
+
# @return [Array<Array(Providers::Base, Hash)>] the file's provider sections
|
|
63
|
+
# @raise [Error] on a retired key, an unknown provider or an unknown key
|
|
64
|
+
def sections(data)
|
|
65
|
+
retired = %w[vendors direct] & data.keys
|
|
66
|
+
raise Error, "#{path}: #{retired.join(" and ")} no longer exist; run `evals upgrade-config` to move them under provider sections" if retired.any?
|
|
67
|
+
|
|
68
|
+
names = providers.map(&:name)
|
|
69
|
+
data.filter_map do |name, section|
|
|
70
|
+
next unless section.is_a?(Hash) && (names.include?(name.to_s) || section.keys.intersect?(MARKERS))
|
|
71
|
+
raise Error, "#{path}: unknown provider #{name.inspect}; known: #{names.join(", ")}" unless names.include?(name.to_s)
|
|
72
|
+
|
|
73
|
+
known!(name, section, SECTION_KEYS)
|
|
74
|
+
[providers.find { it.name == name.to_s }, section]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Collects one provider section's limits and models, those under its vendors first.
|
|
79
|
+
#
|
|
80
|
+
# @param provider [Providers::Base] the section's provider
|
|
81
|
+
# @param section [Hash] the section as the file has it
|
|
82
|
+
# @return [void]
|
|
83
|
+
# @raise [Error] on a section the file format does not have
|
|
84
|
+
def read(provider, section)
|
|
85
|
+
limit(provider.name, section)
|
|
86
|
+
vendors = section["vendors"] || {}
|
|
87
|
+
raise Error, "#{path}: vendors must be a mapping of vendor to models" unless vendors.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
vendors.each do |vendor, listing|
|
|
90
|
+
raise Error, "#{path}: vendor #{vendor} needs a models: list" unless listing.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
known!(vendor, listing, VENDOR_KEYS)
|
|
93
|
+
limit("#{provider.name}/#{vendor}", listing)
|
|
94
|
+
list(provider, vendor, listing, "#{provider.name}/#{vendor}/", "#{provider.prefix}#{vendor}/")
|
|
95
|
+
end
|
|
96
|
+
list(provider, provider.name, section, "#{provider.name}/", provider.prefix.to_s)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Adds each line of a `models:` list to {#entries}, and any limit it sets to {#limits}.
|
|
100
|
+
#
|
|
101
|
+
# @param provider [Providers::Base] the section's provider
|
|
102
|
+
# @param owner [String] the vendor or provider that lists them, for errors
|
|
103
|
+
# @param listing [Hash] the mapping holding `models:`
|
|
104
|
+
# @param scope [String] prepended to the model name to key its limit
|
|
105
|
+
# @param prefix [String] prepended to the model name to make its id
|
|
106
|
+
# @return [void]
|
|
107
|
+
# @raise [Error] on an entry without a model, or with an unknown key
|
|
108
|
+
def list(provider, owner, listing, scope, prefix)
|
|
109
|
+
Array(listing["models"]).each do |entry|
|
|
110
|
+
entry = { "model" => entry } if entry.is_a?(String)
|
|
111
|
+
raise Error, "#{path}: every entry under #{owner} needs a model" unless entry.is_a?(Hash) && entry["model"]
|
|
112
|
+
|
|
113
|
+
known!(entry["model"], entry, ENTRY_KEYS)
|
|
114
|
+
limit("#{scope}#{entry["model"]}", entry)
|
|
115
|
+
@entries << Entry.new(provider:, id: "#{prefix}#{entry["model"]}", settings: entry)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Refuses keys the format does not have, so a typo is not ignored.
|
|
120
|
+
#
|
|
121
|
+
# @param owner [String] names the mapping in the error
|
|
122
|
+
# @param mapping [Hash] the mapping to check
|
|
123
|
+
# @param keys [Array<String>] the keys it may have
|
|
124
|
+
# @return [void]
|
|
125
|
+
# @raise [Error] on any other key
|
|
126
|
+
def known!(owner, mapping, keys)
|
|
127
|
+
unknown = mapping.keys - keys
|
|
128
|
+
raise Error, "#{path}: unknown keys under #{owner}: #{unknown.join(", ")}; known: #{keys.join(", ")}" if unknown.any?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A scope has one limit, so a model listed twice, say at two efforts, may
|
|
132
|
+
# set it once: the second would otherwise replace the first in silence.
|
|
133
|
+
#
|
|
134
|
+
# @param scope [String] the key {Models::Throttle} looks the limit up by
|
|
135
|
+
# @param owner [Hash] the mapping that may hold `rate_limit:`
|
|
136
|
+
# @return [void]
|
|
137
|
+
# @raise [Error] on a limit that is malformed or set twice
|
|
138
|
+
def limit(scope, owner)
|
|
139
|
+
given = owner["rate_limit"]
|
|
140
|
+
return if given.nil?
|
|
141
|
+
|
|
142
|
+
burst, per_minute = given.values_at("burst", "per_minute") if given.is_a?(Hash)
|
|
143
|
+
unless [burst, per_minute].all? { it.is_a?(Integer) && it.positive? } && given.size == 2
|
|
144
|
+
raise Error, "#{path}: rate_limit of #{scope} needs burst and per_minute, whole numbers above 0"
|
|
145
|
+
end
|
|
146
|
+
raise Error, "#{path}: rate_limit of #{scope} is set twice; a model listed twice shares one limit, so set it once" if @limits.key?(scope)
|
|
147
|
+
|
|
148
|
+
@limits[scope] = Models::Throttle::Limit.new(burst:, per_minute:)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
class Config
|
|
6
|
+
# The `results:` section: where completed runs are saved and how they are
|
|
7
|
+
# presented. Paths are rooted at the caller's working directory, so the same
|
|
8
|
+
# config works from a project root or when invoked by a task runner.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] autosave
|
|
11
|
+
# @return [Boolean] whether completed runs are saved under {#destination}
|
|
12
|
+
# @!attribute [r] destination
|
|
13
|
+
# @return [String] absolute directory for saved runs
|
|
14
|
+
# @!attribute [r] temp_dir
|
|
15
|
+
# @return [String] absolute directory for the temporary JSON used when autosave is off
|
|
16
|
+
# @!attribute [r] display_mode
|
|
17
|
+
# @return [String] one of {RESULT_DISPLAYS}
|
|
18
|
+
Results = Data.define(:autosave, :destination, :temp_dir, :display_mode) do
|
|
19
|
+
# Applies the defaults, checks the types, and roots the paths at the working directory.
|
|
20
|
+
#
|
|
21
|
+
# @param section [Object] the section as the file has it
|
|
22
|
+
# @param path [String] names the file in an error
|
|
23
|
+
# @return [Results]
|
|
24
|
+
# @raise [Error] when a setting has the wrong type or an unknown display
|
|
25
|
+
def self.from_hash(section, path:)
|
|
26
|
+
raise Error, "#{path}: results must be a mapping" unless section.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
autosave = section.fetch("autosave", true)
|
|
29
|
+
raise Error, "#{path}: results.autosave must be true or false" unless [true, false].include?(autosave)
|
|
30
|
+
|
|
31
|
+
display = section.fetch("display", DEFAULT_DISPLAY).to_s
|
|
32
|
+
raise Error, "#{path}: results.display must be one of #{RESULT_DISPLAYS.join(", ")}" unless RESULT_DISPLAYS.include?(display)
|
|
33
|
+
|
|
34
|
+
new(autosave:,
|
|
35
|
+
destination: File.expand_path(section.fetch("destination", "./evals/results").to_s, Dir.pwd),
|
|
36
|
+
temp_dir: File.expand_path(section.fetch("temp_dir", "./tmp").to_s, Dir.pwd),
|
|
37
|
+
display_mode: display)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
class Config
|
|
6
|
+
# The `weighted_score:` section; see {Scoring::WeightedScore}.
|
|
7
|
+
module ScoreWeights
|
|
8
|
+
# Only checks the shape; {Scoring::WeightedScore} judges the columns and numbers.
|
|
9
|
+
#
|
|
10
|
+
# @param section [Object, nil] the section as the file has it
|
|
11
|
+
# @param path [String] names the file in an error
|
|
12
|
+
# @return [Hash{String => Object}] by column; empty when the file sets none
|
|
13
|
+
# @raise [Error] when either level is not a mapping
|
|
14
|
+
def self.from_hash(section, path:)
|
|
15
|
+
return {} if section.nil?
|
|
16
|
+
|
|
17
|
+
weights = section["weights"] if section.is_a?(Hash)
|
|
18
|
+
raise Error, "#{path}: weighted_score needs a weights: mapping of column to number" unless weights.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
weights.transform_keys(&:to_s)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
class Config
|
|
8
|
+
# Rewrites a lineup from top-level `vendors:` and `direct:` to provider
|
|
9
|
+
# sections. It works on the text, since loading YAML would drop the comments
|
|
10
|
+
# that explain a lineup, then proves the result means the same before
|
|
11
|
+
# anything is written.
|
|
12
|
+
class Upgrade
|
|
13
|
+
# A line that opens a new top-level key, which ends any section being moved.
|
|
14
|
+
TOP_KEY = /\A[\w-]+:/
|
|
15
|
+
# The old `vendors:` line, keeping whatever follows the colon.
|
|
16
|
+
VENDORS = /\Avendors:(?<rest>.*)/m
|
|
17
|
+
# The old `direct:` line, with at most a comment after it.
|
|
18
|
+
DIRECT = /\Adirect:\s*(?<comment>#.*)?\n?\z/
|
|
19
|
+
# A key indented by two spaces: a vendor, or a direct provider.
|
|
20
|
+
GROUP = /\A ([\w-]+):([^\n]*)\n?\z/
|
|
21
|
+
|
|
22
|
+
# @param text [String] the lineup as it is
|
|
23
|
+
# @param path [String] named in every error
|
|
24
|
+
# @raise [Error] when the text is not YAML, or not a mapping
|
|
25
|
+
def initialize(text, path:)
|
|
26
|
+
@text = text
|
|
27
|
+
@path = path
|
|
28
|
+
@data = YAML.safe_load(text) || {}
|
|
29
|
+
raise Error, "#{path}: a lineup must be a mapping, not a #{@data.class.name.downcase}" unless @data.is_a?(Hash)
|
|
30
|
+
rescue Psych::SyntaxError => e
|
|
31
|
+
raise Error, "#{path}: not valid YAML: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] whether the lineup still has the old keys
|
|
35
|
+
def needed? = @data.key?("vendors") || @data.key?("direct")
|
|
36
|
+
|
|
37
|
+
# @return [String] the lineup in the new shape
|
|
38
|
+
# @raise [Error] when the rewrite would change what the file means
|
|
39
|
+
def call
|
|
40
|
+
taken = [("openrouter" if @data.key?("vendors")), *(@data["direct"] || {}).keys].compact & @data.keys
|
|
41
|
+
raise Error, "#{@path}: cannot upgrade: the file has both the old keys and a section named #{taken.join(", ")}" if taken.any?
|
|
42
|
+
|
|
43
|
+
rewritten = rewrite
|
|
44
|
+
return rewritten if same_meaning?(rewritten)
|
|
45
|
+
|
|
46
|
+
raise Error, "#{@path}: cannot upgrade: vendors: or direct: is not in the plain list form; move them by hand"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# The proof that a textual rewrite is safe to write.
|
|
52
|
+
#
|
|
53
|
+
# @param rewritten [String] the lineup in the new shape
|
|
54
|
+
# @return [Boolean] whether it loads to what {#expected} says; false when it is not YAML
|
|
55
|
+
def same_meaning?(rewritten)
|
|
56
|
+
YAML.safe_load(rewritten) == expected
|
|
57
|
+
rescue Psych::SyntaxError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Rewrites the text line by line, tracking which old section the line falls in.
|
|
62
|
+
#
|
|
63
|
+
# @return [String] the lineup in the new shape
|
|
64
|
+
def rewrite
|
|
65
|
+
section = nil
|
|
66
|
+
@text.lines.flat_map do |line|
|
|
67
|
+
section = nil if line.match?(TOP_KEY)
|
|
68
|
+
if (vendors = VENDORS.match(line))
|
|
69
|
+
section = :vendors
|
|
70
|
+
["openrouter:#{vendors[:rest]}", " vendors:\n"]
|
|
71
|
+
elsif (direct = DIRECT.match(line))
|
|
72
|
+
section = :direct
|
|
73
|
+
direct[:comment] ? ["#{direct[:comment]}\n"] : []
|
|
74
|
+
else
|
|
75
|
+
moved(section, line)
|
|
76
|
+
end
|
|
77
|
+
end.join
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reindents one line of an old section; comments and blank lines stay as they are.
|
|
81
|
+
#
|
|
82
|
+
# @param section [Symbol, nil] `:vendors`, `:direct`, or nil outside both
|
|
83
|
+
# @param line [String] the line, with its newline
|
|
84
|
+
# @return [Array<String>] the line or lines that replace it
|
|
85
|
+
def moved(section, line)
|
|
86
|
+
return [line] if section.nil? || line.strip.empty? || line.start_with?("#")
|
|
87
|
+
|
|
88
|
+
group = GROUP.match(line)
|
|
89
|
+
case section
|
|
90
|
+
when :vendors then group ? [" #{group[1]}:#{group[2]}\n", " models:\n"] : [" #{line}"]
|
|
91
|
+
else group ? ["#{group[1]}:#{group[2]}\n", " models:\n"] : [line]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# What the file means in the new shape, built from the parsed data rather than the text.
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash{String => Object}] the lineup as {#same_meaning?} expects it to load
|
|
98
|
+
def expected
|
|
99
|
+
rest = @data.except("vendors", "direct")
|
|
100
|
+
vendors = @data["vendors"]&.transform_values { { "models" => it } }
|
|
101
|
+
direct = (@data["direct"] || {}).transform_values { { "models" => it } }
|
|
102
|
+
rest.merge(vendors ? { "openrouter" => { "vendors" => vendors } } : {}).merge(direct)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|