From b25394f3962c298149e4dfbe1eaf9ce5036a485c Mon Sep 17 00:00:00 2001 From: Sandro Gehri 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 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 @@ + + + + + + + + + @if(!$this->type) + + @endif + + + + + @if ($requests->isEmpty()) + + @else + @if($aggregate === 'user') +
+ @foreach ($requests as $requestCount) + + @if ($requestCount->user->avatar ?? false) + + + + @endif + + + @php + $sampleRate = $config['sample_rate']; + @endphp + + @if ($sampleRate < 1) + ~{{ number_format($requestCount->count * (1 / $sampleRate)) }} + @else + {{ number_format($requestCount->count) }} + @endif + + + @endforeach +
+ @else + + + + + + + + + Method + Uri + Count + + + + @foreach ($requests->take(10) as $request) + + + + + + + + /{{ $request->uri }} + + + + @if ($config['sample_rate'] < 1) + ~{{ number_format($request->count * (1 / $config['sample_rate'])) }} + @else + {{ number_format($request->count) }} + @endif + + + @endforeach + + + + @if ($requests->count() > 10) +
Limited to 10 entries
+ @endif + @endif + @endif +
+
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 @@ +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 $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 $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 @@ + + */ + 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 @@ 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 @@ 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 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 + +``` + +If you want to be specific about the metric to show, you can pass it as `type`: + +```blade + + + +``` + --- 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 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 @@ +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 @@ +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 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', ], ], ],