vitest-evals

repository·main·Indexed 43 days ago

https://github.com/getsentry/vitest-evals

A framework for explicit-run evaluations of AI agents and LLM applications using Vitest. It provides harnesses for popular SDKs (AI SDK, OpenAI Agents, Pi AI), automated judges for grading, and reporting tools for CI and local inspection, including a native GitHub Action for reporting results and setting quality gates via pass-rate and score-average thresholds.

Tokens
98.8K
Snippets
269
Records
383
Agent score
55%

What's inside vitest-evals

  1. Use vitest-evals utilities for deterministic checks

    main

    Utilities in vitest-evals are small helper functions designed to read normalized harness results. They are primarily used within direct Vitest checks and custom judges to perform deterministic assertions over the output of an evaluation.

    Instead of manually traversing complex result objects, you can use these helpers to inspect specific parts of a session or a trace. The utilities are categorized into two main groups:

    1. Session Helpers: Used to read messages and flattened tool calls from a result object.
    2. Trace Helpers: Used to read spans and failures from a result object.

    If you are new to the project, start with the Getting Started guide to understand the full workflow before diving into specific utility inspections.

  2. What is a harness and how to choose one

    main

    A harness acts as the boundary between your application's production behavior and the vitest-evals infrastructure. It is responsible for calling your application's entrypoint and returning normalized data (output, transcripts, traces, and usage) that tests and judges require.

    Selection Strategy

    • Use a first-party adapter if your application uses a supported SDK. This allows the harness to automatically attach trace spans from native runtime data.
    • Use createHarness() if your application is not built on a supported SDK, or if you require full control over the normalized run data produced during an evaluation.
  3. Implement model-backed judges using judgeHarness

    main

    If a custom judge requires an LLM call, you must configure a judgeHarness at the matcher, judge, or suite level. Inside the judge's assess function, use ctx.runJudge(...) to execute the model call.

    Key Details:

    • ctx.runJudge automatically handles the current abort signal.
    • Each call to runJudge is recorded as a separate run in the report metadata.
    • Precedence: Matcher options > Judge defaults > Suite defaults.
    • If ctx.runJudge is undefined, it means no judgeHarness was configured for the context.
    import { createJudge } from "vitest-evals";
    
    export const RubricJudge = createJudge({
      name: "RubricJudge",
      async assess(ctx) {
        if (!ctx.runJudge) {
          throw new Error("RubricJudge requires a configured judgeHarness.");
        }
    
        const verdict = await ctx.runJudge({
          prompt: `Grade this answer: ${JSON.stringify(ctx.output)}`,
          responseFormat: { type: "json" },
        });
    
        return parseRubricVerdict(verdict);
      },
    });
  4. API Design Principles for vitest-evals

    main

    When consuming or extending the vitest-evals ecosystem, the APIs follow a specific design philosophy focused on usability and type safety. Developers should keep the following mental models in mind:

    Core Philosophy

    • The Common Path First: APIs are designed around the most frequent use cases. Advanced capabilities are provided via 'escape hatches' rather than cluttering the primary interface.
    • Small, Native Shapes: Expect primary interfaces to use plain objects, direct values, and familiar verbs (like run) rather than complex class hierarchies.
    • Layered Abstractions: Low-level primitives are kept stable. Convenience helpers are added on top of these primitives to reduce boilerplate, but they do not replace the underlying primitives.

    Role-Based Responsibility

    • Harness vs. Judge: The system distinguishes between the entity that executes the test and the entity that evaluates it.
      • A Harness runs the system under test.
      • A Judge assesses a run.
    • Judge Configuration: Assessment prompts, model choices, and parsers reside on the Judge. When authoring a custom judge, the preferred path is using createJudge("Name", assess).

    Type Safety and Context

    • Strong Typing for Capabilities: Instead of methods that fail at runtime if a feature isn't configured, the API uses strong TypeScript types to return more capable objects only when specific configurations are present.
    • Context Management: To avoid repetitive parameters, the API often infers context from fixtures, registered runs, or the current test. However, explicit overrides are provided for values that cannot be reliably inferred.
    • Output Handling: The API prefers typed run() output over mapping hooks. If a provider result needs to be projected into application output, use semantic selectors like output rather than generic normalization knobs.
    • Generic Ordering: Public generics are ordered by frequency of use, typically following the pattern Input, Output, Metadata.
  5. When to use a custom harness

    main

    Use a custom harness instead of a first-party adapter in the following scenarios:

    Runtime shapeWhy
    Full product workflowThe app has events, side effects, or domain output beyond a simple provider result.
    Non-AI-SDK providerFirst-party adapters cannot infer provider steps or tool calls.
    Existing observability seamThe app already emits messages or tool records that you can normalize directly.
    Multi-service workflowA thin adapter can preserve the real app boundary while normalizing a single run.
  6. Follow code commenting policies

    main

    Comments should be used to explain non-obvious intent, invariants, and tradeoffs, rather than narrating obvious code.

    When to add comments:

    • When behavior is easy to misread.
    • When the code is policy-driven.
    • When the code is coupled to a non-obvious invariant.
    • For tricky local helpers where future readers need context to change them safely (use inline docstrings).

    When to avoid comments:

    • Do not comment obvious transformations or control flow.
    • Do not add comments that simply restate the code in English.

    Best practices:

    • Keep comments short and concrete.
    • Explain why the code exists or what boundary it is protecting.
    • Delete or rewrite stale comments immediately when behavior changes.
  7. Understand AI SDK Harness normalization behavior

    main

    The harness performs several normalization steps to ensure consistent data formats:

    • Messages & Metadata: Reads steps to construct assistant messages, tool calls, tool results, and metadata.
    • Usage: Reads totalUsage or usage. If neither is present, it aggregates usage from individual steps where available.
    • Provider Info: Preserves the provider and model from the last step if present.
    • Sessions: Uses result.session or result.trace directly if they already conform to NormalizedSession.
    • Tool Traces: Adds runtime tool calls that were executed via wrapped tools but were missing from the provider's steps.
    • Error Handling: Attaches partial runs to thrown errors so that failed executions still expose tool traces.
  8. Rules for Matcher Context and Judge Usage

    main

    When using judges within Vitest matchers or custom logic, follow these rules:

    Matcher Context

    • expect(result).toSatisfyJudge(...) reuses the fixture-backed run context.
    • expect(result.output).toSatisfyJudge(...) can reuse the exact run when the output object came from that run.
    • Raw values outside an eval context require explicit input, session, run, harness, or custom judge params if the judge depends on them.
    • Normalized sessions infer output from the latest assistant message content.
    • Calling ctx.harness.run(...) inside a judge executes the app again; only do this when the judge intentionally needs a second run.

    Usage and Reporting

    • A judge harness is separate from the app harness. Return a full HarnessRun when the provider exposes usage.
    • ctx.runJudge(...) returns the judge output, while vitest-evals records the complete normalized run under the score's judgeRuns field.
    • Multiple runJudge(...) calls from one judge are retained separately.
    • Stable token fields and costUsd contribute to separate application, judge, and combined report totals. Provider-specific pricing details stay under usage.metadata.
  9. Use normalized results for assertions and reporting

    main

    vitest-evals uses standardized data structures to ensure that assertions, judges, replays, and reports all work with the same data format. Key types include:

    • HarnessRun: The core normalized result shape. This is the shared data structure used by assertions, judges, replay tools, and reporting modules.
    • NormalizedSession: A collection of serializable, ordered transcript events captured from a single execution of an app. This is useful for replaying or analyzing the step-by-step flow of an agent or workflow.
    • UsageSummary: A standardized object containing usage metadata, including stable fields for the provider, model, token counts, USD cost, tools used, retries, and other metadata.
  10. Understand the role of Judges in vitest-evals

    main

    In vitest-evals, Judges are responsible for scoring the execution captured by a harness. While harnesses own the execution of the test case, judges own the evaluation logic, including:

    • Rubrics and scoring criteria
    • Parser logic
    • Model calls (for model-scored rubrics)
    • Score thresholds
    • Failure messages

    Best Practices

    • Order of operations: Use deterministic judges (e.g., checking exact strings or JSON structures) before using model-scored rubrics to save costs and increase reliability.
    • Scope:
      • Place judges at the suite level (inside describeEval) when every test case in that suite must satisfy the same contract.
      • Use explicit judge assertions within a specific test case when only one case requires an extra check.
  11. Understand the Judge Context

    main

    When implementing or using a judge, you receive a context object containing metadata about the evaluation run. This allows the judge to access the original input, the application's output, and the execution history.

    FieldMeaning
    inputOriginal typed eval input.
    outputTyped app output returned by the harness.
    toolCallsFlattened calls from the run.
    runFull normalized HarnessRun.
    sessionNormalized session from the run.
    harnessApp harness for intentional second runs.
    runJudgeRuns the configured judge harness and records its usage.
  12. How `describeEval` and judges work together

    main

    The vitest-evals library uses an explicit-run pattern for AI evaluations. This pattern binds a suite-level harness to a set of tests, allowing for structured LLM-based grading (judges).

    Core Components

    • describeEval: Binds a suite-level harness (which manages the agent/app execution) and judges (which grade the results). It also accepts a judgeThreshold.
    • run(input): Inside a test, you call run to execute the harness with the provided input. This returns the result of the agent/app execution.
    • Judges: Every judge is a named object with an assess(ctx) method. They receive a JudgeContext containing the input, output, normalized run/session, and tool calls.
    • toSatisfyJudge: A Vitest matcher used to assert that a result meets a specific judge's criteria.

    Example Usage

    import { describeEval, FactualityJudge, toolCalls } from "vitest-evals";
    import { piAiHarness, piAiJudgeHarness } from "@vitest-evals/harness-pi-ai";
    
    // 1. Setup a dedicated judge harness (the model used for grading)
    const judgeHarness = piAiJudgeHarness({
      model: getModel("anthropic", "claude-sonnet-4-5"),
      temperature: 0,
    });
    
    // 2. Create a judge instance
    const factualityJudge = FactualityJudge({ judgeHarness });
    
    // 3. Define the eval suite
    describeEval(
      "my eval suite",
      {
        harness: piAiHarness({ agent: () => myAgent }),
        judges: [factualityJudge],
        judgeThreshold: 0.6,
      },
      (it) => {
        it("passes a test", async ({ input }, { run }) => {
          const result = await run(input);
          
          // Assertions on structure/tools
          expect(toolCalls(result)).toBeDefined();
          
          // Assertions via the judge
          await expect(result).toSatisfyJudge(factualityJudge, {
            expected: "The agent should be polite",
            threshold: 0.6,
          });
        });
      },
    );
    import { getModel } from "@mariozechner/pi-ai";
    import { expect } from "vitest";
    import { piAiHarness, piAiJudgeHarness } from "@vitest-evals/harness-pi-ai";
    import {
      describeEval,
      FactualityJudge,
      toolCalls,
    } from "vitest-evals";
    import { createRefundAgent } from "../src/refundAgent";
    
    const judgeHarness = piAiJudgeHarness({
      model: getModel("anthropic", "claude-sonnet-4-5"),
      temperature: 0,
    });
    const factualityJudge = FactualityJudge({ judgeHarness });
    
    describeEval(
      "demo pi refund agent",
      {
        harness: piAiHarness({
          agent: () => createRefundAgent(),
        }),
        judges: [factualityJudge],
        judgeThreshold: 0.6,
      },
      (it) => {
        it.for([
          {
            name: "approves refundable invoice",
            input: "Refund invoice inv_123",
            expected: "The refund request is approved.",
            expectedStatus: "approved",
            expectedTools: ["lookupInvoice", "createRefund"],
          },
        ])("$name", async ({ input, ...expected }, { run }) => {
          const result = await run(input);
    
          expect(result.output).toMatchObject({
            status: expected.expectedStatus,
          });
          expect(toolCalls(result).map((call) => call.name)).toEqual(
            expected.expectedTools,
          );
          await expect(result).toSatisfyJudge(factualityJudge, {
            expected: expected.expected,
            threshold: 0.6,
          });
        });
      },
    );