Add a new delivery category with three subcategories: git, containers, and release-engineering. Each subcategory holds 10 self-contained, deterministically-scored tests, grounded in real git, Docker, and release-engineering behaviour. The git subcategory covers bisect step derivation, rebase versus merge, conventional-commit classification, git worktree, force-with-lease versus bare force, hook choice, detached-HEAD recovery, cherry-pick versus revert, a .gitignore negation trace, and stash-pop conflict behaviour. Two of these tests recompute their ground truth against a real, throwaway git repository created in t.TempDir, and skip if git is not installed. The containers subcategory covers Dockerfile layer-cache busting, ENTRYPOINT and CMD interaction, multi-stage image size, non-root users and capabilities, HEALTHCHECK semantics, .dockerignore effects, COPY versus ADD, build-arg versus runtime env, and layer counting. The release-engineering subcategory covers semver bump derivation from a changelog, the clean-tree requirement, rollback ordering, canary versus blue-green, tag-to-release sequencing, commit-to-changelog mapping, checksum and signing purpose, pipeline stage ordering, pin versus range dependency policy, and hotfix flow ordering. A shared negation-aware evaluator helper, local to this category's files, scores a forbidden phrase only when it appears unnegated, so a response correctly warning against bare --force or a silent tracked-file mutation still scores full credit. registerDeliveryTests wires the three subcategories together and is exercised only by a new wiring test in this worktree, since catalog.go and catalog_test.go stay untouched until the orchestrator integrates this category centrally.
llm-testbench
llm-testbench runs a catalog of realistic tests against multiple language models through an OpenAI-compatible endpoint, scores each response with a deterministic evaluator, and renders a comparison table. It does not use an LLM as a judge.
Requirements
- Go 1.25 or later.
python3andccon PATH, only if you want the Python and C exec-evaluator tests to run instead of skip.- An OpenAI-compatible chat completions endpoint to test against.
Warning: the go-worker-pool, py-log-triage, and c-struct-size
exec-evaluator tests compile and run model-generated code (internal/eval's
GoRun/PyRun/CRun), with the same privileges as the llmtest
process itself. Each call runs in its own temp directory with a minimal
environment (PATH, an isolated HOME/TMPDIR, and GOCACHE/GOMODCACHE
for GoRun), not the operator's full environment - but this is not a
sandbox, and it does not stop a malicious payload from reading files the
operating-system user can read or making outbound network calls. Run
llmtest run in a throwaway VM or container if you test models you do not
already trust.
Setup
Copy the example config and set your endpoint and models:
cp config.example.yaml config.yaml
Set an API key through the config file's api_key field, or through the
LLMTB_API_KEY environment variable. The environment variable always wins
over the file value.
Usage
List the test catalog:
go run ./cmd/llmtest list
go run ./cmd/llmtest list --category programming --subcategory golang
Run the catalog against the configured models:
go run ./cmd/llmtest run
go run ./cmd/llmtest run --category operations --format markdown
go run ./cmd/llmtest run --models uni/deepseek-v4-flash-0731,uni/qwen3.6-27b
Flags
Both list and run accept:
--config- path to the YAML config file. Default:config.yaml.--category- filter to one category (for exampleprogramming).--subcategory- filter to one subcategory (for examplegolang).
run also accepts:
--models- comma-separated model list, overriding the config file.--format-table,markdown, orjson. Default:table.--concurrency- override the config file'sconcurrency.--timeout- override the config file'srequest_timeout(for example60s).--quiet- suppress progress output on stderr. Progress is on by default: a run can issue hundreds of requests at 30-50s each, and a silent CLI reads as a hang.
By default, run prints one line to stderr at the start ("running N tests
x M models = K requests, concurrency C") and one line per completed
request ("[done/total] model=... test=... score=... tokens=... (latency)",
with a TRUNCATED marker when a response was cut off by the token
budget). stdout carries only the table/markdown/json report, so llmtest run --format json > results.json stays pipeable regardless of --quiet.
If config.yaml omits concurrency, request_timeout, or
max_tokens_default, internal/config fills in 8, 300s, and 12000.
The effective per-call token budget is max(test.MaxTokens, max_tokens_default): a test's own MaxTokens can only raise the budget
above the config default, never lower it below it. This matters for
reasoning models, which can spend thousands of completion tokens on
reasoning_content before writing an answer to content; a low budget
truncates the answer before the model reaches it.
Output
table and markdown render three views: per-test scores grouped by
category and subcategory, a category rollup with mean score per model, and
a model summary with pass/partial/fail/error counts, mean latency, total
tokens, and average tokens per second. json dumps the raw per-test
results, joined with each test's category and subcategory.
A scored cell in the per-test table reads score (Ntok), where Ntok is
the prompt plus completion tokens that test burned on that model (for
example 0.85 (312tok)). The model summary's TOK/S column is an
aggregate ratio, sum of completion tokens over sum of elapsed seconds
across all non-skipped, non-error calls, not a mean of each test's own
ratio; this keeps a handful of very short responses from skewing the rate.
A cell reads ERR when the model call itself failed (a transport error or
a non-2xx response after retries), and skip when the test's evaluator
needs a local toolchain that is not installed (for example, a Python
exec-evaluator when python3 is missing). Skipped tests are excluded from
every mean.
A cell carries a trailing ! when the model's response was cut off by the
token budget (finish_reason=length) - for example 0.00 (6000tok)!. A
truncated response's score is still whatever the evaluator computed on the
partial text, so a ! cell needs a closer look: raise
max_tokens_default and re-run. table and markdown print a one-line
legend explaining ! whenever at least one cell has it. json carries the
same signal per result as "truncated": true, plus "finish_reason" and
"response_text" (the exact normalized text that was scored) so a
truncated or unexpectedly low-scoring result can be debugged without
re-running the model.
Test catalog
The catalog lives in internal/tests, one file per category. Every test
has a fully self-contained prompt (all data needed to answer is inline) and
a single deterministic evaluator. Every hardcoded expected value carries a
// ground truth: comment showing its derivation, and where the derivation
is cheap, the test file's _test.go recomputes it independently (for
example, internal/tests/python_test.go recomputes the cosine similarity
with math.Sqrt rather than trusting the constant).
| Category / subcategory | Tests |
|---|---|
| programming / golang | go-struct-align, go-worker-pool, go-semver-classify |
| programming / python | py-log-triage, py-cosine |
| programming / typescript | ts-debounce-composable |
| programming / c | c-struct-size |
| operations / macos | macos-timeout-portability, macos-launchd-cron |
| operations / linux | linux-pct-exec, linux-systemd-oneshot |
| operations / kubernetes | k8s-crashloop-gitops |
| research / web | web-robots-ai-crawlers |
| research / whitepapers | paper-hnsw-params |
| research / codebase | code-trace-go |
| agents / tool-routing | agent-tool-routing |
| agents / planning | agent-plan-ordering |
Adding a test
- Add a
testkit.Testvalue to the relevant file ininternal/tests(or a new file, for a new category). Give it a unique, kebab-caseID, aCategoryandSubcategory, a self-containedPrompt, and aneval.Evaluator. - Register it in that file's
register<Category>Testsfunction, called frominternal/tests/catalog.go'sAll. - Add a
// ground truth:comment above any hardcoded expected value, showing how you derived it. Recompute it in the test file when that is cheap (arithmetic, a struct'sunsafe.Sizeof, running the traced function). - Add table-driven
_test.gocases covering a correct answer, a wrong answer, and at least one edge case (a common trap, a partial match, or a malformed response). - Add the test's ID to the
wantCatalogmap ininternal/tests/catalog_test.go.
Adding an evaluator
Evaluators live in internal/eval and implement:
type Evaluator interface {
Evaluate(ctx context.Context, response string) Score
}
Score.Value is in the range 0 to 1; set Score.Skipped only when a
required toolchain is missing, not for a wrong answer. Compose existing
evaluators with eval.All (weighted mean) or eval.Mean (equal-weighted
mean) rather than writing a one-off composite by hand. For a check that the
built-in evaluators cannot express, wrap a closure with
eval.EvaluatorFunc. Add table-driven tests in internal/eval covering a
match, a non-match, and an edge case (empty input, malformed JSON, and so
on).
Quality gates
go build ./...
go vet ./...
go test ./...
gofmt -l . # must print nothing
golangci-lint run # if the binary is installed
Every unit test runs offline against a mock llm.Client or a local
httptest.Server; none of them call the live endpoint. The exec-evaluator
tests (GoRun, PyRun, CRun) detect a missing go, python3, or cc
toolchain and skip rather than fail.