SDKs

Node.js SDK

The Pendra Node SDK is a TypeScript-first, zero-dependency client for sovereign UK inference. Streaming support, dual ESM/CJS. Node.js 20+.

Installation

bash
$ npm install pendra

Zero runtime dependencies. View on npm.

Quick start

quickstart.ts
import Pendra from 'pendra';

const client = new Pendra({
  apiKey: 'pdr_sk_...', // or set PENDRA_API_KEY env var
});

const response = await client.chat.completions.create({
  model: 'qwen3.6:27b',
  messages: [{ role: 'user', content: 'Hello!' }],
});

console.log(response.choices[0].message.content);

Streaming

Stream responses token by token using async iteration.

stream.ts
const stream = await client.chat.completions.create({
  model: 'qwen3.6:27b',
  messages: [{ role: 'user', content: 'Write a poem' }],
  stream: true,
});

for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}

Image generation

Generate images from a text prompt. Returns base64-encoded PNGs by default — decode with Buffer.from(b64, 'base64') and write to disk, or set response_format: 'url' when supported by the model.

images.ts
import { writeFileSync } from 'node:fs';

const response = await client.images.generations.create({
  model: 'flux.1-schnell',
  prompt: 'A red London double-decker bus at sunset',
});

const b64 = response.data[0].b64_json;
if (b64) {
  writeFileSync('bus.png', Buffer.from(b64, 'base64'));
}

Image generation is non-streaming — the endpoint returns a single JSON response once the worker finishes.

Embeddings

Generate vector embeddings for retrieval, search, and RAG pipelines. OpenAI-compatible — pass a string or array of strings and get back a CreateEmbeddingResponse with one embedding per input.

embeddings.ts
const response = await client.embeddings.create({
  model: 'nomic-embed-text',
  input: ['The quick brown fox', 'jumps over the lazy dog'],
});

for (const item of response.data) {
  console.log(item.index, (item.embedding as number[]).length, 'dims');
}

console.log(response.usage.prompt_tokens);

Any embedding model in the Pendra catalogue works — nomic-embed-text, qwen3-embedding, bge-m3, embeddinggemma.

Audio transcription

Transcribe audio to text using Whisper-class models. Multipart upload — pass a Blob/File directly, or a { filename, content } object where content is a Blob, ArrayBuffer, or Uint8Array. Files capped at 25 MB.

transcribe.ts
import { readFileSync } from 'node:fs';

const audio = readFileSync('meeting.mp3');
const result = await client.audio.transcriptions.create({
  file: { filename: 'meeting.mp3', content: audio },
  model: 'whisper-large-v3-turbo',
  language: 'en',
});

console.log(result.text);

In the browser (or Bun), pass the File from an <input type="file"> straight through. Set response_format: 'srt' or 'vtt' to get subtitles back instead of JSON:

transcribe.browser.ts
// Browser / Bun: pass a Blob or File directly
const input = document.querySelector('input[type=file]');
const file = input.files[0]; // a File (which extends Blob)

const result = await client.audio.transcriptions.create({
  file,
  model: 'whisper-large-v3-turbo',
  response_format: 'srt', // or 'vtt' for subtitles
});

console.log(result.text); // SRT string — text/srt/vtt are wrapped as { text }

Transcription is non-streaming. result.duration and result.language are populated when the model reports them; result.segments appears when response_format: 'verbose_json'.

List models

models.ts
const models = await client.models.list();
models.forEach((m) => console.log(m.id));

Migrating from OpenAI

The Pendra SDK mirrors the OpenAI interface. Two lines to switch — your existing code just works.

migration.ts
// Before
import OpenAI from 'openai';
const client = new OpenAI({ apiKey: 'sk-...' });

// After
import Pendra from 'pendra';
const client = new Pendra({ apiKey: 'pdr_sk_...' });

API reference

client.chat.completions.create()

Create a chat completion. Returns ChatCompletion or Stream.

ParameterTypeDescription
modelstringModel ID (e.g. "qwen3.6:27b")
messagesArrayChat messages with role and content
streamboolean?Enable streaming (default false)
temperaturenumber?Sampling temperature (0–2)
max_tokensnumber?Maximum tokens to generate
top_pnumber?Top-p (nucleus) sampling value (1 = off)
top_knumber?Keep only the K most likely tokens (0 = off)
min_pnumber?Drop tokens below min_p × the top token's probability
frequency_penaltynumber?Penalise tokens by how often they've appeared (−2 to 2; 0 = off)
presence_penaltynumber?Penalise tokens that have appeared at all (−2 to 2; 0 = off)
repeat_penaltynumber?Classic repetition penalty (1 = off; e.g. 1.1–1.3)
seednumber?Seed the sampler for reproducible output
stopstring | string[]?Stop sequence(s)
toolsArray?Functions the model may call — see Tool calling. Calls come back on message.tool_calls.
tool_choicestring | object?'auto', 'none', or a specific function to force
parallel_tool_callsboolean?Allow the model to request several tool calls in one reply
response_formatobject?{ type: 'json_object' } or a json_schema — see Structured outputs
enable_thinkingboolean?Set false to make a reasoning model answer directly — see Thinking
reasoning_effortstring?'low' / 'medium' / 'high', or 'none' to turn thinking off
logit_biasobject?Bias specific tokens, keyed by token id (−100 to 100)
logprobs, top_logprobsboolean?, number?Return token log-probabilities, model permitting
repeat_last_nnumber?How many recent tokens repeat_penalty looks back over (default 64)
worker_idstring?Pin the request to a specific worker that serves this model (sent as the X-Pendra-Worker-Id header). Defaults to automatic routing.

