uttapen

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'] نشان می‌دهد چقدر از کش استفاده شده.