PHPackages                             mikibuilder/llm-vcr - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. mikibuilder/llm-vcr

ActiveLibrary[Testing &amp; Quality](/categories/testing)

mikibuilder/llm-vcr
===================

Record &amp; replay semántico para features de IA en PHP. Graba respuestas reales de tu LLM, reprodúcelas en CI sin red ni API key, y detecta cuándo el proveedor cambia el modelo por debajo.

v0.1.0(today)00MITPHPPHP &gt;=8.2CI passing

Since Jul 28Pushed todayCompare

[ Source](https://github.com/MikiBuilder/llm-vcr)[ Packagist](https://packagist.org/packages/mikibuilder/llm-vcr)[ Docs](https://github.com/MikiBuilder/llm-vcr)[ RSS](/packages/mikibuilder-llm-vcr/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (2)Versions (2)Used By (0)

llm-vcr
=======

[](#llm-vcr)

**Record &amp; replay semántico para features de IA en PHP.**

Graba las respuestas reales de tu LLM, reprodúcelas en CI sin red ni API key, y entérate cuando el proveedor cambie el modelo por debajo y rompa tus DTOs.

[![CI](https://github.com/MikiBuilder/llm-vcr/actions/workflows/ci.yml/badge.svg)](https://github.com/MikiBuilder/llm-vcr/actions/workflows/ci.yml)[![PHPStan](https://camo.githubusercontent.com/b72adb1f27170ecf486459c4b07e920bb3db2b464444bce8277e018270665646/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c253230392d627269676874677265656e)](https://phpstan.org/)[![License](https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565)](LICENSE)[![PHP](https://camo.githubusercontent.com/63c146e4b472633934fb889ce28499b215beb5d41fbe6f52d05b6c320730a5e5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253345253344382e322d373737626234)](https://www.php.net/)

---

El problema
-----------

[](#el-problema)

Tienes un servicio que clasifica tickets con un LLM. Quieres testearlo. Y entonces:

```
$result = $this->analyzer->analyze('No puedo acceder a mi cuenta');

$this->assertSame('acceso', $result->categoria); // 🎲 a veces pasa, a veces no
```

1. **No es determinista.** El mismo prompt devuelve texto distinto cada vez.
2. **Cuesta dinero.** 200 tests × cada push × cada desarrollador.
3. **Es lento.** Entre 0,5 y 3 segundos por llamada. Tu suite pasa de segundos a minutos.
4. **Necesita red y una API key de producción en CI.** Si el proveedor tiene un incidente, tu build se pone rojo sin que tú hayas roto nada.
5. **Y lo peor: la deriva silenciosa.** El proveedor actualiza el modelo, `urgencia` empieza a llegar como `"alta"` en vez de `4`, tu DTO tipado revienta en producción — **y tú no has tocado una sola línea de código.** Ningún test lo detecta, porque tus mocks tienen congelado el valor viejo.

La solución
-----------

[](#la-solución)

```
$platform = new RecordingPlatform(
    inner: new GroqPlatform($apiKey),   // tu proveedor real
    cassetteDir: __DIR__ . '/cassettes',
    mode: Mode::fromEnv(),              // record en local, replay en CI
);
```

Ya está. Tu código no cambia: `RecordingPlatform` implementa la misma interfaz.

- **En local** graba las respuestas reales en ficheros JSON versionables.
- **En CI** las reproduce desde disco: cero red, cero API key, cero coste.
- **Cada noche** las reproduce contra el proveedor real y te avisa si algo cambió.

Qué lo hace distinto
--------------------

[](#qué-lo-hace-distinto)

`php-vcr`Mocks a mano**llm-vcr**Determinismo en CI✅✅✅Tolera prompts que cambian❌ hash exacto—✅ **similitud semántica**La respuesta es la real del modelo✅❌ te la inventas✅Redacta secretos y PII❌—✅ **por defecto**Detecta deriva del proveedor❌❌ imposible✅Entiende de modelos y tokens❌—✅> **La clave:** `php-vcr` casa peticiones por hash exacto. Un prompt real lleva timestamps, UUIDs e IDs que cambian en cada ejecución, así que la cassette se invalida en cuanto tocas una coma. `llm-vcr` normaliza ese ruido y compara por **similitud de coseno**.

---

Instalación
-----------

[](#instalación)

```
composer require --dev mikibuilder/llm-vcr
```

Requiere PHP 8.2+ con `ext-json` y `ext-mbstring`. Sin dependencias de runtime.

---

Empezar en 2 minutos (sin registrarte en nada)
----------------------------------------------

[](#empezar-en-2-minutos-sin-registrarte-en-nada)

```
git clone https://github.com/MikiBuilder/llm-vcr.git
cd llm-vcr
composer install
php examples/demo.php
```

La demo enseña los seis comportamientos con una plataforma simulada. Sin API key, sin red.

### Con un LLM real y gratuito

[](#con-un-llm-real-y-gratuito)

[Groq](https://console.groq.com/keys) da una clave gratis **sin tarjeta de crédito**(30 req/min, ~1.000 al día en el free tier).

```
cp .env.example .env      # pega tu GROQ_API_KEY
php examples/groq_record.php   # primera vez: llama a la API
php examples/groq_record.php   # segunda: desde la cassette, sin red
```

### Con Docker

[](#con-docker)

```
make build && make up && make install
make demo     # demo sin API key
make test     # 45 tests
make check    # PHPStan nivel 9 + tests
```

---

Integración con Symfony
-----------------------

[](#integración-con-symfony)

El bundle añade configuración declarativa y un **panel en el Web Profiler** con las métricas de cada petición.

Symfony es una dependencia **opcional**: si solo usas PHPUnit o Pest, no arrastras nada.

```
// config/bundles.php
return [
    // ...
    MikiBuilder\LlmVcr\Bridge\Symfony\LlmVcrBundle::class => ['all' => true],
];
```

```
# config/packages/llm_vcr.yaml
llm_vcr:
    cassette_dir: '%kernel.project_dir%/tests/cassettes'
    mode: record

    matcher:
        strategy: semantic     # semantic | placeholder | exact
        threshold: 0.82

    redaction:
        pii: true              # las credenciales se redactan SIEMPRE
```

```
# config/packages/test/llm_vcr.yaml
# En tests nunca se toca la red: si falta una cassette, el test falla.
llm_vcr:
    mode: replay
```

Y en tu servicio:

```
use MikiBuilder\LlmVcr\Bridge\Symfony\PlatformFactory;

final class TicketAnalyzer
{
    public function __construct(
        private PlatformFactory $vcr,
        private MiClienteLlm $cliente,
    ) {}

    public function analyze(string $texto): TicketDto
    {
        $platform = $this->vcr->wrap($this->cliente, cassette: 'tickets');

        $result = $platform->invoke('llama-3.1-8b-instant', [
            ['role' => 'system', 'content' => 'Clasifica tickets. Responde JSON.'],
            ['role' => 'user',   'content' => $texto],
        ]);

        return TicketDto::fromArray($result->asStructured() ?? []);
    }
}
```

### El panel del Profiler

[](#el-panel-del-profiler)

En la barra de depuración verás de un vistazo cuántas invocaciones vinieron de disco y cuántas tocaron la API. El panel desglosa:

MétricaQué te dice**Modo**`record`, `replay`, `bypass` o `refresh`**Desde cassette** / **Llamadas reales**Si esta petición gastó cuota**Hit rate**Porcentaje servido desde disco**Tokens no gastados**Ahorro acumulado**Latencia evitada**Milisegundos que no esperasteEl badge se pone **rojo** si se hicieron llamadas reales estando en modo `replay`: normalmente significa que falta grabar una cassette.

### Comando de consola

[](#comando-de-consola)

```
bin/console llm-vcr:drift              # ¿ha cambiado el modelo del proveedor?
bin/console llm-vcr:drift --markdown   # tabla para pegar en una PR
```

Devuelve código de salida 1 si detecta deriva ALTA o CRÍTICA, así que puedes encadenarlo en un cron nocturno y romper el build.

Necesita que tu cliente LLM esté registrado con el alias `llm_vcr.live_platform`:

```
services:
    llm_vcr.live_platform:
        alias: App\Llm\MiClienteLlm
```

---

Integración con PHPUnit y Pest
------------------------------

[](#integración-con-phpunit-y-pest)

El objetivo es que montar un test con LLM sea **una línea**, y que las aserciones hablen el idioma del problema en vez de obligarte a escribir plomería.

### PHPUnit — el trait `InteractsWithLlm`

[](#phpunit--el-trait-interactswithllm)

```
use MikiBuilder\LlmVcr\Testing\InteractsWithLlm;

final class TicketTest extends TestCase
{
    use InteractsWithLlm;

    public function testClasificaUnProblemaDeAcceso(): void
    {
        $platform = $this->recordLlm(GroqPlatform::fromEnv());

        $result = $platform->invoke('llama-3.1-8b-instant', [
            ['role' => 'system', 'content' => 'Clasifica tickets. Responde JSON.'],
            ['role' => 'user',   'content' => 'No puedo acceder a mi cuenta.'],
        ]);

        $this->assertNoLiveLlmCalls();
        $this->assertLlmJsonShape([
            'categoria' => 'string',
            'urgencia'  => 'int',
        ], $result);
    }
}
```

Sin rutas que configurar: las cassettes van a `/cassettes/` y el nombre sale de la clase y el método (`ticket--clasifica-un-problema-de-acceso.json`).

AserciónQué comprueba`assertNoLiveLlmCalls()`El test **no ha tocado la red**. Ponla en tu suite y CI te avisará el día que alguien queme cuota sin querer`assertLlmJsonShape([...], $r)`La **forma** del JSON: claves y tipos. Admite `'float|null'` y rutas `'meta.score'``assertLlmValueIn([...], 'campo', $r)`El valor está en un dominio cerrado (enums del modelo)`assertLlmJson($r)`Es JSON válido, y te lo devuelve como array`assertResultCameFromCassette($r)`La respuesta vino de disco, no de la API`assertLlmCallsWereReplayed(n)`Se reprodujeron exactamente `n` interacciones### Pest — expectativas nativas

[](#pest--expectativas-nativas)

```
use function MikiBuilder\LlmVcr\Testing\recordLlm;

it('clasifica un problema de acceso', function () {
    $platform = recordLlm(GroqPlatform::fromEnv(), cassette: 'tickets');

    $result = $platform->invoke('llama-3.1-8b-instant', [...]);

    expect($platform)->toHaveMadeNoLiveCalls()->toHaveReplayed(1);
    expect($result)->toBeLlmJson()
                   ->toMatchLlmShape(['categoria' => 'string', 'urgencia' => 'int'])
                   ->toHaveLlmValueIn(['acceso', 'facturacion'], 'categoria');
});
```

Se registran solas al instalar el paquete: no hay que tocar `Pest.php`. Disponibles: `toBeLlmJson()`, `toMatchLlmShape()`, `toHaveLlmValueIn()`, `toComeFromCassette()`, `toHaveMadeNoLiveCalls()`, `toHaveReplayed()`.

> **Por qué validar la forma y no el valor:** el valor que devuelve un LLM no es determinista, pero el **contrato** sí debe serlo. `toMatchLlmShape()` falla cuando `urgencia` pasa de `int` a `string`— que es exactamente el bug que rompe tu DTO en producción.

### Prompts con parámetros dinámicos

[](#prompts-con-parámetros-dinámicos)

Tres estrategias, de más estricta a más tolerante:

```
// 1. Exacta — el prompt no varía nunca
new ExactMatcher();

// 2. Placeholders — TÚ declaras qué es variable. Cero falsos positivos.
new PlaceholderMatcher([
    'order_id' => '/PED-\d+/',
    'importe'  => '/\d+,\d{2} ?€/',
]);
// "Revisa el pedido PED-4417 por 89,90 €"
// "Revisa el pedido PED-9902 por 12,50 €"  → misma cassette

// 3. Semántica — tolera cambios de redacción (por defecto)
new SemanticMatcher(threshold: 0.82);
```

`PlaceholderMatcher` es el punto medio que suele querer la gente: sigue siendo una comparación **exacta y determinista**, revisable en una PR, pero inmune a los datos que tú marques como variables. Si cambia algo que *no* declaraste, no casa — y eso es lo correcto. Ya trae fechas, horas y UUIDs cubiertos por defecto.

---

Uso
---

[](#uso)

### Los cuatro modos

[](#los-cuatro-modos)

ModoCuándoComportamiento`Mode::Record`Desarrollo localGraba si no existe; si existe, reproduce`Mode::Replay`**CI**Solo reproduce. Si falta, **falla con un mensaje que explica cómo arreglarlo**`Mode::Bypass`DepuraciónIgnora cassettes, siempre API real`Mode::Refresh`Actualizar fixturesRegraba todo desde cero```
$mode = Mode::fromEnv();                    // lee LLM_VCR_MODE
$mode = Mode::fromEnv(default: Mode::Replay);
```

### En PHPUnit

[](#en-phpunit)

```
final class TicketAnalyzerTest extends TestCase
{
    private RecordingPlatform $platform;

    protected function setUp(): void
    {
        $this->platform = new RecordingPlatform(
            inner: GroqPlatform::fromEnv(),
            cassetteDir: __DIR__ . '/cassettes',
            mode: Mode::fromEnv(default: Mode::Replay),
        );
    }

    public function testClasificaUnProblemaDeAcceso(): void
    {
        $analyzer = new TicketAnalyzer($this->platform);

        $result = $analyzer->analyze('No puedo acceder a mi cuenta desde ayer');

        self::assertSame('acceso', $result->categoria);
        self::assertGreaterThanOrEqual(3, $result->urgencia);
    }
}
```

Ejecuta una vez con `LLM_VCR_MODE=record`, commitea las cassettes, y a partir de ahí CI corre gratis.

### Detección de deriva

[](#detección-de-deriva)

```
php bin/llm-vcr drift               # informe en consola
php bin/llm-vcr drift --markdown    # tabla para pegar en una PR
php bin/llm-vcr stats               # resumen de las cassettes
```

Sale con **código 1** si detecta deriva ALTA o CRÍTICA, así que rompe el build. El workflow `.github/workflows/drift.yml` lo ejecuta cada noche y abre un issue automáticamente.

Ejemplo de salida real:

```
🔴  CRITICA   sim 0.79  cambio de tipo en "urgencia": int -> string | campo nuevo: "confianza" (float)
🟢  OK        sim 1.00  sin cambios de esquema
🟡  MEDIA     sim 0.60  sin cambios de esquema

```

Ese `int -> string` es el bug que te habría costado una guardia a las 3 de la mañana.

---

Configuración
-------------

[](#configuración)

### Matchers

[](#matchers)

```
new SemanticMatcher(threshold: 0.82);  // por defecto
new SemanticMatcher(threshold: 0.95);  // más estricto
new ExactMatcher();                    // hash exacto, tolerancia cero

// Normalizar ruido propio de tu dominio
new SemanticMatcher(extraNoise: ['/\bPED-\d+\b/' => '']);
```

### Redacción

[](#redacción)

Activada **por defecto**, porque las cassettes se commitean a git.

Detecta: claves de OpenAI/Groq/GitHub/AWS, JWT, Bearer tokens, emails, teléfonos españoles, DNI, NIE, IBAN y números de tarjeta.

```
new Redactor();                    // credenciales + PII
Redactor::credentialsOnly();       // solo credenciales
new Redactor(customRules: ['/\bEXP-\d{4}\b/' => '']);
```

### Otros proveedores

[](#otros-proveedores)

`GroqPlatform` habla el dialecto OpenAI, así que sirve para cualquier endpoint compatible:

```
new GroqPlatform($key, baseUrl: 'https://openrouter.ai/api/v1');
new GroqPlatform('ollama', baseUrl: 'http://localhost:11434/v1');
```

Para cualquier otro, implementa `PlatformInterface` — son tres líneas.

---

Cómo funciona
-------------

[](#cómo-funciona)

```
┌─────────────────────────────────────────────────────────┐
│  Tu código  →  TicketAnalyzer                           │
│                      │                                  │
│                      ▼                                  │
│          ┌───────────────────────┐                      │
│          │  RecordingPlatform    │  ← decorador         │
│          │  (PlatformInterface)  │                      │
│          └───────────┬───────────┘                      │
│                      │                                  │
│      ┌───────────────┼───────────────┐                  │
│      ▼               ▼               ▼                  │
│  SemanticMatcher  Redactor      Cassette (.json)        │
│  coseno +         claves, PII   versionable en git      │
│  normalización                                          │
│      │                                                  │
│      ▼  (miss)                                          │
│  GroqPlatform → API real                                │
└─────────────────────────────────────────────────────────┘

```

Es un **decorador**, no un fork. Sustitución de Liskov pura: envuelve cualquier implementación de `PlatformInterface` sin que el código de negocio se entere.

### Una cassette por dentro

[](#una-cassette-por-dentro)

```
{
  "cassette": "clasifica-tickets-de-soporte-9a89bea9",
  "version": 1,
  "interactions": [
    {
      "fingerprint": "cc915e9e1164167c",
      "request": {
        "model": "llama-3.1-8b-instant",
        "messages": [
          { "role": "system", "content": "Clasifica tickets de soporte en JSON." },
          { "role": "user", "content": "Soy , tel , no puedo acceder." }
        ]
      },
      "response": {
        "text": "{\"categoria\":\"acceso\",\"urgencia\":4}",
        "input_tokens": 27,
        "output_tokens": 15
      }
    }
  ]
}
```

Legible, diffable en una PR, y sin un solo secreto.

---

Preguntas frecuentes
--------------------

[](#preguntas-frecuentes)

**¿Debo commitear las cassettes?**Sí. Son el fixture del proyecto: sin ellas, CI no puede correr en modo replay. Por eso la redacción va activada por defecto.

**¿Y si cambio el prompt?**Si el cambio es menor, el matcher semántico lo absorbe. Si es grande, el test falla con un mensaje que te dice exactamente qué hacer. Regrabas con `LLM_VCR_MODE=record` y commiteas.

**¿Reemplaza a los evals?**No, son complementarios. Los evals miden **calidad** (¿la respuesta es buena?). `llm-vcr` resuelve **determinismo, coste y deriva**. Puedes usar los dos.

**¿Placeholders o similitud semántica?**Empieza por `PlaceholderMatcher` si sabes exactamente qué partes del prompt varían (IDs, importes, fechas de negocio): es determinista y no da falsos positivos. Usa `SemanticMatcher` cuando el prompt se redacta de formas distintas o lo genera otro sistema.

**¿Funciona con Symfony AI?**Sí. `RecordingPlatform` es un decorador sobre una interfaz mínima, así que basta con un adaptador de tres líneas. Un bundle nativo está en la hoja de ruta.

**¿Por qué no usar embeddings para el matching?**Porque requeriría una llamada de red justo en el camino que intenta evitarla. El coseno sobre bag-of-words normalizado funciona sorprendentemente bien y es instantáneo. Un `EmbeddingMatcher` opcional está previsto.

---

Hoja de ruta
------------

[](#hoja-de-ruta)

- Núcleo: record/replay, matching semántico, redacción, deriva
- CLI `llm-vcr drift` con salida Markdown para PRs
- GitHub Actions: CI en replay + cron nocturno de deriva
- Trait `InteractsWithLlm` para PHPUnit con 6 aserciones
- Expectativas nativas de Pest (verificadas contra Pest 3)
- `PlaceholderMatcher` para prompts con parámetros dinámicos
- `LlmVcrBundle` para Symfony, con panel en el Web Profiler
- `EmbeddingMatcher` con caché en disco
- Soporte para respuestas en streaming y tool calls

Contribuir
----------

[](#contribuir)

Los PRs son bienvenidos. El listón: **PHPStan nivel 9 y tests en verde**.

```
composer check     # análisis estático + tests
```

Licencia
--------

[](#licencia)

MIT — [MikiBuilder](https://github.com/MikiBuilder)

###  Health Score

36

—

LowBetter than 79% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity35

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/264189149?v=4)[MikiBuilder](/maintainers/MikiBuilder)[@MikiBuilder](https://github.com/MikiBuilder)

---

Top Contributors

[![MikiBuilder](https://avatars.githubusercontent.com/u/264189149?v=4)](https://github.com/MikiBuilder "MikiBuilder (7 commits)")

---

Tags

phptestingsymfonyaiopenairecordreplayllmvcrdriftgroqcassette

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/mikibuilder-llm-vcr/health.svg)

```
[![Health](https://phpackages.com/badges/mikibuilder-llm-vcr/health.svg)](https://phpackages.com/packages/mikibuilder-llm-vcr)
```

###  Alternatives

[deepseek-php/deepseek-php-client

deepseek PHP client is a robust and community-driven PHP client library for seamless integration with the Deepseek API, offering efficient access to advanced AI and data processing capabilities.

46688.8k5](/packages/deepseek-php-deepseek-php-client)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
