From b25394f3962c298149e4dfbe1eaf9ce5036a485c Mon Sep 17 00:00:00 2001
From: Sandro Gehri <sandrogehri@gmail.com>
Date: Tue, 5 Dec 2023 09:11:01 +0100
Subject: [PATCH 1/5] WIP: add pulse card

---
 composer.json           | 2 +-
 src/Facades/OpenAI.php  | 1 +
 src/ServiceProvider.php | 2 ++
 3 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/composer.json b/composer.json
index ba9c259..c136f75 100644
--- a/composer.json
+++ b/composer.json
@@ -13,7 +13,7 @@
         "php": "^8.1.0",
         "guzzlehttp/guzzle": "^7.7.0",
         "laravel/framework": "^9.46.0|^10.14.1",
-        "openai-php/client": "^v0.8.0"
+        "openai-php/client": "dev-add-events as v0.8.0"
     },
     "require-dev": {
         "laravel/pint": "^1.13.6",
diff --git a/src/Facades/OpenAI.php b/src/Facades/OpenAI.php
index ffd0294..1d7ab62 100644
--- a/src/Facades/OpenAI.php
+++ b/src/Facades/OpenAI.php
@@ -7,6 +7,7 @@
 use Illuminate\Support\Facades\Facade;
 use OpenAI\Contracts\ResponseContract;
 use OpenAI\Laravel\Testing\OpenAIFake;
+use OpenAI\Resources\Assistants;
 use OpenAI\Responses\StreamResponse;
 
 /**
diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php
index 976fcee..4005fa7 100644
--- a/src/ServiceProvider.php
+++ b/src/ServiceProvider.php
@@ -4,6 +4,7 @@
 
 namespace OpenAI\Laravel;
 
+use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
 use Illuminate\Contracts\Support\DeferrableProvider;
 use Illuminate\Support\ServiceProvider as BaseServiceProvider;
 use OpenAI;
@@ -35,6 +36,7 @@ public function register(): void
                 ->withOrganization($organization)
                 ->withHttpHeader('OpenAI-Beta', 'assistants=v1')
                 ->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)]))
+                ->withEventDispatcher(resolve(DispatcherContract::class)) // @phpstan-ignore-line
                 ->make();
         });
 

From 4b9f108830901cbf3db7f7402f9e03e1f149f309 Mon Sep 17 00:00:00 2001
From: Sandro Gehri <sandrogehri@gmail.com>
Date: Tue, 5 Dec 2023 13:46:43 +0100
Subject: [PATCH 2/5] Add OpenAI requests card for Laravel Pulse

---
 composer.json                                 |   1 +
 .../views/livewire/openai-requests.blade.php  | 104 ++++++++++++++++
 src/Facades/OpenAI.php                        |   1 -
 src/Pulse/Livewire/OpenAIRequestsCard.php     | 112 ++++++++++++++++++
 src/Pulse/Recorders/OpenAIRequests.php        |  69 +++++++++++
 src/ServiceProvider.php                       |  16 ++-
 tests/Arch.php                                |   3 +
 tests/Facades/OpenAI.php                      |   9 ++
 tests/ServiceProvider.php                     |  20 ++++
 9 files changed, 330 insertions(+), 5 deletions(-)
 create mode 100644 resources/views/livewire/openai-requests.blade.php
 create mode 100644 src/Pulse/Livewire/OpenAIRequestsCard.php
 create mode 100644 src/Pulse/Recorders/OpenAIRequests.php

diff --git a/composer.json b/composer.json
index c136f75..cd9ebfb 100644
--- a/composer.json
+++ b/composer.json
@@ -17,6 +17,7 @@
     },
     "require-dev": {
         "laravel/pint": "^1.13.6",
+        "laravel/pulse": "^1.0.0",
         "pestphp/pest": "^2.8.2",
         "pestphp/pest-plugin-arch": "^2.2.2",
         "pestphp/pest-plugin-mock": "^2.0.0",
diff --git a/resources/views/livewire/openai-requests.blade.php b/resources/views/livewire/openai-requests.blade.php
new file mode 100644
index 0000000..3c61f32
--- /dev/null
+++ b/resources/views/livewire/openai-requests.blade.php
@@ -0,0 +1,104 @@
+<x-pulse::card :cols="$cols" :rows="$rows" :class="$class">
+    <x-pulse::card-header
+            name="{{ $this->label }}"
+            title="Time: {{ number_format($time) }}ms; Run at: {{ $runAt }};"
+            details="past {{ $this->periodForHumans() }}"
+    >
+        <x-slot:icon>
+            <svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img"
+                 xmlns="http://www.w3.org/2000/svg">
+                <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
+            </svg>
+        </x-slot:icon>
+        <x-slot:actions>
+            @if(!$this->type)
+                <x-pulse::select
+                        wire:model.live="openaiRequests"
+                        label="By"
+                        :options="[
+                        'user' => 'Users',
+                        'endpoint' => 'API endpoint',
+                    ]"
+                        class="flex-1"
+                        @change="loading = true"
+                />
+            @endif
+        </x-slot:actions>
+    </x-pulse::card-header>
+
+    <x-pulse::scroll :expand="$expand" wire:poll.5s="">
+        @if ($requests->isEmpty())
+            <x-pulse::no-results/>
+        @else
+            @if($aggregate === 'user')
+                <div class="grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 gap-2">
+                    @foreach ($requests as $requestCount)
+                        <x-pulse::user-card wire:key="{{ $requestCount->user->id.$this->period }}"
+                                            :name="$requestCount->user->name" :extra="$requestCount->user->extra">
+                            @if ($requestCount->user->avatar ?? false)
+                                <x-slot:avatar>
+                                    <img height="32" width="32" src="{{ $requestCount->user->avatar }}" loading="lazy"
+                                         class="rounded-full">
+                                </x-slot:avatar>
+                            @endif
+
+                            <x-slot:stats>
+                                @php
+                                    $sampleRate = $config['sample_rate'];
+                                @endphp
+
+                                @if ($sampleRate < 1)
+                                    <span title="Sample rate: {{ $sampleRate }}, Raw value: {{ number_format($requestCount->count) }}">~{{ number_format($requestCount->count * (1 / $sampleRate)) }}</span>
+                                @else
+                                    {{ number_format($requestCount->count) }}
+                                @endif
+                            </x-slot:stats>
+                        </x-pulse::user-card>
+                    @endforeach
+                </div>
+            @else
+                <x-pulse::table>
+                    <colgroup>
+                        <col width="0%"/>
+                        <col width="100%"/>
+                        <col width="0%"/>
+                    </colgroup>
+                    <x-pulse::thead>
+                        <tr>
+                            <x-pulse::th>Method</x-pulse::th>
+                            <x-pulse::th>Uri</x-pulse::th>
+                            <x-pulse::th class="text-right">Count</x-pulse::th>
+                        </tr>
+                    </x-pulse::thead>
+                    <tbody>
+                    @foreach ($requests->take(10) as $request)
+                        <tr class="h-2 first:h-0"></tr>
+                        <tr wire:key="{{ $request->method.$request->uri.$this->period }}">
+                            <x-pulse::td>
+                                <x-pulse::http-method-badge :method="$request->method"/>
+                            </x-pulse::td>
+                            <x-pulse::td class="overflow-hidden max-w-[1px]">
+                                <code class="block text-xs text-gray-900 dark:text-gray-100 truncate"
+                                      title="{{ $request->uri }}">
+                                    /{{ $request->uri }}
+                                </code>
+                            </x-pulse::td>
+                            <x-pulse::td numeric class="text-gray-700 dark:text-gray-300 font-bold">
+                                @if ($config['sample_rate'] < 1)
+                                    <span title="Sample rate: {{ $config['sample_rate'] }}, Raw value: {{ number_format($request->count) }}">~{{ number_format($request->count * (1 / $config['sample_rate'])) }}</span>
+                                @else
+                                    {{ number_format($request->count) }}
+                                @endif
+                            </x-pulse::td>
+                        </tr>
+                    @endforeach
+                    </tbody>
+                </x-pulse::table>
+
+                @if ($requests->count() > 10)
+                    <div class="mt-2 text-xs text-gray-400 text-center">Limited to 10 entries</div>
+                @endif
+            @endif
+        @endif
+    </x-pulse::scroll>
+</x-pulse::card>
diff --git a/src/Facades/OpenAI.php b/src/Facades/OpenAI.php
index 1d7ab62..ffd0294 100644
--- a/src/Facades/OpenAI.php
+++ b/src/Facades/OpenAI.php
@@ -7,7 +7,6 @@
 use Illuminate\Support\Facades\Facade;
 use OpenAI\Contracts\ResponseContract;
 use OpenAI\Laravel\Testing\OpenAIFake;
-use OpenAI\Resources\Assistants;
 use OpenAI\Responses\StreamResponse;
 
 /**
diff --git a/src/Pulse/Livewire/OpenAIRequestsCard.php b/src/Pulse/Livewire/OpenAIRequestsCard.php
new file mode 100644
index 0000000..b53b722
--- /dev/null
+++ b/src/Pulse/Livewire/OpenAIRequestsCard.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace OpenAI\Laravel\Pulse\Livewire;
+
+use Illuminate\Contracts\Support\Renderable;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Facades\View;
+use Laravel\Pulse\Facades\Pulse;
+use Laravel\Pulse\Livewire\Card;
+use Laravel\Pulse\Livewire\Concerns\HasPeriod;
+use Laravel\Pulse\Livewire\Concerns\RemembersQueries;
+use Livewire\Attributes\Computed;
+use Livewire\Attributes\Lazy;
+use Livewire\Attributes\Url;
+use OpenAI\Laravel\Pulse\Recorders\OpenAIRequests;
+
+/**
+ * @internal
+ */
+#[Lazy]
+class OpenAIRequestsCard extends Card
+{
+    use HasPeriod, RemembersQueries;
+
+    /**
+     * The type of request aggregation to show.
+     *
+     * @var 'user'|'endpoint'|null
+     */
+    public ?string $type = null;
+
+    /**
+     * The openai requests type.
+     *
+     * @var 'user'|'endpoint'
+     */
+    #[Url]
+    public string $openaiRequests = 'user';
+
+    #[Computed]
+    public function label(): string
+    {
+        return match ($this->type ?? $this->openaiRequests) {
+            'user' => 'Top 10 OpenAI Users',
+            'endpoint' => 'Top 10 OpenAI Endpoints',
+        };
+    }
+
+    /**
+     * Render the component.
+     */
+    public function render(): Renderable
+    {
+        $aggregate = $this->type ?? $this->openaiRequests;
+
+        [$requests, $time, $runAt] = $this->remember(
+            function () use ($aggregate) {
+                /** @var Collection<int, object{key: string, count: int}> $counts */
+                $counts = Pulse::aggregate(
+                    match ($aggregate) {
+                        'user' => 'openai_request_handled_per_user',
+                        'endpoint' => 'openai_request_handled_per_endpoint',
+                    },
+                    'count', // @phpstan-ignore-line
+                    $this->periodAsInterval(),
+                    limit: 10,
+                );
+
+                if ($aggregate === 'user') {
+                    /** @var Collection<int, array{id: string|int, name: string, email?: ?string, avatar?: ?string, extra?: ?string}> $users */
+                    $users = Pulse::resolveUsers($counts->pluck('key'));
+
+                    return $counts->map(function ($row) use ($users) {
+                        $user = $users->firstWhere('id', $row->key);
+
+                        return (object) [
+                            'user' => (object) [
+                                'id' => $row->key,
+                                'name' => $user['name'] ?? ($row->key === 'null' ? 'Guest' : 'Unknown'),
+                                'extra' => $user['extra'] ?? $user['email'] ?? '',
+                                'avatar' => $user['avatar'] ?? (($user['email'] ?? false)
+                                        ? sprintf('https://gravatar.com/avatar/%s?d=mp', hash('sha256', trim(strtolower($user['email']))))
+                                        : null),
+                            ],
+                            'count' => (int) $row->count,
+                        ];
+                    });
+                }
+
+                return $counts->map(function ($row) {
+                    [$method, $uri] = json_decode($row->key, flags: JSON_THROW_ON_ERROR); // @phpstan-ignore-line
+
+                    return (object) [
+                        'uri' => $uri,
+                        'method' => $method,
+                        'count' => (int) $row->count,
+                    ];
+                });
+            },
+            $aggregate
+        );
+
+        return View::make('openai-php::livewire.openai-requests', [
+            'time' => $time,
+            'runAt' => $runAt,
+            'config' => Config::get('pulse.recorders.'.OpenAIRequests::class),
+            'requests' => $requests,
+            'aggregate' => $aggregate,
+        ]);
+    }
+}
diff --git a/src/Pulse/Recorders/OpenAIRequests.php b/src/Pulse/Recorders/OpenAIRequests.php
new file mode 100644
index 0000000..7b07118
--- /dev/null
+++ b/src/Pulse/Recorders/OpenAIRequests.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace OpenAI\Laravel\Pulse\Recorders;
+
+use Carbon\CarbonImmutable;
+use Illuminate\Config\Repository;
+use Laravel\Pulse\Pulse;
+use Laravel\Pulse\Recorders\Concerns\Groups;
+use Laravel\Pulse\Recorders\Concerns\Ignores;
+use Laravel\Pulse\Recorders\Concerns\Sampling;
+use OpenAI\Events\RequestHandled;
+
+/**
+ * @internal
+ */
+class OpenAIRequests
+{
+    use Groups, Ignores, Sampling;
+
+    /**
+     * The events to listen for.
+     *
+     * @var list<class-string>
+     */
+    public array $listen = [
+        RequestHandled::class,
+    ];
+
+    /**
+     * Create a new recorder instance.
+     */
+    public function __construct(
+        protected Pulse $pulse,
+        protected Repository $config,
+    ) {
+        //
+    }
+
+    /**
+     * Record the request.
+     */
+    public function record(RequestHandled $event): void
+    {
+        [$timestamp, $method, $uri, $userId] = [
+            CarbonImmutable::now()->getTimestamp(),
+            $event->payload->method->value,
+            $event->payload->uri->toString(),
+            $this->pulse->resolveAuthenticatedUserId(),
+        ];
+
+        $this->pulse->lazy(function () use ($timestamp, $method, $uri, $userId) {
+            if (! $this->shouldSample() || $this->shouldIgnore($uri)) {
+                return;
+            }
+
+            $this->pulse->record(
+                type: 'openai_request_handled_per_user',
+                key: json_encode($userId, flags: JSON_THROW_ON_ERROR),
+                timestamp: $timestamp,
+            )->count();
+
+            $this->pulse->record(
+                type: 'openai_request_handled_per_endpoint',
+                key: json_encode([$method, $this->group($uri)], flags: JSON_THROW_ON_ERROR),
+                timestamp: $timestamp,
+            )->count();
+        });
+    }
+}
diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php
index 4005fa7..e8f3c8f 100644
--- a/src/ServiceProvider.php
+++ b/src/ServiceProvider.php
@@ -4,26 +4,28 @@
 
 namespace OpenAI\Laravel;
 
+use Illuminate\Container\Container;
 use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
-use Illuminate\Contracts\Support\DeferrableProvider;
 use Illuminate\Support\ServiceProvider as BaseServiceProvider;
+use Livewire\Livewire;
 use OpenAI;
 use OpenAI\Client;
 use OpenAI\Contracts\ClientContract;
 use OpenAI\Laravel\Commands\InstallCommand;
 use OpenAI\Laravel\Exceptions\ApiKeyIsMissing;
+use OpenAI\Laravel\Pulse\Livewire\OpenAIRequestsCard;
 
 /**
  * @internal
  */
-final class ServiceProvider extends BaseServiceProvider implements DeferrableProvider
+final class ServiceProvider extends BaseServiceProvider
 {
     /**
      * Register any application services.
      */
     public function register(): void
     {
-        $this->app->singleton(ClientContract::class, static function (): Client {
+        $this->app->singleton(ClientContract::class, static function (Container $container): Client {
             $apiKey = config('openai.api_key');
             $organization = config('openai.organization');
 
@@ -36,7 +38,7 @@ public function register(): void
                 ->withOrganization($organization)
                 ->withHttpHeader('OpenAI-Beta', 'assistants=v1')
                 ->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)]))
-                ->withEventDispatcher(resolve(DispatcherContract::class)) // @phpstan-ignore-line
+                ->withEventDispatcher($container->make(DispatcherContract::class)) // @phpstan-ignore-line
                 ->make();
         });
 
@@ -58,6 +60,12 @@ public function boot(): void
                 InstallCommand::class,
             ]);
         }
