The Laravel AI SDK (laravel/ai) is Laravel's official package for agents, text generation and other AI features. It has an openai-compatible driver that talks to any server with an OpenAI-style /chat/completions endpoint, which is what Tokens is. You declare a provider in config/ai.php with the Tokens URL and key, then pick it by name when you call an agent.
If you only want the plain OpenAI client for PHP, without Laravel's agent layer, see PHP.
What was checked
Based on the Laravel AI SDK documentation for Laravel 13.x and 12.x (laravel.com/docs, checked October 2026) and on the laravel/ai 1.2.0 source (released 7 October 2026). The 12.x page does not describe the openai-compatible driver, so that part comes from the 13.x docs and the package source. The code was checked against the documentation and source, not run end to end against Tokens.
What you need#
- PHP 8.3 or newer and Laravel 12 or 13.
laravel/ai1.2.0 requiresphp ^8.3andilluminate/* ^12.0|^13.0. - A Tokens key from API keys.
- A model id from /models. Use one that supports tool calling if your agents use tools (Choosing a model).
Install#
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrateThe publish step creates config/ai.php and a migration. The migration creates the agent_conversations and agent_conversation_messages tables that the SDK uses to store conversations. Run it even if you do not plan to use conversation memory yet.
If you already had config/ai.php from an older release, run composer update laravel/ai and compare your file with the current published one. The openai-compatible entry may be missing from an old copy, and you can add it by hand as shown next.
Configure the Tokens provider#
Add a provider to the providers array in config/ai.php. The name is yours to choose; this page uses tokens.
return [
'default' => 'tokens',
// ...
'providers' => [
// ... the providers that ship in the file stay as they are
'tokens' => [
'driver' => 'openai-compatible',
'url' => env('TOKENS_BASE_URL', 'https://tokens.bd/v1'),
'key' => env('TOKENS_API_KEY'),
'models' => [
'text' => [
'default' => env('TOKENS_MODEL', 'deepseek/deepseek-v4.1-flash'),
],
],
],
],
];TOKENS_API_KEY=tok_live_your_keyHow each option works:
urlis required. Usehttps://tokens.bd/v1including/v1. The driver removes a trailing slash and posts tochat/completionsunder it, so the request goes tohttps://tokens.bd/v1/chat/completions.keyis optional in the SDK and sent as a bearer token when present. For Tokens it is required.models.text.defaultis the model used when a call does not name one. Without it, a call that does not passmodel:throws anInvalidArgumentExceptionthat says the provider needs a default text model.'default' => 'tokens'makes this provider the default for text. If you would rather leave the default alone, skip it and passprovider: 'tokens'on each call, as the examples below do.
Keep the key out of config files
Read the key with env() as above and keep .env out of version control. After you change .env or config/ai.php on a server that caches config, run php artisan config:clear (or config:cache again).
Laravel 12 and 13: the variable names differ, the config does not#
The variable names in the Laravel docs differ between versions, and they only matter for the built-in openai provider:
| Docs | Variable for a custom OpenAI URL | openai-compatible provider |
|---|---|---|
| Laravel 13.x | OPENAI_URL | Documented. Env names in the shipped config/ai.php: OPENAI_COMPATIBLE_URL, OPENAI_COMPATIBLE_API_KEY. |
| Laravel 12.x | OPENAI_BASE_URL | Not described on the page. The driver is in the laravel/ai package, which supports Laravel 12 and 13. |
A variable name is only whatever your config/ai.php passes to env(). The tokens entry above uses its own names, so it works on both versions. If you prefer the entry that already ships in the file, set OPENAI_COMPATIBLE_URL=https://tokens.bd/v1 and OPENAI_COMPATIBLE_API_KEY in .env and add models.text.default to that entry.
Why not the built-in openai provider#
The built-in openai provider also accepts a custom url. In the package source it sends requests to {url}/responses, the OpenAI Responses API, while the openai-compatible driver sends them to {url}/chat/completions. Tokens serves both (Chat Completions, Responses). This page uses openai-compatible because it is the driver the docs describe for third-party gateways and it sends the Chat Completions format, which is the most widely supported one.
Make a first call#
An anonymous agent needs no class. Save this as a route or run it in php artisan tinker:
use function Laravel\Ai\agent;
$response = agent(
instructions: 'Answer in one short paragraph.',
)->prompt(
'When should I use a queue instead of running code in the request?',
provider: 'tokens',
model: 'deepseek/deepseek-v4.1-flash',
);
echo $response->text;$response->text is the reply. (string) $response gives the same text. provider accepts a plain string for a provider you named in config/ai.php. You can drop model: if models.text.default is set.
Check that it works#
First list the models the key can use, outside Laravel:
curl -s https://tokens.bd/v1/models -H "Authorization: Bearer $TOKENS_API_KEY"Then, inside the app:
php artisan tinker\Laravel\Ai\agent(instructions: 'Reply with one word.')->prompt('Say ready.', provider: 'tokens')->textA short reply means the URL, key and model are right. A 404 model_not_found means the id is wrong; a 401 means the key did not arrive. See Troubleshooting below.
An agent class#
For anything reused, generate an agent class and pin its provider and model with attributes:
php artisan make:agent ReviewCoachnamespace App\Ai\Agents;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Stringable;
#[Provider('tokens')]
#[Model('deepseek/deepseek-v4.1-flash')]
#[Timeout(120)]
class ReviewCoach implements Agent
{
use Promptable;
public function instructions(): Stringable|string
{
return 'You review pull request descriptions and suggest one improvement.';
}
}$response = (new \App\Ai\Agents\ReviewCoach)->prompt('Fix login redirect');
echo $response->text;The Provider, Model and Timeout attributes are in the Laravel docs' agent configuration section. Provider accepts a Lab enum value, a string or an array.
Stream responses#
stream() returns a StreamableAgentResponse. Returning it from a route sends it to the browser as server-sent events:
use function Laravel\Ai\agent;
Route::get('/ask', function () {
return agent(instructions: 'Answer briefly.')
->stream('Explain job batching in Laravel.', provider: 'tokens');
});To handle the events yourself, for example in an Artisan command, loop over the stream:
use Laravel\Ai\Streaming\Events\Error;
use Laravel\Ai\Streaming\Events\TextDelta;
$stream = agent(instructions: 'Answer briefly.')
->stream('Explain job batching in Laravel.', provider: 'tokens');
foreach ($stream as $event) {
if ($event instanceof TextDelta) {
echo $event->delta;
} elseif ($event instanceof Error) {
// $event->type holds the error code, $event->message the text
fwrite(STDERR, "{$event->type}: {$event->message}\n");
}
}The Laravel docs also describe a then() callback that runs when the whole response has been streamed; its StreamedAgentResponse carries text, events and usage. The driver asks Tokens for stream_options.include_usage, so token counts arrive at the end of the stream. See Streaming for the wire format.
Tools#
Tools are classes with a description, a schema and a handle method. Generate one:
php artisan make:tool GetWeathernamespace App\Ai\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class GetWeather implements Tool
{
public function description(): Stringable|string
{
return 'Current weather for a city.';
}
public function handle(Request $request): Stringable|string
{
return $request['city'].': light rain, 29C'; // call your real weather source here
}
public function schema(JsonSchema $schema): array
{
return [
'city' => $schema->string()->required(),
];
}
}Pass it to an anonymous agent, or return it from the tools() method of an agent class:
use App\Ai\Tools\GetWeather;
use function Laravel\Ai\agent;
$response = agent(
instructions: 'Use the weather tool when asked about weather.',
tools: [new GetWeather],
)->prompt('Is it raining in Dhaka?', provider: 'tokens');
echo $response->text;The SDK sends the tool definitions in the Chat Completions format and runs the loop for you. Tool calling only works on models that support it, so check the model page in /models and read Tool calling.
Timeouts#
The agent timeout defaults to 60 seconds. Raise it per call, per class, or both:
$response = $agent->prompt('...', provider: 'tokens', timeout: 180);For a class, use #[Timeout(180)]. The value is the HTTP client timeout in seconds for the request. In Laravel's HTTP client it is Guzzle's timeout option, which Guzzle describes as the total time for the request. A long streamed answer can therefore be cut at that limit, so set it above your longest expected generation.
Other limits to know:
- The gateway waits up to 600 seconds for response headers from the upstream, so long generations are fine on its side.
- PHP itself can stop a web request first. Check
max_execution_timeinphp.ini(the PHP default for web requests is 30 seconds), or run long jobs in a queue.
Handle errors#
The SDK maps some HTTP statuses to its own exceptions. For Tokens:
| Status | Exception | Tokens codes |
|---|---|---|
| 402 | Laravel\Ai\Exceptions\InsufficientCreditsException | insufficient_credits, no_funding, outstanding_debt, member_cap_reached |
| 429 | Laravel\Ai\Exceptions\RateLimitedException | rate_limited, concurrency_limit, window_exhausted, model_limit_reached, rate_limit_exceeded |
| 502, 503, 504 | Laravel\Ai\Exceptions\ProviderOverloadedException | upstream_unreachable, no_upstream_available, upstream_timeout |
| connection failure | Laravel\Ai\Exceptions\ProviderConnectionException | none; the request never reached Tokens |
| other 4xx and 5xx | Illuminate\Http\Client\RequestException | invalid_api_key, model_not_found, model_not_allowed_on_key and the rest |
The Tokens code is in the response body. The SDK exceptions keep the original error as the previous exception:
use Illuminate\Http\Client\RequestException;
use Laravel\Ai\Exceptions\AiException;
try {
$text = agent(instructions: 'Say hi.')->prompt('hi', provider: 'tokens')->text;
} catch (AiException|RequestException $e) {
$request = $e instanceof RequestException ? $e : $e->getPrevious();
$response = $request instanceof RequestException ? $request->response : null;
logger()->warning('Tokens call failed', [
'status' => $response?->status(),
'code' => $response?->json('error.code'),
'request_id' => $response?->header('x-tokens-request-id'),
]);
throw $e;
}Log x-tokens-request-id with every failure. Support can trace a request from it (Errors). Retrying 429 window_exhausted does not help until the window resets; Retry-After says when.
What the openai-compatible driver covers#
In the package source the driver implements text generation (with streaming and tools), embeddings and transcription. Image generation and text-to-speech go through other providers in the SDK, not this one. Embeddings need models.embeddings.default in the provider config; Tokens serves embeddings (Embeddings), but this page does not walk through that setup.
Troubleshooting#
| Symptom | Cause and fix |
|---|---|
The [tokens] openai-compatible provider requires a 'url' to be configured | url is empty. Check the tokens entry and clear the config cache. |
... requires a default text model | Add models.text.default, or pass model: on the call. |
404 model_not_found | The model id is wrong. Copy it from /models. |
404 on every call, error mentions a path | The URL is missing /v1. Use https://tokens.bd/v1. |
401 missing_api_key or invalid_api_key | TOKENS_API_KEY is empty in the running process, often because config is cached. Run php artisan config:clear. |
RateLimitedException | Read error.code as shown above. rate_limited and concurrency_limit clear after Retry-After; window_exhausted clears at the plan reset. |
InsufficientCreditsException | Top up in billing. |
cURL error 28: Operation timed out | The timeout value is lower than the response time. Raise it. |
| Streamed text stops partway | The total timeout was reached, or PHP's max_execution_time ended the request. Raise both. |
Every Tokens error code is in Errors, and Troubleshooting has the general fixes.