OpenAI-compatible API

One base URL. Every model.

Point any OpenAI SDK at unbottled.ai. 202 models, streaming, tool calling. Your key, your credits, no new client to learn.

Quickstart

Call a model in 60 seconds

The API speaks the OpenAI wire format. If your code runs against OpenAI, it runs here after two changes: the base URL and the key.

1

Get a key

Create an sk-unb key under Settings > API keys. It is shown once. The API is part of every paid plan: an active subscription or a credit pack purchase unlocks it.

Base URL: http://localhost:3000/api/en-DE/v1

Any locale works in the path (en-US, de-DE, pl-PL). It only changes the language of error messages.

2

Send a request

Plain HTTP. Works from any language.

curl
curl http://localhost:3000/api/en-DE/v1/chat/completions \
  -H "Authorization: Bearer sk-unb-YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k2",
    "messages": [
      { "role": "system", "content": "You answer in one sentence." },
      { "role": "user", "content": "Why is the sky blue?" }
    ]
  }'
3

Or use an OpenAI SDK

Change baseURL and apiKey. Nothing else.

Python
from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:3000/api/en-DE/v1",
    api_key="sk-unb-YOUR_KEY",
)

reply = client.chat.completions.create(
    model="kimi-k2",
    messages=[{"role": "user", "content": "Why is the sky blue?"}],
)
print(reply.choices[0].message.content)
Node.js
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "http://localhost:3000/api/en-DE/v1",
  apiKey: process.env.UNBOTTLED_API_KEY, // sk-unb-YOUR_KEY
});

const reply = await client.chat.completions.create({
  model: "kimi-k2",
  messages: [{ role: "user", content: "Why is the sky blue?" }],
});
console.log(reply.choices[0].message.content);
Paid plans only. Creating keys and calling /v1 requires a paid account: an active subscription, or at least one credit purchase. Free-tier accounts get HTTP 402 with code paid_plan_required, and can still read these docs. Credits stay yours: buy a pack once and the API keeps working until the balance runs out.
Authentication

Authentication

Send your key as a Bearer token on every request. Keys start with sk-unb-.

header
Authorization: Bearer sk-unb-YOUR_KEY

We store a hash, not the key. Lose it and you create a new one.

A key works on /v1 only. It cannot touch your account, payments or settings.

Up to 20 active keys per account. Revoke any key at any time: it stops working at once.

No key or a revoked key returns 401. A key whose account has no paid plan returns 402.

Models

GET /v1/models

Lists the models your key can call right now, with context size, tool support and credit prices per million tokens. Use the id as the model field in requests.

request
curl http://localhost:3000/api/en-DE/v1/models \
  -H "Authorization: Bearer sk-unb-YOUR_KEY"
200
{
  "object": "list",
  "data": [
    {
      "id": "kimi-k2",
      "object": "model",
      "created": 1735689600,
      "owned_by": "moonshot",
      "name": "Kimi K2",
      "context_length": 131072,
      "supports_tools": true,
      "input_modalities": ["text"],
      "output_modalities": ["text"],
      "pricing": {
        "input_credits_per_million_tokens": 8,
        "output_credits_per_million_tokens": 32
      }
    }
  ]
}

The list depends on your account. Unknown or unavailable ids return 404 model_not_found. The API never silently swaps your model.

Chat completions

POST /v1/chat/completions

Standard OpenAI chat completions. Messages in, one assistant message out. Text and image inputs (image_url as https URL or base64 data URL).

Non-streaming

The default. You get one JSON body when the model is done. usage.credits_charged is what this call cost you.

request
curl http://localhost:3000/api/en-DE/v1/chat/completions \
  -H "Authorization: Bearer sk-unb-YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k2",
    "messages": [
      { "role": "system", "content": "You answer in one sentence." },
      { "role": "user", "content": "Why is the sky blue?" }
    ]
  }'
200
{
  "id": "chatcmpl-3f9c1a...",
  "object": "chat.completion",
  "created": 1767225600,
  "model": "kimi-k2",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "Air scatters blue light more than red." },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 31,
    "completion_tokens": 9,
    "total_tokens": 40,
    "credits_charged": 0.1
  }
}

Streaming

Set stream: true for server-sent events in the OpenAI chunk format, ended by data: [DONE]. Add stream_options.include_usage to get a final chunk with token counts and credits_charged. Closing the connection stops generation. You pay only for what ran.

request
curl -N http://localhost:3000/api/en-DE/v1/chat/completions \
  -H "Authorization: Bearer sk-unb-YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k2",
    "stream": true,
    "stream_options": { "include_usage": true },
    "messages": [{ "role": "user", "content": "Count to three." }]
  }'
200
data: {"id":"chatcmpl-3f9c...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

data: {"id":"chatcmpl-3f9c...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"One, "},"finish_reason":null}]}

data: {"id":"chatcmpl-3f9c...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: {"id":"chatcmpl-3f9c...","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19,"credits_charged":0.1}}

data: [DONE]
Python
stream = client.chat.completions.create(
    model="kimi-k2",
    messages=[{"role": "user", "content": "Count to three."}],
    stream=True,
)
for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="")
Node.js
const stream = await client.chat.completions.create({
  model: "kimi-k2",
  messages: [{ role: "user", content: "Count to three." }],
  stream: true,
});
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}

Request fields

FieldWhat it does
modelRequired. A model id from GET /v1/models.
messagesRequired. system, developer, user, assistant and tool messages. User content may be a string or text and image_url parts.
streamtrue for server-sent events. Default false.
stream_options{ include_usage: true } adds a final usage chunk.
max_tokensUpper bound on generated tokens. max_completion_tokens takes precedence. Capped at 32768.
temperature0 to 2. Ignored by models that do not support it.
top_p0 to 1.
stopA stop string or a list of them.
nOnly 1 is supported. Other values return 400.
toolsFunction tools the model may call. See tool calling.
tool_choicenone, auto, required, or one named function.
Tool calling