Anything else you pass is sent to the API as-is, so a Chat Completions field newer than your SDK version still reaches the model. TypeScript flags an undeclared key written straight into the call — that's what catches typos — so pass a newer field through a variable or a cast when you mean it.

messages takes a content string, or an array of { type: 'text' } / { type: 'image_url' } parts for vision models. On the way back, a reasoning model's working arrives on message.reasoning_content (mirrored on message.reasoning, and streamed as delta.reasoning_content), separate from the answer in message.content.

Replies also carry a Pendra-specific pendra field alongside choices. Read response.pendra?.notice when an answer looks wrong for no obvious reason — a truncated_during_reasoning code is the model telling you it spent the whole max_tokens budget thinking and never reached an answer, which otherwise just looks like an empty message.content:

const response = await client.chat.completions.create({
  model: 'ornith-1.5:9b',
  messages: [{ role: 'user', content: 'Which is heavier, 1kg of steel or 1kg of feathers?' }],
  max_tokens: 200,
});

if (response.pendra?.notice?.code === 'truncated_during_reasoning') {
  // Retry with a bigger budget, or with thinking off.
  console.log(response.pendra.notice.message);
  console.log(response.usage?.completion_tokens_details?.reasoning_tokens); // where it went
}

The other codes are truncated_during_structured_output and strict_schema_not_enforced — see Notices for what each one means. The same field carries pendra.web_tool_steps when the serving worker has web tools enabled.

When streaming, the last few chunks report on the request rather than continuing the answer: one carries chunk.usage, one carries chunk.pendra?.timing (ttft_ms, tokens_per_second, queue_wait_ms, worker_queue_wait_ms), and a notice arrives on its own chunk too — so check chunk.pendra on every chunk, not just the last. Those chunks have no text, which is why the streaming example reads content with chunk.choices[0]?.delta?.content rather than indexing choices[0] unconditionally.

client.images.generations.create()

Generate images from a text prompt. Returns Promise<ImageResponse>.

ParameterTypeDescription
modelstringImage model ID (e.g. "sdxl-turbo")
promptstringText description of the image to generate
nnumber?Number of images, 1–4 (default 1)
sizestring?Dimensions as WIDTHxHEIGHT (defaults to the model's native resolution, e.g. 512x512 for SD 1.5, 1024x1024 for SDXL/FLUX)
response_formatstring?"b64_json" (default) or "url"
num_inference_stepsnumber?Diffusion steps; defaults per model (~30 for standard SD/SDXL, 4 for turbo/schnell)
seednumber?Random seed for reproducibility
negative_promptstring?Text to avoid in the generated image

client.embeddings.create()

Create embeddings. Returns Promise<CreateEmbeddingResponse>.

ParameterTypeDescription
modelstringEmbedding model ID (e.g. "nomic-embed-text")
inputstring | string[]Text to embed. Accepts a single string or a batch.
encoding_format"float" | "base64"?Defaults to float
dimensionsnumber?Output dimensionality (Matryoshka models like nomic-embed-text)
userstring?Optional end-user identifier

client.models.list()

Returns an array of Model objects. Each model has id, object, created, and owned_by fields.

client.audio.transcriptions.create()

Transcribe an audio file. Returns TranscriptionResponse.

ParameterTypeDescription
fileAudioFileInputBlob/File, or { filename, content }. ≤ 25 MB.
modelstringTranscription model id (e.g. "whisper-large-v3-turbo")
languagestring?ISO-639-1 language hint, optional
promptstring?Biasing prompt (vocabulary, formatting), optional
response_formatstring?"json" (default), "text", "srt", "vtt", or "verbose_json"
temperaturenumber?Sampling temperature. 0 (the default) is the most accurate; above 0 the decoder samples instead of using the beam
beam_sizenumber?Decoding beam width, 1–8. Omit for the accuracy-first default (beam search); pass 1 to decode greedily. Applies at temperature 0.
timestamp_granularities("word" | "segment")[]?verbose_json only

Configuration

OptionEnv varDefault
apiKeyPENDRA_API_KEY
baseURLhttps://api.pendra.ai
timeout120000 (ms). Raise to up to 1800000 if you use non-streaming with slow or large models — the server supports requests up to 30 minutes.

Error handling

All exceptions extend APIError.

ExceptionStatusWhen
AuthenticationError401Invalid or missing API key
RateLimitError429Too many requests
APIStatusError4xx/5xxAny other non-2xx response
APIConnectionErrorNetwork or connection failure

Links