Test your prompts like you test your code.
Prompts drift. Harnesses and the tooling around them break in ways unit tests never catch. gauge treats prompt evaluation like a test suite β declarative specs, a runner, assertions, CI-friendly reporters β decoupled from whatever language your harness is written in.
$ gauge run β evals/router.eval.md βΊ password-reset (1293ms) β contains "auth" β llm-rate "routes without asking a clarifying question" >= 0.8 β evals/router.eval.md βΊ refund (1170ms) β equals "billing" β evals/summarize.eval.md (980ms) β regression β score dropped 0.91 β 0.62 β llm-judge "faithful to the source" β judge failed: invents a statistic 2 passed, 1 failed, 3 total
Why gauge
πSpecs, not scripts
Evals are Markdown files with YAML frontmatter. The prompt is the body. No glue code, no framework lock-in.
π¬Given / When / Then
A scenario sets up the world β agent system prompt and shared context. The prompt is the action, assertions are the expectations.
πLanguage-agnostic
The exec provider shells out to any harness β Python, Go, Rust β over stdin/stdout.
βοΈReal scoring
Exact match, regex, JSON-schema, embedding similarity, LLM-as-judge, 0β1 rated thresholds.
πRegression baselines
Snapshot a passing run. Later runs flag anything that regressed β and fail the build.
β‘Fast & cheap
Bounded concurrency, automatic retries, opt-in response caching.
πExtensible
OpenAI, Anthropic, Gemini, Mistral, GLM, Hugging Face, OpenRouter, Ollama, Azure built in β plus a generic OpenAI-compatible adapter. Register custom providers and scorers with one function.
Quickstart
From zero to a passing eval in three steps.
1. Set your API key
gauge reads keys from the environment (or a .env file in the working directory).
# OpenAI, Anthropic, or both
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...
2. Write an eval
Create evals/router.eval.md. Frontmatter is config; the body is the prompt.
--- provider: openai/gpt-4o-mini # scenario = the setup (BDD "Given"): the agent + shared context scenario: system: | You are a support router. Reply with one lowercase category slug (auth, billing, technical, other). No punctuation. vars: input: "I forgot my password" assert: - contains: "auth" - llm-judge: "Routes correctly without asking a clarifying question" --- User request: {{input}}
3. Run it
$ npx gauge-eval run
β evals/router.eval.md (1293ms) β contains "auth" β llm-judge "Routes correctly without asking a clarifying question" 1 passed, 0 failed, 1 total
**/*.eval.md and **/*.eval.yaml automatically. Exit code is non-zero on failure β drop it straight into CI.Eval format
One file is one eval: a prompt, the variables it needs, and the assertions its output must satisfy.
Two shapes are supported. Markdown with YAML frontmatter β best when the prompt is real prose:
--- provider: anthropic/claude-opus-4-8 # scenario is optional β it sets up the world before the prompt runs scenario: system: You are a patient science teacher. vars: topic: photosynthesis assert: - contains: "sunlight" --- Explain {{topic}} to a 10-year-old in two sentences.
The optional scenario block is the setup step β the agent system prompt and shared context. See Scenarios for the full picture; skip it for a bare prompt.
β¦or a single YAML document β handy for matrix-heavy, prose-light cases (the prompt lives in a prompt: field):
provider: openai/gpt-4o prompt: "Classify sentiment (pos/neg): {{text}}" assert: - regex: "/^(pos|neg)$/"
Variables
{{name}} placeholders in the prompt are filled from vars. A missing variable is an error, not a silent blank β so typos surface immediately.
Scenarios
If you like BDD, the pieces line up: a scenario is the Given, the prompt body is the When, and assert is the Then. The scenario sets up the world before the prompt runs.
| BDD | gauge |
|---|---|
| Given β set up the world | scenario (system + vars) |
| When β the action | the prompt body + vars |
| Then β the expectation | assert |
| Examples / table | cases |
system becomes the model's system / agent instruction; vars form the base layer that spec- and case-level vars merge over. Both are rendered with the case's vars, so a scenario can interpolate {{...}} too.
--- provider: anthropic/claude-opus-4-8 scenario: system: You are a terse support router. Queues: {{queues}}. vars: queues: "auth, billing, other" assert: - regex: "/^(auth|billing|other)$/" --- Classify: {{input}}
Var precedence
Last wins: scenario.vars β spec vars β case vars. A scenario sets the shared defaults; individual cases override what they need.
How system reaches the model
It maps to each provider's native system role β an OpenAI/Azure system message, Anthropic's top-level system, Gemini's systemInstruction, Ollama's system. The exec provider receives it as the GAUGE_SYSTEM environment variable, so stdin stays the bare prompt.
system and shared context in scenario, then drive it with a matrix of cases β each case is one example of the same set-up world.Providers
Where the prompt runs. Use the vendor/model shorthand, or the object form for providers that need more.
| Provider | Spec | Notes |
|---|---|---|
| openai | openai/gpt-4o-mini | Reads OPENAI_API_KEY |
| anthropic | anthropic/claude-opus-4-8 | Reads ANTHROPIC_API_KEY |
google/gemini-1.5-flash | Reads GEMINI_API_KEY | |
| mistral | mistral/mistral-large-latest | Reads MISTRAL_API_KEY |
| glm | glm/glm-4.6 | Zhipu / Z.AI Β· GLM_API_KEY Β· GLM_BASE_URL for CN |
| huggingface | hf/meta-llama/Llama-3.3-70B-Instruct | Router for OSS models Β· HF_TOKEN |
| openrouter | openrouter/anthropic/claude-3.5-sonnet | Gateway to many vendors Β· OPENROUTER_API_KEY |
| ollama | ollama/llama3 | Local, no key Β· OLLAMA_HOST |
| azure | azure/my-deployment | AZURE_OPENAI_ENDPOINT + _API_KEY |
| openai-compat | object form β | Any OpenAI-compatible host β DeepSeek, Groq, Together, β¦ |
| exec | object form β | Shell out to any harness |
gauge report. Override prices with the GAUGE_PRICING env var.The exec provider β test any harness, any language
This is the escape hatch that makes gauge work for tooling built on top of harnesses. The rendered prompt is written to the command's stdin; its stdout becomes the output. Your harness stays in whatever language it's written in.
--- provider: type: exec command: "python my_agent.py --tool-router" assert: - llm-judge: "Selected the search tool, not the calculator" --- Find me flights to Lisbon under $600.
exec speaks stdin/stdout, the same eval suite works whether your agent is in Python, Go, Rust, or a shell script. The contract is the prompt and the output β never the implementation.Any OpenAI-compatible host
Most vendors speak the OpenAI chat API. Point the openai-compat object form at any base URL and name the env var holding the key β DeepSeek, Groq, Together, Fireworks, and more, without waiting for a built-in shorthand.
provider: type: openai-compat baseUrl: https://api.deepseek.com/v1 model: deepseek-chat apiKeyEnv: DEEPSEEK_API_KEY
Assertions
Each assertion is a one-key object. A case passes when every assertion passes.
| Assertion | Passes when |
|---|---|
equals: "x" | output is exactly x |
contains: "x" | output contains x |
regex: "/x/i" | output matches β bare pattern or /pattern/flags |
llm-judge: "rubric" | a judge model grades the output PASS against the rubric |
llm-rate: { rubric, min } | a judge rates the output 0β1; passes at or above min |
json-schema: {β¦} | output parses as JSON and validates against the schema |
similarity: { reference, min } | embedding cosine of output vs reference β₯ min |
Deterministic checks
assert: - equals: "billing" - contains: "refund" - regex: "/^(auth|billing|other)$/" # JS has no (?i) β use /.../i
LLM-as-judge
When correctness is fuzzy, let a model grade it. llm-judge is a binary gate; llm-rate returns a graded 0β1 score with a threshold β useful for tracking drift over time.
assert: - llm-judge: "Faithful to the source; invents no facts" - llm-rate: rubric: "Clear enough for a 10-year-old" min: 0.8
openai/gpt-4o-mini. Override it with the GAUGE_JUDGE env var or the judge config key. Every case also gets a numeric score β the mean of its assertion scores.Structured output & similarity
For agents that emit JSON β tool calls, structured plans β validate the shape with json-schema. For fuzzy text targets, similarity scores embedding cosine against a reference.
assert: - json-schema: type: object required: [tool, args] properties: tool: { enum: [web_search, calculator] } - similarity: reference: "The capital of France is Paris." min: 0.8
similarity uses OpenAI embeddings (text-embedding-3-small by default; set GAUGE_EMBED_MODEL). Need a different check entirely? Register your own β see Custom providers & scorers.Matrix cases
One prompt, many inputs. Spec-level vars and assert are shared; each case merges its own vars over them and appends its own assertions.
--- provider: openai/gpt-4o-mini assert: - regex: "/^(auth|billing|technical|other)$/" # every case cases: - name: password-reset vars: { input: "I forgot my password" } assert: [{ equals: "auth" }] - name: refund vars: { input: "I want a refund" } assert: [{ equals: "billing" }] --- Classify the request into one category slug: {{input}}
β evals/router.eval.md βΊ password-reset (1293ms) β regex /^(auth|billing|technical|other)$/ β equals "auth" β evals/router.eval.md βΊ refund (1170ms) β equals "billing" 2 passed, 0 failed, 2 total
CLI & reporters
A few commands, a handful of flags, exit codes that mean something.
$ gauge init # scaffold gauge.config.yaml + example eval $ gauge run [paths...] [-r tty|json|junit|html] [-f <substr>] [-u] [-c <n>] [--cache] $ gauge watch [paths...] # re-run on change $ gauge report # reprint last run β with cost + baseline diffs
| Flag | Effect |
|---|---|
-r, --reporter | tty (default) Β· json Β· junit Β· html |
-f, --filter | keep only specs whose path matches the substring |
-u, --update-baseline | save this run as the regression baseline |
-c, --concurrency | max cases in parallel (default 5) |
--cache / --no-cache | cache provider responses on disk |
Reporters
π₯οΈtty
Colorized pass/fail for humans. The default.
{ }json
Machine-readable β pipe into dashboards or scripts.
CIjunit
JUnit XML for GitHub Actions, GitLab, Jenkins, and friends.
πhtml
One self-contained page β inline styles, no external assets, light/dark aware. gauge run -r html > report.html.
Regression baselines
The feature that turns a test runner into an eval framework: catch the day your prompt quietly gets worse.
$ gauge run -u # snapshot this run as the baseline $ gauge run # later runs compare against it
A case regresses β and fails the run β when:
- it was passing in the baseline and now fails,
- its score drops by more than
0.05, or - (for assertion-free cases) its output changes.
β 1 regression(s) vs baseline. β evals/summarize.eval.md (980ms) β regression β score dropped 0.91 β 0.62 0 passed, 1 failed, 1 total exit 1
Every run is also written to .gauge/last-run.json for gauge report. Add .gauge/ to your .gitignore β or commit baseline.json if you want the baseline versioned with the code.
Config & caching
Optional gauge.config.yaml in the working directory sets defaults. CLI flags always win.
paths: [evals] reporter: tty # tty | json | junit | html judge: openai/gpt-4o-mini filter: "" concurrency: 5 cache: false
Response caching
With --cache (or cache: true), identical (model, prompt) calls are served from .gauge/cache. Two wins: re-runs are near-instant, and runs become deterministic β which pairs well with baselines.
$ time gauge run --cache # first run β live API ... 2.96s $ time gauge run --cache # second run β cache hit ... 0.07s
Custom providers & scorers
Use gauge as a library and register your own vendor or assertion with one function.
A custom provider
import { registerProvider } from "gauge-eval"; registerProvider("myllm", () => ({ vendor: "myllm", async complete({ model, prompt }) { const output = await callMyModel(model, prompt); return { output, latencyMs: 0 }; }, }));
Then reference it in any spec:
provider: myllm/some-model
A custom scorer
The key you register becomes the assertion name. Return { pass, score, label, message }.
import { registerScorer } from "gauge-eval"; registerScorer("word-count-under", (value, output) => { const limit = value as number; const n = output.split(/\s+/).filter(Boolean).length; return { pass: n <= limit, score: n <= limit ? 1 : 0, label: `word-count-under ${limit}`, message: n <= limit ? "" : `${n} words`, }; });
assert: - word-count-under: 50
The library also exports the runner, parser, scorers, and store, so you can build gauge into a larger workflow:
import { parseSpec, runAll } from "gauge-eval"; const spec = parseSpec("x.eval.md", source); const results = await runAll([spec], { concurrency: 8 });
Testing an agent harness
gauge was built for the layer most eval tools ignore: the tooling wrapped around a model β routers, tool-selectors, retrievers, multi-step agents.
The pattern
- Expose a text-in / text-out entry point in your harness (a subcommand, a script).
- Point the
execprovider at it. gauge feeds the prompt on stdin, reads the result on stdout. - Assert on behavior β the tool it picked, the route it chose, the faithfulness of the answer.
- Baseline it so a prompt tweak that breaks tool selection fails CI instead of shipping.
Example: a tool-router regression suite
--- provider: type: exec command: "node dist/agent.js --print-tool" cases: - name: weather-needs-search vars: { q: "what's the weather in Lisbon?" } assert: [{ equals: "web_search" }] - name: math-needs-calc vars: { q: "what is 4.2% of 1,900?" } assert: [{ equals: "calculator" }] --- {{q}}
gauge run --reporter junit into CI and gate merges on it. Now "the model got worse" is a red build, not a support ticket three weeks later.