+
+        $this->loadViewsFrom(__DIR__.'/../resources/views', 'openai-php');
+
+        if (class_exists(Livewire::class)) {
+            Livewire::component('openai.pulse.requests', OpenAIRequestsCard::class);
+        }
     }
 
     /**
diff --git a/tests/Arch.php b/tests/Arch.php
index e1ca9cc..9556939 100644
--- a/tests/Arch.php
+++ b/tests/Arch.php
@@ -17,9 +17,12 @@
     ->expect('OpenAI\Laravel\ServiceProvider')
     ->toOnlyUse([
         'GuzzleHttp\Client',
+        'Illuminate\Container\Container',
         'Illuminate\Support\ServiceProvider',
+        'Livewire\Livewire',
         'OpenAI\Laravel',
         'OpenAI',
+        'Illuminate\Contracts\Events\Dispatcher',
         'Illuminate\Contracts\Support\DeferrableProvider',
 
         // helpers...
diff --git a/tests/Facades/OpenAI.php b/tests/Facades/OpenAI.php
index a30f161..cea7a19 100644
--- a/tests/Facades/OpenAI.php
+++ b/tests/Facades/OpenAI.php
@@ -1,11 +1,13 @@
 <?php
 
 use Illuminate\Config\Repository;
+use Illuminate\Contracts\Events\Dispatcher;
 use OpenAI\Laravel\Facades\OpenAI;
 use OpenAI\Laravel\ServiceProvider;
 use OpenAI\Resources\Completions;
 use OpenAI\Responses\Completions\CreateResponse;
 use PHPUnit\Framework\ExpectationFailedException;
+use Psr\EventDispatcher\EventDispatcherInterface;
 
 it('resolves resources', function () {
     $app = app();
@@ -16,6 +18,13 @@
         ],
     ]));
 
+    $app->bind(Dispatcher::class, fn () => new class implements EventDispatcherInterface
+    {
+        public function dispatch(object $event)
+        {
+        }
+    });
+
     (new ServiceProvider($app))->register();
 
     OpenAI::setFacadeApplication($app);
diff --git a/tests/ServiceProvider.php b/tests/ServiceProvider.php
index cae4dd5..f31020f 100644
--- a/tests/ServiceProvider.php
+++ b/tests/ServiceProvider.php
@@ -1,10 +1,23 @@
 <?php
 
 use Illuminate\Config\Repository;
+use Illuminate\Contracts\Events\Dispatcher;
 use OpenAI\Client;
 use OpenAI\Contracts\ClientContract;
 use OpenAI\Laravel\Exceptions\ApiKeyIsMissing;
 use OpenAI\Laravel\ServiceProvider;
+use Psr\EventDispatcher\EventDispatcherInterface;
+
+beforeEach(function () {
+    $app = app();
+
+    $app->bind(Dispatcher::class, fn () => new class implements EventDispatcherInterface
+    {
+        public function dispatch(object $event)
+        {
+        }
+    });
+});
 
 it('binds the client on the container', function () {
     $app = app();
@@ -15,6 +28,13 @@
         ],
     ]));
 
+    $app->bind(DispatcherContract::class, fn () => new class implements DispatcherContract
+    {
+        public function dispatch(object $event): void
+        {
+        }
+    });
+
     (new ServiceProvider($app))->register();
 
     expect($app->get(Client::class))->toBeInstanceOf(Client::class);

From b0e076af8555afcfebbd65cc7468f2295d9efb81 Mon Sep 17 00:00:00 2001
From: Sandro Gehri <sandrogehri@gmail.com>
Date: Tue, 5 Dec 2023 13:55:54 +0100
Subject: [PATCH 3/5] Update README

---
 README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 45 insertions(+)

diff --git a/README.md b/README.md
index 93917b8..8e312ab 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,51 @@ OpenAI::assertSent(Completions::class, function (string $method, array $paramete
 
 For more testing examples, take a look at the [openai-php/client](https://github.com/openai-php/client#testing) repository.
 
+## Laravel Pulse
+
+This package provides a [Laravel Pulse](https://pulse.laravel.com) card to show statistics about your OpenAI usage.
+
+The card supports two metrics:
+- **Requests per user**: Shows the number of requests per user.
+- **Requests per endpoint**: Shows the number of requests per endpoint.
+
+### Installation
+
+First, make sure Laravel Pulse is [installed](https://laravel.com/docs/10.x/pulse#installation).
+
+Next, you need to register the recorder in your `config/pulse.php` file:
+
+```php
+'recorders' => [
+    // ...
+
+    \OpenAI\Laravel\Pulse\Recorders\OpenAIRequests::class => [
+        'enabled' => env('PULSE_OPENAI_REQUESTS_ENABLED', true),
+        'sample_rate' => env('PULSE_OPENAI_REQUESTS_SAMPLE_RATE', 1),
+        'ignore' => [],
+        'groups' => [
+            '/(.*)\/(asst_|file-|ft-|msg_|run_|step_|thread_)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3',
+        ],
+    ],
+],
+```
+
+### Usage
+
+Finally, add the card to your pulse `dashboard.blade.php` or any other Blade file.
+
+```blade
+<livewire:openai.pulse.requests />
+```
+
+If you want to be specific about the metric to show, you can pass it as `type`:
+
+```blade
+<livewire:openai.pulse.requests type="endpoint" />
+
+<livewire:openai.pulse.requests type="user" />
+```
+
 ---
 
 OpenAI PHP for Laravel is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.

From a6006cfdcb40c89814586742d90daa276f4bb9ef Mon Sep 17 00:00:00 2001
From: Sandro Gehri <sandrogehri@gmail.com>
Date: Tue, 5 Dec 2023 15:09:02 +0100
Subject: [PATCH 4/5] Add DispatcherDecorator

---
 composer.json                          |  2 +-
 src/Events/DispatcherDecorator.php     | 19 +++++++++++
 src/ServiceProvider.php                |  3 +-
 tests/Facades/OpenAI.php               |  9 ++----
 tests/Fixtures/NullEventDispatcher.php | 44 ++++++++++++++++++++++++++
 tests/ServiceProvider.php              | 16 ++--------
 6 files changed, 70 insertions(+), 23 deletions(-)
 create mode 100644 src/Events/DispatcherDecorator.php
 create mode 100644 tests/Fixtures/NullEventDispatcher.php

diff --git a/composer.json b/composer.json
index cd9ebfb..318ffe0 100644
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,7 @@
     },
     "require-dev": {
         "laravel/pint": "^1.13.6",
-        "laravel/pulse": "^1.0.0",
+        "laravel/pulse": "^v1.0.0-beta3",
         "pestphp/pest": "^2.8.2",
         "pestphp/pest-plugin-arch": "^2.2.2",
         "pestphp/pest-plugin-mock": "^2.0.0",
diff --git a/src/Events/DispatcherDecorator.php b/src/Events/DispatcherDecorator.php
new file mode 100644
index 0000000..3585ebd
--- /dev/null
+++ b/src/Events/DispatcherDecorator.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace OpenAI\Laravel\Events;
+
+use Illuminate\Contracts\Events\Dispatcher;
+use Psr\EventDispatcher\EventDispatcherInterface;
+
+class DispatcherDecorator implements EventDispatcherInterface
+{
+    public function __construct(
+        private readonly Dispatcher $events
+    ) {
+    }
+
+    public function dispatch(object $event)
+    {
+        return (object) $this->events->dispatch($event);
+    }
+}
diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php
index e8f3c8f..eb115e8 100644
--- a/src/ServiceProvider.php
+++ b/src/ServiceProvider.php
@@ -12,6 +12,7 @@
 use OpenAI\Client;
 use OpenAI\Contracts\ClientContract;
 use OpenAI\Laravel\Commands\InstallCommand;
+use OpenAI\Laravel\Events\DispatcherDecorator;
 use OpenAI\Laravel\Exceptions\ApiKeyIsMissing;
 use OpenAI\Laravel\Pulse\Livewire\OpenAIRequestsCard;
 
@@ -38,7 +39,7 @@ public function register(): void
                 ->withOrganization($organization)
                 ->withHttpHeader('OpenAI-Beta', 'assistants=v1')
                 ->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)]))
-                ->withEventDispatcher($container->make(DispatcherContract::class)) // @phpstan-ignore-line
+                ->withEventDispatcher(new DispatcherDecorator($container->make(DispatcherContract::class))) // @phpstan-ignore-line
                 ->make();
         });
 
diff --git a/tests/Facades/OpenAI.php b/tests/Facades/OpenAI.php
index cea7a19..19b8af4 100644
--- a/tests/Facades/OpenAI.php
+++ b/tests/Facades/OpenAI.php
@@ -7,7 +7,7 @@
 use OpenAI\Resources\Completions;
 use OpenAI\Responses\Completions\CreateResponse;
 use PHPUnit\Framework\ExpectationFailedException;
-use Psr\EventDispatcher\EventDispatcherInterface;
+use Tests\Fixtures\NullEventDispatcher;
 
 it('resolves resources', function () {
     $app = app();
@@ -18,12 +18,7 @@
         ],
     ]));
 
-    $app->bind(Dispatcher::class, fn () => new class implements EventDispatcherInterface
-    {
-        public function dispatch(object $event)
-        {
-        }
-    });
+    $app->bind(Dispatcher::class, fn () => new NullEventDispatcher());
 
     (new ServiceProvider($app))->register();
 
diff --git a/tests/Fixtures/NullEventDispatcher.php b/tests/Fixtures/NullEventDispatcher.php
new file mode 100644
index 0000000..464be9e
--- /dev/null
+++ b/tests/Fixtures/NullEventDispatcher.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Fixtures;
+
+use Illuminate\Contracts\Events\Dispatcher;
+
+class NullEventDispatcher implements Dispatcher
+{
+    public function listen($events, $listener = null)
+    {
+    }
+
+    public function hasListeners($eventName)
+    {
+    }
+
+    public function subscribe($subscriber)
+    {
+    }
+
+    public function until($event, $payload = [])
+    {
+    }
+
+    public function dispatch($event, $payload = [], $halt = false)
+    {
+    }
+
+    public function push($event, $payload = [])
+    {
+    }
+
+    public function flush($event)
+    {
+    }
+
+    public function forget($event)
+    {
+    }
+
+    public function forgetPushed()
+    {
+    }
+}
diff --git a/tests/ServiceProvider.php b/tests/ServiceProvider.php
index f31020f..080840b 100644
--- a/tests/ServiceProvider.php
+++ b/tests/ServiceProvider.php
@@ -6,17 +6,12 @@
 use OpenAI\Contracts\ClientContract;
 use OpenAI\Laravel\Exceptions\ApiKeyIsMissing;
 use OpenAI\Laravel\ServiceProvider;
-use Psr\EventDispatcher\EventDispatcherInterface;
+use Tests\Fixtures\NullEventDispatcher;
 
 beforeEach(function () {
     $app = app();
 
-    $app->bind(Dispatcher::class, fn () => new class implements EventDispatcherInterface
-    {
-        public function dispatch(object $event)
-        {
-        }
-    });
+    $app->bind(Dispatcher::class, fn () => new NullEventDispatcher());
 });
 
 it('binds the client on the container', function () {
@@ -28,13 +23,6 @@ public function dispatch(object $event)
         ],
     ]));
 
-    $app->bind(DispatcherContract::class, fn () => new class implements DispatcherContract
-    {
-        public function dispatch(object $event): void
-        {
-        }
-    });
-
     (new ServiceProvider($app))->register();
 
     expect($app->get(Client::class))->toBeInstanceOf(Client::class);

From ff5c7c1b26e4c034a6fe53e18526a842070bd04c Mon Sep 17 00:00:00 2001
From: Sandro Gehri <sandrogehri@gmail.com>
Date: Tue, 12 Dec 2023 15:05:51 +0100
Subject: [PATCH 5/5] Improve grouping example in README

---
 README.md | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 8e312ab..368120a 100644
--- a/README.md
+++ b/README.md
@@ -149,7 +149,13 @@ Next, you need to register the recorder in your `config/pulse.php` file:
         'sample_rate' => env('PULSE_OPENAI_REQUESTS_SAMPLE_RATE', 1),
         'ignore' => [],
         'groups' => [
-            '/(.*)\/(asst_|file-|ft-|msg_|run_|step_|thread_)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3',
+            '/(.*)\/(asst_)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3',
+            '/(.*)\/(file-)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3',
+            '/(.*)\/(ft-)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3',
+            '/(.*)\/(thread_)[0-9a-zA-Z]*(.*)\/(run_)[0-9a-zA-Z]*(.*)\/(step_)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3/\4*\5/\6*\7',
+            '/(.*)\/(thread_)[0-9a-zA-Z]*(.*)\/(run_)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3/\4*\5',
+            '/(.*)\/(thread_)[0-9a-zA-Z]*(.*)\/(msg_)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3/\4*\5',
+            '/(.*)\/(thread_)[0-9a-zA-Z]*(.*)/' => '\1/\2*\3',
         ],
     ],
 ],