Last updated 31 July 2026
The API is OpenAI-compatible chat completions. If your client already speaks that, the base URL and the model name are the only two things that change. Everything below is the whole of it.
Send everything to https://api.plubo-ai.com/v1 with your key in an Authorization: Bearer header. Write to keys@plubo-ai.com and we will mint one. A key looks like pk-…. We store only a hash of it, so a lost key cannot be recovered — we revoke it and issue another.
/v1/models is the source of truth for model ids, context lengths, output limits and prices. It is a static document, it needs no key, and it is what this page and the rest of the site read their numbers from. If it disagrees with the prose here, believe it.
All three take text, images, audio and video in, and return text. On the 12B every modality bills at the plain input rate — it has no separate vision or audio encoder, so a media token costs what a text token costs. E4B and E2B carry real encoders and price image and audio input separately; both rates are in /v1/models. Video bills at the input rate on all three.
Reach for 12B when the answer matters more than the price, and for the long-context work — it is the only one with the full 262K window. E4B is the sensible default for ordinary work: most of the quality, a little over half the price. E2B is for classification, extraction and routing at volume, where throughput and cost are the whole question. It is not the one to hand arithmetic to.
The model field is required on every request. We deliberately do not guess a default: guessing means quietly serving — and billing for — a model you did not ask for. Omit it and you get a 400.
POST /v1/chat/completions, in the shape you already know.
curl https://api.plubo-ai.com/v1/chat/completions \ -H "Authorization: Bearer $PLUBO_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gemma-4-e4b-it", "messages": [ {"role": "system", "content": "You are terse."}, {"role": "user", "content": "Name the three Baltic states."} ], "max_tokens": 256, "temperature": 0.7 }'
{
"id": "chatcmpl-…",
"object": "chat.completion",
"created": 1785456000,
"model": "gemma-4-e4b-it",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Estonia, Latvia, Lithuania."},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 24, "completion_tokens": 9, "total_tokens": 33}
}
/v1/completions is there too, for older clients. Sampling is the usual set: temperature, top_p, top_k, min_p, seed, stop, the frequency, presence and repetition penalties, logit_bias, logprobs and n. A given seed reproduces its output. Prompts are prefix-cached, so a repeated preamble is markedly cheaper in latency the second time.
Set "stream": true and you get server-sent events: one data: line per chunk, terminated by data: [DONE]. The id and model stay stable across the stream, and finish_reason arrives on the last content chunk.
Ask for usage and the stream ends with one extra chunk carrying token counts and an empty choices array — the last thing before [DONE]. Streamed requests are metered whether or not you ask for it, so the counts you get back are the same ones the invoice is built from.
{
"model": "gemma-4-12b-it",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true,
"stream_options": {"include_usage": true}
}
data: {"choices":[{"delta":{"role":"assistant"},"index":0}], …}
data: {"choices":[{"delta":{"content":"Hi"},"index":0}], …}
data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}], …}
data: {"choices":[],"usage":{"prompt_tokens":8,"completion_tokens":2,"total_tokens":10}}
data: [DONE]
This is the part other providers do not do. Audio and video go straight into the model — there is no transcription step in front of it and no separate vision endpoint beside it. A voice memo is just another message part.
Media must be inline. Every image, audio clip and video is sent as a data: URI in the request body. We do not fetch media on your behalf: an http(s):// or file:// URL is rejected with 400 invalid_media_url, by design — a server that fetches arbitrary URLs for its callers is a request-forgery vector into its own network. Base64 your bytes and send them.
An image, as a complete request body:
{
"model": "gemma-4-12b-it",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "What does the sign say?"},
{"type": "image_url",
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo…"}}
]}]
}
Audio — base64 WAV, with the format named separately:
{
"model": "gemma-4-12b-it",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Summarize this in a line."},
{"type": "input_audio",
"input_audio": {"data": "UklGRiQ…", "format": "wav"}}
]}]
}
Video — a data: URI again, under video_url:
{
"model": "gemma-4-12b-it",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "What happens at the end?"},
{"type": "video_url",
"video_url": {"url": "data:video/mp4;base64,AAAAIGZ0…"}}
]}]
}
Building the base64, in Python:
import base64, pathlib b64 = base64.b64encode(pathlib.Path("memo.wav").read_bytes()).decode() part = {"type": "input_audio", "input_audio": {"data": b64, "format": "wav"}}
The media caps count everything in the request, not just the newest turn. That catches people out in chat: send a clip, get an answer, send a second clip, and the request now carries two audio parts and comes back 400 At most 1 audio(s) may be provided in one prompt — even though you attached one. Drop the older attachments when you resend the conversation, keeping the newest up to each cap. Replacing them with a short text marker like [earlier audio clip] keeps the transcript readable, and the model's own previous reply already carries what they contained. With two image slots you can leave one picture in place and still ask a follow-up about it.
Video must be H.264 or H.265. AV1 and VP9 decode to zero frames here, so we refuse them by name with a 400 rather than failing somewhere less obvious — worth knowing because a clip downloaded from YouTube is usually AV1. Re-encode with ffmpeg -i in.mp4 -c:v libx264 -pix_fmt yuv420p -c:a aac out.mp4.
Video is analysed as frames only; the audio track inside the file is never heard. This is easy to miss, because nothing errors — you simply get an answer based on the pictures alone. To have the model watch and listen, send the audio as its own input_audio part in the same message. Both modalities are accepted together, and it is often the audio that carries the answer.
content = [
{"type": "text", "text": "What is happening here?"},
{"type": "video_url", "video_url": {"url": f"data:video/mp4;base64,{video_b64}"}},
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
]
Extract the track with ffmpeg -i clip.mp4 -vn -ar 16000 -ac 1 audio.wav. Remember the audio cap is thirty seconds while video has no length limit, so a long clip can be watched in full but only heard in part.
Audio and video behave differently. Audio has a hard window: past thirty seconds the rest is discarded, so we reject the request with a 400 naming the duration we measured instead of answering from half a recording. Video has no such cliff — frames are drawn evenly across the whole clip against a fixed budget, so a two-minute video costs about what a four-second one does and the model still sees the end of it. Length costs you detail between frames, not the tail.
Both work. Pass a tools array and the model emits tool_calls in the standard shape, streamed or not, with arguments that reassemble into valid JSON; send the result back as a tool message to continue the exchange. Pass response_format with json_object or a JSON schema and decoding is constrained to it.
Two current limits. tool_choice is honoured for "auto" and "none"; "required" and naming a specific function return a 400, because this deployment does not constrain decoding to either. The guided_json family returns a 400 for the same reason — use response_format, which is enforced.
Both features are actively being worked on, and this section will get more specific as that lands.
Every error we generate has the same shape, and the HTTP status carries the same meaning as the type.
{"error": {"message": "model 'gemma-9b' not found — see /v1/models",
"type": "model_not_found"}}
Validation the inference server does itself — an out-of-range temperature, a prompt past the context window, an unparseable body — comes back OpenAI-shaped as well, naming the offending parameter. A rejected request is always answered with a status code, even a large one: we read the whole body before refusing it, so you get a 401 or 429 rather than a severed connection.
Keys are limited by requests per minute — 600 by default, as a bucket that refills continuously rather than a cliff at the top of each minute. There is no monthly quota and no per-token ceiling. There is a concurrency cap: a bounded number of requests run against each model at once, and beyond it a request queues briefly before being refused. Over either limit you get 429 rate_limit_exceeded with a Retry-After header; wait and retry. If the default is the wrong shape for what you are building, say so and we will raise it.
The playground on the homepage is a separate, keyless lane, limited to 20 requests a minute per IP address. It is for trying the models, not for building on.
Things we do not log, and what we keep instead, are in the privacy policy. Current uptime and past incidents are on the status page. For anything this page does not answer, hello@plubo-ai.com reaches a person.