Tool calling

Function calling works like OpenAI. You describe functions, the model decides to call one, you run it and send the result back. We never execute your tools.

The loop

Send tools with your messages.

The model answers with tool_calls and finish_reason: tool_calls. Each call has an id, a function name and JSON-string arguments.

Run the function yourself. Append the assistant message, then one tool message per call with its tool_call_id.

Call again. The model uses the results and answers.

1. Request with tools

request
curl http://localhost:3000/api/en-DE/v1/chat/completions \
  -H "Authorization: Bearer sk-unb-YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k2",
    "messages": [{ "role": "user", "content": "Weather in Berlin?" }],
    "tools": [{
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Current weather for a city",
        "parameters": {
          "type": "object",
          "properties": { "city": { "type": "string" } },
          "required": ["city"]
        }
      }
    }],
    "tool_choice": "auto"
  }'

2. The model asks for a call

200
{
  "object": "chat.completion",
  "model": "kimi-k2",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_8x2k",
            "type": "function",
            "function": { "name": "get_weather", "arguments": "{\"city\":\"Berlin\"}" }
          }
        ]
      },
      "finish_reason": "tool_calls"
    }
  ]
}

3. Return the result

request
# Run get_weather yourself, then send the result back:
curl http://localhost:3000/api/en-DE/v1/chat/completions \
  -H "Authorization: Bearer sk-unb-YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k2",
    "messages": [
      { "role": "user", "content": "Weather in Berlin?" },
      { "role": "assistant", "content": null, "tool_calls": [
        { "id": "call_8x2k", "type": "function",
          "function": { "name": "get_weather", "arguments": "{\"city\":\"Berlin\"}" } }
      ]},
      { "role": "tool", "tool_call_id": "call_8x2k", "content": "18C, cloudy" }
    ],
    "tools": [ ...same tools as before... ]
  }'

The same loop with the Python SDK

Python
import json

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Current weather for a city",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]
messages = [{"role": "user", "content": "Weather in Berlin?"}]

first = client.chat.completions.create(
    model="kimi-k2", messages=messages, tools=tools
)
call = first.choices[0].message.tool_calls[0]
result = get_weather(**json.loads(call.function.arguments))  # your code

messages += [
    first.choices[0].message,
    {"role": "tool", "tool_call_id": call.id, "content": result},
]
final = client.chat.completions.create(
    model="kimi-k2", messages=messages, tools=tools
)
print(final.choices[0].message.content)

Streaming tool calls

With stream: true, calls arrive as delta.tool_calls chunks, addressed by index. The first chunk for an index carries id and function.name. Later chunks append to function.arguments. Concatenate them, then parse the JSON when finish_reason is tool_calls. Every OpenAI SDK does this for you.

Rules

The model must support tools: check supports_tools in GET /v1/models. Others return 400 tools_not_supported.

tool_choice none, auto and required work as in OpenAI. Naming a function that is not in tools returns 400.

Function names: letters, digits, underscore, dash, up to 64 characters.

Tool definitions and tool results are input tokens. Calls are output tokens. They bill like any other tokens.

Credits and billing

Credits and billing

The API draws from the same credits as the chat app, at the same prices. One balance, one bill.

Cost = input tokens plus output tokens at the model's credit price. Prices per million tokens are listed in GET /v1/models.

Every response reports usage.credits_charged. Your balance is in the app under Credits.

Before a call runs we check your balance against a worst-case estimate. Not enough: HTTP 402 insufficient_credits, nothing runs, nothing is charged.

After the call you pay the real token usage the provider reports, not the estimate.

Out of credits? Buy a pack or renew your plan. The next call works immediately.

402
HTTP/1.1 402 Payment Required
{
  "error": {
    "message": "Insufficient credits. This call needs about 1.2, you have 0.4. Top up to continue.",
    "type": "insufficient_quota",
    "param": null,
    "code": "insufficient_credits"
  }
}
Rate limits

Rate limits

Each key allows 60 requests per minute by default. Over the limit you get HTTP 429 with a Retry-After header in seconds. Back off for that long and retry. Limits are per key, so split heavy workloads across keys.

429
HTTP/1.1 429 Too Many Requests
Retry-After: 12
{ "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", ... } }
Errors

Errors

Errors use the OpenAI envelope, so SDKs raise their usual exceptions. Branch on error.code, not on the message.

error
{
  "error": {
    "message": "Invalid or revoked API key. ...",
    "type": "authentication_error",
    "param": null,
    "code": "invalid_api_key"
  }
}
HTTP / codeMeaning
401 missing_api_keyNo Authorization header. Send Bearer sk-unb-...
401 invalid_api_keyKey is wrong or revoked.
402 paid_plan_requiredAccount has no paid plan. Subscribe or buy credits.
402 insufficient_creditsBalance too low for this call. Top up.
402 free_tier_cap_reachedFree-tier daily cap reached. Credits fix it.
404 model_not_foundModel does not exist or is not available to you.
400 invalid_value / unsupported_parameter / tools_not_supportedBad request: unsupported parameter, bad tool_choice, bad image URL.
429 rate_limit_exceededOver 60 requests per minute for this key. Read Retry-After.
502 upstream_errorThe model provider failed. Safe to retry. Streams end with an error event.
503 balance_unavailableWe could not read your balance, so nothing ran. Retry.
Errors that happen mid-stream arrive as a data: event with the same error object, followed by data: [DONE].

Build on every model.

Create a key, point your SDK here, ship.