استریم پاسخ (SSE)
دریافت پاسخ مدل بهصورت زنده با stream=true، فرمت رویدادهای SSE، رویداد اختیاری uttapen.meta با هزینهٔ تومانی، مدیریت قطع اتصال و proxy در Next.js و Laravel.
بهروزرسانی: ۱۶ شهریور ۱۴۰۵
با stream: true پاسخ مدل بهجای یک JSON در پایان، تکهتکه و همان لحظهٔ تولید به تو میرسد. برای رابط کاربری چت، ابزارهای CLI و هر جایی که زمان تا اولین توکن مهم است، این حالت پیشفرض خوبی است. gateway هیچ بافری بین provider و تو نمیگذارد؛ هر رویداد که رسید، همان لحظه flush میشود.
فرمت رویدادها
پاسخ Content-Type: text/event-stream است و هر رویداد یک خط data: با JSON از نوع chat.completion.chunk دارد. آخرین چانک قبل از [DONE] بلوک usage (تعداد توکنها) را حمل میکند. یک خط : processing هم اول جریان میآید که فقط keep-alive است و SDKها نادیدهاش میگیرند.
curl -N https://api.uttapen.ir/v1/chat/completions \
-H "Authorization: Bearer $UTTAPEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"openai/gpt-5-mini","messages":[{"role":"user","content":"سلام"}],"stream":true}'
: processing
data: {"id":"gen-2ebb2e50a57ff83a","object":"chat.completion.chunk","model":"openai/gpt-5-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"سلام"},"finish_reason":null}]}
data: {"id":"gen-2ebb2e50a57ff83a","object":"chat.completion.chunk","model":"openai/gpt-5-mini","choices":[{"index":0,"delta":{"content":"، چطور"},"finish_reason":null}]}
data: {"id":"gen-2ebb2e50a57ff83a","object":"chat.completion.chunk","model":"openai/gpt-5-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":24,"total_tokens":34}}
data: [DONE]
-N در curl بافر خروجی را خاموش میکند؛ بدون آن فکر میکنی استریم کار نمیکند.
رویداد uttapen.meta
فرمت بالا دقیقاً فرمت OpenAI است تا هیچ SDK سختگیری نشکند. اگر هزینهٔ تومانی همین پاسخ را میخواهی، هدر X-Uttapen-Include-Meta: 1 بفرست. آن وقت یک رویداد اضافه، درست قبل از [DONE]، میآید:
data: {"id":"gen-2ebb2e50a57ff83a","object":"uttapen.meta","cost_toman":"6.659688","balance_toman":"97557.754121","hold_toman":"100.000000"}
cost_toman— مبلغی که از کیف پول کم شد (تا ۶ رقم اعشار).balance_toman— موجودی بعد از تسویه.hold_toman— مبلغی که قبل از ارسال رزرو شده بود؛ برای تنظیمmax_tokensمفید است.
این رویداد choices ندارد. SDKهای Python، Node و Go آن را بهعنوان چانک بدون choices قبول میکنند و میتوانی با object == "uttapen.meta" جدایش کنی. SDK PHP (openai-php/client) روی آن خطا میدهد؛ برای PHP هدر را فقط با Guzzle خام بفرست (جزئیات).
Python
from openai import OpenAI
client = OpenAI(base_url="https://api.uttapen.ir/v1", api_key="sk-up-...")
stream = client.chat.completions.create(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "یک شعر کوتاه دربارهٔ Postgres بگو."}],
stream=True,
extra_headers={"X-Uttapen-Include-Meta": "1"},
)
for chunk in stream:
if chunk.object == "uttapen.meta":
print("\nهزینه:", chunk.model_extra["cost_toman"], "تومان")
continue
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
if chunk.usage:
print("\nتوکن خروجی:", chunk.usage.completion_tokens)
Node.js
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api.uttapen.ir/v1", apiKey: process.env.UTTAPEN_API_KEY });
const stream = await client.chat.completions.create(
{
model: "openai/gpt-5-mini",
messages: [{ role: "user", content: "یک شعر کوتاه دربارهٔ Postgres بگو." }],
stream: true,
},
{ headers: { "X-Uttapen-Include-Meta": "1" } },
);
for await (const chunk of stream) {
if (chunk.object === "uttapen.meta") {
console.log("\nهزینه:", chunk.cost_toman, "تومان");
continue;
}
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
برای لغو استریم از سمت کلاینت، AbortController را به آپشنها بده (signal) یا در Python روی stream متد close() را صدا بزن.
قطع اتصال و هزینه
وقتی کلاینت وسط استریم قطع میکند، gateway درخواست upstream را همان لحظه لغو میکند تا توکن بیشتری تولید نشود. اما توکنهایی که تا آن لحظه تولید شده واقعاً هزینه داشتهاند. رفتار ما:
- اگر شناسهٔ generation را داریم (با اولین چانک میرسد)، درخواست به حالت
reconcilingمیرود و یک job پسزمینه هزینهٔ واقعی را از provider میپرسد و همان را تسویه میکند. این معمولاً کمتر از یک دقیقه طول میکشد. - اگر هنوز هیچ بایتی نرسیده بود، رزرو کامل آزاد میشود و چیزی شارژ نمیشود.
پس «قطع کردن» راهی برای فرار از هزینه نیست، ولی هرگز بیشتر از مصرف واقعی هم شارژ نمیشوی. تا وقتی تسویه انجام نشده، مبلغ رزرو در held_toman میماند و از موجودی قابلاستفاده کم است.
اگر upstream ۱۲۰ ثانیه هیچ بایتی نفرستد، اتصال با 504 upstream_timeout بسته میشود و همان مسیر reconcile اجرا میشود. با مدلهای استدلالی که فکر کردنشان طولانی است، stream: true بگذار تا این watchdog با رسیدن چانکهای reasoning ریست شود.
استریم همزمان
هر کاربر حداکثر ۱۰ استریم باز همزمان دارد؛ یازدهمی 429 too_many_concurrent_streams میگیرد. اگر اپ چت چندکاربره داری، این عدد برای بکاند تو است نه کاربران نهاییات؛ صف کوچکی جلوی gateway بگذار یا در محدودیت نرخ راههای دیگر را ببین.
Proxy استریم به مرورگر
کلید API نباید به مرورگر برسد. سرور تو استریم را از یوتاپن میگیرد و به مرورگر رله میکند.
Next.js (route handler):
// app/api/chat/route.ts
export const runtime = "nodejs";
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch("https://api.uttapen.ir/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UTTAPEN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "openai/gpt-5-mini", messages, stream: true }),
});
return new Response(upstream.body, {
status: upstream.status,
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" },
});
}
اگر از Vercel AI SDK استفاده میکنی، بهجای رلهٔ دستی createOpenAICompatible را ببین (یکپارچهسازیها).
Laravel (StreamedResponse با Guzzle):
Route::post('/chat', function (Request $request) {
return response()->stream(function () use ($request) {
$res = (new \GuzzleHttp\Client())->post('https://api.uttapen.ir/v1/chat/completions', [
'headers' => ['Authorization' => 'Bearer ' . config('services.uttapen.key')],
'json' => ['model' => 'openai/gpt-5-mini', 'messages' => $request->input('messages'), 'stream' => true],
'stream' => true,
]);
$body = $res->getBody();
while (!$body->eof()) {
echo $body->read(1024);
if (ob_get_level() > 0) ob_flush();
flush();
}
}, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no']);
});
در هر دو حالت، روی nginx خودت proxy_buffering off; بگذار وگرنه مرورگر همهچیز را یکجا در پایان میبیند. X-Accel-Buffering: no همین کار را برای هر مسیر میکند.