🎯 gauge-eval v1.2.0 · MIT · Bun + TypeScript

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.

$npx gauge-eval run
$ 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
gauge discovers **/*.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.

BDDgauge
Given β€” set up the worldscenario (system + vars)
When β€” the actionthe prompt body + vars
Then β€” the expectationassert
Examples / tablecases

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.

Reuse one agent across many checks: put the 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.

ProviderSpecNotes
openaiopenai/gpt-4o-miniReads OPENAI_API_KEY
anthropicanthropic/claude-opus-4-8Reads ANTHROPIC_API_KEY
googlegoogle/gemini-1.5-flashReads GEMINI_API_KEY
mistralmistral/mistral-large-latestReads MISTRAL_API_KEY
glmglm/glm-4.6Zhipu / Z.AI Β· GLM_API_KEY Β· GLM_BASE_URL for CN
huggingfacehf/meta-llama/Llama-3.3-70B-InstructRouter for OSS models Β· HF_TOKEN
openrouteropenrouter/anthropic/claude-3.5-sonnetGateway to many vendors Β· OPENROUTER_API_KEY
ollamaollama/llama3Local, no key Β· OLLAMA_HOST
azureazure/my-deploymentAZURE_OPENAI_ENDPOINT + _API_KEY
openai-compatobject form ↓Any OpenAI-compatible host β€” DeepSeek, Groq, Together, …
execobject form ↓Shell out to any harness
Native providers report token usage, and gauge estimates per-run cost from a built-in price table β€” shown in the tty and JSON reporters and in 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.
Because 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.

AssertionPasses 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
βœ“ PASS Β· score 0.95 βœ— FAIL Β· scored 0.50 (< 0.8) judge: openai/gpt-4o-mini
The judge model defaults to 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
FlagEffect
-r, --reportertty (default) Β· json Β· junit Β· html
-f, --filterkeep only specs whose path matches the substring
-u, --update-baselinesave this run as the regression baseline
-c, --concurrencymax cases in parallel (default 5)
--cache / --no-cachecache 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
Requests automatically retry on transient errors (429 and 5xx) with exponential backoff, so a flaky rate limit won't fail your suite.

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 exec provider 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}}
Wire 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.