lukaszraczylo 28aeb63dd8 ci: add goreleaser and semver-driven release workflow
The workflow calculates the version with semver-generator, tags the
commit when the version is new, and runs goreleaser. goreleaser ships
two distribution paths: tar.gz binaries for darwin and linux on amd64
and arm64, and a multi-arch ghcr.io container that carries the go,
python3, and C toolchains so the exec evaluators run in-container.
The new llmtest version subcommand reports the stamped build version.
2026-08-17 10:49:35 +01:00

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.
  • python3 and cc on 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 example programming).
  • --subcategory - filter to one subcategory (for example golang).

run also accepts:

  • --models - comma-separated model list, overriding the config file.
  • --format - table, markdown, or json. Default: table.
  • --concurrency - override the config file's concurrency.
  • --timeout - override the config file's request_timeout (for example 60s).
  • --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

  1. Add a testkit.Test value to the relevant file in internal/tests (or a new file, for a new category). Give it a unique, kebab-case ID, a Category and Subcategory, a self-contained Prompt, and an eval.Evaluator.
  2. Register it in that file's register<Category>Tests function, called from internal/tests/catalog.go's All.
  3. 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's unsafe.Sizeof, running the traced function).
  4. Add table-driven _test.go cases covering a correct answer, a wrong answer, and at least one edge case (a common trap, a partial match, or a malformed response).
  5. Add the test's ID to the wantCatalog map in internal/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.

S
Description
No description provided
Readme
944 KiB
Languages
Go 99.9%