uttapen

SDK Node.js

استفاده از پکیج رسمی openai در Node.js و TypeScript با یوتاپن؛ نصب، کلاینت، chat با هدرهای هزینه، استریم و AbortController، tool calling، embeddings.

به‌روزرسانی: ۱۶ شهریور ۱۴۰۵

پکیج رسمی openai برای Node (نسخهٔ ۴ به بالا؛ نمونه‌ها با ۷ تست شده) بدون تغییر با یوتاپن کار می‌کند. در مرورگر استفاده نکن؛ کلید لو می‌رود. برای فرانت‌اند، یک route در سرور خودت بگذار (نمونهٔ Next.js).

نصب و کلاینت

npm install openai
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.uttapen.ir/v1",
  apiKey: process.env.UTTAPEN_API_KEY, // sk-up-...
});

گزینه‌های مفید: timeout (میلی‌ثانیه)، maxRetries (پیش‌فرض ۲)، defaultHeaders برای X-Uttapen-Include-Meta. متغیرهای محیطی OPENAI_BASE_URL و OPENAI_API_KEY هم خوانده می‌شوند.

Chat

const resp = await client.chat.completions.create({
  model: "openai/gpt-5-mini",
  messages: [
    { role: "system", content: "پاسخ کوتاه و فارسی بده." },
    { role: "user", content: "تفاوت map و forEach در JavaScript؟" },
  ],
  max_tokens: 300,
});
console.log(resp.choices[0].message.content);
console.log(resp.usage?.prompt_tokens, resp.usage?.completion_tokens);

برای هدرهای هزینه از .withResponse() استفاده کن:

const { data, response } = await client.chat.completions
  .create({ model: "openai/gpt-5-mini", messages: [{ role: "user", content: "سلام" }] })
  .withResponse();
console.log(response.headers.get("x-uttapen-cost-toman"), response.headers.get("x-uttapen-balance-toman"));
console.log(data.choices[0].message.content);

استریم

const controller = new AbortController();

const stream = await client.chat.completions.create(
  {
    model: "openai/gpt-5-mini",
    messages: [{ role: "user", content: "یک تابع اعتبارسنجی شماره موبایل ایران بنویس." }],
    stream: true,
  },
  { headers: { "X-Uttapen-Include-Meta": "1" }, signal: controller.signal },
);

for await (const chunk of stream) {
  if (chunk.object === "uttapen.meta") {
    console.log(`\n[هزینه: ${(chunk as any).cost_toman} تومان]`);
    continue;
  }
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}

controller.abort() استریم را از سمت تو قطع می‌کند؛ gateway درخواست upstream را لغو و فقط مصرف تا آن لحظه را تسویه می‌کند. در TypeScript فیلدهای uttapen.meta در تایپ ChatCompletionChunk نیستند، برای همین cast لازم است.

Tool calling

const tools: OpenAI.Chat.ChatCompletionTool[] = [{
  type: "function",
  function: {
    name: "get_weather",
    description: "دمای فعلی یک شهر",
    parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
  },
}];

const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "user", content: "هوای تهران؟" }];
const r1 = await client.chat.completions.create({ model: "openai/gpt-5-mini", messages, tools });
const msg = r1.choices[0].message;
messages.push(msg);
for (const call of msg.tool_calls ?? []) {
  if (call.type !== "function") continue;
  const args = JSON.parse(call.function.arguments);
  messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ city: args.city, temp_c: 31 }) });
}
const r2 = await client.chat.completions.create({ model: "openai/gpt-5-mini", messages, tools });
console.log(r2.choices[0].message.content);

اگر حلقهٔ خودکار می‌خواهی، client.chat.completions.runTools(...) در SDK همین کار را با تابع‌های JavaScript می‌کند. جزئیات و استریم tool_calls در Tool calling.

Embeddings

const emb = await client.embeddings.create({
  model: "openai/text-embedding-3-small",
  input: ["قرارداد اجاره", "اجاره‌نامهٔ ملک مسکونی"],
});
console.log(emb.data.length, emb.data[0].embedding.length, emb.usage.prompt_tokens);

خروجی ساخت‌یافته با Zod

import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";

const Ticket = z.object({ title: z.string(), priority: z.enum(["low", "medium", "high"]) });
const parsed = await client.chat.completions.parse({
  model: "openai/gpt-5-mini",
  messages: [{ role: "user", content: "سایت از دیشب بالا نمی‌آید، مشتری‌ها شاکی‌اند." }],
  response_format: zodResponseFormat(Ticket, "ticket"),
});
console.log(parsed.choices[0].message.parsed);

مدیریت خطا

