diff --git a/README.md b/README.md index 93917b8..368120a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,57 @@ 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_)[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', + ], + ], +], +``` + +### 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)**. diff --git a/composer.json b/composer.json index ba9c259..318ffe0 100644 --- a/composer.json +++ b/composer.json @@ -13,10 +13,11 @@ "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", + "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/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/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/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 976fcee..eb115e8 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -4,25 +4,29 @@ namespace OpenAI\Laravel; -use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Container\Container; +use Illuminate\Contracts\Events\Dispatcher as DispatcherContract; 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\Events\DispatcherDecorator; 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'); @@ -35,6 +39,7 @@ public function register(): void ->withOrganization($organization) ->withHttpHeader('OpenAI-Beta', 'assistants=v1') ->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)])) + ->withEventDispatcher(new DispatcherDecorator($container->make(DispatcherContract::class))) // @phpstan-ignore-line ->make(); }); @@ -56,6 +61,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..19b8af4 100644 --- a/tests/Facades/OpenAI.php +++ b/tests/Facades/OpenAI.php @@ -1,11 +1,13 @@ bind(Dispatcher::class, fn () => new NullEventDispatcher()); + (new ServiceProvider($app))->register(); OpenAI::setFacadeApplication($app); 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 NullEventDispatcher()); +}); it('binds the client on the container', function () { $app = app();