tinyeval 0.11.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/LICENSE +21 -0
- data/README.md +70 -0
- data/exe/tinyeval +21 -0
- data/lib/tinyeval/worker.rb +46 -0
- data/lib/tinyeval.rb +8 -0
- data/tinyeval +784 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b7c6ac99dd71dd4c4be5357ed72256fab49e2515a40192e78ebc95b4c8897fa6
|
|
4
|
+
data.tar.gz: 2c657612bb89482b501e563df4a2f7f44279dee5db09cd234ecca607c187ac78
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 64940ca3bbee3d08b992aba4ae828d33d945ade046494a23b836d6f92860fe71bc172c5dd01eb7046767245ad8a6df90b215f1f31a1991e07ff84e4f657128fd
|
|
7
|
+
data.tar.gz: 99c679395ab5c523bf5f3e0f2eefaf1791db80e88657253df69038dc9d48d0e1c5bd4ac261b95b363c9b7d594bad9f0e97bbfceb0db6c45fe484f4be99c3a972
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 evalship
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# tinyeval
|
|
2
|
+
|
|
3
|
+
Test the AI calls in your Ruby or Rails app with evals, and run them in CI next to your other tests.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bundle add tinyeval --group development
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Write an eval
|
|
12
|
+
|
|
13
|
+
`evals/support.sh` lists the test cases and how to grade each reply:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
worker bundle exec ruby evals/worker.rb
|
|
17
|
+
|
|
18
|
+
test_case "refund after the window" "Can I still return my shoes? They arrived 40 days ago."
|
|
19
|
+
matches "30[- ]day"
|
|
20
|
+
judge "Says a refund isn't possible anymore, politely"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`evals/worker.rb` loads your app once and answers each test case:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "tinyeval/worker" # before your app, so its output stays off the replies
|
|
27
|
+
require_relative "../lib/support"
|
|
28
|
+
|
|
29
|
+
support = Support.new
|
|
30
|
+
Tinyeval.worker do |input|
|
|
31
|
+
raise Tinyeval::CouldntRun, "ANTHROPIC_API_KEY isn't set" unless ENV["ANTHROPIC_API_KEY"]
|
|
32
|
+
support.answer(input)
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
In a Rails app, Rails boots once per worker with this line, and the worker calls your own class:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
worker bin/rails runner evals/worker.rb
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Run it
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
bundle exec tinyeval
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## When the AI call fails
|
|
49
|
+
|
|
50
|
+
Raise `Tinyeval::CouldntRun` when the call can't run at all. tinyeval then counts the run as couldn't run, apart from the failures. Errors from the `anthropic` and `openai` gems count too when they are a 401, a 429, a 5xx, a timeout or a lost connection.
|
|
51
|
+
|
|
52
|
+
Any other error becomes the reply, so the eval fails. Its stack trace goes to stderr.
|
|
53
|
+
|
|
54
|
+
## Needs bash
|
|
55
|
+
|
|
56
|
+
tinyeval is a bash script. It runs on bash 3.2 or later: macOS and Linux have it, and on Windows it uses Git for Windows' bash.
|
|
57
|
+
|
|
58
|
+
## Updating
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
bundle update tinyeval
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Dependabot and Renovate see new versions in your `Gemfile.lock`. A repo that commits its own `./tinyeval` file keeps running that file.
|
|
65
|
+
|
|
66
|
+
## More
|
|
67
|
+
|
|
68
|
+
- [tinyeval.com](https://tinyeval.com)
|
|
69
|
+
- [Writing evals](https://github.com/evalship-hq/tinyeval#writing-evals): every check line, the judge and the options
|
|
70
|
+
- [evalship-hq/ruby-llm-evals](https://github.com/evalship-hq/ruby-llm-evals): a Ruby support bot with its evals in CI
|
data/exe/tinyeval
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Runs the bundled bash tinyeval. It loads nothing from lib: the worker's stdout redirect would hide tinyeval's output.
|
|
5
|
+
runner = File.expand_path("../tinyeval", __dir__)
|
|
6
|
+
ENV["TINYEVAL_PACKAGE_UPDATE"] = ENV["BUNDLE_GEMFILE"] ? "bundle update tinyeval" : "gem update tinyeval"
|
|
7
|
+
|
|
8
|
+
bash = "bash"
|
|
9
|
+
if Gem.win_platform?
|
|
10
|
+
# Git for Windows' bash, next to the cmd folder that has git.exe, before a bash on PATH, which may be WSL's.
|
|
11
|
+
git_dirs = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).select { |dir| File.file?(File.join(dir, "git.exe")) }
|
|
12
|
+
candidates = git_dirs.map { |dir| File.expand_path("../bin/bash.exe", dir) } << "C:/Program Files/Git/bin/bash.exe"
|
|
13
|
+
bash = candidates.find { |path| File.file?(path) } || bash
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
exec(bash, runner, *ARGV)
|
|
18
|
+
rescue SystemCallError
|
|
19
|
+
warn "tinyeval needs bash 3.2 or later. On Windows, install Git for Windows: https://git-scm.com/download/win"
|
|
20
|
+
exit 2
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../tinyeval"
|
|
4
|
+
|
|
5
|
+
# Require this before your app. From here on, all output to stdout goes to stderr, at the file descriptor, so
|
|
6
|
+
# puts, child processes and C extensions stay off the replies. The replies go to the real stdout.
|
|
7
|
+
module Tinyeval
|
|
8
|
+
REPLIES = STDOUT.dup.binmode
|
|
9
|
+
STDOUT.reopen(STDERR)
|
|
10
|
+
|
|
11
|
+
# Answers each input tinyeval sends, up to a NUL byte, with the block's return value and a NUL byte.
|
|
12
|
+
def self.worker
|
|
13
|
+
$stdin.binmode
|
|
14
|
+
while (input = $stdin.gets("\0"))
|
|
15
|
+
break unless input.chomp!("\0") # stdin closed partway through an input
|
|
16
|
+
reply =
|
|
17
|
+
begin
|
|
18
|
+
out = yield(input.force_encoding(Encoding::UTF_8)).to_s
|
|
19
|
+
out.encoding == Encoding::BINARY ? out : out.encode(Encoding::UTF_8) # bytes, as Net::HTTP gives, go as is
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
if (reason = couldnt_run(e))
|
|
22
|
+
"#tinyeval couldnt_run #{reason.split.join(" ")}\n"
|
|
23
|
+
else
|
|
24
|
+
warn e.full_message
|
|
25
|
+
"#{e.class}: #{e.message}" # the reply, so the eval fails
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
REPLIES.write(reply.b.delete("\0"), "\0") # .b: delete raises on invalid UTF-8
|
|
29
|
+
REPLIES.flush
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Why the AI call couldn't run, or nil when the error is the app's. The anthropic and openai gems' errors are
|
|
34
|
+
# matched by class name, so neither gem has to be installed: 401, 429, 5xx, connection errors and timeouts.
|
|
35
|
+
def self.couldnt_run(error)
|
|
36
|
+
return error.message if error.is_a?(CouldntRun)
|
|
37
|
+
|
|
38
|
+
names = error.class.ancestors.map(&:name).compact
|
|
39
|
+
sdk = names.grep(/\A(Anthropic|OpenAI)::Errors::APIError\z/).first or return
|
|
40
|
+
status = error.status if error.respond_to?(:status)
|
|
41
|
+
connection = names.include?("#{sdk[/\A\w+/]}::Errors::APIConnectionError") # timeouts are one too
|
|
42
|
+
return unless connection || [401, 429].include?(status) || status.to_i >= 500
|
|
43
|
+
|
|
44
|
+
"#{sdk[/\A\w+/].downcase}: #{[status, error.class.name.to_s[/\w+\z/]].compact.join(" ")}"
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/tinyeval.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Leaves stdout alone, so Bundler.require can load it. A worker requires "tinyeval/worker" instead.
|
|
4
|
+
module Tinyeval
|
|
5
|
+
# Raise it when the AI call couldn't run, such as without an API key. tinyeval then counts the run apart from
|
|
6
|
+
# the failures, since it says nothing about the reply.
|
|
7
|
+
class CouldntRun < StandardError; end
|
|
8
|
+
end
|
data/tinyeval
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tinyeval: evals for AI features, in one bash file. https://github.com/evalship-hq/tinyeval
|
|
3
|
+
# Runs on bash 3.2 (macOS) and up with the tools every bash install has. judge also needs curl.
|
|
4
|
+
|
|
5
|
+
_te_version=0.11.0
|
|
6
|
+
_te_usage="Usage: tinyeval [-n runs] [-j jobs] [-t seconds] [-k text] [-o file] [-x file] [eval files...]
|
|
7
|
+
tinyeval update
|
|
8
|
+
tinyeval share [file]
|
|
9
|
+
|
|
10
|
+
Runs every test case in the eval files (default: evals/*.sh) and writes the
|
|
11
|
+
results to eval-results.json. Exits 1 when an eval fails or couldn't run
|
|
12
|
+
(its AI call failed or timed out, or the judge couldn't answer).
|
|
13
|
+
|
|
14
|
+
-n runs run each test case this many times; it passes when at least half pass
|
|
15
|
+
-j jobs run this many test cases at once (default: 4)
|
|
16
|
+
-t seconds stop an AI call that takes longer (default: 300)
|
|
17
|
+
-k text run only the test cases whose name contains text
|
|
18
|
+
-o file where to write the results (default: eval-results.json)
|
|
19
|
+
-x file also write them as JUnit XML, which most CI systems show
|
|
20
|
+
-v print the version
|
|
21
|
+
update replace this file with the latest release
|
|
22
|
+
share put the results (default: eval-results.json) behind a private
|
|
23
|
+
link on evalship.com for 30 days, with their inputs and replies
|
|
24
|
+
|
|
25
|
+
An eval file is bash. It defines ask, then lists test cases, each followed
|
|
26
|
+
by the lines that grade its reply:
|
|
27
|
+
|
|
28
|
+
ask() { ruby bin/answer.rb; } the AI call: input as \$1 and on stdin, reply on stdout
|
|
29
|
+
worker COMMAND... or keep COMMAND running, one per -j: it reads each
|
|
30
|
+
input up to a NUL byte and prints the reply and a NUL
|
|
31
|
+
A reply may start with #tinyeval couldnt_run REASON
|
|
32
|
+
or #tinyeval cost_usd DOLLARS lines, and the app may
|
|
33
|
+
add each AI call it made to the file \$TINYEVAL_CALLS
|
|
34
|
+
as a line of JSON: {\"call\": NAME, \"model\", \"input\", ...}
|
|
35
|
+
test_case NAME [INPUT] starts a test case; without INPUT it reads a heredoc
|
|
36
|
+
contains TEXT the reply contains TEXT, ignoring case
|
|
37
|
+
not_contains TEXT the reply doesn't contain TEXT, ignoring case
|
|
38
|
+
matches REGEX the reply matches the extended regex
|
|
39
|
+
equals TEXT the reply is exactly TEXT
|
|
40
|
+
expect COMMAND... COMMAND exits 0 with the reply on stdin
|
|
41
|
+
json FILTER the reply is JSON and jq's FILTER is true for it
|
|
42
|
+
problems FILTER jq's FILTER lists what's wrong with the JSON reply;
|
|
43
|
+
an empty list passes, and each item is a failure
|
|
44
|
+
target FILE#NAME the code the test case covers, kept in the results file
|
|
45
|
+
check NAME the lines after it grade the same reply as another
|
|
46
|
+
eval, named \"TEST CASE / NAME\"
|
|
47
|
+
judge CRITERION a model says the reply meets CRITERION; it needs
|
|
48
|
+
OPENROUTER_API_KEY, ANTHROPIC_API_KEY or OPENAI_API_KEY
|
|
49
|
+
|
|
50
|
+
More: https://github.com/evalship-hq/tinyeval"
|
|
51
|
+
_te_download=${TINYEVAL_DOWNLOAD_URL:-https://github.com/evalship-hq/tinyeval/releases/latest/download/tinyeval}
|
|
52
|
+
|
|
53
|
+
# A package manager's copy (gem, pip, npm...) updates through it, so the package's wrapper sets the command that does.
|
|
54
|
+
# Not passed on: a repo's own ./tinyeval, run below, updates itself.
|
|
55
|
+
_te_pkg=${TINYEVAL_PACKAGE_UPDATE-}
|
|
56
|
+
unset TINYEVAL_PACKAGE_UPDATE
|
|
57
|
+
|
|
58
|
+
# The tinyeval on PATH runs the repo's own ./tinyeval, so each repo keeps the version its evals were written for.
|
|
59
|
+
if [ -f ./tinyeval ] && [ -x ./tinyeval ] && ! [ ./tinyeval -ef "$0" ]; then exec ./tinyeval "$@"; fi
|
|
60
|
+
|
|
61
|
+
# Whether version $1 is newer than version $2.
|
|
62
|
+
_te_newer() {
|
|
63
|
+
awk -v a="$1" -v b="$2" 'BEGIN { split(a, x, "."); split(b, y, ".")
|
|
64
|
+
for (i = 1; i <= 3; i++) if (x[i] + 0 != y[i] + 0) exit !(x[i] + 0 > y[i] + 0); exit 1 }'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if [ "${1-}" = update ]; then
|
|
68
|
+
[ -z "$_te_pkg" ] || { echo "tinyeval: a package manager installed this tinyeval. Update it with: $_te_pkg" >&2; exit 2; }
|
|
69
|
+
# Next to this file, so the rename below replaces it in one step: bash may still be reading the old one.
|
|
70
|
+
_te_new="$0.download"
|
|
71
|
+
if ! curl -fsSL "$_te_download" -o "$_te_new" || ! _te_latest=$(sed -n 's/^_te_version=//p' "$_te_new") ||
|
|
72
|
+
[ -z "$_te_latest" ] || ! bash -n "$_te_new"; then
|
|
73
|
+
rm -f "$_te_new"
|
|
74
|
+
echo "tinyeval: couldn't download the latest tinyeval from $_te_download" >&2
|
|
75
|
+
exit 2
|
|
76
|
+
fi
|
|
77
|
+
if ! _te_newer "$_te_latest" "$_te_version"; then
|
|
78
|
+
rm -f "$_te_new"
|
|
79
|
+
echo "tinyeval $_te_version is the latest version"
|
|
80
|
+
exit 0
|
|
81
|
+
fi
|
|
82
|
+
chmod +x "$_te_new" && mv -f "$_te_new" "$0" || exit 2
|
|
83
|
+
if [ -d .git ] && [ "$0" -ef ./tinyeval ]; then _te_commit=" Commit it with your code."; fi
|
|
84
|
+
echo "Updated $0 from $_te_version to $_te_latest.${_te_commit-}"
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# A private link to the results on evalship.com, like a secret gist, for whoever you send it to.
|
|
89
|
+
if [ "${1-}" = share ]; then
|
|
90
|
+
_te_file=${2:-eval-results.json}
|
|
91
|
+
[ -f "$_te_file" ] || { echo "tinyeval: no $_te_file to share. Run tinyeval first, or name a results file" >&2; exit 2; }
|
|
92
|
+
_te_said=$(curl -sS -m 60 -w '\n%{http_code}' -A "tinyeval/$_te_version" -H 'Accept: text/plain' \
|
|
93
|
+
-H 'Content-Type: application/octet-stream' --data-binary @"$_te_file" \
|
|
94
|
+
"${TINYEVAL_SHARE_URL:-https://evalship.com/api/v1/shares}" 2>&1)
|
|
95
|
+
_te_code=${_te_said##*$'\n'} _te_said=${_te_said%$'\n'*}
|
|
96
|
+
_te_said=${_te_said%$'\n'}
|
|
97
|
+
[ "$_te_code" = 201 ] || { echo "tinyeval: couldn't share $_te_file: $_te_said" >&2; exit 2; }
|
|
98
|
+
printf '%s\n' "$_te_said"
|
|
99
|
+
# The first line ends with the link.
|
|
100
|
+
_te_url=${_te_said%%$'\n'*}
|
|
101
|
+
[ -z "${GITHUB_STEP_SUMMARY-}" ] || printf '\nShared results: %s\n' "${_te_url##* }" >>"$GITHUB_STEP_SUMMARY"
|
|
102
|
+
exit 0
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
_te_runs=1 _te_jobs=4 _te_timeout=300 _te_only='' _te_junit=''
|
|
106
|
+
_te_results=eval-results.json
|
|
107
|
+
while getopts n:j:t:k:o:x:vh _te_opt; do
|
|
108
|
+
case $_te_opt in
|
|
109
|
+
n) _te_runs=$OPTARG ;;
|
|
110
|
+
j) _te_jobs=$OPTARG ;;
|
|
111
|
+
t) _te_timeout=$OPTARG ;;
|
|
112
|
+
k) _te_only=$OPTARG ;;
|
|
113
|
+
o) _te_results=$OPTARG ;;
|
|
114
|
+
x) _te_junit=$OPTARG ;;
|
|
115
|
+
v) echo "tinyeval $_te_version"; exit 0 ;;
|
|
116
|
+
h) echo "$_te_usage"; exit 0 ;;
|
|
117
|
+
*) echo "$_te_usage" >&2; exit 2 ;;
|
|
118
|
+
esac
|
|
119
|
+
done
|
|
120
|
+
shift $((OPTIND - 1))
|
|
121
|
+
for _te_opt in "-n $_te_runs" "-j $_te_jobs" "-t $_te_timeout"; do
|
|
122
|
+
case ${_te_opt#-? } in '' | *[!0-9]* | 0) echo "tinyeval: ${_te_opt%% *} takes a whole number, 1 or more" >&2; exit 2 ;; esac
|
|
123
|
+
done
|
|
124
|
+
[ $# -gt 0 ] || set -- evals/*.sh
|
|
125
|
+
for _te_f in "$@"; do
|
|
126
|
+
[ -f "$_te_f" ] || { echo "tinyeval: no eval file at $_te_f. Create one: https://github.com/evalship-hq/tinyeval#quickstart" >&2; exit 2; }
|
|
127
|
+
done
|
|
128
|
+
|
|
129
|
+
# In a terminal, once a day, look up the latest release in the background. The summary names it. GitHub Actions
|
|
130
|
+
# gets its own check per run, below, for the job summary; other CI never checks.
|
|
131
|
+
_te_cache=${XDG_CACHE_HOME:-$HOME/.cache}/tinyeval
|
|
132
|
+
if [ -t 1 ] && [ -z "${CI-}" ]; then
|
|
133
|
+
_te_check=1
|
|
134
|
+
if [ -z "$(find "$_te_cache/latest" -mmin -1440 2>/dev/null)" ]; then
|
|
135
|
+
(
|
|
136
|
+
mkdir -p "$_te_cache" && touch "$_te_cache/latest" # also when offline, so the next try is a day later
|
|
137
|
+
curl -fsSL -m 20 "$_te_download" | sed -n 's/^_te_version=//p' >"$_te_cache/latest.new"
|
|
138
|
+
[ -s "$_te_cache/latest.new" ] && mv -f "$_te_cache/latest.new" "$_te_cache/latest"
|
|
139
|
+
) >/dev/null 2>&1 &
|
|
140
|
+
fi
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# A coding agent's sandbox may not let it write to $TMPDIR, so the repo holds it then, until tinyeval exits.
|
|
144
|
+
_te_tmp=$(mktemp -d 2>/dev/null || mktemp -d "$PWD/.tinyeval.XXXXXX") || exit 2
|
|
145
|
+
# Git Bash: native Windows programs, like its curl.exe, need C:/ paths, not /tmp ones.
|
|
146
|
+
_te_win=''
|
|
147
|
+
if command -v cygpath >/dev/null; then _te_tmp=$(cygpath -m "$_te_tmp") _te_win=1; fi
|
|
148
|
+
trap 'rm -rf "$_te_tmp"' EXIT
|
|
149
|
+
if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
|
|
150
|
+
curl -fsSL -m 5 "$_te_download" 2>/dev/null | sed -n 's/^_te_version=//p' >"$_te_tmp/latest" &
|
|
151
|
+
_te_check_pid=$!
|
|
152
|
+
fi
|
|
153
|
+
# With an OTLP endpoint set, each run of ask tags its OpenTelemetry spans with its eval's otel id (_te_case), this
|
|
154
|
+
# tinyeval run's part being the temp dir's random suffix.
|
|
155
|
+
_te_otel='' _te_otel_attrs=${OTEL_RESOURCE_ATTRIBUTES-}
|
|
156
|
+
[ -z "${OTEL_EXPORTER_OTLP_ENDPOINT-}${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT-}" ] || _te_otel=${_te_tmp##*.}
|
|
157
|
+
# Background test cases ignore Ctrl-C, as bash's background jobs do, so stop the whole process group.
|
|
158
|
+
_te_stop() {
|
|
159
|
+
trap '' TERM
|
|
160
|
+
# Git Bash: native programs like python.exe ignore signals, so end each child's Windows process tree.
|
|
161
|
+
if [ -r /proc/$$/winpid ]; then
|
|
162
|
+
for _te_p in $(ps | awk -v me=$$ '$2 == me { print $4 }'); do taskkill //F //T //PID "$_te_p" >/dev/null 2>&1; done
|
|
163
|
+
fi
|
|
164
|
+
kill -TERM 0 2>/dev/null
|
|
165
|
+
exit 130
|
|
166
|
+
}
|
|
167
|
+
trap _te_stop INT TERM
|
|
168
|
+
if [ -t 1 ]; then _te_g=$'\033[32m' _te_r=$'\033[31m' _te_y=$'\033[33m' _te_d=$'\033[2m' _te_0=$'\033[0m'; fi
|
|
169
|
+
_te_tab=$'\t'
|
|
170
|
+
|
|
171
|
+
# --- what an eval file uses ---------------------------------------------------
|
|
172
|
+
|
|
173
|
+
# test_case NAME [INPUT]: starts a test case. Without INPUT it reads one from stdin (a heredoc).
|
|
174
|
+
test_case() {
|
|
175
|
+
local next_input
|
|
176
|
+
if [ $# -ge 2 ]; then next_input=$2; else next_input=$(cat); fi
|
|
177
|
+
_te_run_case
|
|
178
|
+
_te_name=$1 _te_file=${BASH_SOURCE[1]} _te_asserts=() _te_targets=() _te_input=$next_input
|
|
179
|
+
# Test cases with one name would merge into one eval, so a later one gets a number and fails.
|
|
180
|
+
local n=1
|
|
181
|
+
while [[ $_te_names == *$'\n'"$_te_name"$'\n'* ]]; do n=$((n + 1)); _te_name="$1 #$n"; done
|
|
182
|
+
_te_names+="$_te_name"$'\n'
|
|
183
|
+
[ $n -eq 1 ] || _te_add _te_fail "another test case in this file is named \"$1\". Give each its own name"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
contains() { _te_add _te_contains "$@"; } # the reply contains TEXT, ignoring case
|
|
187
|
+
not_contains() { _te_add _te_not_contains "$@"; } # the reply doesn't contain TEXT, ignoring case
|
|
188
|
+
matches() { _te_add _te_matches "$@"; } # the reply matches an extended regex
|
|
189
|
+
equals() { _te_add _te_equals "$@"; } # the reply is exactly TEXT
|
|
190
|
+
expect() { _te_add _te_expect "$@"; } # COMMAND exits 0 with the reply on its stdin
|
|
191
|
+
json() { _te_add _te_json_is "$@"; } # the reply is JSON and jq's FILTER is true for it
|
|
192
|
+
problems() { _te_add _te_problems "$@"; } # jq's FILTER lists what's wrong with the reply; none passes
|
|
193
|
+
# check NAME: the grading lines after it are another eval of the same reply, named "TEST CASE / NAME". One AI call can
|
|
194
|
+
# then show as several evals, each passing or failing on its own.
|
|
195
|
+
check() {
|
|
196
|
+
[ -n "$_te_name" ] || { echo "tinyeval: check comes before any test_case in ${BASH_SOURCE[1]}" >&2; return 1; }
|
|
197
|
+
_te_asserts+=("#check $1")
|
|
198
|
+
}
|
|
199
|
+
# target FILE#NAME...: the code this test case covers, for whatever reads the results file. It grades nothing.
|
|
200
|
+
target() {
|
|
201
|
+
[ -n "$_te_name" ] || { echo "tinyeval: target comes before any test_case in ${BASH_SOURCE[1]}" >&2; return 1; }
|
|
202
|
+
_te_targets+=("$@")
|
|
203
|
+
}
|
|
204
|
+
judge() { _te_add _te_judge "$@"; } # a model says the reply meets CRITERION
|
|
205
|
+
|
|
206
|
+
# worker COMMAND...: the AI call stays running instead of starting for each input, as an app that boots slowly
|
|
207
|
+
# needs. One runs per -j. It reads an input up to a NUL byte on stdin, then prints the reply and a NUL byte.
|
|
208
|
+
worker() {
|
|
209
|
+
[ "$_te_jobs" -le 100 ] || { echo "tinyeval: worker runs at most 100 at once, so lower -j" >&2; exit 2; }
|
|
210
|
+
_te_worker=("$@")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# --- internals ----------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
_te_add() {
|
|
216
|
+
[ -n "$_te_name" ] || { echo "tinyeval: ${1#_te_} comes before any test_case in ${BASH_SOURCE[2]}" >&2; return 1; }
|
|
217
|
+
local a
|
|
218
|
+
printf -v a '%q ' "$@"
|
|
219
|
+
_te_asserts+=("$a")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_te_fail() { _te_failures="${_te_failures:+$_te_failures$'\n'}$*"; }
|
|
223
|
+
# The AI call or the judge broke. A run with only these says nothing about the reply: it couldn't run.
|
|
224
|
+
_te_error() { _te_errors="${_te_errors:+$_te_errors$'\n'}$*"; _te_fail "$@"; }
|
|
225
|
+
|
|
226
|
+
# A line the eval file runs that isn't a command, like a misspelled contains, fails its test case instead of grading nothing.
|
|
227
|
+
_te_unknown() { [ -z "$_te_name" ] || _te_add _te_fail "not a tinyeval line: $1"; }
|
|
228
|
+
|
|
229
|
+
# Whether $1 contains $2, ignoring case.
|
|
230
|
+
_te_has() {
|
|
231
|
+
shopt -s nocasematch
|
|
232
|
+
[[ $1 == *"$2"* ]]
|
|
233
|
+
local found=$?
|
|
234
|
+
shopt -u nocasematch
|
|
235
|
+
return $found
|
|
236
|
+
}
|
|
237
|
+
_te_contains() { _te_has "$output" "$1" || _te_fail "expected to contain \"$1\""; }
|
|
238
|
+
_te_not_contains() { ! _te_has "$output" "$1" || _te_fail "expected not to contain \"$1\""; }
|
|
239
|
+
_te_matches() { [[ $output =~ $1 ]] || _te_fail "expected to match /$1/"; }
|
|
240
|
+
_te_equals() { [ "$output" = "$1" ] || _te_fail "expected exactly \"$1\""; }
|
|
241
|
+
_te_expect() {
|
|
242
|
+
local said code
|
|
243
|
+
said=$(printf '%s' "$output" | "$@" 2>&1) && return
|
|
244
|
+
code=$?
|
|
245
|
+
_te_fail "$*: $(printf '%s' "${said:-exited $code}" | tail -n 1)"
|
|
246
|
+
}
|
|
247
|
+
# The app's own checks, run where the reply is made (a worker, say), return their problems in the reply. A filter
|
|
248
|
+
# that gives null fails too: a misspelled field would otherwise pass every reply.
|
|
249
|
+
_te_problems() {
|
|
250
|
+
local said
|
|
251
|
+
command -v jq >/dev/null || { _te_error "problems needs jq: https://jqlang.org/download"; return; }
|
|
252
|
+
if ! said=$(printf '%s' "$output" | jq -r --arg f "$1" "($1) | if type == \"array\" then .[] | (strings // tojson)
|
|
253
|
+
elif type == \"string\" then . else error(\"\\(\$f) is \\(tojson), not a list\") end" 2>&1); then
|
|
254
|
+
_te_fail "problems: $(printf '%s' "$said" | tail -n 1 | sed 's/^jq: error (at [^)]*): //')"
|
|
255
|
+
return
|
|
256
|
+
fi
|
|
257
|
+
said=${said//$'\r'/} # jq.exe on Git Bash ends its lines with CRLF
|
|
258
|
+
[ -z "$said" ] || _te_fail "$said"
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_te_json_is() {
|
|
262
|
+
local said
|
|
263
|
+
command -v jq >/dev/null || { _te_error "json needs jq: https://jqlang.org/download"; return; }
|
|
264
|
+
said=$(printf '%s' "$output" | jq -e "$1" 2>&1) && return
|
|
265
|
+
# 1: the filter gave false or null. Anything else: the reply isn't JSON, or the filter has an error.
|
|
266
|
+
if [ $? -eq 1 ]; then _te_fail "json: $1 is ${said:-false}"; else _te_fail "json: $(printf '%s' "$said" | tail -n 1)"; fi
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_te_judge() {
|
|
270
|
+
local reply verdict
|
|
271
|
+
reply=$(_te_ask_judge "You grade one reply from an AI feature against one criterion.
|
|
272
|
+
|
|
273
|
+
Criterion: $1
|
|
274
|
+
|
|
275
|
+
The input it got:
|
|
276
|
+
<input>
|
|
277
|
+
$_te_input
|
|
278
|
+
</input>
|
|
279
|
+
|
|
280
|
+
Its reply:
|
|
281
|
+
<reply>
|
|
282
|
+
$output
|
|
283
|
+
</reply>
|
|
284
|
+
|
|
285
|
+
Does the reply meet the criterion? Answer PASS or FAIL on the first line, then one sentence on why.") ||
|
|
286
|
+
{ _te_error "judge couldn't run: $reply"; return; }
|
|
287
|
+
# The first word of the first line, after markdown or a label like "Verdict:", so "does not PASS" isn't a pass.
|
|
288
|
+
verdict=$(printf '%s\n' "$reply" | awk 'NF { sub(/^[^[:alnum:]]+/, ""); sub(/^[[:alpha:]]+:[^[:alnum:]]*/, "")
|
|
289
|
+
if ($0 ~ /^(PASS|FAIL)([^[:alnum:]]|$)/) print substr($0, 1, 4); exit }')
|
|
290
|
+
reply=$(printf '%s' "$reply" | tr '\n' ' ' | sed -E 's/^[^A-Z]*(PASS|FAIL)[^[:alnum:]]*//' | cut -c 1-300)
|
|
291
|
+
case $verdict in
|
|
292
|
+
PASS) ;;
|
|
293
|
+
FAIL) _te_fail "judge: $reply" ;;
|
|
294
|
+
*) _te_error "judge gave no PASS or FAIL: $reply" ;;
|
|
295
|
+
esac
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Sets the variable $1 to a JSON string holding $2, without starting a process unless $2 has control characters.
|
|
299
|
+
# Drops those but newline and tab, so ANSI colors lose their ESC.
|
|
300
|
+
_te_json() {
|
|
301
|
+
# bs is two backslashes, not an escaped quote.
|
|
302
|
+
# shellcheck disable=SC1003
|
|
303
|
+
local _te_s=$2 bs='\\' q='\"' n='\n' t='\t' tab=$'\t'
|
|
304
|
+
case $_te_s in *[[:cntrl:]]*) _te_s=$(printf '%s' "$_te_s" | tr -d '\000-\010\013-\037') ;; esac
|
|
305
|
+
# bash 3.2 takes seconds to substitute in tens of KB, so sed and awk escape a long text.
|
|
306
|
+
if [ ${#_te_s} -gt 2000 ]; then
|
|
307
|
+
_te_s=$(printf '%s\n' "$_te_s" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e "s/$tab/\\\\t/g" |
|
|
308
|
+
LC_ALL=C awk 'NR > 1 { printf "%s", "\\n" } { printf "%s", $0 }')
|
|
309
|
+
else
|
|
310
|
+
_te_s=${_te_s//\\/"$bs"} _te_s=${_te_s//\"/"$q"} _te_s=${_te_s//$'\n'/"$n"} _te_s=${_te_s//$'\t'/"$t"}
|
|
311
|
+
fi
|
|
312
|
+
# shellcheck disable=SC2089 # it sets the variable named in $1, which is always quoted where it's used
|
|
313
|
+
printf -v "$1" '"%s"' "$_te_s"
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# Sets the variable $1 to $2 for XML text or an attribute, keeping newline and tab.
|
|
317
|
+
_te_xml() {
|
|
318
|
+
# Quoted, as bash 5.2 reads an unquoted & in a replacement as the text it matched.
|
|
319
|
+
local _te_s=$2 amp='&' lt='<' gt='>' quot='"'
|
|
320
|
+
case $_te_s in *[[:cntrl:]]*) _te_s=$(printf '%s' "$_te_s" | tr -d '\000-\010\013-\037') ;; esac
|
|
321
|
+
if [ ${#_te_s} -gt 2000 ]; then # as in _te_json
|
|
322
|
+
_te_s=$(printf '%s\n' "$_te_s" | LC_ALL=C sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/g')
|
|
323
|
+
else
|
|
324
|
+
_te_s=${_te_s//&/"$amp"} _te_s=${_te_s//</"$lt"} _te_s=${_te_s//>/"$gt"} _te_s=${_te_s//\"/"$quot"}
|
|
325
|
+
fi
|
|
326
|
+
printf -v "$1" '%s' "$_te_s"
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# The string value of the last "KEY": "..." in JSON on stdin, still escaped.
|
|
330
|
+
# shortcut: a regex, not a parser; fine for one chat completion, use jq if this ever reads more.
|
|
331
|
+
_te_json_get() { tr -d '\n' | sed -nE 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p'; }
|
|
332
|
+
|
|
333
|
+
# Reads the AI calls the app wrote to the file $1, one JSON object a line, as evalship's /api/v1/calls takes them:
|
|
334
|
+
# _te_calls gets them joined by commas for the results file, _te_call_lines by newlines, and cost their summed "cost"
|
|
335
|
+
# unless the reply said it. A line that isn't an object fails the test case and is left out, so the results stay JSON.
|
|
336
|
+
# shortcut: only a line's shape is checked, as tinyeval doesn't need jq; an app's JSON encoder writes valid lines.
|
|
337
|
+
_te_read_calls() {
|
|
338
|
+
local l n=0 bad=''
|
|
339
|
+
_te_calls='' _te_call_lines=''
|
|
340
|
+
[ -s "$1" ] || return 0
|
|
341
|
+
while IFS= read -r l || [ -n "$l" ]; do
|
|
342
|
+
l=${l%$'\r'} n=$((n + 1))
|
|
343
|
+
[ -n "$l" ] || continue
|
|
344
|
+
if [[ $l != "{"*"}" ]]; then
|
|
345
|
+
[ -n "$bad" ] || _te_fail "line $n of \$TINYEVAL_CALLS isn't a JSON object on one line (write each call unindented): ${l:0:80}"
|
|
346
|
+
bad=1
|
|
347
|
+
continue
|
|
348
|
+
fi
|
|
349
|
+
_te_calls=${_te_calls:+$_te_calls,}$l _te_call_lines=$_te_call_lines$l$'\n'
|
|
350
|
+
done <"$1"
|
|
351
|
+
[ -z "$_te_calls" ] || [ -n "$cost" ] ||
|
|
352
|
+
cost=$(printf '%s' "$_te_call_lines" | LC_ALL=C awk '
|
|
353
|
+
match($0, /"cost"[ \t]*:[ \t]*[0-9][0-9.eE+-]*/) { v = substr($0, RSTART, RLENGTH); sub(/^"cost"[ \t]*:[ \t]*/, "", v); s += v; n++ }
|
|
354
|
+
END { if (n) printf "%.10g", s }')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# "call (model) → call (model)" for the AI calls in $1, one JSON object a line.
|
|
358
|
+
_te_call_names() {
|
|
359
|
+
printf '%s' "$1" | LC_ALL=C awk '
|
|
360
|
+
function str(k, v) {
|
|
361
|
+
if (!match($0, "\"" k "\"[ \t]*:[ \t]*\"([^\"\\\\]|\\\\.)*\"")) return ""
|
|
362
|
+
v = substr($0, RSTART, RLENGTH); sub(/^"[^"]*"[ \t]*:[ \t]*"/, "", v); return substr(v, 1, length(v) - 1)
|
|
363
|
+
}
|
|
364
|
+
{ c = str("call"); m = str("model"); out = out (NR > 1 ? " → " : "") (c == "" ? "?" : c) (m == "" ? "" : " (" m ")") }
|
|
365
|
+
END { print out }'
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# Prints the judge model's answer to $1, or why there is none and returns 1.
|
|
369
|
+
_te_ask_judge() {
|
|
370
|
+
if [ -n "${TINYEVAL_JUDGE:-}" ]; then printf '%s' "$1" | sh -c "$TINYEVAL_JUDGE"; return; fi
|
|
371
|
+
local url=${TINYEVAL_JUDGE_URL:-} key=${TINYEVAL_JUDGE_KEY:-} model=${TINYEVAL_JUDGE_MODEL:-} res content jmodel jprompt
|
|
372
|
+
if [ -z "$url" ]; then
|
|
373
|
+
if [ -n "${OPENROUTER_API_KEY:-}" ]; then
|
|
374
|
+
url=https://openrouter.ai/api/v1 key=${key:-$OPENROUTER_API_KEY} model=${model:-anthropic/claude-haiku-5.5}
|
|
375
|
+
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
|
376
|
+
url=https://api.anthropic.com/v1 key=${key:-$ANTHROPIC_API_KEY} model=${model:-claude-haiku-5-5}
|
|
377
|
+
elif [ -n "${OPENAI_API_KEY:-}" ]; then
|
|
378
|
+
url=https://api.openai.com/v1 key=${key:-$OPENAI_API_KEY}
|
|
379
|
+
else
|
|
380
|
+
echo "set OPENROUTER_API_KEY, ANTHROPIC_API_KEY or OPENAI_API_KEY, or TINYEVAL_JUDGE to a command"; return 1
|
|
381
|
+
fi
|
|
382
|
+
fi
|
|
383
|
+
[ -n "$model" ] || { echo "set TINYEVAL_JUDGE_MODEL to the model the judge should use"; return 1; }
|
|
384
|
+
command -v curl >/dev/null || { echo "judge needs curl, or TINYEVAL_JUDGE set to a command"; return 1; }
|
|
385
|
+
# The key goes in a header file, not the command line, so it doesn't show in ps.
|
|
386
|
+
(umask 077; printf 'Authorization: Bearer %s\n' "$key" >"$_te_dir/auth")
|
|
387
|
+
# --retry waits out 429 and 5xx replies. -o, because on stdout each retry's reply would add to the last one.
|
|
388
|
+
_te_json jmodel "$model"
|
|
389
|
+
_te_json jprompt "$1"
|
|
390
|
+
if ! res=$(printf '{"model":%s,"messages":[{"role":"user","content":%s}]}' "$jmodel" "$jprompt" |
|
|
391
|
+
curl -sS --retry 3 --max-time 120 -o "$_te_dir/judge.json" "${url%/}/chat/completions" -H "@$_te_dir/auth" \
|
|
392
|
+
-H 'Content-Type: application/json' --data-binary @- 2>&1) || ! res=$(cat "$_te_dir/judge.json"); then
|
|
393
|
+
echo "$res"; return 1
|
|
394
|
+
fi
|
|
395
|
+
content=$(printf '%s' "$res" | _te_json_get content)
|
|
396
|
+
if [ -z "$content" ]; then
|
|
397
|
+
content=$(printf '%s' "$res" | _te_json_get message)
|
|
398
|
+
printf '%s' "${content:-$res}" | cut -c 1-300
|
|
399
|
+
return 1
|
|
400
|
+
fi
|
|
401
|
+
printf '%s' "$content" | sed -e 's/\\[nrt]/ /g' -e 's/\\"/"/g' -e 's/\\\//\//g' -e 's/\\\\/\\/g'
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# Starts the test case test_case set up, in the background. Up to -j run at once; they print in file order.
|
|
405
|
+
_te_run_case() {
|
|
406
|
+
[ -n "$_te_name" ] || return 0
|
|
407
|
+
if [ -z "$_te_only" ] || _te_has "$_te_name" "$_te_only"; then
|
|
408
|
+
_te_n=$((_te_n + 1))
|
|
409
|
+
local dir="$_te_tmp/$_te_i.$_te_n"
|
|
410
|
+
# The slot the test case -j back had, as the oldest one is collected first: its worker is free.
|
|
411
|
+
_te_slot=$(((_te_n - 1) % _te_jobs))
|
|
412
|
+
[ ${#_te_worker[@]} -eq 0 ] || _te_worker_up "$_te_slot"
|
|
413
|
+
{ mkdir "$dir" && _te_case "$dir" >"$dir/printed"; } &
|
|
414
|
+
_te_pids+=("$!")
|
|
415
|
+
_te_dirs+=("$dir")
|
|
416
|
+
while [ ${#_te_pids[@]} -ge "$_te_jobs" ]; do _te_collect; done
|
|
417
|
+
fi
|
|
418
|
+
_te_name=
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# cat without starting a process, as tinyeval's main loop needs two per test case.
|
|
422
|
+
_te_cat() { local l; while IFS= read -r l || [ -n "$l" ]; do printf '%s\n' "$l"; done <"$1"; }
|
|
423
|
+
|
|
424
|
+
# Waits for the oldest running test case, then prints it and keeps its results.
|
|
425
|
+
_te_collect() {
|
|
426
|
+
wait "${_te_pids[0]}"
|
|
427
|
+
[ -n "$_te_header" ] || { echo "$_te_suite"; _te_header=1; }
|
|
428
|
+
_te_cat "${_te_dirs[0]}/printed"
|
|
429
|
+
_te_cat "${_te_dirs[0]}/results.jsonl" >>"$_te_tmp/results.jsonl"
|
|
430
|
+
[ ! -f "${_te_dirs[0]}/junit.xml" ] || _te_cat "${_te_dirs[0]}/junit.xml" >>"$_te_tmp/$_te_i.junit"
|
|
431
|
+
[ ! -f "${_te_dirs[0]}/summary.md" ] || _te_cat "${_te_dirs[0]}/summary.md" >>"$_te_tmp/summary.md"
|
|
432
|
+
_te_pids=("${_te_pids[@]:1}")
|
|
433
|
+
_te_dirs=("${_te_dirs[@]:1}")
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
# Starts the worker for slot $1, or a new one when its last call broke it. Its fifos are fds 20+2*slot and 21+2*slot.
|
|
437
|
+
_te_worker_up() {
|
|
438
|
+
local s=$1 w="$_te_tmp/$_te_i.w$1" f
|
|
439
|
+
if [ -n "${_te_wpids[$s]-}" ]; then
|
|
440
|
+
[ -f "$w.broken" ] || return 0
|
|
441
|
+
_te_worker_down "$s"
|
|
442
|
+
rm "$w.broken"
|
|
443
|
+
fi
|
|
444
|
+
_te_wn=$((_te_wn + 1))
|
|
445
|
+
f="$w.$_te_wn"
|
|
446
|
+
mkfifo "$f.in" "$f.out" || exit 2
|
|
447
|
+
# One calls file per slot: a slot's worker answers one test case at a time, which clears it first.
|
|
448
|
+
if [ -n "$_te_win" ]; then
|
|
449
|
+
# Git Bash: native programs, like python.exe, can't open its FIFOs, so cat carries the bytes over plain pipes.
|
|
450
|
+
{ cat <"$f.in" | TINYEVAL_CALLS="$w.calls" "${_te_worker[@]}" | cat >"$f.out"; } 2>"$f.err" &
|
|
451
|
+
else
|
|
452
|
+
TINYEVAL_CALLS="$w.calls" "${_te_worker[@]}" <"$f.in" >"$f.out" 2>"$f.err" &
|
|
453
|
+
fi
|
|
454
|
+
_te_wpids[s]=$! _te_werrs[s]=$f.err
|
|
455
|
+
eval "exec $((20 + 2 * s))>\"\$f.in\" $((21 + 2 * s))<\"\$f.out\""
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
_te_worker_down() {
|
|
459
|
+
local s=$1
|
|
460
|
+
eval "exec $((20 + 2 * s))>&- $((21 + 2 * s))<&-"
|
|
461
|
+
pkill -TERM -P "${_te_wpids[$s]}" 2>/dev/null
|
|
462
|
+
kill "${_te_wpids[$s]}" 2>/dev/null
|
|
463
|
+
wait "${_te_wpids[$s]}" 2>/dev/null
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# Sends the input to this test case's worker; its reply goes to output. A worker that stops or takes longer than -t
|
|
467
|
+
# is started again for the next test case.
|
|
468
|
+
# shortcut: this test case's later runs couldn't run then; restart here if -n runs need them.
|
|
469
|
+
# shortcut: an input larger than a pipe holds (16 KB or more) waits on the worker without -t.
|
|
470
|
+
_te_worker_ask() {
|
|
471
|
+
local s=$_te_slot w="$_te_tmp/$_te_i.w$_te_slot" started=$SECONDS
|
|
472
|
+
local to=$((20 + 2 * _te_slot)) from=$((21 + 2 * _te_slot))
|
|
473
|
+
if [ -f "$w.broken" ]; then _te_error "the worker stopped on an earlier run"; return; fi
|
|
474
|
+
trap '' PIPE # a worker that stopped is an error here, not the end of the test case
|
|
475
|
+
if printf '%s\0' "$_te_input" 1>&"$to" 2>/dev/null && IFS= read -r -d '' -t "$_te_timeout" -u "$from" output; then
|
|
476
|
+
trap - PIPE
|
|
477
|
+
return
|
|
478
|
+
fi
|
|
479
|
+
trap - PIPE
|
|
480
|
+
: >"$w.broken"
|
|
481
|
+
# By the time, as read fails the same way for both, and Git Bash shows a stopped worker as running for a while.
|
|
482
|
+
if [ $((SECONDS - started)) -ge "$_te_timeout" ]; then
|
|
483
|
+
local why="took longer than ${_te_timeout}s"
|
|
484
|
+
[ -z "$output" ] || why="didn't end its reply with a NUL byte within ${_te_timeout}s"
|
|
485
|
+
_te_error "the worker $why, so tinyeval stopped it (-t sets the limit)"
|
|
486
|
+
else
|
|
487
|
+
_te_error "the worker stopped: $(tail -n 3 "${_te_werrs[$s]}" | tr '\n' ' ')"
|
|
488
|
+
fi
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
# Runs ask with the input, its reply going to $1/out. Writes $1/timed-out when -t stops it.
|
|
492
|
+
# shortcut: a timeout stops ask and its direct children; deeper ones run on, but no longer hold up tinyeval.
|
|
493
|
+
_te_call_ask() {
|
|
494
|
+
local dir=$1 pid watchdog code
|
|
495
|
+
# 3>&-: a child left running after a timeout would hold the time keyword's stderr open, and so the caller's.
|
|
496
|
+
ask "$_te_input" <<<"$_te_input" >"$dir/out" 2>"$dir/err" 3>&- &
|
|
497
|
+
pid=$!
|
|
498
|
+
(
|
|
499
|
+
# A second at a time, with no trap to stop a long sleep: a stop that came before the trap was set waited out -t.
|
|
500
|
+
s=0
|
|
501
|
+
while [ $s -lt "$_te_timeout" ]; do sleep 1; s=$((s + 1)); done
|
|
502
|
+
: >"$dir/timed-out"
|
|
503
|
+
# Git Bash: end the Windows process tree, which takes native programs like python.exe with it.
|
|
504
|
+
if [ -r "/proc/$pid/winpid" ]; then taskkill //F //T //PID "$(cat "/proc/$pid/winpid")"; fi
|
|
505
|
+
pkill -TERM -P "$pid" 2>/dev/null
|
|
506
|
+
kill -TERM "$pid" 2>/dev/null
|
|
507
|
+
) >/dev/null 2>&1 3>&- &
|
|
508
|
+
watchdog=$!
|
|
509
|
+
wait "$pid" 2>/dev/null # no "Terminated" line when the watchdog stopped it
|
|
510
|
+
code=$?
|
|
511
|
+
kill "$watchdog" 2>/dev/null
|
|
512
|
+
wait "$watchdog" 2>/dev/null
|
|
513
|
+
return $code
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
# Runs one test case: ask, then every grading line, once per run. Prints its lines; results go to $1/results.jsonl.
|
|
517
|
+
# A check line starts another eval on the same reply, keyed "TEST CASE / NAME".
|
|
518
|
+
_te_case() {
|
|
519
|
+
_te_dir=$1
|
|
520
|
+
local i=1 g first=0 ms total_ms=0 took='' code a asked passed failure couldnt cost h jcost TIMEFORMAT=%3R
|
|
521
|
+
local suite key file jinput joutput targets='' t jt name=$_te_name
|
|
522
|
+
local keys=("$_te_name") lines=(0) passes=() errors=() fails=() errs=() shown=() shown_output=() shown_calls=() jcalls otel jotel=''
|
|
523
|
+
# Where the app may write its AI calls: this test case's own file, or its worker slot's (_te_worker_up).
|
|
524
|
+
local calls_file="$_te_dir/calls"
|
|
525
|
+
[ ${#_te_worker[@]} -eq 0 ] || calls_file="$_te_tmp/$_te_i.w$_te_slot.calls"
|
|
526
|
+
export TINYEVAL_CALLS=$calls_file
|
|
527
|
+
for a in ${_te_asserts[@]+"${_te_asserts[@]}"}; do
|
|
528
|
+
if [[ $a == "#check "* ]]; then keys+=("$_te_name / ${a#\#check }") lines+=(0)
|
|
529
|
+
else lines[${#lines[@]} - 1]=$((lines[${#lines[@]} - 1] + 1)); fi
|
|
530
|
+
done
|
|
531
|
+
# Grading lines that all come after a check: the test case is only its checks.
|
|
532
|
+
[ "${lines[0]}" -gt 0 ] || [ ${#keys[@]} -eq 1 ] || first=1
|
|
533
|
+
_te_json suite "$_te_suite"
|
|
534
|
+
_te_json file "$_te_file"
|
|
535
|
+
for t in ${_te_targets[@]+"${_te_targets[@]}"}; do _te_json jt "$t"; targets="${targets:+$targets,}$jt"; done
|
|
536
|
+
[ -z "$targets" ] || targets=",\"targets\":[$targets]"
|
|
537
|
+
: >"$_te_dir/results.jsonl"
|
|
538
|
+
while [ $i -le "$_te_runs" ]; do
|
|
539
|
+
_te_failures='' _te_errors='' output=''
|
|
540
|
+
# shortcut: a worker reads OTEL_RESOURCE_ATTRIBUTES once, so only ask's spans are tagged; tag a worker's by slot and time if asked.
|
|
541
|
+
if [ -n "$_te_otel" ] && [ ${#_te_worker[@]} -eq 0 ]; then
|
|
542
|
+
otel="$_te_otel.${_te_dir##*/}.$i" && jotel=",\"otel\":\"$otel\""
|
|
543
|
+
export OTEL_RESOURCE_ATTRIBUTES="${_te_otel_attrs:+$_te_otel_attrs,}evalship.eval=$otel"
|
|
544
|
+
fi
|
|
545
|
+
# bash's time keyword, not a clock command: two per run (perl on macOS's bash 3.2) were most of tinyeval's own time.
|
|
546
|
+
{ time {
|
|
547
|
+
[ ! -f "$_te_dir/timed-out" ] || rm "$_te_dir/timed-out"
|
|
548
|
+
: >"$calls_file"
|
|
549
|
+
if [ ${#_te_worker[@]} -gt 0 ]; then
|
|
550
|
+
_te_worker_ask
|
|
551
|
+
elif ! type ask >/dev/null 2>&1; then
|
|
552
|
+
_te_error "define ask or worker in $_te_file: ask gets the input as \$1 and on stdin, and prints the reply"
|
|
553
|
+
else
|
|
554
|
+
_te_call_ask "$_te_dir"
|
|
555
|
+
code=$?
|
|
556
|
+
# read, not $(<file), which starts a process; $() drops the trailing newlines, so this does too.
|
|
557
|
+
IFS= read -r -d '' output <"$_te_dir/out"
|
|
558
|
+
if [ -f "$_te_dir/timed-out" ]; then
|
|
559
|
+
_te_error "ask took longer than ${_te_timeout}s, so tinyeval stopped it (-t sets the limit)"
|
|
560
|
+
elif [ $code -ne 0 ]; then
|
|
561
|
+
_te_error "ask failed (exit $code): $(tail -n 3 "$_te_dir/err" | tr '\n' ' ')"
|
|
562
|
+
fi
|
|
563
|
+
fi
|
|
564
|
+
while [[ $output == *$'\n' ]]; do output=${output%$'\n'}; done
|
|
565
|
+
# Lines at the reply's start for tinyeval, not graded: "#tinyeval couldnt_run REASON" when the AI call broke (a
|
|
566
|
+
# 429 the app caught), "#tinyeval cost_usd DOLLARS" for what the call cost.
|
|
567
|
+
cost=''
|
|
568
|
+
while [[ $output == "#tinyeval "* ]]; do
|
|
569
|
+
h=${output%%$'\n'*} h=${h#\#tinyeval }
|
|
570
|
+
if [[ $output == *$'\n'* ]]; then output=${output#*$'\n'}; else output=''; fi
|
|
571
|
+
case $h in
|
|
572
|
+
"couldnt_run "*) _te_error "${h#couldnt_run }" ;;
|
|
573
|
+
"cost_usd "*) cost=${h#cost_usd }
|
|
574
|
+
[[ $cost =~ ^[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$ ]] || { _te_fail "#tinyeval cost_usd is a number of dollars, not \"$cost\""; cost=''; } ;;
|
|
575
|
+
*) _te_fail "not a #tinyeval line: $h (couldnt_run REASON or cost_usd DOLLARS)" ;;
|
|
576
|
+
esac
|
|
577
|
+
done
|
|
578
|
+
_te_read_calls "$calls_file"
|
|
579
|
+
# The AI call's errors are every eval's; a grading line's are its own eval's.
|
|
580
|
+
asked=$_te_errors g=0
|
|
581
|
+
for a in ${_te_asserts[@]+"${_te_asserts[@]}"} "#check "; do
|
|
582
|
+
if [[ $a != "#check "* ]]; then
|
|
583
|
+
[ -n "$asked" ] || eval "$a"
|
|
584
|
+
continue
|
|
585
|
+
fi
|
|
586
|
+
if [ -z "$asked" ] && [ "${lines[g]}" -eq 0 ] && [ $g -ge $first ]; then
|
|
587
|
+
_te_fail "nothing grades this $([ $g -eq 0 ] && echo test case || echo check): add contains, matches, json, problems, expect or judge under it"
|
|
588
|
+
fi
|
|
589
|
+
fails[g]=$_te_failures errs[g]=$_te_errors
|
|
590
|
+
g=$((g + 1)) _te_failures=$asked _te_errors=$asked
|
|
591
|
+
done
|
|
592
|
+
} 2>&3; } 3>&2 2>"$_te_dir/took"
|
|
593
|
+
read -r ms <"$_te_dir/took"
|
|
594
|
+
ms=$((10#${ms/[.,]/})) total_ms=$((total_ms + ms))
|
|
595
|
+
_te_json jinput "$_te_input"
|
|
596
|
+
_te_json joutput "$output"
|
|
597
|
+
jcalls='' && [ -z "$_te_calls" ] || jcalls=",\"calls\":[$_te_calls]"
|
|
598
|
+
g=$first
|
|
599
|
+
while [ $g -lt ${#keys[@]} ]; do
|
|
600
|
+
passed=true failure='' couldnt=''
|
|
601
|
+
if [ -z "${fails[g]}" ]; then passes[g]=$((${passes[g]:-0} + 1)); else
|
|
602
|
+
_te_json failure "${fails[g]}"
|
|
603
|
+
passed=false failure=",\"failure\":$failure" shown[g]=${fails[g]} shown_output[g]=$output shown_calls[g]=$_te_call_lines
|
|
604
|
+
# evalship reads couldnt_run too, and leaves such a run out of a test case's repeats.
|
|
605
|
+
if [ "${fails[g]}" = "${errs[g]}" ]; then
|
|
606
|
+
_te_json couldnt "${errs[g]}"
|
|
607
|
+
errors[g]=$((${errors[g]:-0} + 1)) couldnt=",\"metadata\":{\"couldnt_run\":$couldnt}"
|
|
608
|
+
fi
|
|
609
|
+
fi
|
|
610
|
+
_te_json key "${keys[g]}"
|
|
611
|
+
# The call's cost goes on one eval only, so a test case's checks don't count it again.
|
|
612
|
+
jcost='' && [ -z "$cost" ] || [ $g -ne $first ] || jcost=",\"cost_usd\":$cost"
|
|
613
|
+
# One eval per line with suite, key and passed first: the summary and the next run read them back with sed.
|
|
614
|
+
printf '{"suite":%s,"key":%s,"passed":%s,"file":%s%s,"duration_ms":%s%s,"input":%s,"output":%s%s%s%s%s}\n' \
|
|
615
|
+
"$suite" "$key" "$passed" "$file" "$targets" "$ms" "$jcost" "$jinput" "$joutput" "$failure" "$couldnt" "$jcalls" "$jotel" \
|
|
616
|
+
>>"$_te_dir/results.jsonl"
|
|
617
|
+
g=$((g + 1))
|
|
618
|
+
done
|
|
619
|
+
i=$((i + 1))
|
|
620
|
+
done
|
|
621
|
+
|
|
622
|
+
[ "$_te_runs" -gt 1 ] || printf -v took ' %d.%ds' $((ms / 1000)) $((ms % 1000 / 100))
|
|
623
|
+
[ $first -eq 0 ] || printf ' %s%s%s\n' "$name" "$_te_d" "$took$_te_0"
|
|
624
|
+
local mark count ran p e indent label reply last_reply='' verdict
|
|
625
|
+
g=$first
|
|
626
|
+
while [ $g -lt ${#keys[@]} ]; do
|
|
627
|
+
# Runs that couldn't run don't count toward the majority.
|
|
628
|
+
p=${passes[g]:-0} e=${errors[g]:-0} ran=$((_te_runs - ${errors[g]:-0})) mark="${_te_g}✓" count=''
|
|
629
|
+
[ "$_te_runs" -eq 1 ] || count=" $p/$ran"
|
|
630
|
+
[ $g -gt 0 ] || count="$count$took"
|
|
631
|
+
[ "$e" -eq 0 ] || count="$count, $e couldn't run"
|
|
632
|
+
if [ "$ran" -eq 0 ]; then mark="${_te_y}!" count=" couldn't run"
|
|
633
|
+
elif [ $((p * 2)) -lt "$ran" ]; then mark="${_te_r}✗"
|
|
634
|
+
elif [ "$p" -lt "$ran" ]; then mark="${_te_y}~"; fi
|
|
635
|
+
indent=' ' label=$name
|
|
636
|
+
[ $g -eq 0 ] || indent=' ' label=${keys[g]#"$name / "}
|
|
637
|
+
printf '%s%s%s %s%s%s\n' "$indent" "$mark" "$_te_0" "$label" "$_te_d" "$count$_te_0"
|
|
638
|
+
reply=''
|
|
639
|
+
if [ -n "${shown[g]}" ]; then
|
|
640
|
+
reply=$(printf '%s' "${shown_output[g]:-(empty)}" | tr '\n' ' ' | cut -c 1-200)
|
|
641
|
+
printf '%s\n' "${shown[g]}" | sed "s/^/$indent /"
|
|
642
|
+
# A check that failed on the reply another one showed doesn't show it again.
|
|
643
|
+
if [ "$reply" != "$last_reply" ]; then
|
|
644
|
+
printf '%s %sreply: %s%s\n' "$indent" "$_te_d" "$reply" "$_te_0"
|
|
645
|
+
[ -z "${shown_calls[g]}" ] || printf '%s %scalls: %s%s\n' "$indent" "$_te_d" "$(_te_call_names "${shown_calls[g]}")" "$_te_0"
|
|
646
|
+
fi
|
|
647
|
+
last_reply=$reply
|
|
648
|
+
fi
|
|
649
|
+
verdict=pass
|
|
650
|
+
if [ "$ran" -eq 0 ]; then verdict=error; elif [ $((p * 2)) -lt "$ran" ]; then verdict=fail; fi
|
|
651
|
+
_te_name=${keys[g]} shown_failures=${shown[g]}
|
|
652
|
+
[ -z "$_te_junit" ] || _te_junit_case "$verdict" "$reply" >>"$_te_dir/junit.xml"
|
|
653
|
+
if [ -n "${GITHUB_STEP_SUMMARY-}" ] && [ $verdict != pass ]; then _te_summary_row "$verdict" "$reply" >>"$_te_dir/summary.md"; fi
|
|
654
|
+
g=$((g + 1))
|
|
655
|
+
done
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
# A <testcase> for this test case, with a <failure> or <error> unless $1 is pass. $2 is the reply shown.
|
|
659
|
+
_te_junit_case() {
|
|
660
|
+
local suite name file why reply tag=failure
|
|
661
|
+
_te_xml suite "$_te_suite"
|
|
662
|
+
_te_xml name "$_te_name"
|
|
663
|
+
_te_xml file "$_te_file"
|
|
664
|
+
printf ' <testcase classname="%s" name="%s" file="%s" time="%d.%03d"' "$suite" "$name" "$file" \
|
|
665
|
+
$((total_ms / 1000)) $((total_ms % 1000))
|
|
666
|
+
if [ "$1" = pass ]; then echo '/>'; return; fi
|
|
667
|
+
[ "$1" = fail ] || tag=error
|
|
668
|
+
_te_xml why "${shown_failures%%$'\n'*}"
|
|
669
|
+
_te_xml reply "$shown_failures"$'\n'"reply: $2"
|
|
670
|
+
printf '>\n <%s message="%s">%s</%s>\n </testcase>\n' "$tag" "$why" "$reply" "$tag"
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
# A row of the GitHub job summary's table for a test case that failed ($1 fail) or couldn't run ($1 error).
|
|
674
|
+
_te_summary_row() {
|
|
675
|
+
local why=${shown_failures//$'\n'/; }
|
|
676
|
+
why=${why:0:300}
|
|
677
|
+
printf '| %s | %s › %s ' "$([ "$1" = fail ] && echo ✗ || echo !)" "$(_te_cell "$_te_suite")" "$(_te_cell "$_te_name")"
|
|
678
|
+
printf '| %s | %s |\n' "$(_te_cell "$why")" "$(_te_cell "$2")"
|
|
679
|
+
}
|
|
680
|
+
# Text that can stand in a Markdown table cell.
|
|
681
|
+
_te_cell() { local s=${1//|/'\|'} lt='<'; s=${s//</"$lt"}; printf '%s' "${s//$'\n'/ }"; }
|
|
682
|
+
|
|
683
|
+
# suite<TAB>key<TAB>true|false|error per eval line of a results file (or results.jsonl) on stdin.
|
|
684
|
+
# A raw "couldnt_run": can only be the key: inside a JSON string its quotes are escaped.
|
|
685
|
+
_te_statuses() {
|
|
686
|
+
local line='^\{"suite":("([^"\\]|\\.)*"),"key":("([^"\\]|\\.)*"),"passed":(true|false)'
|
|
687
|
+
sed -nE -e "s/$line.*\"couldnt_run\":.*/\\1$_te_tab\\3${_te_tab}error/p" -e t -e "s/$line.*/\\1$_te_tab\\3$_te_tab\\5/p"
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
# suite<TAB>key<TAB>pass|fail|error per eval, in first-run order: the majority of the runs that could run, as evalship
|
|
691
|
+
# counts repeats. error when none could.
|
|
692
|
+
_te_majority() {
|
|
693
|
+
awk -F '\t' '{ k = $1 FS $2; if (!(k in seen)) { seen[k] = 1; order[++m] = k }
|
|
694
|
+
if ($3 != "error") { n[k]++; if ($3 == "true") p[k]++ } }
|
|
695
|
+
END { for (i = 1; i <= m; i++) { k = order[i]; print k FS (!(k in n) ? "error" : p[k] * 2 >= n[k] ? "pass" : "fail") } }'
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
# --- run ------------------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
: >"$_te_tmp/results.jsonl"
|
|
701
|
+
_te_i=0
|
|
702
|
+
for _te_f in "$@"; do
|
|
703
|
+
_te_i=$((_te_i + 1))
|
|
704
|
+
_te_suite=$(basename "$_te_f" .sh)
|
|
705
|
+
# A subshell per file, so one file's ask doesn't leak into the next. In the background, because bash runs
|
|
706
|
+
# the INT/TERM trap during wait but not while a foreground command runs.
|
|
707
|
+
(
|
|
708
|
+
_te_name='' _te_n=0 _te_header='' _te_names=$'\n'
|
|
709
|
+
_te_pids=()
|
|
710
|
+
_te_dirs=()
|
|
711
|
+
# bash 3.2 (macOS) brace-expands inside "$(echo '...')" once an apostrophe written '"'"' flips its quote
|
|
712
|
+
# count: JSON rows like {"a": 1, "b": 2} multiplied into billions of inputs and filled all memory.
|
|
713
|
+
set +B
|
|
714
|
+
trap '[ $? -ne 127 ] || _te_unknown "$BASH_COMMAND"' ERR
|
|
715
|
+
# shellcheck source=/dev/null
|
|
716
|
+
. "$_te_f"
|
|
717
|
+
trap - ERR
|
|
718
|
+
_te_run_case
|
|
719
|
+
while [ ${#_te_pids[@]} -gt 0 ]; do _te_collect; done
|
|
720
|
+
for _te_s in "${!_te_wpids[@]}"; do _te_worker_down "$_te_s"; done
|
|
721
|
+
if [ -n "$_te_junit" ] && [ -f "$_te_tmp/$_te_i.junit" ]; then
|
|
722
|
+
_te_xml _te_sx "$_te_suite"
|
|
723
|
+
# shellcheck disable=SC2154 # _te_xml sets it
|
|
724
|
+
printf ' <testsuite name="%s" tests="%s" failures="%s" errors="%s">\n' "$_te_sx" \
|
|
725
|
+
"$(grep -c '<testcase ' "$_te_tmp/$_te_i.junit")" "$(grep -c '<failure ' "$_te_tmp/$_te_i.junit")" \
|
|
726
|
+
"$(grep -c '<error ' "$_te_tmp/$_te_i.junit")"
|
|
727
|
+
_te_cat "$_te_tmp/$_te_i.junit"
|
|
728
|
+
echo ' </testsuite>'
|
|
729
|
+
fi >>"$_te_tmp/junit.xml"
|
|
730
|
+
) </dev/null &
|
|
731
|
+
wait "$!"
|
|
732
|
+
done
|
|
733
|
+
|
|
734
|
+
_te_statuses <"$_te_tmp/results.jsonl" | _te_majority >"$_te_tmp/now"
|
|
735
|
+
_te_total=$(wc -l <"$_te_tmp/now" | tr -d ' ')
|
|
736
|
+
if [ "$_te_total" -eq 0 ]; then
|
|
737
|
+
if [ -n "$_te_only" ]; then echo "tinyeval: no test case name contains \"$_te_only\"" >&2; else echo "tinyeval: no test cases in $*" >&2; fi
|
|
738
|
+
exit 2
|
|
739
|
+
fi
|
|
740
|
+
|
|
741
|
+
if [ -f "$_te_results" ]; then
|
|
742
|
+
_te_statuses <"$_te_results" | _te_majority >"$_te_tmp/was"
|
|
743
|
+
awk -F '\t' -v g="$_te_g" -v r="$_te_r" -v z="$_te_0" '
|
|
744
|
+
function show(s) { s = substr(s, 2, length(s) - 2); gsub(/\\"/, "\"", s); return s }
|
|
745
|
+
NR == FNR { was[$1 FS $2] = $3; next }
|
|
746
|
+
($1 FS $2) in was && was[$1 FS $2] != $3 && was[$1 FS $2] != "error" && $3 != "error" {
|
|
747
|
+
if (!shown++) print "\nSince the last run"
|
|
748
|
+
print " " ($3 == "pass" ? g "✓" z : r "✗" z) " " show($1) " › " show($2) ($3 == "pass" ? " passes now" : " fails now")
|
|
749
|
+
}' "$_te_tmp/was" "$_te_tmp/now"
|
|
750
|
+
fi
|
|
751
|
+
{ echo '{"evals": ['; sed '$!s/$/,/' "$_te_tmp/results.jsonl"; echo ']}'; } >"$_te_results"
|
|
752
|
+
|
|
753
|
+
_te_failed=$(grep -c "${_te_tab}fail\$" "$_te_tmp/now")
|
|
754
|
+
_te_broke=$(grep -c "${_te_tab}error\$" "$_te_tmp/now")
|
|
755
|
+
if [ "$_te_failed" -gt 0 ]; then
|
|
756
|
+
_te_said="$_te_failed of $_te_total evals failed" _te_color=$_te_r
|
|
757
|
+
[ "$_te_broke" -eq 0 ] || _te_said="$_te_said, $_te_broke couldn't run"
|
|
758
|
+
elif [ "$_te_broke" -gt 0 ]; then _te_said="$_te_broke of $_te_total evals couldn't run" _te_color=$_te_y
|
|
759
|
+
else _te_said="All $_te_total evals passed" _te_color=$_te_g; fi
|
|
760
|
+
printf '\n%s%s%s %s(%s)%s\n' "$_te_color" "$_te_said" "$_te_0" "$_te_d" "$_te_results" "$_te_0"
|
|
761
|
+
|
|
762
|
+
if [ -n "$_te_junit" ]; then
|
|
763
|
+
{ echo '<?xml version="1.0" encoding="UTF-8"?>'
|
|
764
|
+
printf '<testsuites tests="%s" failures="%s" errors="%s">\n' "$_te_total" "$_te_failed" "$_te_broke"
|
|
765
|
+
cat "$_te_tmp/junit.xml"
|
|
766
|
+
echo '</testsuites>'; } >"$_te_junit"
|
|
767
|
+
fi
|
|
768
|
+
# What the update notices below say to run.
|
|
769
|
+
if [ -n "$_te_pkg" ]; then _te_up=$_te_pkg _te_commit=''
|
|
770
|
+
else case $0 in ./*) _te_up="$0 update" ;; *) _te_up='tinyeval update' ;; esac; _te_commit=' and commit it'; fi
|
|
771
|
+
# GitHub Actions shows this on the run's page.
|
|
772
|
+
if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
|
|
773
|
+
{ echo "### tinyeval: $_te_said"
|
|
774
|
+
if [ -s "$_te_tmp/summary.md" ]; then printf '\n| | Test case | Why | Reply |\n|---|---|---|---|\n'; cat "$_te_tmp/summary.md"; fi
|
|
775
|
+
wait "$_te_check_pid" 2>/dev/null # at most curl's 5 seconds, and the run has usually outlasted it
|
|
776
|
+
if _te_latest=$(cat "$_te_tmp/latest" 2>/dev/null) && _te_newer "$_te_latest" "$_te_version"; then
|
|
777
|
+
printf '\ntinyeval %s is out. Run %s%s.\n' "$_te_latest" "$_te_up" "$_te_commit"
|
|
778
|
+
fi
|
|
779
|
+
} >>"$GITHUB_STEP_SUMMARY"
|
|
780
|
+
fi
|
|
781
|
+
if [ -n "${_te_check-}" ] && _te_latest=$(cat "$_te_cache/latest" 2>/dev/null) && _te_newer "$_te_latest" "$_te_version"; then
|
|
782
|
+
printf '%stinyeval %s is out. Run %s%s\n' "$_te_d" "$_te_latest" "$_te_up" "$_te_0"
|
|
783
|
+
fi
|
|
784
|
+
[ $((_te_failed + _te_broke)) -eq 0 ] || exit 1
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tinyeval
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.11.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- evalship
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: tinyeval runs your evals with bundle exec tinyeval. Tinyeval.worker answers
|
|
13
|
+
them from your app, booted once.
|
|
14
|
+
executables:
|
|
15
|
+
- tinyeval
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- exe/tinyeval
|
|
22
|
+
- lib/tinyeval.rb
|
|
23
|
+
- lib/tinyeval/worker.rb
|
|
24
|
+
- tinyeval
|
|
25
|
+
homepage: https://tinyeval.com
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
source_code_uri: https://github.com/evalship-hq/tinyeval/tree/main/packages/ruby
|
|
30
|
+
changelog_uri: https://github.com/evalship-hq/tinyeval/releases
|
|
31
|
+
bug_tracker_uri: https://github.com/evalship-hq/tinyeval/issues
|
|
32
|
+
rubygems_mfa_required: 'true'
|
|
33
|
+
rdoc_options: []
|
|
34
|
+
require_paths:
|
|
35
|
+
- lib
|
|
36
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.0'
|
|
41
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
requirements: []
|
|
47
|
+
rubygems_version: 4.0.20
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: Evals for the AI calls in your Ruby or Rails app, run by one bash file
|
|
50
|
+
test_files: []
|