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.
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.
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.
Any locale works in the path (en-US, de-DE, pl-PL). It only changes the language of error messages.
Send a request
Plain HTTP. Works from any language.
curl http://localhost:3000/api/en-GLOBAL/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?" }
]
}'Or use an OpenAI SDK
Change baseURL and apiKey. Nothing else.
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:3000/api/en-GLOBAL/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)import OpenAI from "openai";
const client = new OpenAI({
baseURL: "http://localhost:3000/api/en-GLOBAL/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);Authentication
Send your key as a Bearer token on every request. Keys start with sk-unb-.
Authorization: Bearer sk-unb-YOUR_KEYWe 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.
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.
curl http://localhost:3000/api/en-GLOBAL/v1/models \
-H "Authorization: Bearer sk-unb-YOUR_KEY"{
"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.
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.
curl http://localhost:3000/api/en-GLOBAL/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?" }
]
}'{
"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.
curl -N http://localhost:3000/api/en-GLOBAL/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." }]
}'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]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="")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
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
curl http://localhost:3000/api/en-GLOBAL/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
{
"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
# Run get_weather yourself, then send the result back:
curl http://localhost:3000/api/en-GLOBAL/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
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.
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
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.
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
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.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
{ "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", ... } }Errors
Errors use the OpenAI envelope, so SDKs raise their usual exceptions. Branch on error.code, not on the message.
{
"error": {
"message": "Invalid or revoked API key. ...",
"type": "authentication_error",
"param": null,
"code": "invalid_api_key"
}
}