agent-evals 0.1.1
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 +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +635 -0
- data/exe/agent-evals +9 -0
- data/lib/agent/evals/adapter.rb +47 -0
- data/lib/agent/evals/case.rb +36 -0
- data/lib/agent/evals/cli/cases.rb +48 -0
- data/lib/agent/evals/cli/command.rb +163 -0
- data/lib/agent/evals/cli/compare.rb +33 -0
- data/lib/agent/evals/cli/model_command.rb +393 -0
- data/lib/agent/evals/cli/progress_bars.rb +90 -0
- data/lib/agent/evals/cli/run.rb +61 -0
- data/lib/agent/evals/cli/verify.rb +24 -0
- data/lib/agent/evals/cli/version.rb +15 -0
- data/lib/agent/evals/cli.rb +40 -0
- data/lib/agent/evals/completion.rb +11 -0
- data/lib/agent/evals/config.rb +146 -0
- data/lib/agent/evals/configuration.rb +172 -0
- data/lib/agent/evals/contender.rb +31 -0
- data/lib/agent/evals/discovery.rb +95 -0
- data/lib/agent/evals/experiment.rb +133 -0
- data/lib/agent/evals/helper.rb +98 -0
- data/lib/agent/evals/launcher.rb +37 -0
- data/lib/agent/evals/metric.rb +26 -0
- data/lib/agent/evals/model.rb +55 -0
- data/lib/agent/evals/providers/base.rb +93 -0
- data/lib/agent/evals/providers/catalog.rb +32 -0
- data/lib/agent/evals/providers/open_router.rb +64 -0
- data/lib/agent/evals/providers/registry.rb +112 -0
- data/lib/agent/evals/providers/type_safe.rb +53 -0
- data/lib/agent/evals/reply.rb +18 -0
- data/lib/agent/evals/run_result.rb +14 -0
- data/lib/agent/evals/runtime.rb +74 -0
- data/lib/agent/evals/schema_validator.rb +84 -0
- data/lib/agent/evals/score.rb +26 -0
- data/lib/agent/evals/scorer.rb +45 -0
- data/lib/agent/evals/testing/echo_adapter.rb +29 -0
- data/lib/agent/evals/testing/fake_model.rb +18 -0
- data/lib/agent/evals/testing/fake_provider.rb +67 -0
- data/lib/agent/evals/tracker.rb +80 -0
- data/lib/agent/evals/trackers/braintrust.rb +91 -0
- data/lib/agent/evals/trackers/local.rb +78 -0
- data/lib/agent/evals/trackers/logfire.rb +143 -0
- data/lib/agent/evals/trackers/registry.rb +82 -0
- data/lib/agent/evals/usage.rb +33 -0
- data/lib/agent/evals/verify.rb +49 -0
- data/lib/agent/evals/version.rb +7 -0
- data/lib/agent/evals/warnings.rb +89 -0
- data/lib/agent/evals.rb +82 -0
- metadata +257 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
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 Agent
|
|
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 Agent
|
|
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 Agent
|
|
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 Agent::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 Agent
|
|
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 Agent
|
|
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
|
+
Agent::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
|
+
Agent::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
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Providers
|
|
6
|
+
# The providers in use. A `vendors:` contender goes to the first
|
|
7
|
+
# provider that serves its id, asked last-registered first, so a
|
|
8
|
+
# suite's own transport beats the defaults and OpenRouter, which claims
|
|
9
|
+
# everything, is always last. A `direct:` contender goes to the
|
|
10
|
+
# provider it names, and a direct provider is never asked about an id.
|
|
11
|
+
class Registry
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
# @return [Registry] TypeSafe, then OpenRouter for everything else
|
|
15
|
+
def self.default = new([OpenRouter.new, TypeSafe.new])
|
|
16
|
+
|
|
17
|
+
# @param providers [Array<Base>] the last one is asked first
|
|
18
|
+
def initialize(providers = [])
|
|
19
|
+
@providers = providers.dup
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Registering a provider under a name already taken replaces it, so a
|
|
23
|
+
# helper and a required file that both register the same provider
|
|
24
|
+
# leave one, not two that would be asked twice.
|
|
25
|
+
#
|
|
26
|
+
# @param provider [Base]
|
|
27
|
+
# @return [Base] the provider
|
|
28
|
+
def register(provider)
|
|
29
|
+
@providers.reject! { it.name == provider.name }
|
|
30
|
+
@providers << provider
|
|
31
|
+
provider
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def each(&) = @providers.reverse.each(&)
|
|
35
|
+
|
|
36
|
+
# @param target [Contender, String] a contender, or a `vendors:` model id
|
|
37
|
+
# @return [Base]
|
|
38
|
+
# @raise [Error] when no provider serves it
|
|
39
|
+
def for(target)
|
|
40
|
+
return direct(target.provider) if target.is_a?(Contender) && target.direct?
|
|
41
|
+
|
|
42
|
+
model_id = target.is_a?(Contender) ? target.model : target
|
|
43
|
+
find { !it.direct? && it.serves?(model_id) } ||
|
|
44
|
+
raise(Error, "no provider serves #{model_id.inspect}; have #{reject(&:direct?).map(&:name).join(", ")}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param name [Symbol, String] a key under `direct:`
|
|
48
|
+
# @return [Base]
|
|
49
|
+
# @raise [Error] when no direct provider has the name
|
|
50
|
+
def direct(name)
|
|
51
|
+
find { it.direct? && it.name == name.to_s } ||
|
|
52
|
+
raise(Error, "no direct provider #{name.to_s.inspect}; have #{direct_names.join(", ")}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Array<String>] the names `direct:` may use
|
|
56
|
+
def direct_names = select(&:direct?).map(&:name)
|
|
57
|
+
|
|
58
|
+
# @param contenders [Array<Contender>]
|
|
59
|
+
# @return [Array<Base>] each provider the contenders use, once
|
|
60
|
+
def used_by(contenders) = contenders.map { self.for(it) }.uniq
|
|
61
|
+
|
|
62
|
+
# @param contenders [Array<Contender>]
|
|
63
|
+
# @return [Array<String>] the API keys the contenders' providers need
|
|
64
|
+
def keys_for(contenders) = used_by(contenders).flat_map(&:env_keys).uniq
|
|
65
|
+
|
|
66
|
+
# @param contender [Contender]
|
|
67
|
+
# @return [Model]
|
|
68
|
+
def model_for(contender) = self.for(contender).model_for(contender)
|
|
69
|
+
|
|
70
|
+
# One refused contender: who, and why.
|
|
71
|
+
Refusal = Data.define(:contender, :reason) do
|
|
72
|
+
def to_s = "#{contender.model} refused: #{reason}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Splits the contenders into those whose provider produces a request
|
|
76
|
+
# type the suite declares and those it does not. A refused contender
|
|
77
|
+
# is named, never run, and never scored 0: a model the suite cannot
|
|
78
|
+
# even ask is not a model that answered wrongly.
|
|
79
|
+
#
|
|
80
|
+
# @param contenders [Array<Contender>]
|
|
81
|
+
# @param request_types [Array<Symbol>] what the suite's config declares
|
|
82
|
+
# @return [Array(Array<Contender>, Array<Refusal>)] admitted, refused
|
|
83
|
+
def admit(contenders, request_types:)
|
|
84
|
+
admitted, refused = contenders.partition { request_types.include?(self.for(it).request_type) }
|
|
85
|
+
refusals = refused.map do |contender|
|
|
86
|
+
needs = self.for(contender).request_type
|
|
87
|
+
Refusal.new(contender: contender, reason: "needs #{needs}; this suite produces #{request_types.join(", ")}")
|
|
88
|
+
end
|
|
89
|
+
[admitted, refusals]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Array<String>] every effort any provider accepts, for the flag's help
|
|
93
|
+
def efforts = flat_map(&:efforts).uniq
|
|
94
|
+
|
|
95
|
+
# Asks each provider in use for its catalog, and names every model it
|
|
96
|
+
# does not list.
|
|
97
|
+
#
|
|
98
|
+
# @param contenders [Array<Contender>]
|
|
99
|
+
# @return [void]
|
|
100
|
+
# @raise [Error] naming every unknown model
|
|
101
|
+
def verify!(contenders)
|
|
102
|
+
unknown = contenders.uniq(&:key).group_by { self.for(it) }.flat_map { |provider, list| provider.unknown(list.map(&:model)) }
|
|
103
|
+
return if unknown.empty?
|
|
104
|
+
|
|
105
|
+
raise Error,
|
|
106
|
+
"unknown model#{"s" if unknown.size > 1} #{unknown.join(", ")}: not listed by its provider; " \
|
|
107
|
+
"check the id in .agent-eval.config.yml or -m"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module Providers
|
|
6
|
+
# TypeSafe's own API, which serves Jev as typed questions (noul, choice,
|
|
7
|
+
# score) rather than chat: request type `typesafe`, reached only through
|
|
8
|
+
# a `direct: typesafe:` entry, whose ids read "typesafe/<name>". Jev on
|
|
9
|
+
# OpenRouter, `typesafe/jev-router` under `vendors:`, is a chat model
|
|
10
|
+
# and goes through {OpenRouter}; the contender carries which is meant.
|
|
11
|
+
# The gem knows this API's model list, key and pricing, enough for
|
|
12
|
+
# `verify` and the cost column; the request shape waits for the first
|
|
13
|
+
# suite that declares `request_types: [typesafe]`.
|
|
14
|
+
class TypeSafe < Base
|
|
15
|
+
PREFIX = "typesafe/"
|
|
16
|
+
URL = "https://api.typesafe.ai/v1/models"
|
|
17
|
+
KEY = "TYPESAFE_API_KEY"
|
|
18
|
+
|
|
19
|
+
# TypeSafe bills input tokens only: $0.042 per million for jev-latest
|
|
20
|
+
# and jev-preview (docs.typesafe.ai/models, read 2026-10-14). Output is free.
|
|
21
|
+
USD_PER_INPUT_TOKEN = 0.042 / 1_000_000
|
|
22
|
+
|
|
23
|
+
def name = "typesafe"
|
|
24
|
+
def request_type = :typesafe
|
|
25
|
+
def request_type_description = "typed questions: noul, choice, score"
|
|
26
|
+
def direct? = true
|
|
27
|
+
def prefix = PREFIX
|
|
28
|
+
def env_keys = [KEY]
|
|
29
|
+
|
|
30
|
+
# Jev takes no reasoning effort.
|
|
31
|
+
def efforts = []
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>] the models, as "typesafe/<name>"
|
|
34
|
+
def catalog
|
|
35
|
+
Catalog.fetch(URL, "Authorization" => "Bearer #{ENV.fetch(KEY, nil)}").fetch("models").map { "#{PREFIX}#{it.fetch("name")}" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param input_tokens [Integer]
|
|
39
|
+
# @return [Usage] with the cost at TypeSafe's input rate
|
|
40
|
+
def usage(input_tokens:, output_tokens: 0)
|
|
41
|
+
Usage.new(input_tokens: input_tokens, output_tokens: output_tokens, cost_usd: input_tokens * USD_PER_INPUT_TOKEN)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @raise [Error] always: there is no typesafe consumer yet
|
|
45
|
+
def complete(model, system:, user:, json: true) # rubocop:disable Lint/UnusedMethodArgument
|
|
46
|
+
raise Error,
|
|
47
|
+
"#{model.id}: no typesafe consumer yet; the typed request shape is defined by the first suite " \
|
|
48
|
+
"that declares request_types: [typesafe]"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# What an {Adapter} returns for one case. `error` is the model's failure
|
|
6
|
+
# (it did not answer the question: no JSON, a refusal) and scores 0; a
|
|
7
|
+
# transport failure is not the model's and is raised instead, so it is
|
|
8
|
+
# counted as an error of the run rather than a wrong answer.
|
|
9
|
+
Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
|
|
10
|
+
def initialize(output: nil, raw: nil, usage: Usage.zero, error: nil, metadata: {})
|
|
11
|
+
super(output: output, raw: raw, usage: Usage.from(usage), error: error, metadata: metadata)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Boolean] whether the model failed to answer
|
|
15
|
+
def error? = !error.nil?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# One case, run once: what came back and what it scored. `reply` is nil
|
|
6
|
+
# when the adapter raised, and `error` then holds the message.
|
|
7
|
+
RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
|
|
8
|
+
def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
|
|
9
|
+
|
|
10
|
+
# @return [Boolean] whether the adapter raised rather than answered
|
|
11
|
+
def failed? = !error.nil?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dotenv"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Evals
|
|
7
|
+
# One-time process setup: secrets from the suite's env files, a check that
|
|
8
|
+
# every key and gem the run needs is there, the suite's own hooks, and
|
|
9
|
+
# the providers' clients.
|
|
10
|
+
class Runtime
|
|
11
|
+
attr_reader :env_files, :hooks, :app_name
|
|
12
|
+
|
|
13
|
+
# @param env_files [Array<String>] absolute paths; those that exist load
|
|
14
|
+
# @param hooks [Array<Proc>] the suite's `before_run` blocks
|
|
15
|
+
# @param app_name [String] what a provider tells its API the caller is
|
|
16
|
+
def initialize(env_files: [], hooks: [], app_name: "agent-evals")
|
|
17
|
+
@env_files = env_files
|
|
18
|
+
@hooks = hooks
|
|
19
|
+
@app_name = app_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Loads the env files that exist, without overriding what is already
|
|
23
|
+
# set, so an exported variable beats the file.
|
|
24
|
+
#
|
|
25
|
+
# @return [void]
|
|
26
|
+
def load_env
|
|
27
|
+
files = env_files.select { File.exist?(it) }
|
|
28
|
+
Dotenv.load(*files) unless files.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fails before anything is called when a key the run needs is neither
|
|
32
|
+
# exported nor in an env file.
|
|
33
|
+
#
|
|
34
|
+
# @param names [Array<String>]
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @raise [Error] naming every missing variable
|
|
37
|
+
def require_env!(names)
|
|
38
|
+
missing = names.uniq.select { ENV[it].to_s.strip.empty? }
|
|
39
|
+
return if missing.empty?
|
|
40
|
+
|
|
41
|
+
found = env_files.select { File.exist?(it) }
|
|
42
|
+
where = if found.empty?
|
|
43
|
+
env_files.empty? ? "no env files are configured" : "there is no #{env_files.join(" or ")}"
|
|
44
|
+
else
|
|
45
|
+
"checked the environment, then #{found.join(", ")}"
|
|
46
|
+
end
|
|
47
|
+
raise Error,
|
|
48
|
+
"missing #{missing.join(", ")}: export them or add them to an env file (#{where}); " \
|
|
49
|
+
"an exported blank value wins over the file"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param providers [Array<Providers::Base>] the providers the run's models use
|
|
53
|
+
# @param tracker [Class<Tracker>, nil] the remote tracker chosen, whose gems and key are checked too
|
|
54
|
+
# @return [void]
|
|
55
|
+
# @raise [Error] when a needed variable or gem is missing
|
|
56
|
+
def setup!(providers: [], tracker: nil)
|
|
57
|
+
load_env
|
|
58
|
+
require_env!(providers.flat_map(&:env_keys))
|
|
59
|
+
tracker&.requirements!(ENV)
|
|
60
|
+
hooks.each(&:call) # after load_env: a hook usually re-reads ENV
|
|
61
|
+
providers.each { it.setup!(app_name: app_name) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Opens a URL in the desktop browser.
|
|
65
|
+
#
|
|
66
|
+
# @param url [String]
|
|
67
|
+
# @return [Boolean] whether the opener ran
|
|
68
|
+
def browse(url)
|
|
69
|
+
opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
|
|
70
|
+
system(opener, url, out: File::NULL, err: File::NULL) || false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# Checks a Hash of answers against a field map, for scorers and verifiers
|
|
6
|
+
# whose expected values are typed and closed. Nothing here is specific to
|
|
7
|
+
# what the fields mean.
|
|
8
|
+
#
|
|
9
|
+
# The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
|
|
10
|
+
# {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
|
|
11
|
+
# nullable field accepts nil (the text never answered it).
|
|
12
|
+
class SchemaValidator
|
|
13
|
+
TYPES = %i[boolean integer number string enum multi_enum].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :fields
|
|
16
|
+
|
|
17
|
+
# @param fields [Hash{String, Symbol => Hash}]
|
|
18
|
+
# @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
|
|
19
|
+
def initialize(fields)
|
|
20
|
+
@fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] the field names
|
|
24
|
+
def names = fields.keys
|
|
25
|
+
|
|
26
|
+
# @param values [Hash] String or Symbol keys
|
|
27
|
+
# @return [Array<String>] every problem found; empty when the values fit the map
|
|
28
|
+
def problems(values)
|
|
29
|
+
return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
actual = values.transform_keys(&:to_s)
|
|
32
|
+
missing = names - actual.keys
|
|
33
|
+
extra = actual.keys - names
|
|
34
|
+
problems = []
|
|
35
|
+
problems << "missing #{missing.join(", ")}" if missing.any?
|
|
36
|
+
problems << "unknown fields #{extra.join(", ")}" if extra.any?
|
|
37
|
+
fields.each do |name, spec|
|
|
38
|
+
next unless actual.key?(name)
|
|
39
|
+
|
|
40
|
+
problem = value_problem(spec, actual[name])
|
|
41
|
+
problems << "#{name}: #{problem}" if problem
|
|
42
|
+
end
|
|
43
|
+
problems
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param values [Hash]
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def valid?(values) = problems(values).empty?
|
|
49
|
+
|
|
50
|
+
# @param spec [Hash] one field's normalized spec
|
|
51
|
+
# @param value [Object]
|
|
52
|
+
# @return [String, nil] what is wrong with the value, or nil
|
|
53
|
+
def value_problem(spec, value)
|
|
54
|
+
return (spec[:nullable] ? nil : "is null") if value.nil?
|
|
55
|
+
|
|
56
|
+
case spec[:type]
|
|
57
|
+
when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
|
|
58
|
+
when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
|
|
59
|
+
when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
|
|
60
|
+
when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
|
|
61
|
+
when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
|
|
62
|
+
when :multi_enum then multi_enum_problem(spec, value)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def multi_enum_problem(spec, value)
|
|
69
|
+
return "#{value.inspect} is not an array" unless value.is_a?(Array)
|
|
70
|
+
|
|
71
|
+
stray = value - spec[:enum]
|
|
72
|
+
"#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize(name, spec)
|
|
76
|
+
spec = spec.transform_keys(&:to_sym)
|
|
77
|
+
type = spec.fetch(:type).to_sym
|
|
78
|
+
raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
|
|
79
|
+
|
|
80
|
+
{ type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|