uttapen

Node.js SDK

Official openai package for Node.js and TypeScript with uttapen: client, cost headers, streaming, AbortController, tool calling, embeddings, errors.

Updated: September 7, 2026

The official openai package for Node (version 4 or newer; these examples were tested with 7) works with uttapen unchanged. Do not use it in the browser — your key would leak. For a front end, put a route on your own server (Next.js example).

Install and client

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-...
});

Useful options: timeout (milliseconds), maxRetries (2 by default), and defaultHeaders for X-Uttapen-Include-Meta. The OPENAI_BASE_URL and OPENAI_API_KEY environment variables are read too.

Chat

const resp = await client.chat.completions.create({
  model: "openai/gpt-5-mini",
  messages: [
    { role: "system", content: "Answer briefly." },
    { role: "user", content: "What is the difference between map and forEach in JavaScript?" },
  ],
  max_tokens: 300,
});
console.log(resp.choices[0].message.content);
console.log(resp.usage?.prompt_tokens, resp.usage?.completion_tokens);

For the cost headers, use .withResponse():

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

Streaming

const controller = new AbortController();

const stream = await client.chat.completions.create(
  {
    model: "openai/gpt-5-mini",
    messages: [{ role: "user", content: "Write a function that validates an Iranian mobile number." }],
    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[cost: ${(chunk as any).cost_toman} toman]`);
    continue;
  }
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}

controller.abort() tears down the stream from your side; the gateway cancels the upstream request and settles only what was consumed up to that moment. In TypeScript the uttapen.meta fields are not part of the ChatCompletionChunk type, hence the cast.

Tool calling

const tools: OpenAI.Chat.ChatCompletionTool[] = [{
  type: "function",
  function: {
    name: "get_weather",
    description: "Current temperature of a city",
    parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
  },
}];

const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "user", content: "Weather in Tehran?" }];
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);

If you want the loop handled for you, client.chat.completions.runTools(...) in the SDK does exactly this with plain JavaScript functions. Details, plus streaming tool_calls, are in Tool calling.

Embeddings

const emb = await client.embeddings.create({
  model: "openai/text-embedding-3-small",
  input: ["lease agreement", "residential property rental contract"],
});
console.log(emb.data.length, emb.data[0].embedding.length, emb.usage.prompt_tokens);

Structured output with 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: "The site has been down since last night and customers are complaining." }],
  response_format: zodResponseFormat(Ticket, "ticket"),
});
console.log(parsed.choices[0].message.parsed);

Error handling

try {
  await client.chat.completions.create({ model: "openai/gpt-5-mini", messages });
} catch (err) {
  if (err instanceof OpenAI.AuthenticationError) {
    console.error("uttapen API key is invalid");
  } 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("top-up needed:", info?.required_toman, "toman", info?.topup_url);
    } else {
      console.error(err.status, err.code, err.message);
    }
  } else throw err;
}

err.code is our error.code and err.error is the whole error body. err.headers.get("x-uttapen-request-id") gives you the request id (in SDK v4, headers was a plain object). The classes are BadRequestError, AuthenticationError, PermissionDeniedError, NotFoundError, RateLimitError, InternalServerError, APIConnectionError; a 402 arrives as the generic APIError. The full table is in Errors.

Extra fields

The SDK passes unknown fields straight through; in TypeScript all you need is a cast:

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

On a server (Express, Next.js, Bun)

Create the client in one module and import it everywhere; the SDK runs on standard fetch, so it works on Node 18+, Bun, Deno and the Next.js Edge Runtime with no polyfill. In Next.js, only call it from a Route Handler or a Server Action, and never prefix the key's variable name with NEXT_PUBLIC_ or it ends up in the browser bundle.

// 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,
});

For long answers, relay the stream straight to the client (Route Handler example) or push the job onto a queue (BullMQ) and store the result against X-Uttapen-Request-Id. In Express, res.flushHeaders() plus a res.write per chunk is all it takes; turn compression off on the streaming route, because it buffers.

Common mistakes

  • A stale OPENAI_API_KEY. If you do not pass apiKey explicitly, the SDK picks up the OpenAI environment variable and you get a 401.
  • Number() on amounts. cost_toman and balance_toman are strings; add them up with decimal.js, or keep them in a NUMERIC column.
  • A for await loop with no try. An error mid-stream (502) leaves the loop as an exception; catch it, and do not store the user-facing message with a truncated body.
  • JSON.parse on tool arguments with no try. Models sometimes emit malformed JSON; return the error back as a tool message so the model can correct itself.
  • The 10-minute default timeout. Lower timeout for short requests so one stuck connection does not tie up a worker.

Typing the uttapen.meta event

The ChatCompletionChunk type has no cost_toman or balance_toman. Rather than sprinkling as any everywhere, write a small type guard — it keeps the code readable and, if a field ever changes, the compiler tells you:

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 ?? "");
}

If you do not need the event, simply do not send the X-Uttapen-Include-Meta header; the stream then matches the SDK's official types exactly and no cast is needed. You can read the cost later from the dashboard or from GET /v1/uttapen/usage.

Responses API

client.responses.create currently returns 404; use chat.completions (migration).