You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@@ -17,7 +17,9 @@ To get started, require the plugin via Composer:
17
17
composer require pestphp/pest-plugin-evals --dev
18
18
```
19
19
20
-
The built-in scorers evaluate output using [Laravel AI](https://github.com/laravel/ai). To use them, install the package and make sure an API key is available for your provider (OpenAI by default):
20
+
That is all you need for deterministic checks such as `toContain()`, `toHaveToolCalls()`, and `toFollowTrajectory()`.
21
+
22
+
The AI-powered scorers — relevance, safety, factuality, LLM-as-judge, and semantic similarity — need two capabilities: a way to send a prompt to a *judge* model, and a way to turn text into *embeddings*. The plugin calls these capabilities [drivers](#drivers). Out of the box it ships drivers backed by [Laravel AI](https://github.com/laravel/ai), so the quickest way to get running is to install it:
You are **not** tied to Laravel AI, however. The drivers are pluggable — you can point them at Anthropic, a self-hosted model, another SDK, or even a deterministic stub without ever installing `laravel/ai`. See [Drivers](#drivers) for the details.
34
+
31
35
---
32
36
33
37
## Writing Your First Eval
@@ -79,7 +83,7 @@ When you run with `--evals`, Pest prints a summary of how many evals passed alon
79
83
80
84
## Prompting
81
85
82
-
The `prompt()` method accepts any class implementing Laravel AI's `Agent` contract (as a class name or an instance), or a plain closure for lightweight tasks that don't warrant a dedicated agent class:
86
+
The `prompt()` method accepts any class implementing Laravel AI's `Agent` contract (as a class name or an instance), or a plain closure. The closure form means the thing *under test* is not tied to any particular SDK — anything that turns a string prompt into a string response can be evaluated, including your own HTTP client or a different AI library:
When part of the response is predictable, you may assert against it directly. These checks make no additional AI calls beyond the agent's response:
108
+
When part of the response is predictable, you may assert against it directly. These checks make no additional AI calls beyond the agent's response, and they need no [driver](#drivers):
105
109
106
110
```php
107
111
expect(CapitalCityAgent::class)
@@ -134,6 +138,8 @@ it('is consistent across multiple samples', function (): void {
134
138
135
139
Deterministic checks can only take you so far. To evaluate qualities like relevance, safety, or factual accuracy, the plugin ships a set of scorers that grade the output on a scale from `0.0` to `1.0`. Each scorer accepts a `threshold` (defaulting to `0.7`) and fails the eval if the score falls below it.
136
140
141
+
These scorers do their grading through the plugin's [drivers](#drivers): the LLM-as-judge scorers use the judge driver, while `toBeSimilar()` uses the embeddings driver. Both default to Laravel AI but can be swapped for any backend.
142
+
137
143
### `toBeRelevant()`
138
144
139
145
Asserts that the response is relevant to the prompt:
@@ -187,7 +193,7 @@ expect(GreetingAgent::class)
187
193
188
194
### `toHaveToolCalls()`
189
195
190
-
Asserts that the agent invoked the expected tools. Provide an array keyed by tool name, with either the expected arguments or a closure to validate them:
196
+
Asserts that the agent invoked the expected tools. Provide an array keyed by tool name, with either the expected arguments or a closure to validate them. This check is deterministic — it parses the tool calls from the output and needs no driver:
A scorer decides *what* to measure. If your scorer needs to reach an LLM or produce embeddings to do its measuring, it should go through the [drivers](#drivers) rather than calling a provider directly — that way it inherits whatever backend the project has configured.
256
262
257
-
<aname="running-evals"></a>
258
-
## Running Evals
263
+
---
259
264
260
-
Because every eval calls a real model, evals are **skipped by default**. This keeps your everyday `pest` run fast, free, and deterministic — your evals live alongside your other tests without ever calling an API or slowing the suite down.
265
+
<aname="drivers"></a>
266
+
## Drivers
261
267
262
-
When you want to actually evaluate your agents, opt in with `--evals`:
268
+
The AI-powered scorers do not talk to a model directly. Instead, they delegate to two small, single-method **drivers** — one for judging, one for embeddings. This indirection is what makes the scorers provider-agnostic: swap the driver and every scorer follows, without touching a single eval.
263
269
264
-
```bash
265
-
./vendor/bin/pest # evals are skipped
266
-
./vendor/bin/pest --evals # evals run against the real model
267
-
```
270
+
There are two driver contracts:
268
271
269
-
This applies to every target, including closures — an eval only runs under `--evals`. You may also force eval mode with the `PEST_EVALS` environment variable, which is convenient in CI:
272
+
| Contract | Method | Powers |
273
+
| --- | --- | --- |
274
+
|`Pest\Evals\Contracts\JudgeDriver`|`generate(string $instructions, string $prompt): string`|`toBeRelevant()`, `toBeSafe()`, `toBeFactual()`, `toPassJudge()`, and any judge-based custom scorer |
275
+
|`Pest\Evals\Contracts\EmbeddingsDriver`|`embed(array $inputs): array`|`toBeSimilar()` and any embeddings-based custom scorer |
270
276
271
-
```bash
272
-
PEST_EVALS=1 ./vendor/bin/pest
273
-
```
277
+
The deterministic checks (`toContain()`, `toBe()`, `toHaveToolCalls()`, `toFollowTrajectory()`, …) use no driver at all — they inspect the output directly.
274
278
275
-
---
279
+
### The Default: Laravel AI
276
280
277
-
<aname="configuration"></a>
278
-
## Configuration
279
-
280
-
By default, the scorers judge and embed output using OpenAI via Laravel AI. The simplest way to change the provider or model is through environment variables, which is convenient for switching providers between environments:
281
+
Unless you say otherwise, the plugin uses `LaravelAiJudge` and `LaravelAiEmbeddings`, which call OpenAI through Laravel AI. The simplest way to change the provider or model is through environment variables, which is convenient for switching providers between environments:
Alternatively, you may configure the drivers explicitly within your `tests/Pest.php` file using `pest()->evals()`. Pass a configured `LaravelAiJudge` or `LaravelAiEmbeddings` instance to select the provider and model in code:
290
+
Alternatively, configure the drivers explicitly in your `tests/Pest.php` file using `pest()->evals()`. Pass a configured `LaravelAiJudge` or `LaravelAiEmbeddings` instance to select the provider and model in code:
If you would rather not use Laravel AI, or you want full control over how scores are produced, you may provide your own judge and embeddings drivers with a closure:
301
+
### Bringing Your Own Driver: A Closure
302
+
303
+
The fastest way to leave Laravel AI behind is to hand `pest()->evals()` a closure. When you do this, `laravel/ai` is never touched, so it does not even need to be installed:
A judge driver is a plain text-in, text-out function. It does **not** need to know about scoring: the scorers build a prompt that already asks the model to reply with `{"score": <float>, "reasoning": "..."}`, and the plugin decodes that JSON for you. Your driver's only job is to forward the instructions and prompt to a model and return whatever text comes back.
319
+
320
+
An embeddings driver receives an array of strings and must return one numeric vector per string, in the same order.
321
+
322
+
### Bringing Your Own Driver: A Class
323
+
324
+
For anything you want to reuse or test, implement the contract as a dedicated class. Here a judge is backed by Anthropic:
325
+
326
+
```php
327
+
use Pest\Evals\Contracts\JudgeDriver;
328
+
329
+
final class AnthropicJudge implements JudgeDriver
330
+
{
331
+
public function generate(string $instructions, string $prompt): string
332
+
{
333
+
// `$instructions` is the system prompt; `$prompt` asks for a JSON score.
334
+
// Return the model's raw text — the plugin handles the parsing.
A closure body is arbitrary code — usually it calls your client, but nothing stops it from returning a fixed value instead. Because a judge is just text-in / text-out and an embeddings driver is just array-in / array-out, you can hand back a canned result to exercise the full scoring path — and your custom scorers — without spending money or hitting the network. This is convenient in local development or CI smoke tests:
Because every eval calls a real model, evals are **skipped by default**. This keeps your everyday `pest` run fast, free, and deterministic — your evals live alongside your other tests without ever calling an API or slowing the suite down.
387
+
388
+
When you want to actually evaluate your agents, opt in with `--evals`:
389
+
390
+
```bash
391
+
./vendor/bin/pest # evals are skipped
392
+
./vendor/bin/pest --evals # evals run against the real model
393
+
```
394
+
395
+
This applies to every target, including closures — an eval only runs under `--evals`. You may also force eval mode with the `PEST_EVALS` environment variable, which is convenient in CI:
0 commit comments