Authentication
All API requests require an API key. Pass it in one of two ways:
- Header:
X-API-Key: YOUR_KEY - JSON body:
"api_key": "YOUR_KEY" - Bearer token
(for
/v1/chat/completions):Authorization: Bearer YOUR_KEY
Log in to create an API key. Keys can be restricted to specific models.
OpenAI SDK compatibility
The /v1/chat/completions
endpoint is compatible with the OpenAI Python and Node SDKs. Set base_url
to the worker's address + /v1
and use your API key. Key differences:
- The
modelparameter in the request is accepted but the worker serves a single model. "stream": trueis supported — responses are returned as Server-Sent Events, includingstream_options: {"include_usage": true}.- Tool/function calling is not supported.
- OpenAI-schema parameters mapped:
temperature,top_p,max_tokens(andmax_completion_tokens),stop. The worker-only knobstop_kandrepeat_penaltyare not part of the OpenAI schema — use/generateor the REST example to set them.
Endpoints
POST /generate
Send a raw text prompt and get a completion. Worker-direct endpoint.
Request / Response
{
"api_key": "YOUR_KEY",
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"prompt": "Explain recursion in one paragraph.",
"temperature": 0.7,
"top_k": 40,
"top_p": 0.9,
"max_tokens": 256,
"repeat_penalty": 1.1,
"stop": []
}
Response (200):
{
"response": "Recursion is a technique where a function calls itself...",
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"instance_id": "abc123",
"prompt_tokens": 12,
"completion_tokens": 87,
"tokens_per_second": 14.3,
"latency_ms": 6083.2
}
Error responses include a detail
or error
field with a human-readable message.
{"error": {"message": "Invalid API key", "type": "authentication_error", "code": 401}}
POST /v1/chat/completions
OpenAI-compatible chat completions endpoint. Supports Authorization: Bearer
header.
Request / Response
{
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is recursion?"}
],
"temperature": 0.7,
"max_tokens": 256
}
Response (200):
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1719000000,
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Recursion is..."},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 25, "completion_tokens": 87, "total_tokens": 112}
}
Streaming:
Set "stream": true
in the request body. The response uses Server-Sent Events (SSE):
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{"content":"token"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
Vision (image input)
For vision-capable models (check "vision_capable" on GET /v1/models), "content" can be an array mixing text and image parts instead of a plain string:
{
"model": "MODEL_NAME",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo..."}}
]}
],
"max_tokens": 200
}
Up to 4 images per request, 8MB each (raw, before decoding). Images are automatically downscaled to 768px on the longer side and count toward your token quota.
"image_url.url" accepts either a data: URI (base64) or a public http(s) URL — localhost/private addresses are rejected.
Documents (file input)
Works on any model, not just vision-capable ones — "content" can include a "file" part whose text is extracted and merged into the prompt before the model ever sees it:
{
"model": "MODEL_NAME",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "Summarize this document."},
{"type": "file", "file": {"filename": "report.pdf", "data": "data:application/pdf;base64,JVBERi0xLjQK..."}}
]}
],
"max_tokens": 200
}
Up to 5 files per request, 15MB each (raw, before decoding). Any file with extractable text is accepted (PDF, ZIP, Office docs, source/config files, etc.) — images, audio, video and other non-text formats are rejected. Extracted text is capped at 40,000 characters per file (truncated beyond that) and counts as ordinary prompt text toward your token quota.
"file.data" must be a data: URI (base64) — unlike image_url, remote http(s) URLs are not accepted for file uploads.
A ZIP is unpacked and every supported file inside it (including nested documents) is converted and concatenated.
POST /v1/completions
Legacy raw-prompt completion (OpenAI format). Accepts a plain prompt
string and returns choices[0].text
response. Use /v1/chat/completions for new integrations.
Request / Response
{
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"prompt": "In one sentence, what is a large language model?",
"max_tokens": 80
}
Response (200):
{
"id": "cmpl-abc123",
"object": "text_completion",
"created": 1719000000,
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"choices": [{"text": "A large language model is...", "index": 0, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 14, "completion_tokens": 18, "total_tokens": 32}
}
Async mode
Add these optional fields to /generate, /v1/chat/completions, or /v1/completions when a generation might take too long to hold the connection open. The worker also switches to async on its own if a sync request runs past the platform's timeout.
mode— "sync" (default) or "async".method— Encryption method for the stored result. Only "xchacha20" is currently accepted (default).key— Base64, 32 bytes. Required if mode is "async". Not needed for sync — if the worker itself falls back to async, it generates one and returns it, since you have no other way to get it.nonce— Base64, 24 bytes. Optional even for async — the worker generates one if you don't supply it.webhook_url— Optional. An http(s) URL the worker POSTs the encrypted result to once the job finishes — same shape as the "ready" poll response below, plus tracking_id. Must resolve to a public address (not localhost/private/link-local); best-effort delivery, so keep polling as a fallback in case it doesn't arrive.
Request / Response
{
"api_key": "YOUR_KEY",
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"prompt": "Explain recursion in one paragraph.",
"mode": "async",
"key": "BASE64_32_BYTE_KEY"
}
Response (200) — tracking ID, no result yet:
{
"mode": "async",
"tracking_id": "10de8f98-88b9-4138-9443-dc14da61150d",
"method": "xchacha20",
"nonce": "BASE64_24_BYTE_NONCE"
}
If the worker falls back from sync to async on its own (no mode was requested), the response also includes a worker-generated "key" field, since you have no other way to obtain it.
If webhook_url was supplied, the worker POSTs this same payload to it once the job finishes (result isn't deleted by the webhook delivery — /api/async_result below still works as a fallback):
{"status": "ready", "tracking_id": "10de8f98-88b9-4138-9443-dc14da61150d", "result": "BASE64_CIPHERTEXT"}
GET /api/async_result
Poll for the encrypted result using the tracking_id above. The file is deleted from the server as soon as it's read, so poll only once you're ready to consume it.
Request / Response
GET BASE_URL/api/async_result?tracking_id=10de8f98-88b9-4138-9443-dc14da61150d&api_key=YOUR_KEY
Response (200) — still generating:
{"status": "pending"}
Response (200) — result ready:
{"status": "ready", "result": "BASE64_CIPHERTEXT"}
"result" is base64-encoded ciphertext — decrypt it yourself with XChaCha20-Poly1305 using the key and nonce from the original request/response. The server never sees the plaintext or stores the key.
Response (404) — unknown tracking_id, expired, or already consumed:
{"error": {"message": "Unknown tracking_id, expired, or its result was already consumed — stop polling.", "type": "not_found_error", "code": 404}}
"pending" only ever means "still generating." A 404 means the tracking_id doesn't exist, expired, or its result was already read by another poll — you will never get a 200 for it again, so stop polling instead of looping forever.
GET /v1/models
List available models on this worker instance. OpenAI-compatible format.
Response
{
"object": "list",
"data": [{"id": "Qwen2.5-1.5B-Instruct-Q2_K", "object": "model",
"created": 1719000000, "owned_by": "5cbot_llm_server",
"avg_tokens_per_second": 18.2, "vision_capable": false}]
}
avg_tokens_per_second is a 30-day rolling average (errors excluded). Omitted for models with no benchmark data yet. Use it to estimate latency before sending a request.
vision_capable indicates whether the model accepts image content-parts — see Vision (image input) above.
GET /status
Worker instance status: model, active users, resource usage, uptime.
Response
{
"instance_id": "abc123",
"model": "Qwen2.5-1.5B-Instruct-Q2_K",
"status": "ready",
"host": "127.0.0.1", "port": 8001, "pid": 12345,
"active_users": 2,
"ram_used_mb": 1200,
"cpu_percent": 45.2,
"uptime_seconds": 3600,
"last_activity": "2025-06-17T12:00:00+00:00"
}
GET /health
Simple health check. Returns {"ok": true}
(200) when the model is loaded, or {"ok": false}
(503) otherwise.
Code examples
Replace YOUR_KEY,
MODEL_NAME, and https://llm.vipresearch.ca
with your actual values.
/generate
curl -X POST https://llm.vipresearch.ca/generate \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_KEY",
"model": "MODEL_NAME",
"prompt": "Hello, world!",
"max_tokens": 100
}'
# HTTP 429 → check Retry-After header | HTTP 401 → bad API key /v1/chat/completions
curl -X POST https://llm.vipresearch.ca/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{
"model": "MODEL_NAME",
"messages": [{"role": "user", "content": "Hello, world!"}],
"max_tokens": 100
}' /v1/chat/completions — streaming
# -N disables output buffering so tokens appear as they arrive
curl -N -X POST https://llm.vipresearch.ca/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_KEY" \
-d '{
"model": "MODEL_NAME",
"messages": [{"role": "user", "content": "Hello, world!"}],
"max_tokens": 100,
"stream": true
}' /generate
import requests
https://llm.vipresearch.ca = "https://llm.vipresearch.ca"
API_KEY = "YOUR_KEY"
MODEL = "MODEL_NAME"
resp = requests.post(f"{https://llm.vipresearch.ca}/generate", json={
"api_key": API_KEY, "model": MODEL,
"prompt": "Hello, world!", "max_tokens": 100,
})
if resp.status_code == 429:
print("Rate limited — retry after", resp.headers.get("Retry-After"), "s")
elif resp.status_code == 401:
print("Bad API key")
else:
print(resp.json()["response"]) /v1/chat/completions
resp = requests.post(
f"{https://llm.vipresearch.ca}/v1/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": MODEL,
"messages": [{"role": "user", "content": "Hello, world!"}],
"max_tokens": 100,
},
)
resp.raise_for_status() # raises on 4xx/5xx
print(resp.json()["choices"][0]["message"]["content"]) /v1/chat/completions — streaming
import json as _json
with requests.post(
f"{https://llm.vipresearch.ca}/v1/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": MODEL,
"messages": [{"role": "user", "content": "Hello, world!"}],
"max_tokens": 100, "stream": True},
stream=True,
) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line or line == b"data: [DONE]":
continue
if line.startswith(b"data: "):
chunk = _json.loads(line[6:])
token = chunk["choices"][0]["delta"].get("content", "")
print(token, end="", flush=True)
print() # final newline Setup
pip install openai
from openai import OpenAI
client = OpenAI(
api_key="YOUR_KEY",
base_url="https://llm.vipresearch.ca/v1",
) /v1/chat/completions
response = client.chat.completions.create(
model="MODEL_NAME",
messages=[{"role": "user", "content": "Hello, world!"}],
max_tokens=100,
)
print(response.choices[0].message.content) /v1/chat/completions — streaming
stream = client.chat.completions.create(
model="MODEL_NAME",
messages=[{"role": "user", "content": "Hello, world!"}],
max_tokens=100,
stream=True,
)
for chunk in stream:
token = chunk.choices[0].delta.content or ""
print(token, end="", flush=True)
print() /generate
const https://llm.vipresearch.ca = "https://llm.vipresearch.ca";
const API_KEY = "YOUR_KEY";
const MODEL = "MODEL_NAME";
const genResp = await fetch(`${https://llm.vipresearch.ca}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: API_KEY, model: MODEL,
prompt: "Hello, world!", max_tokens: 100 }),
});
if (genResp.status === 429) {
console.error("Rate limited. Retry after:", genResp.headers.get("Retry-After"), "s");
} else if (genResp.status === 401) {
console.error("Bad API key.");
} else {
const data = await genResp.json();
console.log(data.response);
} /v1/chat/completions
const chatResp = await fetch(`${https://llm.vipresearch.ca}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}` },
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100,
}),
});
const chat = await chatResp.json();
console.log(chat.choices[0].message.content); /v1/chat/completions — streaming
// Node.js — uses process.stdout.write for streaming output
const streamResp = await fetch(`${https://llm.vipresearch.ca}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}` },
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100,
stream: true,
}),
});
const reader = streamResp.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
const chunk = JSON.parse(line.slice(6));
process.stdout.write(chunk.choices[0].delta.content ?? "");
}
} Setup
npm install openai
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "YOUR_KEY",
baseURL: "https://llm.vipresearch.ca/v1",
dangerouslyAllowBrowser: true, // omit when running in Node.js
}); /v1/chat/completions
const response = await client.chat.completions.create({
model: "MODEL_NAME",
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100,
});
console.log(response.choices[0].message.content); /v1/chat/completions — streaming
// Node.js — uses process.stdout.write for streaming output
const stream = await client.chat.completions.create({
model: "MODEL_NAME",
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100,
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0].delta.content ?? "");
} /generate
const https://llm.vipresearch.ca = "https://llm.vipresearch.ca";
const API_KEY = "YOUR_KEY";
const MODEL = "MODEL_NAME";
const genResp = await fetch(`${https://llm.vipresearch.ca}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: API_KEY, model: MODEL,
prompt: "Hello, world!", max_tokens: 100 }),
});
if (!genResp.ok) {
const err = await genResp.json() as { error: { message: string } };
throw new Error(`${genResp.status}: ${err.error.message}`);
}
const genData = await genResp.json() as { response: string };
console.log(genData.response); /v1/chat/completions
interface ChatResponse {
choices: { message: { role: string; content: string } }[];
}
const chatResp = await fetch(`${https://llm.vipresearch.ca}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}` },
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100,
}),
});
const chat = await chatResp.json() as ChatResponse;
console.log(chat.choices[0].message.content); /v1/chat/completions — streaming
// Node.js — uses process.stdout.write for streaming output
interface ChatChunk {
choices: { delta: { content?: string }; finish_reason: string | null }[];
}
const streamResp = await fetch(`${https://llm.vipresearch.ca}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}` },
body: JSON.stringify({ model: MODEL,
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100, stream: true }),
});
const reader = streamResp.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
const chunk = JSON.parse(line.slice(6)) as ChatChunk;
process.stdout.write(chunk.choices[0].delta.content ?? "");
}
} Setup
npm install openai
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "YOUR_KEY",
baseURL: "https://llm.vipresearch.ca/v1",
dangerouslyAllowBrowser: true, // omit when running in Node.js
}); /v1/chat/completions
const response: OpenAI.ChatCompletion = await client.chat.completions.create({
model: "MODEL_NAME",
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100,
});
console.log(response.choices[0].message.content); /v1/chat/completions — streaming
// Node.js — uses process.stdout.write for streaming output
const stream = await client.chat.completions.create({
model: "MODEL_NAME",
messages: [{ role: "user", content: "Hello, world!" }],
max_tokens: 100,
stream: true,
}) as AsyncIterable<OpenAI.ChatCompletionChunk>;
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0].delta.content ?? "");
} /generate
<?php
$baseUrl = "https://llm.vipresearch.ca";
$apiKey = "YOUR_KEY";
$model = "MODEL_NAME";
$ch = curl_init("$baseUrl/generate");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode([
"api_key" => $apiKey, "model" => $model,
"prompt" => "Hello, world!", "max_tokens" => 100,
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 429) echo "Rate limited.\n";
elseif ($status === 401) echo "Bad API key.\n";
else echo json_decode($body, true)["response"] . "\n"; /v1/chat/completions
$ch = curl_init("$baseUrl/v1/chat/completions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer $apiKey",
],
CURLOPT_POSTFIELDS => json_encode([
"model" => $model,
"messages" => [["role" => "user", "content" => "Hello, world!"]],
"max_tokens" => 100,
]),
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $data["choices"][0]["message"]["content"] . "\n"; /v1/chat/completions — streaming
$ch = curl_init("$baseUrl/v1/chat/completions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer $apiKey",
],
CURLOPT_POSTFIELDS => json_encode([
"model" => $model,
"messages" => [["role" => "user", "content" => "Hello, world!"]],
"max_tokens" => 100,
"stream" => true,
]),
CURLOPT_WRITEFUNCTION => function ($ch, $data) {
foreach (explode("\n", $data) as $line) {
if (!str_starts_with($line, "data: ") || $line === "data: [DONE]") continue;
$chunk = json_decode(substr($line, 6), true);
echo $chunk["choices"][0]["delta"]["content"] ?? "";
flush();
}
return strlen($data);
},
]);
curl_exec($ch);
curl_close($ch); /generate
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
const baseUrl = 'https://llm.vipresearch.ca';
const apiKey = 'YOUR_KEY';
const model = 'MODEL_NAME';
Future<void> generate() async {
final resp = await http.post(
Uri.parse('$baseUrl/generate'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'api_key': apiKey, 'model': model,
'prompt': 'Hello, world!', 'max_tokens': 100,
}),
);
if (resp.statusCode == 429) {
print('Rate limited. Retry after: ${resp.headers['retry-after']}s');
} else if (resp.statusCode == 401) {
print('Bad API key.');
} else {
print(jsonDecode(resp.body)['response']);
}
} /v1/chat/completions
Future<void> chat() async {
final resp = await http.post(
Uri.parse('$baseUrl/v1/chat/completions'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $apiKey',
},
body: jsonEncode({
'model': model,
'messages': [{'role': 'user', 'content': 'Hello, world!'}],
'max_tokens': 100,
}),
);
final data = jsonDecode(resp.body) as Map<String, dynamic>;
print(data['choices'][0]['message']['content']);
} /v1/chat/completions — streaming
Future<void> chatStream() async {
final request = http.Request(
'POST', Uri.parse('$baseUrl/v1/chat/completions'),
)
..headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer $apiKey',
})
..body = jsonEncode({
'model': model,
'messages': [{'role': 'user', 'content': 'Hello, world!'}],
'max_tokens': 100,
'stream': true,
});
final response = await http.Client().send(request);
await for (final raw in response.stream.transform(utf8.decoder)) {
for (final line in raw.split('\n')) {
if (!line.startsWith('data: ') || line == 'data: [DONE]') continue;
final chunk = jsonDecode(line.substring(6)) as Map<String, dynamic>;
stdout.write(
(chunk['choices'] as List).first['delta']['content'] ?? '',
);
}
}
} Rate limits & quotas
- Rate limit:
30 requests/minute per API key. Exceeding returns HTTP 429 with
Retry-Afterheader. - Daily token quota: 100,000 tokens/day (default, admin-configurable per user). Exceeding returns HTTP 429.
Error codes
| Code | Meaning |
|---|---|
400 |
Bad request (invalid JSON, missing required fields, or invalid async-mode parameters) |
401 |
Invalid or missing API key |
403 |
No permission for this model or action |
404 |
Instance not found |
409 |
Instance not ready (still starting) |
429 |
Rate limit or daily quota exceeded |
500 |
Internal error (model inference failure) |
502 |
Worker instance unreachable |
503 |
Platform in maintenance mode |
Error response body example:
{"error": {"message": "Daily token quota exceeded", "type": "quota_error", "code": 429}}
Async mode (see above) can also return 400 for an unsupported method, a missing key when mode="async", or combining stream=true with mode="async":
{"detail": "'key' is required when mode='async'"}
{"error": {"message": "Unsupported method 'aes256'", "type": "api_error", "code": 400}}
{"error": {"message": "'stream' and mode='async' cannot be combined", "type": "api_error", "code": 400}}