SDK PHP (Laravel و openai-php)
پکیج openai-php/client و openai-php/laravel با یوتاپن: نصب، ساخت کلاینت، chat، استریم، tool calling و مدیریت خطا با کد قابل اجرا.
بهروزرسانی: ۱۶ شهریور ۱۴۰۵
پکیج openai-php/client (نمونهها با نسخهٔ ۰٫۲۰ تست شده) با تغییر withBaseUri به یوتاپن وصل میشود. برای Laravel همان پکیج از طریق openai-php/laravel در دسترس است و فقط OPENAI_BASE_URL در .env عوض میشود.
نصب و کلاینت
composer require openai-php/client guzzlehttp/guzzle
<?php
require 'vendor/autoload.php';
$client = OpenAI::factory()
->withBaseUri('https://api.uttapen.ir/v1')
->withApiKey(getenv('UTTAPEN_API_KEY')) // sk-up-...
->make();
Laravel: بعد از composer require openai-php/laravel و php artisan openai:install، در .env:
OPENAI_API_KEY=sk-up-...
OPENAI_BASE_URL=https://api.uttapen.ir/v1
و در کد OpenAI::chat()->create([...]) با facade. اگر OPENAI_BASE_URL در config/openai.php نبود (نسخههای قدیمی)، کلید base_uri را دستی اضافه کن.
Chat
$result = $client->chat()->create([
'model' => 'openai/gpt-5-mini',
'messages' => [
['role' => 'system', 'content' => 'پاسخ کوتاه و فارسی بده.'],
['role' => 'user', 'content' => 'تفاوت Eloquent و Query Builder چیست؟'],
],
'max_tokens' => 300,
]);
echo $result->choices[0]->message->content, PHP_EOL;
echo $result->usage->promptTokens, '/', $result->usage->completionTokens, PHP_EOL;
// هدرهای هزینه
$headers = $result->meta()->toArray()['custom'];
echo $headers['x-uttapen-cost-toman'], ' تومان — موجودی ', $headers['x-uttapen-balance-toman'], PHP_EOL;
این کلاینت فیلدهای ناشناخته را دور میریزد؛ هر چیزی خارج از قرارداد استاندارد OpenAI را از هدرهای meta() بخوان، نه از بدنه.
استریم
$stream = $client->chat()->createStreamed([
'model' => 'openai/gpt-5-mini',
'messages' => [['role' => 'user', 'content' => 'یک کلاس Validator برای کد ملی بنویس.']],
]);
foreach ($stream as $response) {
echo $response->choices[0]->delta->content ?? '';
flush();
}
هدر X-Uttapen-Include-Meta را با createStreamed نفرست. parser این کلاینت انتظار دارد هر چانک choices داشته باشد و روی رویداد uttapen.meta با TypeError میشکند. اگر هزینهٔ تومانی استریم را لازم داری، استریم را با Guzzle خام بخوان (نمونهٔ پایین) یا بعداً از GET /v1/uttapen/usage بگیر.
استریم خام با Guzzle، شامل uttapen.meta:
$http = new \GuzzleHttp\Client(['base_uri' => 'https://api.uttapen.ir/v1/']);
$res = $http->post('chat/completions', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('UTTAPEN_API_KEY'),
'X-Uttapen-Include-Meta' => '1',
],
'json' => ['model' => 'openai/gpt-5-mini', 'messages' => [['role' => 'user', 'content' => 'سلام']], 'stream' => true],
'stream' => true,
]);
$body = $res->getBody();
$buffer = '';
while (!$body->eof()) {
$buffer .= $body->read(1024);
while (($pos = strpos($buffer, "\n\n")) !== false) {
$event = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + 2);
if (!str_starts_with($event, 'data: ')) continue;
$payload = substr($event, 6);
if ($payload === '[DONE]') break 2;
$json = json_decode($payload, true);
if (($json['object'] ?? '') === 'uttapen.meta') {
echo "\n[هزینه: {$json['cost_toman']} تومان]\n";
} else {
echo $json['choices'][0]['delta']['content'] ?? '';
}
}
}
برای رله به مرورگر در Laravel، همین حلقه را داخل response()->stream() بگذار (نمونه).
Tool calling
$tools = [[
'type' => 'function',
'function' => [
'name' => 'get_weather',
'description' => 'دمای فعلی یک شهر',
'parameters' => ['type' => 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']],
],
]];
$messages = [['role' => 'user', 'content' => 'هوای تهران؟']];
$r1 = $client->chat()->create(['model' => 'openai/gpt-5-mini', 'messages' => $messages, 'tools' => $tools]);
$msg = $r1->choices[0]->message;
$messages[] = $msg->toArray();
foreach ($msg->toolCalls as $call) {
$args = json_decode($call->function->arguments, true);
$messages[] = [
'role' => 'tool',
'tool_call_id' => $call->id,
'content' => json_encode(['city' => $args['city'], 'temp_c' => 31], JSON_UNESCAPED_UNICODE),
];
}
$r2 = $client->chat()->create(['model' => 'openai/gpt-5-mini', 'messages' => $messages, 'tools' => $tools]);
echo $r2->choices[0]->message->content;
$msg->toArray() پیام assistant را با tool_calls به شکل درست در تاریخچه میگذارد. جزئیات در Tool calling.
Embeddings
$emb = $client->embeddings()->create([
'model' => 'openai/text-embedding-3-small',
'input' => ['قرارداد اجاره', 'اجارهنامهٔ ملک مسکونی'],
]);
foreach ($emb->embeddings as $item) {
echo $item->index, ': ', count($item->embedding), ' بعد', PHP_EOL;
}
مدیریت خطا
use OpenAI\Exceptions\ErrorException;
use OpenAI\Exceptions\TransporterException;
try {
$result = $client->chat()->create(['model' => 'openai/gpt-5-mini', 'messages' => $messages]);
} catch (ErrorException $e) {
match ($e->getStatusCode()) {
401 => Log::error('کلید uttapen نامعتبر است'),
402 => Log::warning('موجودی کافی نیست: ' . $e->getMessage()),
429 => sleep(5),
default => Log::error("{$e->getStatusCode()} {$e->getErrorCode()}: {$e->getMessage()}"),
};
} catch (TransporterException $e) {
Log::error('اتصال به uttapen برقرار نشد: ' . $e->getMessage());
}
getErrorCode() همان error.code ماست. بلوک uttapen (مثل required_toman) در این exception نگه داشته نمیشود؛ اگر لازمش داری، درخواست را با Guzzle بزن و json_decode بدنهٔ خطا را بخوان. جدول کامل در خطاها.
نکتههای PHP
JSON_UNESCAPED_UNICODEرا درjson_encodeبگذار تا محتوای فارسی ابزارها خوانا و کمتوکن بماند.max_execution_timeرا برای استریمهای طولانی بالا ببر یا صفر کن (set_time_limit(0))؛ پیشفرض ۳۰ ثانیه برای مدلهای استدلالی کم است.mb_*را برای طول و برش متن فارسی استفاده کن نهstrlen/substr.- در Laravel، کلید را در
config/services.phpازenv()بخوان و در.env.exampleمقدار خالی بگذار؛php artisan config:cacheرا فراموش نکن.
درخواستهای طولانی در Laravel
درخواست به مدلهای استدلالی یا با سند بلند میتواند بیش از یک دقیقه طول بکشد؛ آن را داخل چرخهٔ HTTP معمولی PHP-FPM اجرا نکن. یک Job صف بساز، timeout آن را بالا بگذار و نتیجه را با شناسهٔ درخواست ذخیره کن تا فرانتاند با polling یا broadcast بگیرد.
class SummarizeDocument implements ShouldQueue
{
public int $timeout = 300;
public int $tries = 2;
public function handle(): void
{
$result = OpenAI::chat()->create([
'model' => 'anthropic/claude-sonnet-4.5',
'messages' => [['role' => 'user', 'content' => $this->text]],
'max_tokens' => 1500,
]);
$requestId = $result->meta()->toArray()['custom']['x-uttapen-request-id'] ?? null;
$this->document->update(['summary' => $result->choices[0]->message->content, 'llm_request_id' => $requestId]);
}
}
tries را بیش از ۲ نگذار و در failed() خطای 402 را به ادمین اطلاع بده؛ تکرار خودکار روی کمبود موجودی فقط صف را پر میکند. timeout خود کلاینت را هم با Guzzle سفارشی تنظیم کن: OpenAI::factory()->withHttpClient(new \GuzzleHttp\Client(['timeout' => 180])).
کش پاسخهای تکراری
اگر prompt یکسان بارها فرستاده میشود (توضیح محصول، ترجمهٔ برچسبها)، پاسخ را با Cache::remember و کلیدی از hash مدل و پیامها نگه دار. سادهترین صرفهجویی است و روی کیفیت اثر ندارد. برای پرامپتهای بلند با پیشوند ثابت (system prompt چند هزار توکنی)، بعضی مدلها توکنهای کششده را ارزانتر حساب میکنند؛ پیشوند ثابت را اول و بخش متغیر را آخر بگذار تا از آن استفاده شود. مقدار cached_tokens در usage->toArray()['prompt_tokens_details'] نشان میدهد چقدر از کش استفاده شده.