Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OpenAI requests card for Laravel Pulse #78

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<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)**.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions resources/views/livewire/openai-requests.blade.php
Original file line number Diff line number Diff line change
@@ -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>
19 changes: 19 additions & 0 deletions src/Events/DispatcherDecorator.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
112 changes: 112 additions & 0 deletions src/Pulse/Livewire/OpenAIRequestsCard.php
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 65 in src/Pulse/Livewire/OpenAIRequestsCard.php

View workflow job for this annotation

GitHub Actions / Formats P8.2 - ubuntu-latest - prefer-stable

No error to ignore is reported on line 65.
$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,
]);
}
}
69 changes: 69 additions & 0 deletions src/Pulse/Recorders/OpenAIRequests.php
Original file line number Diff line number Diff line change
@@ -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();
});
}
}
Loading
Loading