DEV Community

Aman Kumar
Aman Kumar

Posted on

Calling an OpenAI-Compatible API From PHP and Laravel: openai-php/client Base URI, Timeouts, Streaming and Errors

I'm Aman Kumar. If you write PHP, the usual way to talk to an LLM is openai-php/client, plus openai-php/laravel if you are on Laravel. Both work fine against any OpenAI-compatible server (vLLM, LiteLLM, Ollama's /v1 endpoint, a hosted gateway), but a few defaults behave differently from the Python and Node SDKs, and they fail in ways that look like "the model is wrong".

I tested everything below with openai-php/client 0.21.0 on PHP 8.4, once with Guzzle 7.15.5 and once with Guzzle 8.2.0, against a local mock server that only implements /v1/chat/completions and /v1/models. For the Laravel parts I read the current source of openai-php/laravel (0.21.0 on Packagist), specifically its config file and service provider, rather than guessing. The examples point at APIClaw, an OpenAI-compatible gateway I build, so weigh my opinions accordingly. The base URL is https://apiclaw.biz/v1. I use YOUR_MODEL_ID below; get real IDs from /v1/models or your provider's model list.

The minimal client

composer require openai-php/client guzzlehttp/guzzle
Enter fullscreen mode Exit fullscreen mode
<?php
require 'vendor/autoload.php';

$client = OpenAI::factory()
    ->withApiKey(getenv('LLM_API_KEY'))
    ->withBaseUri('https://apiclaw.biz/v1')   // no trailing slash
    ->withHttpClient(new \GuzzleHttp\Client([
        'timeout'         => 120,
        'connect_timeout' => 10,
    ]))
    ->make();

$response = $client->chat()->create([
    'model'    => 'YOUR_MODEL_ID',
    'messages' => [
        ['role' => 'user', 'content' => 'Say hello in one short sentence.'],
    ],
]);

echo $response->choices[0]->message->content, PHP_EOL;
echo $response->usage->totalTokens, " tokens", PHP_EOL;
Enter fullscreen mode Exit fullscreen mode

OpenAI::client($key) is the shortcut, but it always targets api.openai.com. For any other server you need OpenAI::factory().

The base URI rules (where most 404s come from)

The client builds the base URI with very little cleanup: it appends one /, and if the string has no http:// or https:// it adds https:// in front. Then it sends requests to relative paths like chat/completions. Here is what each form produced on my mock:

withBaseUri(...) Request path Result
https://host/v1 /v1/chat/completions Works
https://host/v1/ /v1//chat/completions 404
https://host /chat/completions 404
https://host/v1/chat/completions /v1/chat/completions/chat/completions 404
host/v1 https://host/v1/... Works for HTTPS hosts, TLS error on a plain-HTTP local server

The trailing slash is the one that catches people. The Python SDK tolerates it; this one does not, and the double slash is easy to miss in a log. Make sure you keep /v1 and drop the trailing slash. For a local vLLM or Ollama server, write http:// explicitly or you will get cURL error 35: wrong version number.

All of these show up as OpenAI\Exceptions\ErrorException with getStatusCode() returning 404 when the server sends a JSON error body. Before you suspect the model ID, print the base URI.

Timeouts: none by default

If you don't pass an HTTP client, the factory finds one through PSR-18 discovery (Guzzle, if installed) with no timeout configured. I sent a request to a deliberately slow endpoint without one, and it just waited. A gateway that hangs will tie up a PHP-FPM worker until max_execution_time or the web server kills it.

So always pass your own Guzzle client. With 'timeout' => 2 against a 5-second response, I got OpenAI\Exceptions\TransporterException: cURL error 28: Operation timed out after 2001 milliseconds.

Streaming works differently, though, and I explain why in the streaming section.

Errors map to four exception classes

This is the part of the library I like most. What I observed:

  • 429 throws OpenAI\Exceptions\RateLimitException. The raw response is on $e->response, so $e->response->getHeaderLine('Retry-After') works.
  • 5xx throws OpenAI\Exceptions\ServerException ("Server error (HTTP 502) occurred").
  • Other 4xx with a JSON error body (bad key, unknown model, 404 path) throws OpenAI\Exceptions\ErrorException with getStatusCode(), getErrorMessage(), getErrorType() and getErrorCode(). Note that getErrorCode() returned the string '400' from my mock because that is what the server put in code. Compare on the status code, not the error code.
  • Network failures and timeouts throw OpenAI\Exceptions\TransporterException.

There are no built-in retries. Here is the small wrapper I use. It respects Retry-After and otherwise backs off with jitter:

use OpenAI\Exceptions\RateLimitException;
use OpenAI\Exceptions\ServerException;
use OpenAI\Exceptions\TransporterException;

function withRetry(callable $fn, int $tries = 4): mixed
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $fn();
        } catch (RateLimitException | ServerException | TransporterException $e) {
            if ($attempt >= $tries) {
                throw $e;
            }
            $retryAfter = isset($e->response)
                ? (int) $e->response->getHeaderLine('Retry-After')
                : 0;
            $sleep = $retryAfter > 0
                ? $retryAfter
                : min(2 ** $attempt, 20) + random_int(0, 1000) / 1000;
            usleep((int) ($sleep * 1_000_000));
        }
    }
}