try {
  await client.chat.completions.create({ model: "openai/gpt-5-mini", messages });
} catch (err) {
  if (err instanceof OpenAI.AuthenticationError) {
    console.error("کلید uttapen نامعتبر است");
  } else if (err instanceof OpenAI.RateLimitError) {
    const wait = Number(err.headers?.get("retry-after") ?? 5);
    await new Promise((r) => setTimeout(r, wait * 1000));
  } else if (err instanceof OpenAI.APIError) {
    if (err.status === 402) {
      const info = (err.error as any)?.uttapen;
      console.error("شارژ لازم:", info?.required_toman, "تومان", info?.topup_url);
    } else {
      console.error(err.status, err.code, err.message);
    }
  } else throw err;
}

err.code همان error.code ما و err.error کل بدنهٔ خطاست. err.headers.get("x-uttapen-request-id") شناسهٔ درخواست است (در نسخهٔ ۴ SDK، headers آبجکت ساده بود). کلاس‌ها: BadRequestError، AuthenticationError، PermissionDeniedError، NotFoundError، RateLimitError، InternalServerError، APIConnectionError؛ برای 402 کلاس عمومی APIError. جدول کامل در خطاها.

فیلدهای اضافی

SDK فیلدهای ناشناخته را عبور می‌دهد؛ در TypeScript فقط cast لازم است:

await client.chat.completions.create({
  model: "deepseek/deepseek-r1",
  messages,
  reasoning: { effort: "low" },
  models: ["openai/o3-mini"],
} as any);

استفاده در سرور (Express، Next.js، Bun)

کلاینت را در یک ماژول بساز و همه‌جا import کن؛ SDK روی fetch استاندارد کار می‌کند و در Node ۱۸ به بالا، Bun، Deno و Edge Runtime نکست بدون polyfill اجرا می‌شود. در Next.js، فراخوانی فقط در Route Handler یا Server Action باشد؛ NEXT_PUBLIC_ روی نام متغیر کلید نگذار وگرنه در باندل مرورگر می‌رود.

// lib/uttapen.ts
import OpenAI from "openai";

export const uttapen = new OpenAI({
  baseURL: "https://api.uttapen.ir/v1",
  apiKey: process.env.UTTAPEN_API_KEY,
  timeout: 120_000,
  maxRetries: 1,
});

برای پاسخ‌های طولانی، استریم را مستقیم به کلاینت رله کن (نمونهٔ Route Handler) یا کار را به یک صف (BullMQ) بده و نتیجه را با X-Uttapen-Request-Id ذخیره کن. در Express، res.flushHeaders() و نوشتن هر چانک با res.write کافی است؛ فشردگی (compression) را برای مسیر استریم خاموش کن چون بافر می‌کند.

اشتباه‌های رایج

  • OPENAI_API_KEY قدیمی. اگر apiKey را صریح ندهی، SDK متغیر محیطی OpenAI را برمی‌دارد و 401 می‌گیری.
  • Number() روی مبالغ. cost_toman و balance_toman رشته‌اند؛ برای جمع کردن از decimal.js استفاده کن یا در دیتابیس NUMERIC نگه دار.
  • حلقهٔ for await بدون try. خطای وسط استریم (502) به‌صورت exception از حلقه بیرون می‌آید؛ آن را بگیر و پیام کاربر را با متن ناقص ذخیره نکن.
  • JSON.parse روی آرگومان ابزار بدون try. مدل‌ها گاهی JSON ناقص می‌دهند؛ خطا را به‌عنوان پیام tool برگردان تا مدل اصلاح کند.
  • timeout پیش‌فرض ۱۰ دقیقه. برای درخواست‌های کوتاه timeout را کم کن تا یک اتصال گیرکرده worker را نگه ندارد.

تایپ رویداد uttapen.meta

تایپ ChatCompletionChunk فیلدهای cost_toman و balance_toman را ندارد. به‌جای as any در همه‌جا، یک type guard کوچک بنویس تا هم خوانایی حفظ شود هم اگر روزی فیلدی عوض شد، کامپایلر خبر بدهد:

type UttapenMeta = { object: "uttapen.meta"; cost_toman: string; balance_toman: string; hold_toman: string };

function isMeta(chunk: unknown): chunk is UttapenMeta {
  return typeof chunk === "object" && chunk !== null && (chunk as { object?: string }).object === "uttapen.meta";
}

for await (const chunk of stream) {
  if (isMeta(chunk)) { console.log(chunk.cost_toman); continue; }
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}

اگر این رویداد را لازم نداری، هدر X-Uttapen-Include-Meta را نفرست؛ آن وقت استریم دقیقاً تایپ رسمی SDK است و هیچ cast لازم نیست. هزینه را بعداً از داشبورد یا GET /v1/uttapen/usage بخوان.

Responses API

client.responses.create فعلاً 404 برمی‌گرداند؛ chat.completions را استفاده کن (migration).