plubo

API reference

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.

Base URL and keys

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.

base url
https://api.plubo-ai.com/v1
auth
Authorization: Bearer $PLUBO_KEY
models
GET /v1/models — public, no key needed
health
GET /health — gateway liveness, plain text
status
status page, from /status.json

/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.

The models

gemma-4-12b-it 262,144 context32,768 max out$0.06 / $0.30 per 1M
gemma-4-e4b-it 131,072 context16,384 max out$0.04 / $0.20 per 1M
gemma-4-e2b-it 131,072 context16,384 max out$0.02 / $0.10 per 1M

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.

Chat completions

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.

Streaming

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]

Images, audio and video

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"}}

What fits

per request
at most 2 images, 1 audio clip and 1 video — counted across the whole prompt, history included
audio
30 seconds, and longer clips are refused rather than trimmed
video
any length; frames are sampled across the whole clip
video codec
H.264 or H.265 in MP4; AV1 and VP9 are refused by name
body
64 MB, base64 included

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.

A video's soundtrack is not read

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.

Tool calling and structured outputs

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.

Errors

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"}}
invalid_request_error
400A required field is missing or unusable — most often model.
invalid_media_url
400Media was referenced by a remote or local URL. Send a data: URI.
invalid_media
400The media could not be decoded — bad base64, or bytes that are not the format you named.
media_too_long
400Audio or video past the cap. The message names the duration we measured.
unsupported_parameter
400A parameter this deployment cannot honour. The message names it and the alternative.
invalid_api_key
401No key, or one we do not recognise. Revoked keys land here too.
model_not_found
404The model is not one of ours. See /v1/models.
payload_too_large
413The body exceeded 64 MB.
rate_limit_exceeded
429Over your per-minute limit. Wait and retry.
upstream_error
502The inference host failed. Ours to fix; retry is reasonable.

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.

Rate limits

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.

Anything else

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.