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
$ npm install pendra
Zero runtime dependencies. View on npm.
Quick start
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.
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.
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.
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.
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:
// 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
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.
// 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.
| Parameter | Type | Description |
|---|---|---|
model | string | Model ID (e.g. "qwen3.6:27b") |
messages | Array | Chat messages with role and content |
stream | boolean? | Enable streaming (default false) |
temperature | number? | Sampling temperature (0–2) |
max_tokens | number? | Maximum tokens to generate |
top_p | number? | Top-p (nucleus) sampling value (1 = off) |
top_k | number? | Keep only the K most likely tokens (0 = off) |
min_p | number? | Drop tokens below min_p × the top token's probability |
frequency_penalty | number? | Penalise tokens by how often they've appeared (−2 to 2; 0 = off) |
presence_penalty | number? | Penalise tokens that have appeared at all (−2 to 2; 0 = off) |
repeat_penalty | number? | Classic repetition penalty (1 = off; e.g. 1.1–1.3) |
seed | number? | Seed the sampler for reproducible output |
stop | string | string[]? | Stop sequence(s) |
tools | Array? | Functions the model may call — see Tool calling. Calls come back on message.tool_calls. |
tool_choice | string | object? | 'auto', 'none', or a specific function to force |
parallel_tool_calls | boolean? | Allow the model to request several tool calls in one reply |
response_format | object? | { type: 'json_object' } or a json_schema — see Structured outputs |
enable_thinking | boolean? | Set false to make a reasoning model answer directly — see Thinking |
reasoning_effort | string? | 'low' / 'medium' / 'high', or 'none' to turn thinking off |
logit_bias | object? | Bias specific tokens, keyed by token id (−100 to 100) |
logprobs, top_logprobs | boolean?, number? | Return token log-probabilities, model permitting |
repeat_last_n | number? | How many recent tokens repeat_penalty looks back over (default 64) |
worker_id | string? | 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>.
| Parameter | Type | Description |
|---|---|---|
model | string | Image model ID (e.g. "sdxl-turbo") |
prompt | string | Text description of the image to generate |
n | number? | Number of images, 1–4 (default 1) |
size | string? | Dimensions as WIDTHxHEIGHT (defaults to the model's native resolution, e.g. 512x512 for SD 1.5, 1024x1024 for SDXL/FLUX) |
response_format | string? | "b64_json" (default) or "url" |
num_inference_steps | number? | Diffusion steps; defaults per model (~30 for standard SD/SDXL, 4 for turbo/schnell) |
seed | number? | Random seed for reproducibility |
negative_prompt | string? | Text to avoid in the generated image |
client.embeddings.create()
Create embeddings. Returns Promise<CreateEmbeddingResponse>.
| Parameter | Type | Description |
|---|---|---|
model | string | Embedding model ID (e.g. "nomic-embed-text") |
input | string | string[] | Text to embed. Accepts a single string or a batch. |
encoding_format | "float" | "base64"? | Defaults to float |
dimensions | number? | Output dimensionality (Matryoshka models like nomic-embed-text) |
user | string? | 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.
| Parameter | Type | Description |
|---|---|---|
file | AudioFileInput | Blob/File, or { filename, content }. ≤ 25 MB. |
model | string | Transcription model id (e.g. "whisper-large-v3-turbo") |
language | string? | ISO-639-1 language hint, optional |
prompt | string? | Biasing prompt (vocabulary, formatting), optional |
response_format | string? | "json" (default), "text", "srt", "vtt", or "verbose_json" |
temperature | number? | Sampling temperature. 0 (the default) is the most accurate; above 0 the decoder samples instead of using the beam |
beam_size | number? | 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
| Option | Env var | Default |
|---|---|---|
apiKey | PENDRA_API_KEY | — |
baseURL | — | https://api.pendra.ai |
timeout | — | 120000 (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.
| Exception | Status | When |
|---|---|---|
AuthenticationError | 401 | Invalid or missing API key |
RateLimitError | 429 | Too many requests |
APIStatusError | 4xx/5xx | Any other non-2xx response |
APIConnectionError | — | Network or connection failure |