diff --git a/config/openai.php b/config/openai.php index ebf66eb..1b94fea 100644 --- a/config/openai.php +++ b/config/openai.php @@ -46,4 +46,15 @@ */ 'request_timeout' => env('OPENAI_REQUEST_TIMEOUT', 30), + + /* + |-------------------------------------------------------------------------- + | HTTP Handler + |-------------------------------------------------------------------------- + | + | Define a custom HTTP handler (class-string or callable) for OpenAI requests. + | Useful for adding Laravel Http Events, logging, or retries via Guzzle HandlerStack. + | Base Handler: OpenAI\Laravel\Http\Handler::class + */ + 'http_handler' => env('OPENAI_HTTP_HANDLER', null), ]; diff --git a/src/Http/Handler.php b/src/Http/Handler.php new file mode 100644 index 0000000..d1ab1c9 --- /dev/null +++ b/src/Http/Handler.php @@ -0,0 +1,146 @@ +then(null, onRejected: static function ($reason) use ($fn) { + + $fn($reason); // side effects only + + // continue rejection chain + return \GuzzleHttp\Promise\Create::rejectionFor($reason); + } + ); + }; + }; + } + + /** + * Set the underlying handler to be used by this middleware. + */ + public function withHandler(callable $handler): static + { + $this->handler = $handler; + + return $this; + } + + /** + * Get or create the handler stack. + */ + protected function getHandlerStack(): HandlerStack + { + return $this->handlerStack ??= HandlerStack::create($this->handler); + } + + /** + * Invoke the handler stack with the given request and options. + * + * @param array $config + * @return ResponseInterface|PromiseInterface + */ + public function __invoke(RequestInterface $request, array $config) + { + $handlerStack = $this->getHandlerStack(); + + // handler to dispatches the event / logger + if ($this->isEventEnabled()) { + $handlerStack->push(HttpEvent::request(), 'request-event'); + $handlerStack->push(HttpEvent::failure(), 'failure-event'); + $handlerStack->push(HttpEvent::response(), 'response-event'); + } + + // Pass the stack directly to handle() + return $this->handle($handlerStack, $request, $config); + } + + /** + * Execute the handler stack with the given request and options. + * + * @param RequestInterface $request + * @param array $config + * @return ResponseInterface|PromiseInterface + */ + public function handle(HandlerStack $handler, $request, array $config) + { + // Now you can push additional middleware directly + // Example: + // $stack->push(Middleware::mapRequest(fn(RequestInterface $request) => $request)); + + return $handler($request, $config); + } +} diff --git a/src/Http/Handlers/HttpEvent.php b/src/Http/Handlers/HttpEvent.php new file mode 100644 index 0000000..3a35fa1 --- /dev/null +++ b/src/Http/Handlers/HttpEvent.php @@ -0,0 +1,118 @@ +handler = $handler; + + return $this; + } + + /** + * Get or create the handler stack. + */ + protected function getHandlerStack(): HandlerStack + { + return $this->handlerStack ??= HandlerStack::create($this->handler); + } + + /** + * Handle the given HTTP request using a composed handler stack. + * + * @param array $config + * @return ResponseInterface|PromiseInterface + */ + public function __invoke(RequestInterface $request, array $config = []) + { + + $handlerStack = $this->getHandlerStack(); + + $handlerStack->push(static::request(), 'request-event'); + $handlerStack->push(static::response(), 'response-event'); + $handlerStack->push(static::failure(), 'failure-event'); + + return $handlerStack($request, $config); + } + + /** + * Middleware to dispatch the Laravel HTTP RequestSending event before sending a request. + * + * @see https://api.laravel.com/docs/12.x/Illuminate/Http/Client/Events/RequestSending.html + */ + public static function request(): callable + { + return Middleware::tap(before: function (RequestInterface $request) { + Event::dispatch(new RequestSending(new Request($request))); + }); + } + + /** + * Middleware to dispatch the Laravel HTTP ResponseReceived event after a response is returned. + * + * @see https://api.laravel.com/docs/12.x/Illuminate/Http/Client/Events/ResponseReceived.html + */ + public static function response(): callable + { + return Middleware::tap( + after: function (RequestInterface $request, array $_o, PromiseInterface $promise) { + // $promise is the Response promise + $promise->then(function (ResponseInterface $response) use ($request) { + Event::dispatch(new ResponseReceived( + new Request($request), + new Response($response) + )); + }); + } + ); + } + + /** + * Middleware to dispatch the Laravel HTTP ConnectionFailed event on connection errors. + * + * @see https://api.laravel.com/docs/12.x/Illuminate/Http/Client/Events/ConnectionFailed.html + */ + public static function failure(): callable + { + return Handler::mapFailure(function ($e) { + if ($e instanceof ConnectException) { + $exception = new ConnectionException($e->getMessage(), $e->getCode()); + Event::dispatch(new ConnectionFailed(new Request($e->getRequest()), $exception)); + } + }); + } +} diff --git a/src/Http/README.md b/src/Http/README.md new file mode 100644 index 0000000..acdc3c4 --- /dev/null +++ b/src/Http/README.md @@ -0,0 +1,186 @@ +## HTTP Handlers and middleware + +The HTTP handler allows you to flexibly control requests and responses with custom middleware for logging, retries, and other features. + +Out of the box, The `\OpenAI\Laravel\Http\Handler` is ready to use and automatically dispatches [Laravel HTTP events](https://laravel.com/docs/12.x/http-client#events) such as `RequestSending`, `ResponseReceived`, and `ConnectionFailed`. + +For more advanced use cases, you can create a custom handler to add `logging`, `retries` , `with headers`, request/response transformations, or any other [Guzzle middleware](https://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#middleware), allowing you to fully tailor the behavior of your HTTP requests. + +### Handler Configuration + +Configure your handler in `config/openai.php`: + +```php +'http_handler' => \OpenAI\Laravel\Http\Handler::class, +``` + +**Add Custom Handler** + +Need custom logging, retries, or middleware? Create your own: + +```php +'http_handler' => \App\Http\Handlers\CustomHandler::class, +``` + +Accepts: callable, class and service container resolvable class. + +### HTTP Handler default features + +The built-in `\OpenAI\Laravel\Http\Handler` provides: + +- Laravel HTTP events (`RequestSending`, `ResponseReceived`, `ConnectionFailed`) +- Custom handler can use the `handle(...)` method through the handler's `__invoke(...)`. +- Add middleware and map failures through `Handler::mapFailure` +- Control whether events are dispatched with `Handler::shouldEvent(true)` + +Perfect for seamless integration with zero configuration. + +### HTTP Custom handler + +You can create a custom handler to interact with the HTTP client and implement custom logic. + +```php +class CustomHandler +{ + /** + * Invoke the handler stack with the given request and options. + */ + public function __invoke($request, array $config) + { + $handler = \GuzzleHttp\HandlerStack::create(); + + // Add custom logic here: logging, retries, modifying requests, etc. + + return $handler($request, $config); + } +} +``` + +**Creating the handler by extending the `Handler` Class** + +Extending the Handler class allow to create handler with `handle(...)`, including a `HandlerStack` instance. Additionally, it dispatches Laravel HTTP events and handles failures, logging, and more through middleware. + +```php +use OpenAI\Laravel\Http\Handler; + +class CustomHandler extends Handler +{ + /** + * Invoke the handler stack with the given request and options. + */ + public function handle($handler, $request, array $config) + { + // Add custom logic here: logging, retries, modifying requests, etc. + + return $handler($request, $config); + } +} +``` +### HTTP Handler with guzzle middleware + +This example demonstrates how to interact with the HTTP client using Guzzle middleware. You can create your own custom [middleware](https://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#middleware) or continue using available [Guzzle middleware](https://github.com/guzzle/guzzle/blob/7.10/src/Middleware.php) like `tap`, `mapRequest`, `mapResponse`, `retry`, etc. + +The middleware can be added to the handler stack using $handler->push, whether you’re using the __invoke method or hanlde(...) method by extending core handler. + +```php +use GuzzleHttp\Middleware; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + +class CustomHandler +{ + /** + * Invoke the handler. + */ + public function __invoke($request, array $config) + { + $handler = \GuzzleHttp\HandlerStack::create(); + + // Example: modify request URI in mapRequest middleware + $handler->push(Middleware::mapRequest(function (RequestInterface $request) { + return $request->withUri(\GuzzleHttp\Psr7\Utils::uriFor('new-path')); + })); + + // Example: modify response body in mapResponse middleware + $handler->push(Middleware::mapResponse(function (ResponseInterface $response) { + return $response->withBody(\GuzzleHttp\Psr7\Utils::streamFor('Hello')); + })); + + return $handler($request, $config); + } +} +``` + +**Adding Custom Headers to Requests** + +Here’s an example of adding a custom header to the request using middleware. This example extends Laravel's Handler class to handle the request and apply the custom header: + +```php +use GuzzleHttp\Middleware; +use OpenAI\Laravel\Http\Handler; + +class CustomHeaderHandler extends Handler +{ + /** + * Invoke the handler. + */ + public function handle($handler, $request, array $config) + { + // Example: modify request URI in mapRequest middleware + $handler->push(Middleware::mapRequest(function ($request) { + return $request->withHeader('X-Custom-Name', 'Laravel'); + })); + + return $handler($request, $config); + } +} +``` + +#### HTTP Handler add retry middleware with guzzle + +You can add retry http client request by creating a custom handler that pushes a [guzzle retry middleware](https://github.com/guzzle/guzzle/blob/7.10/src/Middleware.php#L179) onto the handler stack. + +This example middleware will automatically retry requests in case of server errors (5xx responses) or other conditions you define. + +```php +use GuzzleHttp\Middleware; +use OpenAI\Laravel\Http\Handler; + +class RetryHandler extends Handler +{ + /** + * Invoke the handler. + */ + public function handle(\GuzzleHttp\HandlerStack $handler, $request, array $config) + { + // Example: add retry middleware + $handler->push(Middleware::retry(function ($retries, $request, $response = null, $exception = null) { + + // For instance, 3-retry for 5xx responses + if ($retries < 3 && $response && $response->getStatusCode() >= 500) { + return true; // Retry on server errors + } + + return false; // Don't retry if the conditions above are not met + })); + + return $handler($request, $config); + + } +} +``` + +### HTTP Handler usage with client factory + +You can configure the OpenAI client to use a HTTP client handler when creating the client via the factory + +```php +use OpenAI\Laravel\Facades\OpenAI; +use OpenAI\Laravel\Http\Handler; + +$client = OpenAI::factory() + // Other configuration... + ->withHttpClient(new \GuzzleHttp\Client([ + 'handler' => Handler::resolve(config('openai.http_handler')), + ])); +``` diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b385c8f..525c73d 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -11,6 +11,7 @@ use OpenAI\Contracts\ClientContract; use OpenAI\Laravel\Commands\InstallCommand; use OpenAI\Laravel\Exceptions\ApiKeyIsMissing; +use OpenAI\Laravel\Http\Handler; /** * @internal @@ -35,7 +36,10 @@ public function register(): void $client = OpenAI::factory() ->withApiKey($apiKey) ->withOrganization($organization) - ->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)])); + ->withHttpClient(new \GuzzleHttp\Client([ + 'timeout' => config('openai.request_timeout', 30), + 'handler' => Handler::resolve(config('openai.http_handler')), + ])); if (is_string($project)) { $client->withProject($project); diff --git a/tests/Http/Bootstraping.php b/tests/Http/Bootstraping.php new file mode 100644 index 0000000..ab95486 --- /dev/null +++ b/tests/Http/Bootstraping.php @@ -0,0 +1,65 @@ +container->singleton('events', fn ($app) => new \Illuminate\Events\Dispatcher($app)); + + return $this; + } + + /** + * Add the cache binding + */ + public function cache(): self + { + $this->container->singleton('cache', fn ($app) => new \Illuminate\Cache\CacheManager($app)); + + // You also need to bind a configuration to avoid a "config" binding error + $this->container->singleton('config', function () { + return [ + 'cache' => [ + 'default' => 'array', + 'stores' => [ + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + ], + ], + ]; + }); + + return $this; + } +} diff --git a/tests/Http/HandlerTest.php b/tests/Http/HandlerTest.php new file mode 100644 index 0000000..d953577 --- /dev/null +++ b/tests/Http/HandlerTest.php @@ -0,0 +1,311 @@ +events()->cache(); + Event::fake(); +}); + +/** + * Create common http client assets + */ +function httpClientAsserts( + Client $client, + mixed $toBe, + $toBeStatus = 200, + string $method = 'GET', + string $uri = 'test', + bool $async = true +) { + // Synchronous request + $response = $client->request($method, $uri); + expect($response->getStatusCode())->toBe($toBeStatus); + expect((string) $response->getBody())->toBe($toBe); + + $asyncResponse = null; + + // Asynchronous request + if ($async) { + $promise = $client->requestAsync($method, $uri); + $asyncResponse = $promise->wait(); + expect($asyncResponse->getStatusCode())->toBe($toBeStatus); + expect((string) $asyncResponse->getBody())->toBe($toBe); + } + + return [ + 'response' => $response, + 'asyncResponse' => $asyncResponse, + ]; +} + +it('resolve callable', function () { + + $callback = function (RequestInterface $request, $config) { + $handler = HandlerStack::create(new MockHandler([new Response(200, [], 'PHP is ')])); + + return $handler($request, $config); + }; + + $client = new Client(['handler' => Handler::resolve($callback)]); + + httpClientAsserts($client, 'PHP is '); +}); + +it('resolve the class from the container', function () { + + $clasName = new class + { + public function __invoke(RequestInterface $request, array $config) + { + $handler = HandlerStack::create(new MockHandler([new Response(200, [], 'PHP is ')])); + + return $handler($request, $config); + } + }; + + // Bind an invokable class to the container + app()->instance('TestHandler', $clasName); + + $client = new Client(['handler' => Handler::resolve('TestHandler')]); + + httpClientAsserts($client, 'PHP is '); +}); + +it('resolve the class-string with __invoke', function () { + + $clasName = new class + { + public function __invoke(RequestInterface $request, array $config) + { + $handler = HandlerStack::create(new MockHandler([new Response(200, [], 'PHP is ')])); + + return $handler($request, $config); + } + }; + + $client = new Client(['handler' => Handler::resolve($clasName::class)]); + + httpClientAsserts($client, 'PHP is '); +}); + +it('resolve the class-string without __invoke returns null', function () { + $clasName = new class {}; + expect(Handler::resolve($clasName::class))->toBe(null); +}); + +it('resolve argument null always returns null', function () { + expect(Handler::resolve(null))->toBe(null); +}); + +// ------------------- mapped failure ------------------- + +it('mapped failure calls callback on ConnectException', function () { + $mock = new MockHandler([new ConnectException('failure', new Request('GET', '/'))]); + + $called = false; + $handler = HandlerStack::create($mock); + $handler->push(Handler::mapFailure(function ($exception) use (&$called) { + expect($exception)->toBeInstanceOf(ConnectException::class); + $called = true; + })); + + $client = new Client(['handler' => $handler]); + + expect(fn () => $client->request('GET', 'test'))->toThrow(ConnectException::class); + expect($called)->toBeTrue(); +}); + +it('mapped failure calls callback on non-ConnectException', function () { + $mock = new MockHandler([new \RuntimeException('failure')]); + + $called = false; + $handler = HandlerStack::create($mock); + $handler->push(Handler::mapFailure(function ($e) use (&$called) { + $called = true; + })); + $client = new Client(['handler' => $handler]); + + expect(fn () => $client->request('GET', '/test'))->toThrow(\RuntimeException::class, 'failure'); + expect($called)->toBeTrue(); +}); + +it('mapped failure does not call callback on success response', function () { + $mock = new MockHandler([new Response(200, [], 'PHP is')]); + + $called = false; + $handler = HandlerStack::create($mock); + $handler->push(Handler::mapFailure(function () use (&$called) { + $called = true; + })); + + $client = new Client(['handler' => $handler]); + $response = $client->request('GET', 'test'); + + expect($called)->toBeFalse(); + expect($response)->toBeInstanceOf(Response::class); + expect((string) $response->getBody())->toBe('PHP is'); +}); + +// ------------------- handler invokes ------------------- + +it('__invoke handler stack returns response with Laravel http events', function () { + + Handler::shouldEvent(true); + + $mock = new MockHandler([ + new Response(200, [], 'PHP is '), // for sync + new Response(200, [], 'PHP is '), // for async + ]); + + $client = new Client(['handler' => (new Handler)->withHandler($mock)]); + + httpClientAsserts($client, 'PHP is '); + + Event::assertDispatched(RequestSending::class); + Event::assertDispatched(ResponseReceived::class); +}); + +it('__invoke handler stack failure with Laravel http event ConnectionFailed ', function () { + + Handler::shouldEvent(true); + + // Mock handler: simulate connection failures for sync and async requests + $mock = new MockHandler([ + new ConnectException('Connection failed', new Request('GET', 'test-sync')), + new ConnectException('Connection failed', new Request('GET', 'test-async')), + ]); + + // Client with our custom OpenAI handler + $client = new Client(['handler' => (new Handler)->withHandler($mock)]); + + try { + $client->request('GET', 'tests'); + $client->requestAsync('GET', 'tests')->wait(); + } catch (ConnectException $e) { + } + + // Assert Laravel events + Event::assertDispatched(ConnectionFailed::class); + Event::assertDispatched(RequestSending::class); + Event::assertNotDispatched(ResponseReceived::class); +}); + +it('__invoke handler stack without events when shouldEvent is false', function () { + + // two responses: one for sync, one for async + $mock = new MockHandler([new Response(200, [], 'PHP is '), new Response(200, [], 'PHP is ')]); + + Handler::shouldEvent(false); + + $client = new Client(['handler' => (new Handler)->withHandler($mock)]); + + httpClientAsserts($client, 'PHP is '); + + Event::assertNothingDispatched(); +}); + +it('__invoke handler stack by extending the handler class', function () { + + // two responses: one for sync, one for async + $mock = new MockHandler([new Response(200, [], 'init'), new Response(200, [], 'init')]); + + // Custom handler that modifies the response body + $customHandler = new class extends Handler + { + public function handle(HandlerStack $handler, $request, array $config) + { + $handler->push(Middleware::mapResponse(function (ResponseInterface $response) { + return $response->withBody(\GuzzleHttp\Psr7\Utils::streamFor('PHP is ')); + })); + + // MUST return the response/promise + return $handler($request, $config); + } + }; + + $customHandler->withHandler($mock); + + $client = new Client(['handler' => $customHandler]); + + httpClientAsserts($client, 'PHP is '); +}); + +// ------------------- fluent methods ------------------- + +it('fluent methods exists ===================', function () { + expect(true)->toBe(true); +}); + +it('handle executes handler stack correctly', function () { + + $mock = new MockHandler([new Response(200, [], 'PHP is')]); + $handlerStack = HandlerStack::create($mock); + + $request = (new Handler)->handle($handlerStack, new Request('GET', 'test'), []); + $response = $request->wait(); + + expect($response)->toBeInstanceOf(Response::class); + expect((int) $response->getStatusCode())->toBe(200); + expect((string) $response->getBody())->toBe('PHP is'); +}); + +it('shouldEvent can enable and disable event dispatching', function () { + Handler::shouldEvent(false); + expect(Handler::isEventEnabled())->toBeFalse(); + + Handler::shouldEvent(true); + expect(Handler::isEventEnabled())->toBeTrue(); +}); + +it('handlerStack creates handler stack if not already cached', function () { + $handler = new Handler; + $stack = (new ReflectionClass($handler))->getProperty('handlerStack'); + $stack->setAccessible(true); + + expect($stack->getValue($handler))->toBeNull(); + + $created = (new ReflectionMethod($handler, 'getHandlerStack'))->invoke($handler); + expect($created)->toBeInstanceOf(HandlerStack::class); + expect($stack->getValue($handler))->toBe($created); +}); + +it('getHandlerStack uses existing handler stack when already cached', function () { + $handler = new Handler; + + $fakeStack = HandlerStack::create(); + $property = new ReflectionProperty($handler, 'handlerStack'); + $property->setAccessible(true); + $property->setValue($handler, $fakeStack); + + $stack = (new ReflectionMethod($handler, 'getHandlerStack'))->invoke($handler); + + expect($stack)->toBe($fakeStack); +}); + +it('withHandler sets handler and returns static', function () { + $handler = new Handler; + + $callable = fn () => true; + $result = $handler->withHandler($callable); + + expect($result)->toBeInstanceOf(Handler::class); + $property = new ReflectionProperty($handler, 'handler'); + $property->setAccessible(true); + + expect($property->getValue($handler))->toBe($callable); +}); diff --git a/tests/Http/HttpEventTest.php b/tests/Http/HttpEventTest.php new file mode 100644 index 0000000..1c590bc --- /dev/null +++ b/tests/Http/HttpEventTest.php @@ -0,0 +1,79 @@ +events()->cache(); + Event::fake(); +}); + +it('dispatches ConnectionFailed event on ConnectException', function () { + $mock = new MockHandler([ + new ConnectException('Connection failed', new Request('GET', 'tests')), + new ConnectException('Connection failed', new Request('GET', 'tests')), + ]); + $handler = HandlerStack::create($mock); + $handler->push(HttpEvent::failure()); + $client = new Client(['handler' => $handler]); + + try { + $client->request('GET', 'tests'); + $client->requestAsync('GET', 'tests'); + } catch (\GuzzleHttp\Exception\ConnectException $e) { + // expected + } + + Event::assertDispatched(ConnectionFailed::class); + Event::assertNotDispatched(RequestSending::class); + Event::assertNotDispatched(ResponseReceived::class); +}); + +it('dispatches RequestSending and ResponseReceived events', function () { + $handler = HandlerStack::create(new MockHandler([new Response(200, [], 'PHP is ')])); + $handler->push(HttpEvent::request()); + $handler->push(HttpEvent::response()); + $client = new Client(['handler' => $handler]); + $response = $client->request('GET', 'test'); + Event::assertDispatched(RequestSending::class); + Event::assertDispatched(ResponseReceived::class); +}); + +it('dispatches RequestSending and ResponseReceived events in async request', function () { + $handler = HandlerStack::create(new MockHandler([new Response(200, [], 'PHP is ')])); + $handler->push(HttpEvent::request()); + $handler->push(HttpEvent::response()); + $client = new Client(['handler' => $handler]); + $promise = $client->requestAsync('GET', '/test'); + $response = $promise->wait(); + Event::assertDispatched(RequestSending::class); + Event::assertDispatched(ResponseReceived::class); +}); + +it('dispatches RequestSending and ResponseReceived events using HttpEvent __invoke', function () { + + $handler = HandlerStack::create(new MockHandler([new Response(200, [], 'PHP is ')])); + + $handler->push(static function (callable $handler) { + return function ($request, array $config) use ($handler) { + return (new HttpEvent)->withHandler($handler)($request, $config); + }; + }); + + $client = new Client(['handler' => $handler]); + $response = $client->request('GET', 'test'); + Event::assertDispatched(RequestSending::class); + Event::assertDispatched(ResponseReceived::class); +});