$response = withRetry(fn () => $client->chat()->create([
    'model'    => 'YOUR_MODEL_ID',
    'messages' => [['role' => 'user', 'content' => 'hi']],
]));
Enter fullscreen mode Exit fullscreen mode

I tested it against an endpoint that returned 429 twice with Retry-After: 2 and then succeeded. It slept 2 seconds twice and returned the third response. Don't retry ErrorException: a 401 or an unknown model won't fix itself.

Streaming

$stream = $client->chat()->createStreamed([
    'model'          => 'YOUR_MODEL_ID',
    'messages'       => [['role' => 'user', 'content' => 'Write a haiku about PHP.']],
    'stream_options' => ['include_usage' => true],
]);

foreach ($stream as $chunk) {
    if (isset($chunk->choices[0])) {
        echo $chunk->choices[0]->delta->content ?? '';
    }
    if ($chunk->usage) {
        $total = $chunk->usage->totalTokens;  // arrives on the last chunk
    }
}
Enter fullscreen mode Exit fullscreen mode

createStreamed() sets stream: true for you. With include_usage, the final chunk has an empty choices array, so check isset($chunk->choices[0]) before you read it.

What surprised me is that 'timeout' => 2 did not stop a stream that took 4 seconds to finish. That was true with both Guzzle 7 and Guzzle 8. The library sends streamed requests with Guzzle's stream option, so the timeout only covers the wait for response headers. To catch a stream that stalls halfway, add read_timeout, which limits how long a single read can wait:

new \GuzzleHttp\Client(['timeout' => 0, 'read_timeout' => 30, 'connect_timeout' => 10]);
Enter fullscreen mode Exit fullscreen mode

When read_timeout fires, the exception is thrown from inside the foreach. It is not an OpenAI exception. Guzzle 7 threw a plain RuntimeException("Unable to read from stream"), and Guzzle 8 threw GuzzleHttp\Psr7\Exception\TimeoutException, which extends RuntimeException. Wrap the loop in catch (\RuntimeException $e). Keep in mind that part of the answer may already be on screen, so don't blindly retry a half-sent stream to a user.

Laravel: what openai-php/laravel actually configures

composer require openai-php/laravel
php artisan openai:install
Enter fullscreen mode Exit fullscreen mode
OPENAI_API_KEY=your-key
OPENAI_BASE_URL=https://apiclaw.biz/v1
OPENAI_REQUEST_TIMEOUT=120
Enter fullscreen mode Exit fullscreen mode

According to the package source, the service provider builds the client from config('openai.*'). It passes base_uri straight to withBaseUri(), so the same trailing-slash rule applies. It also creates a Guzzle client with only ['timeout' => config('openai.request_timeout', 30)]. That gives you three practical points:

  1. The default is 30 seconds. Long completions from slower models can hit that, and you'll get a TransporterException with cURL error 28. Raise OPENAI_REQUEST_TIMEOUT for those routes.
  2. Streams get no read_timeout. Because of how streaming works (see above), the 30 seconds doesn't cap a stream, and nothing catches one that stalls.
  3. If you run config:cache, changes to .env don't apply until you cache the config again. I've seen people edit OPENAI_BASE_URL on a server and wonder why the requests still go to the old host.

If you need read_timeout, extra headers or a second provider, I find it simpler to bind your own client under a separate name rather than override the package's binding:

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton('llm', fn () => \OpenAI::factory()
        ->withApiKey(config('services.llm.key'))
        ->withBaseUri(config('services.llm.base_url'))
        ->withHttpClient(new \GuzzleHttp\Client([
            'timeout' => 120, 'read_timeout' => 30, 'connect_timeout' => 10,
        ]))
        ->make());
}

// anywhere
$reply = app('llm')->chat()->create([...]);
Enter fullscreen mode Exit fullscreen mode

Extra parameters and model IDs

The client passes the request array through unchanged. I sent top_k and max_tokens and the mock received both, so provider-specific parameters work as long as the server accepts them. To see which models your key can use:

foreach ($client->models()->list()->data as $model) {
    echo $model->id, PHP_EOL;
}
Enter fullscreen mode Exit fullscreen mode

Checklist

  1. Use OpenAI::factory(), not OpenAI::client().
  2. Set the base URI to https://host/v1, with no trailing slash and the scheme written out.
  3. Always pass a Guzzle client with timeout and connect_timeout.
  4. For streams, add read_timeout and catch \RuntimeException around the loop.
  5. Retry only RateLimitException, ServerException and TransporterException.
  6. On Laravel, raise OPENAI_REQUEST_TIMEOUT and run config:cache again after changing .env.

Disclosure: I build APIClaw, the OpenAI-compatible gateway in these examples (flat monthly plans; crypto on the site, other payment methods through support). Everything above applies to any compatible endpoint.

Top comments (0)