├── CHANGELOG.md ├── src ├── DTO │ ├── Messages │ │ ├── QueueFull.php │ │ ├── SendHash.php │ │ ├── ProcessStarts.php │ │ ├── SendData.php │ │ ├── Log.php │ │ ├── ProcessCompleted.php │ │ ├── ProcessGenerating.php │ │ ├── Estimation.php │ │ └── Message.php │ ├── Resolvers │ │ ├── MessageType.php │ │ └── MessageResolver.php │ ├── Output.php │ └── Config.php ├── Exception │ ├── GradioException.php │ └── QueueFullException.php ├── Event │ ├── EventHandler.php │ ├── EnhancedClient.php │ └── Event.php ├── Client │ ├── Endpoint.php │ ├── RegisterEvents.php │ └── RemoteClient.php └── Client.php ├── LICENSE.md ├── composer.json └── README.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `gradio-client-php` will be documented in this file. 4 | 5 | -------------------------------------------------------------------------------- /src/DTO/Messages/QueueFull.php: -------------------------------------------------------------------------------- 1 | callback, func_get_args()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Exception/QueueFullException.php: -------------------------------------------------------------------------------- 1 | text(json_encode($data, JSON_THROW_ON_ERROR)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Event/Event.php: -------------------------------------------------------------------------------- 1 | _extra[$name] = $value; 20 | } 21 | 22 | public function __get(string $name) 23 | { 24 | return $this->_extra[$name] ?? null; 25 | } 26 | 27 | public function __isset(string $name): bool 28 | { 29 | return isset($this->_extra[$name]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/DTO/Output.php: -------------------------------------------------------------------------------- 1 | _extra[$name] = $value; 20 | } 21 | 22 | public function __get(string $name) 23 | { 24 | return $this->_extra[$name] ?? null; 25 | } 26 | 27 | public function __isset(string $name): bool 28 | { 29 | return isset($this->_extra[$name]); 30 | } 31 | 32 | public function getOutputs(): array 33 | { 34 | return $this->data ?? []; 35 | } 36 | 37 | public function getOutput(int $index = 0): mixed 38 | { 39 | return $this->data[$index] ?? null; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Client/Endpoint.php: -------------------------------------------------------------------------------- 1 | data[$name] ?? null; 19 | } 20 | 21 | public function __isset(string $name): bool 22 | { 23 | return isset($this->data[$name]); 24 | } 25 | 26 | public function skipsQueue(): bool 27 | { 28 | return ! ($this->data['queue'] ?? $this->config->enable_queue); 29 | } 30 | 31 | public function apiName(): ?string 32 | { 33 | return ! empty($this->data['api_name']) ? $this->data['api_name'] : null; 34 | } 35 | 36 | public function uri() 37 | { 38 | $name = $this->apiName(); 39 | if ($name !== null) { 40 | $name = str_replace('/', '', $name); 41 | 42 | return "run/$name"; 43 | } 44 | 45 | return 'run/predict'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) SergiX44 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/DTO/Config.php: -------------------------------------------------------------------------------- 1 | _extra[$name] = $value; 44 | } 45 | 46 | public function __get(string $name) 47 | { 48 | return $this->_extra[$name] ?? null; 49 | } 50 | 51 | public function __isset(string $name): bool 52 | { 53 | return isset($this->_extra[$name]); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sergix44/gradio-client-php", 3 | "description": "Gradio client for PHP", 4 | "keywords": [ 5 | "SergiX44", 6 | "gradio-client-php" 7 | ], 8 | "homepage": "https://github.com/SergiX44/gradio-client-php", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Sergio Brighenti", 13 | "email": "sergio@brighenti.me", 14 | "role": "Developer" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.2", 19 | "guzzlehttp/guzzle": "^7.7", 20 | "nutgram/hydrator": ">=6.0", 21 | "phrity/websocket": "^1.7.2", 22 | "ext-fileinfo": "*" 23 | }, 24 | "require-dev": { 25 | "pestphp/pest": "^1.20", 26 | "laravel/pint": "^1.2" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "SergiX44\\Gradio\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "SergiX44\\Gradio\\Tests\\": "tests" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "vendor/bin/pest", 40 | "test-coverage": "vendor/bin/pest --coverage", 41 | "format": "vendor/bin/pint" 42 | }, 43 | "config": { 44 | "sort-packages": true, 45 | "allow-plugins": { 46 | "pestphp/pest-plugin": true, 47 | "phpstan/extension-installer": true 48 | } 49 | }, 50 | "minimum-stability": "dev", 51 | "prefer-stable": true 52 | } 53 | -------------------------------------------------------------------------------- /src/DTO/Resolvers/MessageResolver.php: -------------------------------------------------------------------------------- 1 | value => SendHash::class, 25 | MessageType::SEND_DATA->value => SendData::class, 26 | MessageType::QUEUE_FULL->value => QueueFull::class, 27 | MessageType::QUEUE_ESTIMATION->value => Estimation::class, 28 | MessageType::PROCESS_STARTS->value => ProcessStarts::class, 29 | MessageType::PROCESS_GENERATING->value => ProcessGenerating::class, 30 | MessageType::PROCESS_COMPLETED->value => ProcessCompleted::class, 31 | MessageType::LOG->value => Log::class, 32 | default => (new class extends Message 33 | { 34 | })::class, 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gradio Client for PHP 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/sergix44/gradio-client-php.svg?style=flat-square)](https://packagist.org/packages/sergix44/gradio-client-php) 4 | [![Tests](https://img.shields.io/github/actions/workflow/status/sergix44/gradio-client-php/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/sergix44/gradio-client-php/actions/workflows/run-tests.yml) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/sergix44/gradio-client-php.svg?style=flat-square)](https://packagist.org/packages/sergix44/gradio-client-php) 6 | 7 | A PHP client to call [Gradio](https://www.gradio.app) APIs. 8 | 9 | ## TODO 10 | - [x] HTTP and WS support 11 | - [x] `predict` 12 | - [x] getConfig 13 | - [ ] client options 14 | - [ ] hf_token support 15 | - [ ] viewApi 16 | - [x] Finish event system 17 | - [ ] Add tests 18 | - [ ] Add more examples 19 | - [ ] Add documentation 20 | 21 | ## Installation 22 | 23 | You can install the package via composer: 24 | 25 | ```bash 26 | composer require sergix44/gradio-client-php 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```php 32 | use SergiX44\Gradio\Client; 33 | 34 | $client = new Client('https://my-special.hf.space'); 35 | 36 | $result = $client->predict(['arg', 1, 2], apiName: 'myFunction'); 37 | 38 | ``` 39 | 40 | ## Testing 41 | 42 | ```bash 43 | composer test 44 | ``` 45 | 46 | ## Changelog 47 | 48 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 49 | 50 | ## Security Vulnerabilities 51 | 52 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 53 | 54 | ## Credits 55 | 56 | - [Sergio Brighenti](https://github.com/SergiX44) 57 | - [All Contributors](../../contributors) 58 | 59 | ## License 60 | 61 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 62 | -------------------------------------------------------------------------------- /src/Client/RegisterEvents.php: -------------------------------------------------------------------------------- 1 | events[Event::SUBMIT->value] = new EventHandler(Event::SUBMIT, $callback); 15 | } 16 | 17 | public function onQueueEstimation(callable $callback): EventHandler 18 | { 19 | return $this->events[Event::QUEUE_ESTIMATION->value] = new EventHandler(Event::QUEUE_ESTIMATION, $callback); 20 | } 21 | 22 | public function onQueueFull(callable $callback): EventHandler 23 | { 24 | return $this->events[Event::QUEUE_FULL->value] = new EventHandler(Event::QUEUE_FULL, $callback); 25 | } 26 | 27 | public function onProcessGenerating(callable $callback): EventHandler 28 | { 29 | return $this->events[Event::PROCESS_GENERATING->value] = new EventHandler(Event::PROCESS_GENERATING, $callback); 30 | } 31 | 32 | public function onProcessStarts(callable $callback): EventHandler 33 | { 34 | return $this->events[Event::PROCESS_STARTS->value] = new EventHandler(Event::PROCESS_STARTS, $callback); 35 | } 36 | 37 | public function onProcessCompleted(callable $callback): EventHandler 38 | { 39 | return $this->events[Event::PROCESS_COMPLETED->value] = new EventHandler(Event::PROCESS_COMPLETED, $callback); 40 | } 41 | 42 | public function onProcessSuccess(callable $callback): EventHandler 43 | { 44 | return $this->events[Event::PROCESS_SUCCESS->value] = new EventHandler(Event::PROCESS_SUCCESS, $callback); 45 | } 46 | 47 | public function onProcessFailed(callable $callback): EventHandler 48 | { 49 | return $this->events[Event::PROCESS_FAILED->value] = new EventHandler(Event::PROCESS_FAILED, $callback); 50 | } 51 | 52 | protected function fireEvent(Event $event, array $data = []): void 53 | { 54 | if (isset($this->events[$event->value])) { 55 | $events = is_array($this->events[$event->value]) ? $this->events[$event->value] : [$this->events[$event->value]]; 56 | foreach ($events as $e) { 57 | $e(...$data); 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Client/RemoteClient.php: -------------------------------------------------------------------------------- 1 | src = str_ends_with($src, '/') ? $src : "{$src}/"; 32 | 33 | $this->hydrator = new Hydrator(); 34 | 35 | $this->httpClient = new Guzzle(array_merge([ 36 | 'base_uri' => str_replace('ws', 'http', $this->src), 37 | 'headers' => [ 38 | 'User-Agent' => 'gradio_client_php/1.0', 39 | 'Accept' => 'application/json', 40 | ], 41 | ], $httpClientOptions)); 42 | } 43 | 44 | protected function http(string $method, string $uri, array $params = [], array $opt = [], ?string $dto = null) 45 | { 46 | $response = $this->httpRaw($method, $uri, $params, $opt); 47 | 48 | return $this->decodeResponse($response, $dto); 49 | } 50 | 51 | protected function httpRaw(string $method, string $uri, array $params = [], array $opt = []) 52 | { 53 | $keyContent = $method === 'get' ? 'query' : 'json'; 54 | 55 | return $this->httpClient->request($method, $uri, array_merge([ 56 | $keyContent => $params, 57 | ], $opt)); 58 | } 59 | 60 | protected function ws(string $uri, array $options = []): EnhancedClient 61 | { 62 | return new EnhancedClient(str_replace('http', 'ws', $this->src).$uri, $options); 63 | } 64 | 65 | protected function decodeResponse(ResponseInterface|string $response, ?string $mapTo = null): mixed 66 | { 67 | $body = $response instanceof ResponseInterface ? $response->getBody()->getContents() : $response; 68 | 69 | if ($mapTo !== null) { 70 | return $this->hydrator->hydrateWithJson($mapTo, $body); 71 | } 72 | 73 | return json_decode($body, true, flags: JSON_THROW_ON_ERROR); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | config = $config ?? $this->http('get', self::HTTP_CONFIG, dto: Config::class); 44 | $this->loadEndpoints($this->config->dependencies); 45 | $this->sessionHash = substr(md5(microtime()), 0, 11); 46 | $this->hfToken = $hfToken; 47 | } 48 | 49 | protected function loadEndpoints(array $dependencies): void 50 | { 51 | foreach ($dependencies as $index => $dp) { 52 | $endpoint = new Endpoint($this->config, $index, $dp); 53 | $this->endpoints[$index] = $endpoint; 54 | if ($endpoint->apiName() !== null) { 55 | $this->endpoints[$endpoint->apiName()] = $endpoint; 56 | } 57 | } 58 | } 59 | 60 | public function getConfig(): Config 61 | { 62 | return $this->config; 63 | } 64 | 65 | public function predict(array $arguments, ?string $apiName = null, ?int $fnIndex = null, bool $raw = false, ?int $triggerId = null): Output|array|null 66 | { 67 | if ($apiName === null && $fnIndex === null) { 68 | throw new InvalidArgumentException('You must provide an apiName or fnIndex'); 69 | } 70 | 71 | $apiName = $apiName !== null ? str_replace('/', '', $apiName) : null; 72 | $endpoint = $this->endpoints[$apiName ?? $fnIndex] ?? null; 73 | 74 | if ($endpoint === null) { 75 | throw new InvalidArgumentException('Endpoint not found'); 76 | } 77 | 78 | return $this->submit($endpoint, $arguments, $raw, $triggerId); 79 | } 80 | 81 | protected function submit(Endpoint $endpoint, array $arguments, bool $raw, ?int $triggerId = null): Output|array|null 82 | { 83 | $payload = $this->preparePayload($arguments); 84 | $this->fireEvent(Event::SUBMIT, $payload); 85 | 86 | if ($endpoint->skipsQueue()) { 87 | return $this->http('post', $endpoint->uri(), [ 88 | 'data' => $payload, 89 | 'fn_index' => $endpoint->index, 90 | 'session_hash' => $this->sessionHash, 91 | 'trigger_id' => $triggerId, 92 | 'event_data' => null, 93 | ], dto: $raw ? null : Output::class); 94 | } 95 | 96 | return match ($this->config->protocol) { 97 | 'sse', 'sse_v1', 'sse_v2', 'sse_v2.1', 'sse_v3' => $this->sseLoop($endpoint, $payload, $this->config->protocol, $triggerId), 98 | 'ws' => $this->websocketLoop($endpoint, $payload), 99 | default => throw new GradioException('Unknown protocol '.$this->config->protocol), 100 | }; 101 | } 102 | 103 | private function preparePayload(array $arguments): array 104 | { 105 | return array_map(static function ($arg) { 106 | if (is_resource($arg)) { 107 | $filename = stream_get_meta_data($arg)['uri']; 108 | $contents = stream_get_contents($filename); 109 | $finfo = new \finfo(FILEINFO_MIME_TYPE); 110 | $mime = $finfo->buffer($contents); 111 | 112 | return [ 113 | 'data' => "data:$mime;base64,".base64_encode($contents), 114 | 'name' => basename($filename), 115 | ]; 116 | } 117 | 118 | if (is_string($arg) && file_exists($arg)) { 119 | $contents = file_get_contents($arg); 120 | $mime = mime_content_type($arg); 121 | 122 | return [ 123 | 'data' => "data:$mime;base64,".base64_encode($contents), 124 | 'name' => basename($arg), 125 | ]; 126 | } 127 | 128 | return $arg; 129 | }, $arguments); 130 | } 131 | 132 | /** 133 | * @throws GradioException 134 | * @throws QueueFullException 135 | * @throws \JsonException 136 | */ 137 | private function websocketLoop(Endpoint $endpoint, array $payload): ?Output 138 | { 139 | $ws = $this->ws(self::QUEUE_JOIN); 140 | 141 | while (true) { 142 | $data = $ws->receive(); 143 | 144 | // why sometimes $data is null? 145 | if ($data === null) { 146 | continue; 147 | } 148 | 149 | $message = $this->hydrator->hydrateWithJson(Message::class, $data); 150 | 151 | if ($message instanceof SendHash) { 152 | $ws->sendJson([ 153 | 'fn_index' => $endpoint->index, 154 | 'session_hash' => $this->sessionHash, 155 | ]); 156 | } elseif ($message instanceof QueueFull) { 157 | $this->fireEvent(Event::QUEUE_FULL, [$message]); 158 | $ws->close(); 159 | throw new QueueFullException(); 160 | } elseif ($message instanceof Estimation) { 161 | $this->fireEvent(Event::QUEUE_ESTIMATION, [$message]); 162 | } elseif ($message instanceof SendData) { 163 | $ws->sendJson([ 164 | 'fn_index' => $endpoint->index, 165 | 'session_hash' => $this->sessionHash, 166 | 'data' => $payload, 167 | 'event_data' => null, 168 | ]); 169 | } elseif ($message instanceof ProcessCompleted) { 170 | $this->fireEvent(Event::PROCESS_COMPLETED, [$message]); 171 | if ($message->success) { 172 | $this->fireEvent(Event::PROCESS_SUCCESS, [$message]); 173 | } else { 174 | $this->fireEvent(Event::PROCESS_FAILED, [$message]); 175 | } 176 | break; 177 | } elseif ($message instanceof ProcessStarts) { 178 | $this->fireEvent(Event::PROCESS_STARTS, [$message]); 179 | } elseif ($message instanceof ProcessGenerating) { 180 | $this->fireEvent(Event::PROCESS_GENERATING, [$message]); 181 | } else { 182 | throw new GradioException("'Unknown message type $data"); 183 | } 184 | } 185 | 186 | $ws->close(); 187 | 188 | return $message?->output; 189 | } 190 | 191 | private function sseLoop(Endpoint $endpoint, array $payload, string $protocol, ?int $triggerId): ?Output 192 | { 193 | if ($protocol === 'sse') { 194 | $getEndpoint = self::QUEUE_JOIN; 195 | } else { 196 | $getEndpoint = self::SSE_QUEUE_DATA; 197 | $response = $this->httpRaw('post', self::QUEUE_JOIN, [ 198 | 'data' => $payload, 199 | 'fn_index' => $endpoint->index, 200 | 'session_hash' => $this->sessionHash, 201 | ]); 202 | 203 | if ($response->getStatusCode() === 503) { 204 | throw new QueueFullException(); 205 | } 206 | 207 | if ($response->getStatusCode() !== 200) { 208 | throw new GradioException('Error joining the queue'); 209 | } 210 | } 211 | 212 | $params = ['session_hash' => $this->sessionHash]; 213 | if ($protocol === 'sse') { 214 | $params['fn_index'] = $endpoint->index; 215 | } 216 | 217 | $response = $this->httpRaw('get', $getEndpoint, $params, [ 218 | 'headers' => [ 219 | 'Accept' => 'text/event-stream', 220 | ], 221 | 'stream' => true, 222 | ]); 223 | 224 | $buffer = ''; 225 | $message = null; 226 | while (! $response->getBody()->eof()) { 227 | $data = $response->getBody()->read(1); 228 | if ($data !== "\n") { 229 | $buffer .= $data; 230 | 231 | continue; 232 | } 233 | 234 | // read second \n 235 | $response->getBody()->read(1); 236 | 237 | // remove data: 238 | $buffer = str_replace('data: ', '', $buffer); 239 | $message = $this->hydrator->hydrateWithJson(Message::class, $buffer); 240 | 241 | if ($message instanceof SendData && $protocol === 'sse') { 242 | $sendData = $this->httpRaw('post', self::SSE_QUEUE_DATA, [ 243 | 'data' => $payload, 244 | 'fn_index' => $endpoint->index, 245 | 'session_hash' => $this->sessionHash, 246 | 'event_id' => $message->event_id, 247 | 'event_data' => $message?->event_data, 248 | 'trigger_id' => $triggerId, 249 | ]); 250 | if ($sendData->getStatusCode() !== 200) { 251 | throw new GradioException('Error sending data'); 252 | } 253 | $buffer = ''; 254 | 255 | continue; 256 | } 257 | 258 | if ($message instanceof ProcessCompleted) { 259 | if (in_array($protocol, ['sse_v2', 'sse_v2.1'], true)) { 260 | $response->getBody()->close(); 261 | } 262 | 263 | $this->fireEvent(Event::PROCESS_COMPLETED, [$message]); 264 | if ($message->success) { 265 | $this->fireEvent(Event::PROCESS_SUCCESS, [$message]); 266 | } else { 267 | $this->fireEvent(Event::PROCESS_FAILED, [$message]); 268 | } 269 | break; 270 | } elseif ($message instanceof ProcessStarts) { 271 | $this->fireEvent(Event::PROCESS_STARTS, [$message]); 272 | } elseif ($message instanceof ProcessGenerating) { 273 | $this->fireEvent(Event::PROCESS_GENERATING, [$message]); 274 | } elseif ($message instanceof Estimation) { 275 | $this->fireEvent(Event::QUEUE_ESTIMATION, [$message]); 276 | } 277 | 278 | $buffer = ''; 279 | } 280 | 281 | return $message?->output; 282 | } 283 | } 284 | --------------------------------------------------------------------------------