# Audio transcriptions (/api-reference/audio-transcriptions) ``` POST https://api.qubittron.ai/v1/audio/transcriptions ``` OpenAI-compatible speech-to-text. Multipart upload, 25 MB cap. ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request [#request] `Content-Type: multipart/form-data` with the following fields: | Field | Type | Required | Notes | | ----------------------- | ------------- | -------- | ---------------------------------------------- | | `file` | file (`Blob`) | yes | Audio. Max 25 MB | | `model` | string | yes | Speech model id | | `language` | string | no | ISO-639-1 (e.g. `"en"`) | | `response_format` | string | no | `"json"` (default), `"text"`, `"verbose_json"` | | `temperature`, `prompt` | various | no | Passthrough | ## Supported models [#supported-models] | Model | Notes | | ------------------------ | ------------------------------- | | `whisper-large-v3` | Highest quality, slower | | `whisper-large-v3-turbo` | Faster, near-equivalent quality | ## Examples [#examples] ```bash curl https://api.qubittron.ai/v1/audio/transcriptions \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -F "model=whisper-large-v3-turbo" \ -F "language=en" \ -F "file=@/path/to/audio.wav" ``` ```ts import OpenAI from "openai"; import { createReadStream } from "node:fs"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const res = await client.audio.transcriptions.create({ model: "whisper-large-v3-turbo", file: createReadStream("/path/to/audio.wav"), language: "en", }); console.log(res.text); ``` ```ts import { readFileSync } from "node:fs"; const fileBytes = readFileSync("/path/to/audio.wav"); const fd = new FormData(); fd.append("model", "whisper-large-v3-turbo"); fd.append("language", "en"); fd.append("file", new Blob([fileBytes], { type: "audio/wav" }), "audio.wav"); const res = await fetch("https://api.qubittron.ai/v1/audio/transcriptions", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}` }, body: fd, }); const json = (await res.json()) as { text: string; language?: string }; console.log(json.text); ``` ## Response [#response] Default (`response_format` omitted or `"json"`): ```json { "text": "the quick brown fox jumps over the lazy dog", "language": "en" } ``` `response_format: "verbose_json"` adds segments and timing. ## Errors [#errors] | Status | Code | When | | ------ | -------------------------- | ----------------------------------------------- | | 400 | `invalid_request` | Missing fields, non-multipart Content-Type | | 400 | `model_not_found` | Model unknown or doesn't support transcriptions | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 413 | `request_entity_too_large` | File or body exceeds 25 MB | | 429 | `rate_limit_exceeded` | Rate limit hit | | 502 | `upstream_error` | Upstream returned 5xx or unparseable JSON | ## Pricing [#pricing] Metered per second of audio. Duration is read from the upstream `usage.seconds` (when present, e.g. `verbose_json`), otherwise parsed from the file's audio header. # Chat completions (/api-reference/chat-completions) ``` POST https://api.qubittron.ai/v1/chat/completions ``` The primary entry point for conversational LLMs. OpenAI-compatible — drop in your existing SDK code with `baseURL` swapped. ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request body [#request-body] | Field | Type | Required | Notes | | ------------------------------------------------------------------------------------- | ----------- | -------- | ------------------------------- | | `model` | `string` | yes | Any LLM model id (see below) | | `messages` | `Message[]` | yes | OpenAI message format | | `stream` | `boolean` | no | When `true`, returns SSE stream | | `max_tokens`, `temperature`, `top_p`, `tools`, `tool_choice`, `response_format`, etc. | various | no | Passed through to upstream | Unknown fields pass through unchanged — Bastion does not strip OpenAI fields it doesn't recognize. ## Supported models [#supported-models] | Model | Category | | ------------------------------------- | -------- | | `gpt-oss-120b` | LLM | | `gpt-oss-20b` | LLM | | `Llama-3.1-8B-Instruct` | LLM | | `Meta-Llama-3_3-70B-Instruct` | LLM | | `Qwen3-32B` | LLM | | `Mistral-7B-Instruct-v0.3` | LLM | | `Mistral-Small-3.2-24B-Instruct-2506` | LLM | | `Mistral-Nemo-Instruct-2407` | LLM | | `Qwen3-Coder-30B-A3B-Instruct` | Code | | `Qwen2.5-VL-72B-Instruct` | Vision | | `Qwen3Guard-Gen-8B` | Safety | | `Qwen3Guard-Gen-0.6B` | Safety | Tool/structured-output support is not enforced by Bastion — the request passes through unchanged. Models that don't support `tools` or `response_format` upstream may return a 502. Use [`GET /v1/models`](/api-reference/models) for the live list on your account. ## Examples [#examples] ```bash curl https://api.qubittron.ai/v1/chat/completions \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-oss-120b", "messages": [{ "role": "user", "content": "Reply with exactly: ok" }], "max_tokens": 16 }' ``` Non-streaming: ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const res = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Reply with exactly: ok" }], max_tokens: 16, }); console.log(res.choices[0]?.message.content); ``` Streaming: ```ts const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Count from 1 to 5." }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } ``` ```ts const res = await fetch("https://api.qubittron.ai/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Reply with exactly: ok" }], max_tokens: 16, }), }); const json = (await res.json()) as { choices: { message: { role: string; content: string } }[]; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; }; console.log(json.choices[0]?.message.content); ``` ## Response [#response] Non-streaming: ```json { "id": "chatcmpl-...", "object": "chat.completion", "created": 1735689600, "model": "gpt-oss-120b", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "ok" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 12, "completion_tokens": 1, "total_tokens": 13 } } ``` Streaming (`stream: true`) returns `text/event-stream` with OpenAI-format `data: {…}` chunks ending with `data: [DONE]`. ## Errors [#errors] | Status | Code | When | | ------ | --------------------- | ------------------------------------------------- | | 400 | `invalid_request` | Body failed validation (`messages` missing, etc.) | | 400 | `model_not_found` | Model unknown or doesn't support chat | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 429 | `rate_limit_exceeded` | Rate limit hit | | 502 | `upstream_error` | Upstream model unreachable or 5xx | ## Pricing [#pricing] Metered per token (input + output). Per-model rates are listed on your dashboard. # Completions (/api-reference/completions) ``` POST https://api.qubittron.ai/v1/completions ``` OpenAI-compatible legacy completions. Useful for non-chat prompts and porting older code. New integrations should prefer [chat completions](/api-reference/chat-completions). ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request body [#request-body] | Field | Type | Required | Notes | | -------------------------------------------------- | -------------------- | -------- | ------------------------ | | `model` | `string` | yes | LLM model id | | `prompt` | `string \| string[]` | yes | Single string or array | | `stream` | `boolean` | no | When `true`, returns SSE | | `max_tokens`, `temperature`, `top_p`, `stop`, etc. | various | no | Passthrough | ## Supported models [#supported-models] The same LLM, code, vision, and safety models that support chat completions also support completions: | Model | Category | | ------------------------------------- | -------- | | `gpt-oss-120b` | LLM | | `gpt-oss-20b` | LLM | | `Llama-3.1-8B-Instruct` | LLM | | `Meta-Llama-3_3-70B-Instruct` | LLM | | `Qwen3-32B` | LLM | | `Mistral-7B-Instruct-v0.3` | LLM | | `Mistral-Small-3.2-24B-Instruct-2506` | LLM | | `Mistral-Nemo-Instruct-2407` | LLM | | `Qwen3-Coder-30B-A3B-Instruct` | Code | | `Qwen2.5-VL-72B-Instruct` | Vision | | `Qwen3Guard-Gen-8B` | Safety | | `Qwen3Guard-Gen-0.6B` | Safety | ## Examples [#examples] ```bash curl https://api.qubittron.ai/v1/completions \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-oss-120b", "prompt": "The capital of Canada is", "max_tokens": 16 }' ``` ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const res = await client.completions.create({ model: "gpt-oss-120b", prompt: "The capital of Canada is", max_tokens: 16, }); console.log(res.choices[0]?.text); ``` ```ts const res = await fetch("https://api.qubittron.ai/v1/completions", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "gpt-oss-120b", prompt: "The capital of Canada is", max_tokens: 16, }), }); const json = (await res.json()) as { choices: { text: string; finish_reason: string }[]; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; }; console.log(json.choices[0]?.text); ``` ## Response [#response] ```json { "id": "cmpl-...", "object": "text_completion", "created": 1735689600, "model": "gpt-oss-120b", "choices": [ { "text": " Ottawa.", "index": 0, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 6, "completion_tokens": 3, "total_tokens": 9 } } ``` Streaming returns `text/event-stream` with OpenAI-format chunks. ## Errors [#errors] | Status | Code | When | | ------ | --------------------- | -------------------------------------------- | | 400 | `invalid_request` | Body failed validation | | 400 | `model_not_found` | Model unknown or doesn't support completions | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 429 | `rate_limit_exceeded` | Rate limit hit | | 502 | `upstream_error` | Upstream model unreachable or 5xx | ## Pricing [#pricing] Metered per token (input + output). # Embeddings (/api-reference/embeddings) ``` POST https://api.qubittron.ai/v1/embeddings ``` Generate dense vector embeddings, OpenAI-compatible. ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request body [#request-body] | Field | Type | Required | Notes | | --------------------------------------- | -------------------- | -------- | ------------------------------ | | `model` | `string` | yes | Embedding model id | | `input` | `string \| string[]` | yes | Single string or array (batch) | | `encoding_format`, `dimensions`, `user` | various | no | Passthrough | ## Supported models [#supported-models] | Model | Dim | Context | Notes | | ------------------------- | ------ | ------- | ---------------------------- | | `Qwen3-Embedding-8B` | varies | 32K | High quality, larger | | `bge-m3` | 1024 | 8K | Multilingual, strong default | | `bge-multilingual-gemma2` | varies | 8K | Multilingual, Gemma2 base | ## Examples [#examples] ```bash curl https://api.qubittron.ai/v1/embeddings \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "bge-m3", "input": "hello world" }' ``` ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); // Single const single = await client.embeddings.create({ model: "bge-m3", input: "hello world", }); // Batch const batch = await client.embeddings.create({ model: "bge-m3", input: ["one", "two", "three"], }); console.log(batch.data.map((d) => d.embedding.length)); ``` ```ts const res = await fetch("https://api.qubittron.ai/v1/embeddings", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "bge-m3", input: "hello world" }), }); const json = (await res.json()) as { data: { object: "embedding"; embedding: number[]; index: number }[]; model: string; usage: { prompt_tokens: number; total_tokens: number }; }; console.log(json.data[0].embedding.slice(0, 4)); ``` ## Response [#response] ```json { "object": "list", "data": [ { "object": "embedding", "embedding": [0.0123, -0.0456, ...], "index": 0 } ], "model": "bge-m3", "usage": { "prompt_tokens": 2, "total_tokens": 2 } } ``` ## Errors [#errors] | Status | Code | When | | ------ | --------------------- | ------------------------------------------- | | 400 | `invalid_request` | Body failed validation | | 400 | `model_not_found` | Model unknown or doesn't support embeddings | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 429 | `rate_limit_exceeded` | Rate limit hit | | 502 | `upstream_error` | Upstream model unreachable or 5xx | ## Pricing [#pricing] Metered per input token. Embedding endpoints have no output token cost. # Image generation (/api-reference/images) ``` POST https://api.qubittron.ai/v1/images/generations ``` OpenAI-compatible image generation. ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request body [#request-body] | Field | Type | Required | Notes | | ----------------- | --------------------- | -------- | --------------------------- | | `model` | `string` | yes | Image model id | | `prompt` | `string` | yes | Text description | | `n` | `number` | no | How many images (default 1) | | `size` | `string` | no | e.g. `"1024x1024"` | | `output_format` | `string` | no | e.g. `"png"`, `"jpeg"` | | `quality` | `string` | no | Model-dependent | | `response_format` | `"b64_json" \| "url"` | no | Default depends on model | ## Supported models [#supported-models] | Model | Notes | | ------------------------------ | ---------------------- | | `stable-diffusion-xl-base-v10` | SDXL base, 3.5B params | ## Examples [#examples] ```bash curl https://api.qubittron.ai/v1/images/generations \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "stable-diffusion-xl-base-v10", "prompt": "a small red apple on a white background" }' ``` ```ts import OpenAI from "openai"; import { writeFileSync } from "node:fs"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const res = await client.images.generate({ model: "stable-diffusion-xl-base-v10", prompt: "a small red apple on a white background", }); const b64 = res.data?.[0]?.b64_json; if (b64) writeFileSync("/tmp/apple.png", Buffer.from(b64, "base64")); ``` ```ts import { writeFileSync } from "node:fs"; const res = await fetch("https://api.qubittron.ai/v1/images/generations", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "stable-diffusion-xl-base-v10", prompt: "a small red apple on a white background", }), }); const json = (await res.json()) as { created: number; data: { b64_json?: string; url?: string }[]; }; const b64 = json.data[0]?.b64_json; if (b64) writeFileSync("/tmp/apple.png", Buffer.from(b64, "base64")); ``` ## Response [#response] ```json { "created": 1735689600, "data": [ { "b64_json": "iVBORw0KGgo...", "url": null } ] } ``` ## Errors [#errors] | Status | Code | When | | ------ | --------------------- | ------------------------------------------------- | | 400 | `invalid_request` | Body failed validation | | 400 | `model_not_found` | Model unknown or doesn't support image generation | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 429 | `rate_limit_exceeded` | Rate limit hit | | 502 | `upstream_error` | Upstream returned 5xx or unparseable JSON | ## Pricing [#pricing] Metered per image generated (`data[].length`). # API reference (/api-reference) All endpoints require `Authorization: Bearer qbt_...`. Most live under `https://api.qubittron.ai/v1/`; TTS is at `/api/v1/tts/text_to_audio`. | Endpoint | Path | Notes | | ----------------------------------------------------------- | -------------------------------- | ---------------------------- | | [List models](/api-reference/models) | `GET /v1/models` | Discovery | | [Chat completions](/api-reference/chat-completions) | `POST /v1/chat/completions` | Streaming optional | | [Completions](/api-reference/completions) | `POST /v1/completions` | Legacy text completions | | [Responses](/api-reference/responses) | `POST /v1/responses` | Requires `store: false` | | [Embeddings](/api-reference/embeddings) | `POST /v1/embeddings` | Single string or array | | [Image generation](/api-reference/images) | `POST /v1/images/generations` | Returns base64 or URL | | [Audio transcriptions](/api-reference/audio-transcriptions) | `POST /v1/audio/transcriptions` | Multipart, 25 MB cap | | [Text to speech](/api-reference/tts) | `POST /api/v1/tts/text_to_audio` | NVIDIA Riva, language-routed | ## Common patterns [#common-patterns] **Auth header** ``` Authorization: Bearer qbt_ ``` **Error envelope** ```json { "error": { "message": "...", "type": "...", "code": "..." } } ``` **Status codes** 400 (validation / unknown model), 401 (bad key), 402 (no balance), 413 (oversize upload), 429 (rate limited), 502 (upstream), 503 (transient internal — retry). See [Getting started](/getting-started) for the full error table and rate-limit behavior. # List models (/api-reference/models) ``` GET https://api.qubittron.ai/v1/models ``` Returns the full set of models available to your key, in OpenAI's `list` format. ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request [#request] No body. No query parameters. ## Examples [#examples] ```bash curl https://api.qubittron.ai/v1/models \ -H "Authorization: Bearer $QUBITTRON_API_KEY" ``` ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const models = await client.models.list(); for (const m of models.data) { console.log(m.id); } ``` ```ts const res = await fetch("https://api.qubittron.ai/v1/models", { headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}` }, }); const json = (await res.json()) as { object: "list"; data: { id: string; object: "model"; created: number; owned_by: string }[]; }; console.log(json.data.map((m) => m.id)); ``` ## Response [#response] ```json { "object": "list", "data": [ { "id": "gpt-oss-120b", "object": "model", "created": 1735689600, "owned_by": "qubittron" } ] } ``` ## Errors [#errors] | Status | Code | When | | ------ | --------------------- | ---------------------------- | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 429 | `rate_limit_exceeded` | Rate limit hit | ## Pricing [#pricing] Free. Listing models is not metered. # Responses (/api-reference/responses) ``` POST https://api.qubittron.ai/v1/responses ``` OpenAI's Responses API in stateless mode. Pass `store: false` — Bastion's upstream does not retain server-side state. ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request body [#request-body] | Field | Type | Required | Notes | | ------------------------------------------------- | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `model` | `string` | yes | One of the 9 supported models below | | `input` | `string \| InputItem[]` | yes | Plain text or structured input array | | `stream` | `boolean` | no | When `true`, returns SSE event stream | | `store` | `boolean` | no | The Bastion server does not enforce this, but the upstream rejects stateful mode — **send `store: false`** or expect a 502 | | `max_output_tokens`, `temperature`, `tools`, etc. | various | no | Passthrough | ## Supported models [#supported-models] Nine models support `/v1/responses`: * `gpt-oss-120b` * `gpt-oss-20b` * `Llama-3.1-8B-Instruct` * `Meta-Llama-3_3-70B-Instruct` * `Qwen3-32B` * `Mistral-Small-3.2-24B-Instruct-2506` * `Mistral-Nemo-Instruct-2407` * `Qwen3-Coder-30B-A3B-Instruct` * `Qwen2.5-VL-72B-Instruct` Other LLMs (e.g. `Mistral-7B-Instruct-v0.3`) return `model_not_found` on this endpoint — use `/v1/chat/completions` instead. ## Examples [#examples] ```bash curl https://api.qubittron.ai/v1/responses \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-oss-120b", "input": "Reply with: ok", "max_output_tokens": 32, "store": false }' ``` ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const res = await client.responses.create({ model: "gpt-oss-120b", input: "Reply with: ok", max_output_tokens: 32, store: false, }); console.log(res.output_text); ``` ```ts const res = await fetch("https://api.qubittron.ai/v1/responses", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "gpt-oss-120b", input: "Reply with: ok", max_output_tokens: 32, store: false, }), }); const json = await res.json(); console.log(json); ``` ## Response [#response] OpenAI Responses API format — see [OpenAI's Responses reference](https://platform.openai.com/docs/api-reference/responses) for the full shape. The response contains `output[]` items, `usage` (`input_tokens`, `output_tokens`), and `model`. Streaming (`stream: true`) returns the standard Responses event stream (`response.created`, `response.output_item.added`, `response.completed`, etc.). ## Errors [#errors] | Status | Code | When | | ------ | --------------------- | ------------------------------------------------------- | | 400 | `invalid_request` | Body failed validation | | 400 | `model_not_found` | Model unknown or doesn't support responses | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 429 | `rate_limit_exceeded` | Rate limit hit | | 502 | `upstream_error` | Upstream returned 5xx, often when `store: true` is sent | ## Pricing [#pricing] Metered per token (input + output). # Text to speech (/api-reference/tts) ``` POST https://api.qubittron.ai/api/v1/tts/text_to_audio ``` > Note the path: TTS lives under `/api/v1/tts/...`, **not** `/v1/...`. The upstream is NVIDIA Riva and the URL mirrors its native shape. ## Authentication [#authentication] `Authorization: Bearer qbt_` ## Request body [#request-body] | Field | Type | Required | Notes | | ---------------- | -------- | -------- | -------------------------------------------- | | `text` | `string` | yes | Text to synthesize (min 1 char) | | `language_code` | `string` | yes | One of `en-US`, `de-DE`, `es-ES`, `it-IT` | | `encoding` | `number` | yes | Riva audio encoding (e.g. `1` = LINEAR\_PCM) | | `sample_rate_hz` | `number` | yes | e.g. `22050`, `44100` | | `voice_name` | `string` | yes | e.g. `English-US.Female-1` | The request is routed by `language_code` to the matching per-language upstream. ## Supported models / languages [#supported-models--languages] | Model | Language | | ----------- | ------------------------- | | `tts-en-us` | English (US) — `en-US` | | `tts-de-de` | German — `de-DE` | | `tts-es-es` | Spanish (Spain) — `es-ES` | | `tts-it-it` | Italian — `it-IT` | ## Examples [#examples] ```bash curl https://api.qubittron.ai/api/v1/tts/text_to_audio \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Hello from Qubi Bastion.", "language_code": "en-US", "encoding": 1, "sample_rate_hz": 22050, "voice_name": "English-US.Female-1" }' \ --output speech.pcm ``` The OpenAI Node SDK does not model NVIDIA Riva's TTS contract — it expects an audio response shape that doesn't match. Use **fetch** (next tab) or any HTTP client. There is no SDK helper to wrap. ```ts import { writeFileSync } from "node:fs"; const res = await fetch( "https://api.qubittron.ai/api/v1/tts/text_to_audio", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "Hello from Qubi Bastion.", language_code: "en-US", encoding: 1, sample_rate_hz: 22050, voice_name: "English-US.Female-1", }), }, ); if (!res.ok) throw new Error(`tts failed: ${res.status}`); writeFileSync("/tmp/speech.pcm", Buffer.from(await res.arrayBuffer())); ``` ## Response [#response] Binary audio bytes, with `Content-Type` set to match the requested encoding. Status 200 on success. For LINEAR\_PCM (`encoding: 1`), play raw on macOS: ```bash sox -t raw -r 22050 -e signed -b 16 -c 1 speech.pcm -d ``` ## Errors [#errors] | Status | Code | When | | ------ | ------------------------ | ------------------------------------------------------------ | | 400 | `invalid_request` | Body failed validation | | 400 | `language_not_supported` | `language_code` not in \[`en-US`, `de-DE`, `es-ES`, `it-IT`] | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 429 | `rate_limit_exceeded` | Rate limit hit | | 502 | `upstream_error` | Upstream Riva returned 5xx | ## Pricing [#pricing] Metered per character of input `text`. # API key management (/best-practices/api-key-management) API keys (`qbt_...`) are the only credential the Bastion API accepts. Treat them like passwords — leakage is a billing and data-access risk. ## Issue one key per environment and per service [#issue-one-key-per-environment-and-per-service] Use a distinct key for each combination of (environment, service). Keys are cheap to create and they give you a clean audit and revocation surface. | Environment | Service | Example key label | | ----------- | ------------ | ----------------------- | | `dev` | local laptop | `dev-laptop-` | | `staging` | api worker | `staging-api-worker` | | `prod` | api worker | `prod-api-worker` | | `prod` | batch job | `prod-batch-nightly` | Mixing environments (one key powering both staging and prod) defeats audit logs and means revoking a leak takes down everything. ## Store keys in a secrets manager — never in client code [#store-keys-in-a-secrets-manager--never-in-client-code] A key in client-side JavaScript, a mobile app binary, or a public Git history is **compromised the moment it ships**. Acceptable homes: * AWS Secrets Manager / SSM Parameter Store * HashiCorp Vault * GCP Secret Manager * GitHub Actions / GitLab CI encrypted secrets * 1Password / Doppler (for developer machines) Inject the secret as an environment variable at runtime — code should only ever read it via `process.env.QUBITTRON_API_KEY` (or your platform's equivalent). ## Rotate on a schedule and on suspected exposure [#rotate-on-a-schedule-and-on-suspected-exposure] The dashboard supports rolling rotation: create a new key, deploy services to use it, then revoke the old one. Old and new keys coexist until you revoke, so there's no downtime. Trigger an immediate rotation when: * A key appears in a log, an error report, a stack trace, or a screenshot. * A laptop with developer keys is lost or sold. * An employee with key access leaves the company. * A `git log -p` shows a key was ever committed (even if reverted — assume Git history is forever). ## Test that a key works [#test-that-a-key-works] A small smoke test is useful right after issuing or rotating a key. It costs nothing and confirms the key is live before you depend on it in code: ```bash curl https://api.qubittron.ai/v1/models \ -H "Authorization: Bearer $QUBITTRON_API_KEY" \ -o /dev/null -w "%{http_code}\n" ``` A `200` means the key is live. A `401` means it's missing, malformed, or revoked. ## Never log or echo the raw key [#never-log-or-echo-the-raw-key] Redact secrets at the logging boundary. Most logging libraries support a redactor or a structured-logging pattern that drops known-secret fields: ```ts const safeHeaders = { ...headers, authorization: headers.authorization ? "Bearer qbt_***" : undefined, }; logger.info({ url, headers: safeHeaders }, "outbound request"); ``` Treat error reporters (Sentry, Datadog, etc.) the same way — accidentally shipping headers in an error payload is a common leak path. # Errors and retries (/best-practices/errors-and-retries) Bastion uses OpenAI-compatible error envelopes. The full error code table lives on the [Getting started](/getting-started#error-format) page. This page is about deciding what to **do** with each response in production. ## Decision tree [#decision-tree] | Status | Code class | Retry? | Backoff | | ------------------------- | --------------------- | ------- | ------------------- | | `2xx` | success | — | — | | `400` | client | **no** | fix the request | | `401` | client | **no** | fix the key | | `402` | `insufficient_funds` | **no** | top up first | | `404` | client | **no** | fix the path/model | | `413` | client | **no** | shrink the upload | | `429` | `rate_limit_exceeded` | **yes** | honor `Retry-After` | | `5xx` (esp. `502`, `503`) | transient | **yes** | exponential backoff | | network error / timeout | transient | **yes** | exponential backoff | The rule of thumb: **retry only what's clearly transient.** Retrying a 400 just produces 400s faster. ## Exponential backoff with jitter [#exponential-backoff-with-jitter] For 429 and 5xx, retry up to 3 times with jittered exponential backoff. Pseudocode: ```ts async function withRetry(fn: () => Promise, maxAttempts = 3): Promise { let lastErr: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fn(); } catch (err) { lastErr = err; if (!isTransient(err) || attempt === maxAttempts - 1) throw err; const base = 1000 * 2 ** attempt; // 1s, 2s, 4s const jitter = Math.random() * base * 0.25; await new Promise((r) => setTimeout(r, base + jitter)); } } throw lastErr; } ``` If the response is `429`, prefer the server-supplied delay: ```ts const retryAfter = Number(res.headers.get("Retry-After")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : computedBackoff; ``` `Retry-After` can technically be an HTTP-date instead of a delta-seconds integer. Bastion emits the integer form; the snippet above falls through to `computedBackoff` if a date ever shows up. ## Stream retries [#stream-retries] You **cannot** resume a stream mid-flight. If a stream connection drops, retry the **entire** request and discard any partial output. Buffer client-side output until you commit to a successful completion if your UX cannot tolerate "the first three sentences came back twice." ## What to surface to users [#what-to-surface-to-users] * `4xx` (except `429`) → user-facing error. Show the message from `error.message` if it's safe; otherwise a generic "invalid request." * `402` → billing alert. Surface "out of credit" with a link to the billing page. * `429` → soft retry behind the scenes. If retries are exhausted, show "we're at capacity, try again in a moment." * `5xx` → soft retry. If exhausted, log loudly and surface "service temporarily unavailable." ## Don't auto-retry idempotency-sensitive calls indefinitely [#dont-auto-retry-idempotency-sensitive-calls-indefinitely] The Bastion API does not require idempotency keys, and most endpoints are naturally retry-safe (chat completions, embeddings, models, transcriptions). But if your application performs side effects on success (deduct credits, send an email, write to a downstream system), debounce the downstream side effect on your side — retrying the API call repeatedly is fine; firing the downstream action twice is not. # Best practices (/best-practices) These pages cover what to get right before you point production traffic at the Bastion API. Each one is short and opinionated; treat them as defaults, not gospel. # Production checklist (/best-practices/production-checklist) A short, opinionated pre-launch checklist. Most teams already do most of this — the value is in catching the one item you forgot. ## Credentials [#credentials] * [ ] API key lives in a secrets manager, **not** in source control or in client code. * [ ] Separate keys per environment (`dev`, `staging`, `prod`) and per service. * [ ] Key rotation runbook documented and tested at least once. * [ ] Logs, error reports, and metrics tags are redacted to never include the raw key. ## Reliability [#reliability] * [ ] Retry wrapper in place for `429` and `5xx` with exponential backoff and jitter. * [ ] `429` responses honor the `Retry-After` header. * [ ] No retry-on-`4xx` (other than `429`). * [ ] Streaming requests retry the entire request on drop — no mid-stream resume. * [ ] Downstream side effects (billing, emails, DB writes) are guarded against duplicate execution if the same Bastion call is retried. ## Observability [#observability] * [ ] Latency (p50, p95, p99), error rate, and 4xx-vs-5xx breakdown are dashboards you can open in under 30 seconds. * [ ] Alerts for: sustained 5xx rate above your SLO, sustained 429 rate, `402 insufficient_funds`, key revocation. * [ ] Sampled request/response logging (sampled, redacted, retained per your policy — never log full PII-bearing prompts). ## Cost and capacity [#cost-and-capacity] * [ ] Per-environment monthly spend cap configured in the dashboard (so a runaway test doesn't drain prod budget). * [ ] Model selection documented per use-case (don't default everything to the largest model). * [ ] Auto-recharge or low-balance alerts wired up to billing — `402` should never be the way you learn you ran out of credit. * [ ] Token budgets per call (`max_tokens`) set to the smallest reasonable value for each surface. ## Streaming-specific [#streaming-specific] * [ ] Reverse proxies and CDNs do **not** buffer SSE responses (`proxy_buffering off`, no caching, no compression on stream routes). * [ ] Client UI commits partial assistant output only after the stream ends cleanly. * [ ] AbortController / cancellation tested end-to-end (user clicks "stop" → upstream connection closes promptly). ## Data and privacy [#data-and-privacy] * [ ] `responses` endpoint usage always sets `store: false` (required by the API). * [ ] Prompts containing personally identifiable information are redacted or scoped per your data agreement before send. * [ ] Audit logging for which user prompted which call (your own user ID → Bastion call), separate from request bodies. * [ ] Data-residency requirements (PIPEDA, provincial, sector-specific) are documented and the Canadian-inference guarantee is referenced in your DPA where relevant. ## Failure drills [#failure-drills] * [ ] You've tested the path where Bastion returns 5xx for sustained periods (chaos test or a fault-injection switch). * [ ] You've tested the path where the API key is revoked mid-traffic (401 cascade). * [ ] You've tested the path where the account hits a hard credit limit (402 cascade — your UI should degrade gracefully, not 500). When every box is checked, you're ready to ship. If any box is unchecked, write down why before launching anyway. # Streaming (/best-practices/streaming) The `chat/completions` and `completions` endpoints both support server-sent events (SSE) via `"stream": true`. Streaming improves perceived latency dramatically, but it adds failure modes that buffered requests do not have. ## Wire format [#wire-format] Each event is a `data:` line followed by a blank line: ``` data: {"id":"...","choices":[{"delta":{"content":"Hel"}}]} data: {"id":"...","choices":[{"delta":{"content":"lo"}}]} data: [DONE] ``` The terminating `data: [DONE]` is **not** JSON — it's a sentinel. Parse the chunks before it as JSON; treat the `[DONE]` line as end-of-stream. ## Use the SDK if you can [#use-the-sdk-if-you-can] The OpenAI SDK handles parsing, async iteration, and cleanup correctly: ```ts const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Hello" }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } ``` Raw `fetch` parsing is only worth it when you need to embed in a constrained runtime (workers, edge) that can't bring the SDK along. See [Chat completions](/api-reference/chat-completions) for the canonical SDK and `fetch` examples. ## Raw `fetch` parser sketch [#raw-fetch-parser-sketch] ```ts const res = await fetch("https://api.qubittron.ai/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Hello" }], stream: true, }), }); const reader = res.body!.getReader(); const decoder = new TextDecoder(); let buf = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let nl: number; while ((nl = buf.indexOf("\n\n")) !== -1) { const event = buf.slice(0, nl); buf = buf.slice(nl + 2); if (!event.startsWith("data: ")) continue; const payload = event.slice(6); if (payload === "[DONE]") return; const chunk = JSON.parse(payload); process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } } ``` Two things to get right: 1. **Buffer until you see `\n\n`.** TCP can deliver any byte range; a chunk may split a JSON payload mid-string. 2. **Use a streaming `TextDecoder`** (`{ stream: true }`) so multi-byte UTF-8 characters that straddle chunks decode correctly. ## Don't buffer streams behind a reverse proxy [#dont-buffer-streams-behind-a-reverse-proxy] If you're proxying Bastion responses through your own server (Express, Hono, Next.js route handler, etc.), buffering kills the latency win. Make sure your proxy: * Passes through the upstream `Content-Type: text/event-stream` unchanged. * Sets `Cache-Control: no-cache` on the response. * Flushes after each chunk (most frameworks do this automatically when you pipe a `ReadableStream`; some need an explicit `res.flush()`). * Disables gzip / brotli compression on the streaming response (compression buffers). Behind nginx, set `proxy_buffering off;` for the route. Behind Cloudflare, streaming works but caching must be off. ## Reconnection [#reconnection] There is no `Last-Event-ID` semantics on Bastion streams — **a dropped stream means you discard the partial output and retry the entire request.** Treat your UI accordingly: * For chat: commit the assistant message only after the stream ends cleanly. On reconnect, replace the partial message rather than appending to it. * For agent loops: persist the request inputs, not the partial output. Idempotency belongs to your own state machine. ## Cancellation [#cancellation] The OpenAI SDK exposes a `controller` you can `abort()`. With raw `fetch`, pass an `AbortSignal`: ```ts const ctrl = new AbortController(); fetch(url, { signal: ctrl.signal, /* ... */ }); // later: ctrl.abort(); ``` Aborting closes the connection. The model stops generating on the upstream side once the connection drops. # Getting started (/getting-started) ## 1. Create an account and an API key [#1-create-an-account-and-an-api-key] 1. Sign up at [qubittron.ai](https://qubittron.ai) and verify your phone number. 2. Open the dashboard → **API keys** → **Create key**. 3. Copy the key — it starts with `qbt_` and is shown only once. Treat the key like a password. Set it as `QUBITTRON_API_KEY` in your environment, never in client-side code: ```bash export QUBITTRON_API_KEY=qbt_your_key_here ``` The cURL examples in this doc reference `$QUBITTRON_API_KEY` — they assume this `export` is in scope. ## 2. Set the base URL [#2-set-the-base-url] ``` https://api.qubittron.ai/v1 ``` The TTS endpoint sits one level higher (`/api/v1/tts/text_to_audio`) — see the [TTS page](/api-reference/tts). > **HTTPS only.** Plain HTTP requests are redirected to HTTPS, and the redirect strips the `Authorization` header for security. Always start with `https://`. ## 3. Authenticate [#3-authenticate] Every request needs a Bearer token: ``` Authorization: Bearer qbt_ ``` A request with a missing or invalid key returns: ```json { "error": { "message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key" } } ``` ## 4. Make your first request [#4-make-your-first-request] ```bash curl https://api.qubittron.ai/v1/models \ -H "Authorization: Bearer $QUBITTRON_API_KEY" ``` ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const models = await client.models.list(); console.log(models.data.map((m) => m.id)); ``` ```ts const res = await fetch("https://api.qubittron.ai/v1/models", { headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}` }, }); const json = await res.json(); console.log(json.data.map((m: { id: string }) => m.id)); ``` ## Error format [#error-format] All errors share an OpenAI-compatible shape: ```json { "error": { "message": "Human-readable description", "type": "invalid_request_error" | "api_error" | "insufficient_quota" | "rate_limit_error", "code": "invalid_request" | "invalid_api_key" | "model_not_found" | "rate_limit_exceeded" | "upstream_error" | "insufficient_funds" | "service_unavailable" | "..." } } ``` | Status | Code | When | | ------ | -------------------------- | ----------------------------------------------------- | | 400 | `invalid_request` | Body failed validation | | 400 | `model_not_found` | Model unknown or doesn't support this endpoint | | 401 | `invalid_api_key` | Missing/invalid Bearer token | | 402 | `insufficient_funds` | Account credit exhausted | | 413 | `request_entity_too_large` | Multipart upload exceeds 25 MB (transcriptions) | | 429 | `rate_limit_exceeded` | Per-key or per-tier limit | | 502 | `upstream_error` | Upstream model unreachable or returned 5xx | | 503 | `service_unavailable` | Internal limiter or balance check unavailable — retry | ## Rate limits [#rate-limits] Limits are per API key, evaluated per minute. Defaults vary by tier — view your current limits in the dashboard. A 429 response includes a `Retry-After` header in seconds; back off and retry. ## Idempotency and retries [#idempotency-and-retries] The API does not require idempotency keys. Treat 5xx responses (especially 502) and network errors as transient and retry with exponential backoff (e.g. 1s, 2s, 4s, max 3 attempts). Do **not** auto-retry on 4xx — those indicate a client problem. For long-running streams, retry the full request rather than resuming mid-stream. ## Next [#next] # Audio pipelines (/guides/audio-pipelines) Bastion's audio surface has two endpoints: * [`POST /v1/audio/transcriptions`](/api-reference/audio-transcriptions) — speech-to-text, OpenAI-compatible multipart upload. * [`POST /api/v1/tts/text_to_audio`](/api-reference/tts) — text-to-speech via NVIDIA Riva (note: different base path). This guide chains them: take a user's voice input → transcribe → run through an LLM → speak the answer back. ## Step 1: transcribe [#step-1-transcribe] Multipart upload, OpenAI-shaped. The OpenAI SDK works directly: ```ts import OpenAI from "openai"; import { createReadStream } from "node:fs"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const transcription = await client.audio.transcriptions.create({ model: "whisper-large-v3-turbo", file: createReadStream("./input.wav"), }); console.log(transcription.text); ``` `whisper-large-v3-turbo` is the default; `whisper-large-v3` is also available — see [`/v1/audio/transcriptions`](/api-reference/audio-transcriptions). Caveats: * Max upload size is 25 MB. For longer audio, segment client-side and concatenate the transcripts. * Supported encodings depend on the upstream model; WAV and MP3 are the safe choices. * A `413` response means the upload exceeded the size cap; shrink or split. ## Step 2: reason [#step-2-reason] Plain chat completion against the transcript: ```ts const answer = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [ { role: "system", content: "You are a concise voice assistant. Reply in one or two short sentences suitable for being spoken aloud.", }, { role: "user", content: transcription.text }, ], }); const reply = answer.choices[0]!.message.content ?? ""; ``` The "suitable for being spoken aloud" instruction is doing real work — without it, the model emits bullet lists and markdown, which sound terrible through TTS. ## Step 3: speak [#step-3-speak] TTS lives at a **different** base path (`/api/v1/tts/...`) and the OpenAI SDK does not cover it. Call `fetch` directly: ```ts const ttsRes = await fetch( "https://api.qubittron.ai/api/v1/tts/text_to_audio", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: reply, language_code: "en-US", encoding: 1, // LINEAR_PCM sample_rate_hz: 22050, voice_name: "English-US.Female-1", }), }, ); if (!ttsRes.ok) { throw new Error(`TTS failed: ${ttsRes.status}`); } const audio = Buffer.from(await ttsRes.arrayBuffer()); // audio is raw LINEAR_PCM @ 22.05 kHz; wrap in a WAV header before playing // in a browser, or stream straight to a speaker via ffmpeg / sox. ``` See the [TTS reference](/api-reference/tts) for the full encoding / sample-rate / voice matrix. ## Putting it together [#putting-it-together] A minimal end-to-end function: ```ts async function voiceTurn(inputPath: string): Promise { const transcript = await client.audio.transcriptions.create({ model: "whisper-large-v3-turbo", file: createReadStream(inputPath), }); const completion = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [ { role: "system", content: "You are a concise voice assistant. Reply in one or two short sentences.", }, { role: "user", content: transcript.text }, ], }); const reply = completion.choices[0]!.message.content ?? ""; const ttsRes = await fetch( "https://api.qubittron.ai/api/v1/tts/text_to_audio", { method: "POST", headers: { Authorization: `Bearer ${process.env.QUBITTRON_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: reply, language_code: "en-US", encoding: 1, // LINEAR_PCM sample_rate_hz: 22050, voice_name: "English-US.Female-1", }), }, ); return Buffer.from(await ttsRes.arrayBuffer()); } ``` ## Latency tips [#latency-tips] End-to-end voice latency is dominated by the LLM step. To keep round-trips snappy: * Stream the chat completion and start synthesizing TTS chunks as soon as you have a sentence boundary, rather than waiting for the full response. * Pick the smallest model that gives acceptable quality for your use case — `gpt-oss-20b` and `Llama-3.1-8B-Instruct` are good first picks for voice. * Cap `max_tokens` aggressively. Voice replies should be \~30–60 tokens; nobody wants to hear a paragraph. * 16 kHz mono LINEAR\_PCM is half the bytes of 22.05 kHz and is plenty for speech. For retry / streaming / production hardening, see [Streaming](/best-practices/streaming) and [Errors and retries](/best-practices/errors-and-retries). # Guides (/guides) Pick a guide based on what you're trying to build. Each one is concrete: real code, real endpoints, no hand-waving. # Migrating from OpenAI (/guides/migrating-from-openai) Bastion's HTTP surface is OpenAI-compatible for `chat.completions`, `completions`, `embeddings`, `images.generations`, `audio.transcriptions`, and `models`. The OpenAI Node, Python, and Go SDKs all work unchanged — point them at Bastion's `baseURL` and use a Bastion API key. ## Step 1: swap `baseURL` and key [#step-1-swap-baseurl-and-key] ```diff import OpenAI from "openai"; const client = new OpenAI({ - baseURL: "https://api.openai.com/v1", - apiKey: process.env.OPENAI_API_KEY, + baseURL: "https://api.qubittron.ai/v1", + apiKey: process.env.QUBITTRON_API_KEY, }); ``` ```diff from openai import OpenAI client = OpenAI( - base_url="https://api.openai.com/v1", - api_key=os.environ["OPENAI_API_KEY"], + base_url="https://api.qubittron.ai/v1", + api_key=os.environ["QUBITTRON_API_KEY"], ) ``` That's it for the SDK. Existing calls to `chat.completions.create`, `embeddings.create`, etc. all keep working. ## Step 2: swap model ids [#step-2-swap-model-ids] OpenAI model ids (`gpt-4o`, `gpt-4-turbo`, `text-embedding-3-small`, etc.) do not exist on Bastion. Pick a Bastion model in the same class — these are *categorical* matches, not feature-for-feature equivalents. Confirm per-row parity (tool calling, vision input format, structured output, etc.) against the [API reference](/api-reference) before swapping in production. | Use case | OpenAI default | Bastion model in the same class | | -------------------- | ------------------------ | ------------------------------------------------------------ | | General-purpose chat | `gpt-4o`, `gpt-4-turbo` | `gpt-oss-120b` | | Cost-sensitive chat | `gpt-4o-mini` | `gpt-oss-20b`, `Llama-3.1-8B-Instruct` | | Long-context chat | `gpt-4-turbo` | `Meta-Llama-3_3-70B-Instruct` | | Coding | `gpt-4o` | `Qwen3-Coder-30B-A3B-Instruct` | | Vision | `gpt-4o` (vision) | `Qwen2.5-VL-72B-Instruct` | | Embeddings | `text-embedding-3-small` | `bge-m3` (see [`/v1/embeddings`](/api-reference/embeddings)) | Call [`GET /v1/models`](/api-reference/models) for the live list on your account — model availability evolves. ## Caveats [#caveats] These are the cases where "drop-in compatible" needs a footnote. ### `responses` requires `store: false` [#responses-requires-store-false] The [`/v1/responses`](/api-reference/responses) endpoint is stateless. You must pass `store: false` (or it will reject the request). If your OpenAI code relied on server-side conversation state, move that state to your own database. ### TTS lives at a different path [#tts-lives-at-a-different-path] OpenAI's TTS lives at `/v1/audio/speech`. Bastion's TTS endpoint follows NVIDIA Riva's contract at `/api/v1/tts/text_to_audio` — see the [TTS page](/api-reference/tts). The OpenAI SDK's `audio.speech.create` will **not** work; call `fetch` directly. ### Pass-through, not parity, on advanced fields [#pass-through-not-parity-on-advanced-fields] Unknown fields (`tools`, `tool_choice`, `response_format`, `parallel_tool_calls`, etc.) pass through unchanged to the upstream model. If the upstream model doesn't implement that field, you may get a `502 upstream_error`. Confirm tool/structured-output support per model before relying on it in production. ### HTTPS only, no redirect [#https-only-no-redirect] Bastion's HTTP→HTTPS redirect strips the `Authorization` header for security. Always call `https://`. If you have any code constructing `http://api.qubittron.ai/...`, fix it before deploying. ## Step 3: keep your retry and observability code [#step-3-keep-your-retry-and-observability-code] The error envelope is OpenAI-compatible (`error.message`, `error.type`, `error.code`). Status codes (`401`, `429`, `5xx`, etc.) carry the same semantics. Existing retry middleware, rate-limit handling, and logging should keep working without changes. See [Errors and retries](/best-practices/errors-and-retries) if you want to revisit your defaults. ## Step 4: rotate keys [#step-4-rotate-keys] Don't reuse the same key across `dev`, `staging`, and `prod`. Issue distinct keys in the dashboard before you swap your environment variables — see [API key management](/best-practices/api-key-management). # RAG with embeddings (/guides/rag-with-embeddings) This guide walks through the smallest useful RAG loop: 1. Split your documents into chunks. 2. Embed each chunk via [`/v1/embeddings`](/api-reference/embeddings). 3. At query time, embed the question, find the closest chunks by cosine similarity, and pass them as context to [`/v1/chat/completions`](/api-reference/chat-completions). The example uses an in-memory store to keep the moving parts visible. In production you'd swap that for a vector database (pgvector, Qdrant, Pinecone, etc.) — the shape of the code stays the same. ## 1. Chunk and embed [#1-chunk-and-embed] Pick a chunking strategy that matches your content. For prose, \~500–1,000 tokens per chunk with \~10% overlap is a sensible default. The example below assumes you've already chunked into strings: ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); type Embedded = { text: string; vector: number[] }; async function embedAll(chunks: string[]): Promise { const res = await client.embeddings.create({ model: "bge-m3", input: chunks, }); return res.data.map((d, i) => ({ text: chunks[i]!, vector: d.embedding })); } ``` `bge-m3` is the default embedding model; see [`/v1/embeddings`](/api-reference/embeddings) for `Qwen3-Embedding-8B` and `bge-multilingual-gemma2` if you need higher dimensions or different language coverage. Embed in batches of 50–100 strings per call to amortize per-request overhead. ## 2. Cosine similarity [#2-cosine-similarity] Cosine similarity is the standard scoring function for normalized embeddings: ```ts function cosine(a: number[], b: number[]): number { let dot = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i]! * b[i]!; normA += a[i]! * a[i]!; normB += b[i]! * b[i]!; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } function topK(query: number[], corpus: Embedded[], k = 4): Embedded[] { return [...corpus] .map((c) => ({ ...c, score: cosine(query, c.vector) })) .sort((a, b) => b.score - a.score) .slice(0, k); } ``` For a few thousand chunks this is fine in memory. Past that, use a vector DB with an ANN index — linear scan stops being free. ## 3. Retrieve, then answer [#3-retrieve-then-answer] Embed the question, retrieve the top-k chunks, and inject them as the system message: ```ts async function answer(question: string, corpus: Embedded[]): Promise { const [{ embedding: qVec }] = ( await client.embeddings.create({ model: "bge-m3", input: [question], }) ).data; const context = topK(qVec, corpus) .map((c, i) => `[chunk ${i + 1}]\n${c.text}`) .join("\n\n"); const completion = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [ { role: "system", content: "Answer using only the provided context. If the context doesn't contain the answer, say you don't know.\n\nContext:\n" + context, }, { role: "user", content: question }, ], }); return completion.choices[0]!.message.content ?? ""; } ``` The "answer using only the context, say you don't know otherwise" instruction is the most important line in the prompt — without it, the model will fall back to its training data and silently confabulate. ## 4. What to improve next [#4-what-to-improve-next] Once the basic loop works, the high-leverage upgrades, in order: * **Cite chunks.** Have the model reference `[chunk 1]`, `[chunk 2]` in its answer; expose those as clickable sources in your UI. * **Better chunking.** Recursive chunking that respects headings / paragraph boundaries usually beats naive character splits. * **Hybrid search.** Combine BM25 (keyword) and vector retrieval; rerank the union with a cross-encoder. Cheap quality wins. * **Move to a real vector DB.** Once you cross \~10k chunks or you need persistence, swap the in-memory `corpus[]` for pgvector or Qdrant. The retrieve-then-answer code stays identical. * **Cache embeddings.** Embeddings of unchanged chunks don't need to be recomputed every deploy. Persist them keyed by chunk hash. For production hardening — retries, rate limits, observability — see [Errors and retries](/best-practices/errors-and-retries) and the [Production checklist](/best-practices/production-checklist). # Bastion API (/) Qubi Bastion is a sovereign Canadian inference platform. One OpenAI-compatible API surface across open-source LLMs (Llama, Mistral, Qwen, GPT-OSS), embeddings, image generation, transcription, and TTS — all hosted on Canadian infrastructure with data residency you can audit. Drop in your existing OpenAI SDK calls — change only the `baseURL` and key. ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const res = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Hello!" }], }); ``` ## Endpoint surface [#endpoint-surface] | Endpoint | What it does | | -------------------------------- | ------------------------------------- | | `GET /v1/models` | List available models | | `POST /v1/chat/completions` | Chat completions (streaming optional) | | `POST /v1/completions` | Legacy text completions | | `POST /v1/responses` | OpenAI Responses API (`store: false`) | | `POST /v1/embeddings` | Vector embeddings | | `POST /v1/images/generations` | Image generation | | `POST /v1/audio/transcriptions` | Speech-to-text (multipart) | | `POST /api/v1/tts/text_to_audio` | Text-to-speech (NVIDIA Riva) | ## Compatibility [#compatibility] Authentication uses a Bearer token (`Authorization: Bearer qbt_...`). Request and response shapes mirror OpenAI's API for `chat.completions`, `completions`, `embeddings`, `images.generations`, and `audio.transcriptions`. The `responses` endpoint requires `store: false`. `text_to_audio` follows NVIDIA Riva's TTS contract. # audio (/sdk/api-reference/audio) The `audio` namespace bundles two unrelated capabilities that happen to share a media type: synthesis (`audio.speech`) and transcription (`audio.transcriptions.create`). ## `audio.speech` [#audiospeech] ```ts client.audio.speech( params: SpeechParams, options?: { signal?: AbortSignal }, ): Promise ``` Synthesizes speech via Bastion's NVIDIA Riva proxy. Returns raw audio bytes plus the upstream `Content-Type`, so you can pick the right file extension without re-parsing the header. ### Parameters [#parameters] ```ts interface SpeechParams { text: string; language_code: string; // e.g. "en-US", "fr-CA" voice_name: string; // e.g. "English-US.Female-1", "French-Canadian.Male-1" encoding: number; // use SpeechEncoding constants below sample_rate_hz: number; // e.g. 44100 for LINEAR_PCM } ``` ### `SpeechEncoding` [#speechencoding] NVIDIA Riva encoding numbers. The SDK forwards `encoding` verbatim — use the constants to avoid magic numbers: ```ts import { SpeechEncoding } from "@qubittron/bastion-sdk"; SpeechEncoding.LINEAR_PCM // 1 SpeechEncoding.FLAC // 2 SpeechEncoding.MULAW // 3 SpeechEncoding.ALAW // 4 SpeechEncoding.OGGOPUS // 5 ``` ### Response [#response] ```ts interface SpeechResponse { audio: Uint8Array; // raw bytes from the upstream contentType: string; // e.g. "audio/wav", "audio/ogg" } ``` ### Example [#example] ```ts import { writeFile } from "node:fs/promises"; import { SpeechEncoding } from "@qubittron/bastion-sdk"; const { audio, contentType } = await client.audio.speech({ text: "Bonjour le monde", language_code: "fr-CA", voice_name: "French-Canadian.Female-1", encoding: SpeechEncoding.LINEAR_PCM, sample_rate_hz: 44100, }); console.log(contentType); // "audio/wav" await writeFile("hello.wav", audio); ``` ## `audio.transcriptions.create` [#audiotranscriptionscreate] ```ts client.audio.transcriptions.create( params: TranscriptionParams, options?: { signal?: AbortSignal }, ): Promise ``` Speech-to-text. Accepts a `Blob` or `File`; the SDK posts as `multipart/form-data`. ### Parameters [#parameters-1] ```ts interface TranscriptionParams { file: Blob | File; model: string; // e.g. "whisper-large-v3" language?: string; // ISO 639-1, optional hint response_format?: "json" | "verbose_json" | "text"; prompt?: string; // biasing prompt temperature?: number; // 0–1 } ``` * `verbose_json` is the only format that returns timestamped `segments`. * `text` returns a plain string body — the SDK still wraps it as `{ text: "..." }`. ### Response [#response-1] ```ts interface TranscriptionSegment { id?: number; start?: number; // seconds end?: number; // seconds text?: string; [key: string]: unknown; } interface TranscriptionResponse { text: string; language?: string; duration?: number; // seconds segments?: TranscriptionSegment[]; [key: string]: unknown; } ``` ### Example [#example-1] ```ts import { readFile } from "node:fs/promises"; const buf = await readFile("speech.wav"); const file = new File([buf], "speech.wav", { type: "audio/wav" }); const res = await client.audio.transcriptions.create({ file, model: "whisper-large-v3", response_format: "verbose_json", }); console.log(res.text); for (const seg of res.segments ?? []) { console.log(`[${seg.start?.toFixed(2)}–${seg.end?.toFixed(2)}] ${seg.text}`); } ``` ### Browser uploads [#browser-uploads] In a browser, pass a `File` straight from an ``: ```ts const file = inputEl.files?.[0]; if (!file) return; const res = await client.audio.transcriptions.create({ file, model: "whisper-large-v3", }); ``` But see [Environments](/sdk/guides/environments) before exposing an API key in browser code — proxy through your server instead. ## Errors [#errors] | When | Class | | ------------------------------------------- | --------------------- | | Missing/invalid `file`, unsupported `model` | `BadRequestError` | | Invalid key | `AuthenticationError` | | Rate-limited | `RateLimitError` | | Riva / Whisper down | `UpstreamError` | | Network failure | `APIConnectionError` | # chat.completions (/sdk/api-reference/chat) ```ts // Discriminated overloads: client.chat.completions.create( params: ChatCompletionCreateParamsNonStreaming, options?: { signal?: AbortSignal }, ): Promise; client.chat.completions.create( params: ChatCompletionCreateParamsStreaming, options?: { signal?: AbortSignal }, ): Promise>; ``` The primary LLM entry point. Mirrors OpenAI's `chat.completions.create` shape — most code written against the OpenAI SDK ports by changing the import and the `baseURL`. The return type is discriminated by `params.stream`: when `stream: true`, you get a `Stream`; otherwise a `ChatCompletion`. Pass an `AbortSignal` via the second argument to cancel — see [Streaming → cancellation](/sdk/guides/streaming#cancellation). ## Parameters [#parameters] `ChatCompletionCreateParams` is the union of the streaming and non-streaming variants: | Field | Type | Required | Notes | | ------------- | --------------- | -------- | ------------------------------------------------------------------ | | `model` | `string` | yes | Any LLM model id — see [`models.list`](/sdk/api-reference/models). | | `messages` | `ChatMessage[]` | yes | OpenAI message format. Re-exported from `@qubi_bastion/shared`. | | `temperature` | `number` | no | | | `max_tokens` | `number` | no | | | `top_p` | `number` | no | | | `stream` | `boolean` | no | When `true`, returns a `Stream`. | Other OpenAI-compatible fields (`tools`, `tool_choice`, `response_format`, `seed`, …) pass through unchanged to the upstream — the gateway does not strip unknown fields. The SDK's static types only cover the listed fields; the rest are accepted at the HTTP layer. ## Non-streaming [#non-streaming] ```ts const res = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Reply with exactly: ok" }], max_tokens: 16, }); console.log(res.choices[0]?.message.content); console.log(res.usage.total_tokens); ``` ### `ChatCompletion` [#chatcompletion] ```ts interface ChatCompletion { id: string; object: "chat.completion"; created: number; // unix seconds model: string; choices: ChatChoice[]; usage: TokenUsage; // { prompt_tokens, completion_tokens, total_tokens } } ``` `ChatChoice`, `ChatMessage`, and `TokenUsage` are re-exported from `@qubittron/bastion-sdk` (originally defined in `@qubi_bastion/shared`); import them from the SDK. ## Streaming [#streaming] ```ts const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Write a haiku about Canada." }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta.content ?? ""); } ``` The async iterator yields one chunk per SSE `data:` event. The terminating `data: [DONE]` is consumed internally — your loop ends naturally on the final `next()`. See [`Stream`](/sdk/api-reference/streaming) for cancellation. ### `ChatCompletionChunk` [#chatcompletionchunk] ```ts interface ChatCompletionChunk { id: string; object: "chat.completion.chunk"; created: number; model: string; choices: ChatChoiceDelta[]; usage?: TokenUsage; // provider-dependent; some emit it only on the final chunk } interface ChatChoiceDelta { index: number; delta: { role?: ChatMessage["role"]; content?: string; reasoning?: string; // OVH GPT-OSS, OpenAI Responses convention reasoning_content?: string; // vLLM --enable-reasoning, DeepSeek-R1 }; finish_reason: "stop" | "length" | "content_filter" | null; } ``` ### Reasoning tokens [#reasoning-tokens] Bastion forwards reasoning / chain-of-thought tokens from upstreams that emit them. The field name varies by provider: * `reasoning` — OVH GPT-OSS (matches OpenAI Responses API). * `reasoning_content` — vLLM with `--enable-reasoning`, DeepSeek-R1. * Inline `…` inside `content` — Qwen3 on stock vLLM. Consumers should treat `delta.reasoning ?? delta.reasoning_content` as the reasoning channel, and strip `…` from `content` when present. ## Cancellation [#cancellation] Pass an `AbortSignal` as the second argument: ```ts const ac = new AbortController(); setTimeout(() => ac.abort(), 30_000); const res = await client.chat.completions.create( { model: "gpt-oss-120b", messages }, { signal: ac.signal }, ); ``` On a streamed call, breaking out of the `for await` loop releases the reader and the upstream connection closes shortly after. See [Streaming → cancellation](/sdk/guides/streaming#cancellation) for the abort-vs-break tradeoff. ## Errors [#errors] This call throws subclasses of `BastionError`. Common ones: | When | Class | | --------------------------------------- | ----------------------- | | Missing `model` or malformed `messages` | `BadRequestError` | | Invalid key | `AuthenticationError` | | Quota exhausted | `PermissionDeniedError` | | Rate-limited | `RateLimitError` | | Upstream provider down | `UpstreamError` | | Network failure | `APIConnectionError` | See [Errors](/sdk/api-reference/errors) for the full mapping. # Bastion client (/sdk/api-reference/client) The `Bastion` class is the only stateful object in the SDK. Construct it once at startup and reuse it for the lifetime of your process. ## Signature [#signature] ```ts class Bastion { constructor(options?: ClientOptions); readonly apiKey: string; readonly baseURL: string; readonly chat: ChatResource; readonly models: ModelsResource; readonly images: ImagesResource; readonly audio: AudioResource; } ``` ## ClientOptions [#clientoptions] | Field | Type | Default | Notes | | ---------------- | ------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------- | | `apiKey` | `string` | `process.env.BASTION_API_KEY` | Required at runtime. Constructor throws if missing in both places. | | `baseURL` | `string` | `https://api.bastion.qubittron.com` | Trailing slash is stripped. Override for self-hosted gateways or local dev. | | `fetch` | `typeof globalThis.fetch` | `globalThis.fetch.bind(globalThis)` | Inject a custom fetch (e.g. `undici` with a connect timeout, or a proxy-aware fetch). | | `defaultHeaders` | `Record` | `{}` | Sent on every request. **Cannot override `Authorization`** — the SDK applies the Bearer token last. | ```ts import { Bastion } from "@qubittron/bastion-sdk"; const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, baseURL: "https://api.bastion.qubittron.com", defaultHeaders: { "x-trace-id": crypto.randomUUID(), }, }); ``` ## Auth precedence [#auth-precedence] 1. `options.apiKey` (constructor arg) 2. `process.env.BASTION_API_KEY` (Node / Bun) 3. **Throw** — synchronously, with a message pointing the caller at either fix. The SDK never reads `OPENAI_API_KEY` or any other variable. ## What's reachable from the client [#whats-reachable-from-the-client] * [`client.chat.completions.create(params)`](/sdk/api-reference/chat) * [`client.models.list()`](/sdk/api-reference/models) * [`client.images.generate(params)`](/sdk/api-reference/images) * [`client.audio.speech(params)`](/sdk/api-reference/audio#audiospeech) * [`client.audio.transcriptions.create(params)`](/sdk/api-reference/audio#audiotranscriptionscreate) ## Header handling [#header-handling] Every request sends: ``` Accept: application/json (or text/event-stream when streaming) User-Agent: @qubittron/bastion-sdk/ Content-Type: application/json (omitted for multipart requests) Authorization: Bearer ``` `defaultHeaders` and per-call headers merge into the above. `Authorization` is always applied last — by design — so a stray `defaultHeaders.Authorization` cannot leak a wrong key onto a request. ## Lifecycle [#lifecycle] `Bastion` is stateless beyond the fields documented above. No background timers, no keepalive pool. Garbage-collecting a client is safe; you don't need to call a `close()` method. # Errors (/sdk/api-reference/errors) Every error thrown by the SDK is a subclass of `BastionError`. Branch on `instanceof` — never on `name` or `message`. ## `BastionError` [#bastionerror] ```ts class BastionError extends Error { readonly status: number; // HTTP status, or 0 for network errors readonly code: string | undefined; // upstream `error.code` if present readonly type: string | undefined; // upstream `error.type` if present readonly body: unknown; // parsed JSON body, or raw string, or cause } ``` `message` defaults to the upstream `error.message`, falling back to a status-appropriate string (`"Invalid API key"`, `"Rate limit exceeded"`, …) if the body is empty or unparseable. ## HTTP → class mapping [#http--class-mapping] | HTTP status | Class | | ----------------------- | --------------------------------- | | 400 | `BadRequestError` | | 401 | `AuthenticationError` | | 402, 403 | `PermissionDeniedError` | | 404 | `NotFoundError` | | 429 | `RateLimitError` | | 502, 503, 504 | `UpstreamError` | | other 4xx/5xx | `APIError` | | network / fetch failure | `APIConnectionError` (status `0`) | All eight classes inherit from `BastionError`, so a single `catch (err) { if (err instanceof BastionError) ... }` catches everything API-related. ## Class signatures [#class-signatures] Most subclasses share `BastionError`'s constructor — they just narrow the type: ```ts type ConstructorOpts = { status: number; code?: string; type?: string; body?: unknown; }; class BadRequestError extends BastionError { constructor(message: string, opts: ConstructorOpts) } // 400 class AuthenticationError extends BastionError { constructor(message: string, opts: ConstructorOpts) } // 401 class PermissionDeniedError extends BastionError { constructor(message: string, opts: ConstructorOpts) } // 402, 403 class NotFoundError extends BastionError { constructor(message: string, opts: ConstructorOpts) } // 404 class RateLimitError extends BastionError { constructor(message: string, opts: ConstructorOpts) } // 429 class UpstreamError extends BastionError { constructor(message: string, opts: ConstructorOpts) } // 502, 503, 504 class APIError extends BastionError { constructor(message: string, opts: ConstructorOpts) } // catch-all 4xx/5xx ``` `APIConnectionError` is shaped differently — it wraps a thrown cause rather than an HTTP response: ```ts class APIConnectionError extends BastionError { constructor(message: string, cause?: unknown); // status is forced to 0; cause is stored on `body`. } ``` So `err.status === 0` and `err.body === cause` for connection errors. Everything else has the HTTP status and a parsed (or raw) response body. ## Branching example [#branching-example] ```ts import { Bastion, AuthenticationError, BadRequestError, PermissionDeniedError, RateLimitError, UpstreamError, APIConnectionError, BastionError, } from "@qubittron/bastion-sdk"; const client = new Bastion(); try { await client.chat.completions.create({ model: "x", messages: [] }); } catch (err) { if (err instanceof RateLimitError) { // exponential backoff and retry } else if (err instanceof AuthenticationError) { // rotate / refresh credentials } else if (err instanceof PermissionDeniedError) { // quota exhausted or content blocked — bubble to the user } else if (err instanceof BadRequestError) { // a bug in our request — log err.body, do not retry } else if (err instanceof UpstreamError) { // try a different model or back off } else if (err instanceof APIConnectionError) { // transient network — retry with jitter } else if (err instanceof BastionError) { // some other 4xx/5xx } else { throw err; // not from the SDK — rethrow } } ``` ## Retry semantics [#retry-semantics] The SDK **does not retry** automatically (as of `0.1`). Build a thin wrapper on top: ```ts async function withRetry(fn: () => Promise, max = 3): Promise { let attempt = 0; while (true) { try { return await fn(); } catch (err) { attempt += 1; const retriable = err instanceof RateLimitError || err instanceof UpstreamError || err instanceof APIConnectionError; if (!retriable || attempt >= max) throw err; await new Promise((r) => setTimeout(r, 2 ** attempt * 250 + Math.random() * 250)); } } } ``` See [error-handling guide](/sdk/guides/error-handling) for a fuller treatment. ## Inspecting the body [#inspecting-the-body] The raw parsed body is on `err.body`. For API errors it follows OpenAI's shape: ```json { "error": { "message": "...", "type": "invalid_request_error", "code": "invalid_api_key" } } ``` `err.code` and `err.type` are populated from this body when present. The raw `body` field is preserved for forensics — log it with care, the upstream may include the offending request fragment. # images (/sdk/api-reference/images) ```ts client.images.generate( params: ImageGenerateParams, options?: { signal?: AbortSignal }, ): Promise ``` Text-to-image generation. OpenAI-compatible response shape. ## Example [#example] ```ts const res = await client.images.generate({ model: "sd-xl", prompt: "a sovereign Canadian moose, oil painting", size: "1024x1024", n: 1, }); console.log(res.data[0]?.url); ``` ## Parameters [#parameters] ```ts interface ImageGenerateParams { model: string; prompt: string; size?: string; // e.g. "1024x1024" quality?: string; // upstream-specific n?: number; // number of images response_format?: "url" | "b64_json"; // default: upstream default ("url") } ``` * `size` follows the OpenAI `WxH` format; valid combinations depend on the upstream model. * `response_format: "b64_json"` returns base64-encoded image bytes inline. Use `"url"` (the default) when you want CDN URLs. ## Response [#response] ```ts interface ImageData { url?: string; b64_json?: string; } interface ImageGenerateResponse { data: ImageData[]; created: number; // unix seconds model?: string; // echoed back by some upstreams } ``` Each entry has either `url` or `b64_json` populated, never both. ## Saving a result [#saving-a-result] ```ts import { writeFile } from "node:fs/promises"; const res = await client.images.generate({ model: "sd-xl", prompt: "a maple leaf in the style of A.Y. Jackson", response_format: "b64_json", }); const b64 = res.data[0]?.b64_json; if (b64) { await writeFile("out.png", Buffer.from(b64, "base64")); } ``` ## Errors [#errors] | When | Class | | ------------------------------------- | ----------------------- | | Invalid `model` or unsupported `size` | `BadRequestError` | | Invalid key | `AuthenticationError` | | Quota / content policy | `PermissionDeniedError` | | Rate-limited | `RateLimitError` | | Upstream issue | `UpstreamError` | # API reference (/sdk/api-reference) The SDK's public surface is exported from a single entry point: `@qubittron/bastion-sdk`. Everything documented here is type-checked against the source in [`packages/sdk/src`](https://github.com/qubittron/qubi_bastion/tree/main/packages/sdk/src). ## Entry point [#entry-point] ```ts import { Bastion, Stream, // error classes BastionError, APIError, APIConnectionError, AuthenticationError, BadRequestError, NotFoundError, PermissionDeniedError, RateLimitError, UpstreamError, // constants SpeechEncoding, } from "@qubittron/bastion-sdk"; import type { // chat ChatCompletion, ChatCompletionChunk, ChatCompletionCreateParams, ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming, ChatChoiceDelta, // client ClientOptions, // images ImageData, ImageGenerateParams, ImageGenerateResponse, // models Model, ModelList, // audio SpeechParams, SpeechResponse, TranscriptionParams, TranscriptionResponse, TranscriptionSegment, } from "@qubittron/bastion-sdk"; ``` `ChatChoice`, `ChatMessage`, and `TokenUsage` are re-exported from `@qubi_bastion/shared` and are part of the public type surface. ## Resources [#resources] # models (/sdk/api-reference/models) ```ts client.models.list(options?: { signal?: AbortSignal }): Promise ``` Returns every model your API key can call. The list is account-scoped — if a feature is gated, models you don't have access to will not appear. ## Example [#example] ```ts const { data } = await client.models.list(); for (const m of data) { console.log(m.id, m.owned_by); } ``` ## Types [#types] ```ts interface Model { id: string; object: "model"; created?: number; // unix seconds, if upstream provides it owned_by?: string; } interface ModelList { object: "list"; data: Model[]; } ``` `Model.id` is what you pass as `params.model` to [`chat.completions.create`](/sdk/api-reference/chat) and the other resources. Treat ids as opaque strings — Bastion does not promise stability across versions for non-OSS upstreams. ## Caching [#caching] The SDK does not cache. If you call `models.list()` on a hot path, wrap it yourself or call it once at process start. ## Errors [#errors] | When | Class | | --------------- | --------------------- | | Invalid key | `AuthenticationError` | | Network failure | `APIConnectionError` | | Upstream issue | `UpstreamError` | # Stream (/sdk/api-reference/streaming) ```ts class Stream implements AsyncIterable { [Symbol.asyncIterator](): AsyncIterator; } ``` `Stream` is what `chat.completions.create({ stream: true })` returns. It wraps a `ReadableStream` and turns server-sent-events into a typed async iterable. You typically don't construct `Stream` yourself — but the class is exported for advanced use (custom parsers, testing). ## Iteration [#iteration] The `for await` loop yields one parsed event per SSE message: ```ts const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "hi" }], stream: true, }); for await (const chunk of stream) { // chunk is a ChatCompletionChunk } ``` ### Parsing rules [#parsing-rules] * The stream consumes the body chunk-by-chunk and buffers across reads. * Events are split on `\n\n` or `\r\n\r\n` (whichever appears first in the buffer). * Only lines starting with `data:` contribute to a parsed message. The optional leading space after the colon is stripped. * Multi-line `data:` lines are joined with newlines (SSE spec). * `data: [DONE]` terminates the iterator cleanly — your `for await` loop ends after the chunk preceding `[DONE]`. * Non-JSON `data:` lines (e.g. heartbeats, comments) are dropped silently. * A trailing event without a terminator is flushed at end-of-stream. ### Yield shape [#yield-shape] For `chat.completions.create({ stream: true })`, the yielded type is `ChatCompletionChunk`: ```ts interface ChatCompletionChunk { id: string; object: "chat.completion.chunk"; created: number; model: string; choices: ChatChoiceDelta[]; usage?: TokenUsage; // present only on the final chunk for providers that emit it } ``` The reader is released in a `finally` block, so abnormal termination (a `throw` inside your loop, a `break`) closes the underlying connection deterministically. ## Cancellation [#cancellation] ```ts const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages, stream: true, }); for await (const chunk of stream) { if (shouldStop()) break; // releases the reader, closes the connection emit(chunk); } ``` ### Abort via `AbortSignal` [#abort-via-abortsignal] Every resource method accepts an optional `{ signal }` as a second argument: ```ts const ac = new AbortController(); setTimeout(() => ac.abort(), 30_000); const stream = await client.chat.completions.create( { model: "gpt-oss-120b", messages, stream: true }, { signal: ac.signal }, ); ``` Behavior depends on **when** the signal fires: * **Before the response opens** — the abort surfaces at the initial `create()` call as `APIConnectionError` (wrapping the runtime's abort error). * **Mid-stream** — the reader's next `read()` rejects with the runtime's native abort error (`AbortError` in most runtimes). It propagates **raw** out of the `for await` loop; the SDK does not re-wrap it. You can also wire a global signal via a custom fetch (see [custom fetch → timeouts](/sdk/guides/custom-fetch#per-request-timeout)) when you want a single budget across every call. ## Errors during iteration [#errors-during-iteration] Network-level failures mid-stream surface as exceptions thrown from the iterator — wrap the loop in a `try/catch`: ```ts try { for await (const chunk of stream) { // ... } } catch (err) { // err is typically the runtime's AbortError or a TypeError from the reader } ``` Note: API-level errors (4xx / 5xx) happen at the **initial** `create()` call, not mid-stream. Once the HTTP response opens with `200 OK`, the iterator either yields chunks or terminates on `[DONE]` — Bastion does not embed error events in the stream body. # Best practices (/sdk/best-practices) A short, opinionated list. Treat it as a checklist, not a tutorial. ## Secrets [#secrets] * **Never embed `BASTION_API_KEY` in client-side bundles.** Bundlers will inline it. Proxy through your server. See [Environments → Browsers](/sdk/guides/environments). * **Rotate quarterly**, and on any suspected leak. The SDK reads the env var on construction — restart the process to pick up a new key. * **Use a key per environment** (dev, staging, prod). Mixing them masks misuse and complicates revocation. * **Don't log `err.body` raw** when handling errors — upstreams sometimes echo request fragments that include user content. Log `status`, `code`, `type`, and a redacted view of `body`. ## Timeouts [#timeouts] `fetch` has no default timeout. A hung connection on a hot path will pin a worker until the runtime kills it (minutes, sometimes never). Always wire one: ```ts const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, fetch: (url, init) => { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), 60_000); return globalThis.fetch(url, { ...init, signal: ac.signal }) .finally(() => clearTimeout(timer)); }, }); ``` For streaming, the "timeout" you want is usually time-to-first-byte plus an inactivity timer between chunks — not a wall-clock budget — so consumers can complete long generations. Implement that in your iteration loop, not in fetch. ## Retries [#retries] * **Retry**: `RateLimitError`, `UpstreamError`, `APIConnectionError`. * **Do not retry**: `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`. These are deterministic. * **Cap retries** (3–5 typical). Exponential backoff with jitter. * For batch / offline jobs, lean on the rate limiter — don't hammer. A reference implementation lives in [Error handling → Retries](/sdk/guides/error-handling#retries). ## Concurrency [#concurrency] * Bastion enforces a per-key concurrent-request ceiling. Going over it surfaces as `RateLimitError`. * Use a semaphore on the client side if your traffic is bursty. A simple `p-limit` of 20–50 concurrent calls per worker is a reasonable starting point. ## Observability [#observability] * Tag each request with a stable id and propagate it via `defaultHeaders` (e.g. `x-trace-id`) so server-side logs join your traces. * Measure latency in your fetch wrapper (see [Custom fetch → Tracing](/sdk/guides/custom-fetch#tracing--observability)). * On error, capture `{ status, code, type, model, requestId }` at minimum. Add an exemplar `err.body` sample at debug level. ## Cost / token control [#cost--token-control] * **Set `max_tokens`** for every chat completion. Without it, you're paying for whatever the model decides to say. A budget of `512–2048` covers most chat UIs. * **Choose the cheapest model that meets quality**: `Llama-3.1-8B-Instruct` or `Mistral-7B-Instruct-v0.3` for simple tasks; reserve `gpt-oss-120b` and `Meta-Llama-3_3-70B-Instruct` for heavyweight reasoning. * **Cache aggressively** for deterministic prompts (`temperature: 0`). A simple keyed cache (prompt-hash → response) cuts steady-state costs by 30–60% on most apps. * **Stream UIs** improve perceived latency but cost the same — don't stream for batch jobs. ## Model selection [#model-selection] * Call [`models.list()`](/sdk/api-reference/models) at startup to verify the models you depend on exist for this key. * For multi-tenant apps, let your tenants choose from a curated allowlist — surfacing the raw list invites support load. * Pin specific model versions where available (`Mistral-Small-3.2-24B-Instruct-2506`); behavior of unversioned model ids may shift. ## Testing [#testing] * Inject a fake `fetch` (see [Custom fetch → Mocking](/sdk/guides/custom-fetch#mocking-in-tests)) rather than hitting the real API in unit tests. * For one or two end-to-end smoke tests, use a separate sandbox key with a low spend cap. * Pin streaming-test fixtures (raw SSE bytes) — Bastion's parser handles `\r\n\r\n` and `\n\n` event separators, multi-line `data:`, and `[DONE]`. Cover all three in fixtures. ## Versioning [#versioning] * Pre-1.0 (`v0.x`), pin to a minor in `package.json` (e.g. `"~0.1.0"`). The patch range is safe; minors may rename types. * Read the changelog before bumping a minor. Migrations are usually one rename. ## Multi-tenancy [#multi-tenancy] * One client per process is fine. Don't construct a new `Bastion` per request — there's no connection pool to warm, but you do allocate closures and headers needlessly. * For per-tenant keys, hold a `Map` and evict on logout / key rotation. # Examples (/sdk/examples) Every snippet here is self-contained — drop it into a file, set `BASTION_API_KEY`, and run. ## Streaming token-by-token to stdout [#streaming-token-by-token-to-stdout] ```ts import { Bastion } from "@qubittron/bastion-sdk"; const client = new Bastion(); const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Explain TLS in two sentences." }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta.content ?? ""); } process.stdout.write("\n"); ``` ## Streaming to an HTTP client (Hono) [#streaming-to-an-http-client-hono] Re-emit SSE chunks to a browser so the user sees output as it generates: ```ts import { Hono } from "hono"; import { Bastion } from "@qubittron/bastion-sdk"; const app = new Hono(); const client = new Bastion(); app.post("/chat", async (c) => { const { messages } = await c.req.json(); const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages, stream: true, }); return new Response( new ReadableStream({ async start(controller) { const enc = new TextEncoder(); try { for await (const chunk of stream) { controller.enqueue(enc.encode(`data: ${JSON.stringify(chunk)}\n\n`)); } controller.enqueue(enc.encode("data: [DONE]\n\n")); } finally { controller.close(); } }, }), { headers: { "Content-Type": "text/event-stream" } }, ); }); export default app; ``` ## JSON-mode output [#json-mode-output] `response_format` and other OpenAI-compatible fields aren't on the SDK's static type but pass through to the upstream verbatim. Build the params object with an intersection type so TS still checks the rest: ```ts import type { ChatCompletionCreateParamsNonStreaming } from "@qubittron/bastion-sdk"; type WithJsonMode = ChatCompletionCreateParamsNonStreaming & { response_format: { type: "json_object" }; }; const params: WithJsonMode = { model: "gpt-oss-120b", response_format: { type: "json_object" }, messages: [ { role: "system", content: "Respond with JSON: { city: string, country: string }." }, { role: "user", content: "Where is the CN Tower?" }, ], }; const res = await client.chat.completions.create(params); const data = JSON.parse(res.choices[0]?.message.content ?? "{}"); console.log(data.city, data.country); ``` ## Tool calling (passthrough) [#tool-calling-passthrough] Bastion forwards `tools` / `tool_choice` unchanged. Same pattern — extend the param type rather than casting to `object`: ```ts import type { ChatCompletionCreateParamsNonStreaming, } from "@qubittron/bastion-sdk"; type ToolDef = { type: "function"; function: { name: string; parameters: Record; }; }; type WithTools = ChatCompletionCreateParamsNonStreaming & { tools: ToolDef[]; tool_choice: "auto" | "none"; }; const params: WithTools = { model: "gpt-oss-120b", messages: [{ role: "user", content: "What's the weather in Toronto?" }], tools: [ { type: "function", function: { name: "get_weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"], }, }, }, ], tool_choice: "auto", }; const res = await client.chat.completions.create(params); const toolCalls = (res.choices[0]?.message as { tool_calls?: unknown[] }).tool_calls; ``` Whether tool calls actually fire depends on the upstream model — `gpt-oss-120b` and `Mistral-Small-3.2` support them; safety/guard models do not. ## Whisper transcription → summarize [#whisper-transcription--summarize] A two-step pipeline that's common in voice apps: ```ts import { readFile } from "node:fs/promises"; import { Bastion } from "@qubittron/bastion-sdk"; const client = new Bastion(); const buf = await readFile("meeting.wav"); const file = new File([buf], "meeting.wav", { type: "audio/wav" }); const transcript = await client.audio.transcriptions.create({ file, model: "whisper-large-v3", }); const summary = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [ { role: "system", content: "Summarize this transcript in five bullets." }, { role: "user", content: transcript.text }, ], }); console.log(summary.choices[0]?.message.content); ``` ## Text → speech file [#text--speech-file] ```ts import { writeFile } from "node:fs/promises"; import { Bastion, SpeechEncoding } from "@qubittron/bastion-sdk"; const client = new Bastion(); const { audio, contentType } = await client.audio.speech({ text: "The quick brown fox jumps over the lazy dog.", language_code: "en-US", voice_name: "English-US.Female-1", encoding: SpeechEncoding.LINEAR_PCM, sample_rate_hz: 44100, }); const ext = contentType.includes("ogg") ? "ogg" : "wav"; await writeFile(`out.${ext}`, audio); ``` ## Embeddings → cosine search [#embeddings--cosine-search] Embeddings are on the Bastion HTTP API but not yet wrapped by the SDK (see roadmap in the SDK README). Until then, call the HTTP endpoint with the OpenAI SDK pointed at Bastion: ```ts import OpenAI from "openai"; const openai = new OpenAI({ baseURL: "https://api.qubittron.ai/v1", apiKey: process.env.QUBITTRON_API_KEY, }); const res = await openai.embeddings.create({ model: "bge-large-en-v1.5", input: "sovereign Canadian inference", }); const vec = res.data[0]?.embedding; ``` See the [Bastion API docs](/api-reference/embeddings) for the full HTTP shape. ## Retry wrapper [#retry-wrapper] The `withRetry` helper from [error handling](/sdk/guides/error-handling) applied to every SDK call: ```ts import { Bastion, RateLimitError, UpstreamError, APIConnectionError, } from "@qubittron/bastion-sdk"; const client = new Bastion(); async function withRetry(fn: () => Promise, max = 4): Promise { let attempt = 0; while (true) { try { return await fn(); } catch (err) { attempt += 1; const retriable = err instanceof RateLimitError || err instanceof UpstreamError || err instanceof APIConnectionError; if (!retriable || attempt >= max) throw err; await new Promise((r) => setTimeout(r, 2 ** attempt * 250 + Math.random() * 250)); } } } const res = await withRetry(() => client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "hi" }], }), ); ``` # Getting started (/sdk/getting-started) ## 1. Install [#1-install] ```sh npm install @qubittron/bastion-sdk # or pnpm add @qubittron/bastion-sdk # or bun add @qubittron/bastion-sdk # or yarn add @qubittron/bastion-sdk ``` Requirements: * **Node** ≥ 20 (uses global `fetch` and `ReadableStream`) * **Bun**, **Deno** (with Node compatibility), or any edge runtime exposing `fetch` * **Browsers**: any with global `fetch` — but see [Environments](/sdk/guides/environments) before shipping a browser bundle (your API key would leak). ## 2. Get an API key [#2-get-an-api-key] 1. Sign up at [qubittron.ai](https://qubittron.ai) and verify your phone number. 2. Open the dashboard → **API keys** → **Create key**. Keys start with `qbt_` and are shown only once. 3. Set the key in your environment: ```bash export BASTION_API_KEY=qbt_your_key_here ``` The SDK reads `BASTION_API_KEY` automatically when you don't pass `apiKey` to the constructor. ## 3. Construct a client [#3-construct-a-client] ```ts import { Bastion } from "@qubittron/bastion-sdk"; const client = new Bastion(); // reads BASTION_API_KEY from env // or, explicitly: const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, }); ``` If `apiKey` is missing and `BASTION_API_KEY` is unset, the constructor throws synchronously. See [`Bastion` API](/sdk/api-reference/client) for all options. ## 4. Make your first call [#4-make-your-first-call] ```ts const res = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Reply with exactly: ok" }], max_tokens: 16, }); console.log(res.choices[0]?.message.content); console.log(res.usage); // { prompt_tokens, completion_tokens, total_tokens } ``` ## 5. Stream tokens [#5-stream-tokens] Pass `stream: true` to get an `AsyncIterable` over `ChatCompletionChunk`s: ```ts const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Write a haiku about Canada." }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta.content ?? ""); } ``` See the [streaming guide](/sdk/guides/streaming) for cancellation, partial-tool-call handling, and reasoning tokens. ## 6. Handle errors [#6-handle-errors] ```ts import { AuthenticationError, RateLimitError, } from "@qubittron/bastion-sdk"; try { await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "hi" }], }); } catch (err) { if (err instanceof RateLimitError) { // back off } else if (err instanceof AuthenticationError) { // refresh / rotate the key } else { throw err; } } ``` Every API error is a subclass of `BastionError` and carries `status`, `code`, `type`, and the raw `body`. See [error handling](/sdk/guides/error-handling). ## Next steps [#next-steps] # Custom fetch (/sdk/guides/custom-fetch) `new Bastion({ fetch })` accepts any function with the standard `fetch` signature. The SDK uses it for every HTTP call (regular and streaming), so anything you bolt onto it applies uniformly. ```ts new Bastion({ apiKey: process.env.BASTION_API_KEY, fetch: (url, init) => globalThis.fetch(url, init), // identity — the default }); ``` ## Per-request timeout [#per-request-timeout] `fetch` itself has no timeout. Wrap it with an `AbortController`: ```ts const withTimeout = (ms: number): typeof globalThis.fetch => (url, init) => { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms); return globalThis.fetch(url, { ...init, signal: ac.signal }).finally(() => clearTimeout(timer), ); }; const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, fetch: withTimeout(30_000), }); ``` For streaming calls, the timeout fires from the moment the request starts — including the time spent iterating the stream. If you want a "time-to-first-byte" timeout, gate the signal on a flag you flip when iteration begins. ## Proxy [#proxy] In Node, use `undici`'s `ProxyAgent`: ```ts import { fetch, ProxyAgent, type RequestInit } from "undici"; const dispatcher = new ProxyAgent("http://proxy.internal:8080"); const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, fetch: ((url, init) => fetch(url, { ...(init as RequestInit), dispatcher })) as typeof globalThis.fetch, }); ``` `undici`'s `fetch` is a near-superset of the spec — the `dispatcher` field is the proxy hook. ## Tracing / observability [#tracing--observability] Wrap fetch to attach request ids and time each call: ```ts const traced: typeof globalThis.fetch = async (url, init) => { const requestId = crypto.randomUUID(); const started = performance.now(); const headers = new Headers(init?.headers); headers.set("x-trace-id", requestId); try { const res = await globalThis.fetch(url, { ...init, headers }); metrics.observe("bastion_call_ms", performance.now() - started, { status: String(res.status), }); return res; } catch (err) { metrics.increment("bastion_call_error", { requestId }); throw err; } }; const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, fetch: traced }); ``` The SDK applies `Authorization: Bearer ` **last**, just before invoking your custom `fetch`. By then the header is in `init.headers` — your wrapper can read it (don't log it) but `defaultHeaders.Authorization` cannot leak a wrong key onto a request, because the SDK overwrites it. ## Mocking in tests [#mocking-in-tests] The cleanest way to test code that uses the SDK: inject a fake fetch. ```ts import { Bastion } from "@qubittron/bastion-sdk"; import { expect, test } from "vitest"; test("happy path", async () => { const fakeFetch: typeof globalThis.fetch = async () => new Response( JSON.stringify({ id: "cmpl_x", object: "chat.completion", created: 0, model: "gpt-oss-120b", choices: [ { index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop", }, ], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); const client = new Bastion({ apiKey: "test", fetch: fakeFetch }); const res = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "hi" }], }); expect(res.choices[0]?.message.content).toBe("ok"); }); ``` For streaming, return a `Response` whose body is a `ReadableStream` emitting SSE-formatted bytes. ## Order of operations inside the SDK [#order-of-operations-inside-the-sdk] For each request: 1. Compose `headers` from `Accept`, `User-Agent`, `defaultHeaders`, per-call headers (in that order). 2. Set `Content-Type` to `application/json` for JSON bodies; strip it for multipart so the runtime can set its own boundary. 3. Stringify the body (or pass through `FormData` for multipart). 4. Apply `Authorization: Bearer ` last — the SDK overwrites any `defaultHeaders.Authorization`. (The header is visible to your fetch wrapper at this point; do not log it.) 5. Call your `fetch`. 6. On a network error, throw `APIConnectionError`. On a non-2xx, parse the body and throw the matching `BastionError` subclass. That predictable shape makes a custom fetch a safe place for cross-cutting concerns. # Environments (/sdk/guides/environments) The SDK is built against the Fetch standard. Anywhere `fetch`, `Response`, `Request`, and `ReadableStream` are globals, the SDK should work. | Runtime | Status | Notes | | ---------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Node.js 20+** | ✅ First-class | `fetch` is global since Node 18, stable in 20. | | **Node.js 18** | ⚠ Works | Same global `fetch`, but technically experimental. Upgrade to 20 if you can. | | **Bun** | ✅ First-class | Drop-in replacement for Node for SDK purposes. | | **Deno** | ✅ Works | Use `npm:` specifier. Pass `apiKey` explicitly via `Deno.env.get`; the SDK's `process.env.BASTION_API_KEY` fallback only fires under Deno's Node-compat mode. | | **Cloudflare Workers** | ✅ Works | No `process` — pass `apiKey` explicitly via the binding. | | **Vercel Edge** | ✅ Works | Same as Workers — pass `apiKey` explicitly. | | **Browsers (modern)** | ⚠ Works but **don't** | Your API key would leak. Proxy through your server. | | **React Native** | ⚠ Use with care | Requires a `fetch` polyfill that supports streaming bodies if you stream. | ## Node.js [#nodejs] Nothing to configure: ```ts import { Bastion } from "@qubittron/bastion-sdk"; const client = new Bastion(); // reads BASTION_API_KEY ``` Module formats: both ESM (`.mjs`) and CJS (`.cjs`) are shipped — TS types included for both. ## Bun [#bun] Identical to Node: ```ts const client = new Bastion({ apiKey: Bun.env.BASTION_API_KEY }); ``` ## Cloudflare Workers / Vercel Edge [#cloudflare-workers--vercel-edge] `process.env` does not exist on these runtimes. Pass the key explicitly from the binding/env: ```ts export default { async fetch(req: Request, env: { BASTION_API_KEY: string }) { const client = new Bastion({ apiKey: env.BASTION_API_KEY }); // ... }, }; ``` The SDK uses no Node-only APIs (`fs`, `crypto`, `buffer`) on the hot path. Streaming via `ReadableStream` works. ## Deno [#deno] ```ts import { Bastion } from "npm:@qubittron/bastion-sdk"; const client = new Bastion({ apiKey: Deno.env.get("BASTION_API_KEY") }); ``` Always pass `apiKey` explicitly under Deno. The SDK's automatic `process.env.BASTION_API_KEY` fallback assumes a Node `process` object exists, which only happens under Deno's Node-compatibility mode — relying on it is fragile across Deno versions. ## Browsers — don't ship your key [#browsers--dont-ship-your-key] The SDK runs in browsers, but **never put a real API key in browser-bundled code**: * Static-site bundlers (Vite, Next.js client components, Astro) will embed any value you import from `process.env.SOMETHING` into the JS sent to the user. * Even a "short-lived" key visible in DevTools is a key in the wild. The right pattern: proxy through your server. ```ts // server (Hono, Express, Next.js route handler, etc.) const client = new Bastion(); app.post("/api/chat", async (c) => { const body = await c.req.json(); const res = await client.chat.completions.create(body); return c.json(res); }); // browser const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ model: "gpt-oss-120b", messages }), }); ``` If you *must* call directly from the browser (e.g. an internal admin tool, an authenticated app where the key is per-user and short-lived), set up a key-per-user system on your backend — never share one global key across users. ## React Native [#react-native] The SDK uses streaming bodies for SSE. Older RN versions ship a `fetch` polyfill that does not stream — `response.body` may be `null`. Verify on your target before relying on `stream: true`. Non-streaming calls work everywhere. ## Polyfill notes [#polyfill-notes] If your runtime is missing `fetch`, you can inject one: ```ts import { Bastion } from "@qubittron/bastion-sdk"; import { fetch } from "undici"; const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, fetch: fetch as typeof globalThis.fetch, }); ``` See [Custom fetch](/sdk/guides/custom-fetch) for more uses of this hook. # Error handling (/sdk/guides/error-handling) Three rules cover 95% of cases: 1. **Branch on `instanceof`**, never on `name` or `message`. 2. **Retry only `RateLimitError`, `UpstreamError`, and `APIConnectionError`.** Everything else is a bug or a permanent failure. 3. **Don't swallow `BastionError`** — log `err.status`, `err.code`, and a redacted view of `err.body` so on-call has something to grep. ## Minimum viable handler [#minimum-viable-handler] ```ts import { Bastion, AuthenticationError, BadRequestError, PermissionDeniedError, RateLimitError, UpstreamError, APIConnectionError, BastionError, } from "@qubittron/bastion-sdk"; const client = new Bastion(); try { const res = await client.chat.completions.create({ model, messages }); return res; } catch (err) { if (err instanceof RateLimitError) return userFriendly("We're being rate-limited — try again in a moment."); if (err instanceof AuthenticationError) return userFriendly("API key invalid — contact support."); if (err instanceof PermissionDeniedError) return userFriendly("Quota exhausted or content blocked."); if (err instanceof BadRequestError) { log.error({ status: err.status, code: err.code, body: err.body }, "bad request to Bastion"); return userFriendly("Internal error."); } if (err instanceof UpstreamError) return userFriendly("Upstream provider is down — try a different model."); if (err instanceof APIConnectionError) return userFriendly("Network glitch — try again."); if (err instanceof BastionError) { log.error({ status: err.status, code: err.code, body: err.body }, "unexpected Bastion error"); return userFriendly("Internal error."); } throw err; // not from the SDK } ``` ## Retries [#retries] The SDK does not retry automatically (as of `0.1`). Compose a simple exponential-backoff wrapper: ```ts async function withRetry(fn: () => Promise, max = 3): Promise { let attempt = 0; while (true) { try { return await fn(); } catch (err) { attempt += 1; const retriable = err instanceof RateLimitError || err instanceof UpstreamError || err instanceof APIConnectionError; if (!retriable || attempt >= max) throw err; const base = 2 ** attempt * 250; const jitter = Math.random() * 250; await new Promise((r) => setTimeout(r, base + jitter)); } } } await withRetry(() => client.chat.completions.create({ model, messages })); ``` Tune `max` and the backoff curve to your latency budget. **Do not retry `BadRequestError` or `AuthenticationError`** — those are deterministic and will fail again. ## Streaming errors [#streaming-errors] API errors throw at the **initial** `create()` call, before iteration starts: ```ts let stream; try { stream = await client.chat.completions.create({ model, messages, stream: true }); } catch (err) { // 4xx / 5xx — handle here, just like a non-streaming call } try { for await (const chunk of stream) render(chunk); } catch (err) { // Mid-stream failures are network-level — APIConnectionError or AbortError } ``` Wrap each phase separately; the failure modes differ and so should your handlers. ## Surfacing to users [#surfacing-to-users] `err.message` is the **upstream** message — often useful to a developer, sometimes confusing to an end user. For user-facing surfaces: * Show a generic, action-oriented message ("Try again", "Contact support"). * Log the full error with `err.status`, `err.code`, `err.type`, and a redacted `err.body`. * Include a request id from your own infra so on-call can find the trace. ```ts log.error( { bastion: { status: err.status, code: err.code, type: err.type, }, requestId, }, err.message, ); ``` ## Logging `err.body` [#logging-errbody] `err.body` is the parsed JSON body (or the raw string if non-JSON). Upstreams sometimes echo the offending request fragment in `error.message` — be deliberate about what you log. A safe default: log `status`, `code`, `type`, and the first 500 chars of a stringified body. Don't log `Authorization` headers or full request bodies that may contain user content. # Guides (/sdk/guides) The [API reference](/sdk/api-reference) tells you what each method returns. These guides tell you how to use them together for the things real apps actually do. # Streaming (/sdk/guides/streaming) Set `stream: true` and the SDK returns a `Stream` — an async iterable over typed chunks. The discriminated union on `params.stream` narrows the return type at compile time, so you don't need a runtime `if (stream instanceof Stream)` check. ## Print-as-it-arrives (Node) [#print-as-it-arrives-node] ```ts const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Write a haiku about Canada." }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta.content ?? ""); } process.stdout.write("\n"); ``` ## Accumulate into a final message [#accumulate-into-a-final-message] For UIs that show partial output but also need the full string: ```ts let buffer = ""; for await (const chunk of stream) { const piece = chunk.choices[0]?.delta.content ?? ""; buffer += piece; render(buffer); } ``` `finish_reason` on the **last** chunk's `choices[0]` tells you why the stream ended: `"stop"`, `"length"`, or `"content_filter"`. Earlier chunks have `finish_reason: null`. ## Reasoning tokens [#reasoning-tokens] Some upstreams emit chain-of-thought alongside content. The field name differs: ```ts for await (const chunk of stream) { const delta = chunk.choices[0]?.delta; if (!delta) continue; // reasoning channel (OVH GPT-OSS uses `reasoning`; vLLM uses `reasoning_content`) const reasoning = delta.reasoning ?? delta.reasoning_content; if (reasoning) onReasoning(reasoning); // visible content if (delta.content) onContent(delta.content); } ``` Qwen3 on stock vLLM embeds reasoning inside `…` in `delta.content`. If you care, strip those tags before showing content to users: ```ts const visible = (delta.content ?? "").replace(/[\s\S]*?<\/think>/g, ""); ``` ## Cancellation [#cancellation] ### Break out of the loop [#break-out-of-the-loop] The cleanest way — `break` releases the reader and closes the upstream connection: ```ts for await (const chunk of stream) { if (userClickedStop()) break; render(chunk); } ``` ### Abort via `AbortSignal` [#abort-via-abortsignal] Wire one through a custom fetch: ```ts import { Bastion } from "@qubittron/bastion-sdk"; const ac = new AbortController(); const client = new Bastion({ apiKey: process.env.BASTION_API_KEY, fetch: (url, init) => fetch(url, { ...init, signal: ac.signal }), }); // later, from anywhere: ac.abort(); ``` When the signal fires mid-stream, the iterator throws — wrap the loop in `try/catch`: ```ts try { for await (const chunk of stream) render(chunk); } catch (err) { if (isAbortError(err)) return; throw err; } function isAbortError(err: unknown): boolean { return err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message)); } ``` ## Error semantics [#error-semantics] API-level errors (4xx, 5xx) throw at the **initial** `create()` call, before iteration begins. Mid-stream failures are network failures and throw `APIConnectionError` (or a runtime `AbortError`) from inside the loop. Bastion never injects error events into the SSE body — once you're iterating, the only outcomes are: chunks, then a clean end, **or** a thrown exception. ## Server-to-browser passthrough [#server-to-browser-passthrough] If you stream from your server to a browser client, the cleanest pattern is to re-emit chunks as you receive them. With the Fetch API: ```ts // server (Hono / Express / etc.) app.get("/stream", async (c) => { const stream = await client.chat.completions.create({ model: "gpt-oss-120b", messages, stream: true, }); return new Response( new ReadableStream({ async start(controller) { try { for await (const chunk of stream) { controller.enqueue( new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`), ); } controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); } finally { controller.close(); } }, }), { headers: { "Content-Type": "text/event-stream" } }, ); }); ``` This keeps your API key on the server and gives the browser a familiar SSE shape. # TypeScript SDK (/sdk) `@qubittron/bastion-sdk` is the official TypeScript SDK for Bastion. It wraps the OpenAI-compatible HTTP API with strongly typed resources, async iterables for streaming, and a structured error hierarchy. It runs on Node 20+, Bun, Deno, Cloudflare Workers, Vercel Edge, and modern browsers. ```sh npm install @qubittron/bastion-sdk ``` ```ts import { Bastion } from "@qubittron/bastion-sdk"; const client = new Bastion({ apiKey: process.env.BASTION_API_KEY }); const res = await client.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Hello!" }], }); console.log(res.choices[0]?.message.content); ``` ## What's in the box [#whats-in-the-box] | Resource | Methods | | ----------------------------- | ------------------------------------------------- | | `client.chat.completions` | `create()` — sync + streaming chat completions | | `client.models` | `list()` — list available models for your account | | `client.images` | `generate()` — text-to-image generation | | `client.audio` | `speech()` — TTS via NVIDIA Riva | | `client.audio.transcriptions` | `create()` — speech-to-text (Whisper) | All resources return strongly typed responses. Streaming returns an `AsyncIterable` over typed chunks. Errors throw subclasses of `BastionError` (`AuthenticationError`, `RateLimitError`, `UpstreamError`, …) so you can branch on `instanceof`. ## Versioning [#versioning] Pre-1.0 (`v0.x`) the public surface may change between minors. Pin to a minor for production. The SDK reads `BASTION_API_KEY` from the environment when no `apiKey` is passed, so promotion across environments is just an env-var swap. ## Source [#source] The SDK lives at [`packages/sdk`](https://github.com/qubittron/qubi_bastion/tree/main/packages/sdk) in the Bastion monorepo. Issues and feature requests go on the [main repo](https://github.com/qubittron/qubi_bastion/issues).