Zukko API — Full Knowledge Base
This file is the full Zukko API documentation. Upload it to an AI (ChatGPT, Claude, Cursor, etc.) so the assistant knows endpoints, auth, models, and examples.
Website: https://zukko.pro
API: https://api.zukko.pro
OpenAI-compatible base: https://api.zukko.pro/v1
Anthropic-compatible base: https://api.zukko.pro
Auth: Authorization: Bearer fetch_YOUR_KEY (or x-api-key: fetch_YOUR_KEY)
Models and prices: GET https://api.zukko.pro/v1/models/info (public, no key)
----------
Getting started
What is Zukko
Zukko is a single entrance to many AI models. You create one account, top up one balance, and get one key. With that key you can use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, and other models. You pay only for what you use — no subscriptions, no monthly fee.
A simple analogy
Think of a trip through several countries. Each one has its own wall socket, so you end up carrying a bag of adapters. Zukko is one universal adapter: you have a single plug, and it fits every AI "socket".
Without Zukko, using Claude, GPT, and DeepSeek means three accounts with three companies, three keys, three payment methods, and three separate bills. With Zukko:
- one key that looks like fetch_… works with every model;
- one balance in US dollars, topped up by SBP or crypto;
- pay as you go: each request costs a fraction of a cent, with no monthly fee;
- switching models means changing one word in the request — the rest of your code stays the same.
How a request travels
Diagram: You or your app (question and fetch_ key) → Zukko (checks the key and balance) → [Claude / GPT / DeepSeek / Qwen / Gemini] → Zukko (counts tokens and charges) → Answer (comes back to you)
The model reads your text and writes a reply. Text is measured in tokens (/docs/introduction/for-beginners#token) — small pieces of words. Tokens are what you pay for: the ones the model reads (input) and the ones it writes (output).
What you can do
- Chat in the browser (/app/chat) — ask any model a question without code, like in a messenger
- Claude Code (/docs/ide-integrations/claude-code) — an AI programmer in your terminal, paid from your Zukko balance
- Cursor and other editors (/docs/ide-integrations/cursor-ide) — plug the models into your code editor
- Telegram bot (/docs/guides/telegram-bot) — a bot that answers in chat using AI
- Support bot (/docs/guides/support-bot) — answers customers from your knowledge base
- Agent with tools (/docs/guides/tool-agent) — the model calls your functions: searches, calculates, saves
- Pipelines (/docs/guides/pipelines) — process thousands of rows: reviews, leads, product cards
- JSON output (/docs/guides/structured-output) — data in a shape your program can read
Who it is for
If you don't write code
Open the chat in your dashboard (/app/chat): pick any model and talk to it like in a messenger. It is a quick way to see how Claude, GPT, and DeepSeek answer the same question. In Telegram the same is available through @zukkopro_bot. Both spend from the same balance.
Many ready-made apps (writing tools, bot builders, browser extensions) ask for an "API key" and an "API address". Put your Zukko key and the Zukko address (/docs/introduction/for-beginners#base-url) there and the app will run through Zukko. If you get stuck, message support (https://t.me/zukkopro?direct).
If you are a developer
Zukko speaks the two most common request formats, so your existing code and SDKs keep working — you only change the address and the key:
Format | Base URL | Use with
--- | --- | ---
OpenAI-compatible | https://api.zukko.pro/v1 | OpenAI SDK, Cursor, most apps
Anthropic-compatible | https://api.zukko.pro | Anthropic SDK, Claude Code (no /v1)
Available endpoints: /v1/chat/completions, /v1/responses, /v1/messages, /v1/messages/count_tokens, /v1/images/generations, /v1/models, /v1/models/info, /v1/balance, /v1/usage, and /v1/web-search. There are no embeddings, speech-to-text or text-to-speech, files, batch API, or fine-tuning — those requests return 404. Moving from OpenAI or Anthropic? Start with Migration (/docs/introduction/migration).
If you run a business or agents
- Give every bot, agent, or contractor its own key with a spend cap and a list of allowed models, so nobody spends more than you allowed. See Key limits (/docs/api-reference/key-limits-and-cache).
- Mix models: a cheap one handles the stream of simple tasks, a strong one takes the hard ones, and another one checks the result.
- Every request shows up in your history: which model, how many tokens, what it cost.
Diagram: Task → Planner (Claude Opus) → [Worker 1 (DeepSeek) / Worker 2 (Qwen)] → Reviewer (Claude Sonnet) → Result
What it costs
Prices are in US dollars per 1 million tokens, with separate prices for input and output. An English word is about 1.3–1.5 tokens, so a page of text is roughly 600–1,000 tokens.
What it costs: A one-page answer (about 1,000 tokens) costs roughly:
- claude-haiku-5-5 — $0.0002, so $1 buys about 5,000 such answers;
- claude-sonnet-5 — $0.002, about 500 answers per $1;
- claude-opus-5-5 — $0.004, about 250 answers per $1.
The minimum top-up is $1, and a request needs at least $0.01 on the balance. Worked examples are in Payments and costs (/docs/introduction/billing); every price is in Models & pricing (/docs/introduction/models-pricing).
Where to start
- Your first request in 3 minutes (/docs/introduction/quick-start) — key, top-up, and the first answer from a model
- Glossary in plain words (/docs/introduction/for-beginners) — token, context, prompt, and other terms without the fog
- How to choose a model (/docs/introduction/choose-model) — which model fits your task and budget
- Payments and costs (/docs/introduction/billing) — how to top up and what your use case will cost
----------
Glossary in plain words
These are the words you will meet across the docs. Each one is explained so that it makes sense without any programming background.
The whole idea in one picture
Diagram: You (write a prompt) → App or chat (sends a request with an API key) → Zukko API (base URL) → Model (reads the context)
Diagram: Model (writes the answer in tokens) → Zukko API (counts the tokens) → Balance (a fraction of a cent is charged) → You (get the answer)
Now each word on its own.
Token
A token is a small piece of text that a model reads and writes. It is not always a whole word: a long word gets split into several pieces. In English one word is about 1.3–1.5 tokens; in Russian it is about 2–3.
Analogy: a taxi charges by the kilometre, not by the trip. Models count tokens, not messages — and that is what you pay for.
Example: a 10-word English question is about 15 tokens, and a one-page answer (around 500 words) is about 700 tokens. Prices are quoted per 1 million tokens: with claude-haiku-5-5, a million output tokens costs $0.20.
Model
A model is a specific AI that writes the answer: Claude Sonnet, GPT-6, DeepSeek, and so on. Each has a name (id) that goes into the model field of a request, for example claude-sonnet-5-5.
Analogy: models are like specialists in one office. One is quick and cheap, another takes longer but handles the hard cases.
The list and prices are in Models & pricing (/docs/introduction/models-pricing); tips for picking one are in How to choose a model (/docs/introduction/choose-model).
API
An API is the way one program talks to another. Instead of clicking buttons on a website, a program sends a request to an address and gets a response back.
Analogy: a takeaway counter. You pass in a note with your order and get the dish back. The Zukko API is that counter: your question and the model name go in, the model's answer comes out.
Example: POST https://api.zukko.pro/v1/chat/completions means "send a message to a model and get a reply".
API key
An API key is a secret string that looks like fetch_…. It tells Zukko the request is yours and which balance to charge. You create it in Dashboard → API keys (/app/api-keys).
Analogy: a house key. Whoever holds it gets in — that is, spends your balance. So never post the key in code on GitHub, in chats, or in screenshots.
Example: programs keep the key in the ZUKKO_API_KEY environment variable rather than in the code itself. It is handy to give each bot or contractor its own key with a spend cap. If a key leaks, delete it in the dashboard and create a new one.
Base URL
The base URL is the address a program sends its requests to. Many apps send requests to OpenAI or Anthropic by default; put the Zukko address into the base URL setting and they will go to Zukko instead.
Analogy: the address on an envelope. Same letter, different destination.
What you use | Base URL
--- | ---
OpenAI SDK, Cursor, most apps | https://api.zukko.pro/v1
Claude Code, Anthropic SDK | https://api.zukko.pro (no /v1)
Both lead to the same Zukko; only the request format differs.
Prompt
A prompt is the text you send to a model: a question, a task, or an example of what you want back.
Analogy: a brief for a contractor. The clearer the brief, the better the work.
Example: "write about coffee" is a weak prompt. "Write three sentences for a product page: whole-bean coffee from Colombia, smooth with notes of chocolate, for someone choosing a gift" is a strong one.
System prompt
A system prompt is a standing instruction: "who you are and how to answer". It comes before the user's messages and applies to the whole conversation.
Analogy: a job description for a new hire. You hand it over once instead of repeating it before every customer.
Example: {"role": "system", "content": "You are a shoe store assistant. Keep answers short and only about our store."}. Keep a long, unchanging instruction at the start of the request — that is how the cache (/docs/introduction/for-beginners#cache) kicks in and makes it cheaper.
Context
Context is everything the model sees in one request: the system prompt, the chat history, documents, and the question itself. Important: the model remembers nothing between requests. To make it "remember" a conversation, your program sends the history again every time.
Analogy: a consultant who rereads the client's entire file before every reply.
Example: most models have a 1-million-token context window — roughly 700,000 English words. Grok has 256K tokens, glm-5.3 has 128K. A longer context means more input tokens and a more expensive request.
Cache
A cache is a saved result that doesn't have to be computed again. Zukko has two:
- Prompt cache. When the start of a request repeats (a long instruction, a knowledge base), the model reads it from the cache. With claude-sonnet-5 and claude-opus-5-5 that part costs 10 times less than regular input. It turns on by itself.
- Zukko response cache. Send exactly the same request again with the X-Zukko-Cache: true header and Zukko returns the saved answer for free.
Analogy: a cook who chopped the vegetables in the morning — the soup is faster and cheaper. More in Key limits, idempotency and caching (/docs/api-reference/key-limits-and-cache).
Streaming
Streaming means the answer arrives in pieces while the model is writing, instead of all at once at the end. That is how chats show text "typing" in front of you.
Analogy: a live broadcast instead of a recording shown in the evening.
Example: set "stream": true in the request. People see the beginning of the answer right away, and slow models don't hit connection timeouts. Note: if you close the stream halfway, the generated answer is still billed in full. More in Streaming (/docs/api-reference/streaming).
Agent
An agent is a model that doesn't just answer but completes a task step by step: it decides what to do, calls tools (/docs/introduction/for-beginners#tools), looks at the result, and keeps going until it is done.
Analogy: an assistant with access to your calendar and mail. You say "book a meeting with Ivan on Thursday", and they check free slots and send the invite.
Example: Claude Code is an agent that reads project files, runs commands, and edits code. To build your own, see Agent with tools (/docs/guides/tool-agent) and Agent orchestration (/docs/guides/multi-agent).
Tools
Tools are functions your program offers to the model: "find an order by number", "get the exchange rate", "add a row to a spreadsheet". The model doesn't run them itself. It replies "call find_order with number 123"; your code runs the function and hands the result back to the model.
Analogy: a manager asks an assistant to look something up in the database, then decides what to do.
Example: in the request this is the tools field. Tools are verified to work with deepseek-v4.1-flash, claude-haiku-5-5, and gpt-6-luna.
Pipeline
A pipeline is a conveyor belt: data passes through several steps automatically, often thousands of times in a row.
Analogy: a factory line where every part goes through the same machines.
Example: 10,000 reviews → the model detects mood and topic → JSON output → rows in a spreadsheet. Such jobs use cheap models and the Idempotency-Key header, so a retry after a failure is not charged twice. More in Pipelines (/docs/guides/pipelines).
RAG
RAG (Retrieval-Augmented Generation) means "answering with a peek at the documents". Before asking, your program finds the relevant pieces of your documents and puts them into the prompt; the model answers from them.
Analogy: an open-book exam. The student doesn't remember everything but finds the right page quickly.
Example: a customer asks about delivery, so the bot finds three FAQ paragraphs about delivery and sends them to the model along with the question. Zukko has no embeddings, so the document search happens in your own code. More in Answers from your documents (/docs/guides/knowledge-base).
JSON
JSON is a text data format that programs read easily: {"name": "Anna", "city": "Kazan"}.
Analogy: a form with fields instead of a free-form letter. A form is easy to split into spreadsheet columns.
Example: you can ask a model to reply strictly in JSON with the response_format parameter. deepseek-v4.1-flash and claude-haiku-5-5 are verified to follow a schema. Still, always check the JSON in your code and retry on errors. More in JSON output (/docs/guides/structured-output).
Temperature
Temperature (temperature) controls how much randomness goes into the answers. At 0 the answers are predictable and nearly identical; at 0.7–1 they are more varied and "creative".
Analogy: a strict accountant versus a writer who loves to improvise.
Example: use a low temperature for data extraction and a higher one for ad slogans. Newer Claude models may reject a non-default temperature — if you get an error, just remove the parameter.
Reasoning
Reasoning is a mode where the model "thinks" first: it writes a hidden draft, checks itself, and only then answers. It works better for math, code, and planning, but it is slower and costs more — the thinking is tokens too, and you pay for them.
Analogy: solving a problem on scrap paper before writing the clean copy.
Example: strong reasoning models (Kimi K3, Opus) can think for 30–60 seconds before the first word. Set your client timeout to 120–300 seconds and use streaming (/docs/introduction/for-beginners#streaming).
What next
- Your first request in 3 minutes (/docs/introduction/quick-start) — put the words into practice
- How to choose a model (/docs/introduction/choose-model) — which model to use for your task
- Payments and costs (/docs/introduction/billing) — how tokens turn into money
----------
First request in 3 minutes
No code
1. Sign up (/register) and top up (/app/payments) from $1.
2. Open Chat (/app/chat), pick a model and ask a question.
Not sure which model? Start with claude-sonnet-5-5.
Through the API
Create a key
Dashboard → API keys (/app/api-keys) → "Create". The fetch_... key is shown once — save it. You need a verified email (not needed if you signed in with Telegram).
Top up
Payments (/app/payments), from $1.
Put the key in a variable
export ZUKKO_API_KEY="fetch_..."
$env:ZUKKO_API_KEY = "fetch_..."
The variable lasts until you close the terminal. To keep it, add the line to ~/.zshrc or ~/.bashrc; on Windows run setx ZUKKO_API_KEY "fetch_...".
Run the sample
Python: pip install openai, Node.js: npm install openai, curl needs nothing.
first.py
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
)
response = client.chat.completions.create(
model="claude-haiku-5-5",
messages=[{"role": "user", "content": "Explain in two sentences what a neural network is."}],
)
print(response.choices[0].message.content)
// first.mjs
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.zukko.pro/v1",
apiKey: process.env.ZUKKO_API_KEY,
});
const response = await client.chat.completions.create({
model: "claude-haiku-5-5",
messages: [{ role: "user", content: "Explain in two sentences what a neural network is." }],
});
console.log(response.choices[0].message.content);
curl https://api.zukko.pro/v1/chat/completions \
-H "Authorization: Bearer $ZUKKO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-haiku-5-5",
"messages": [{"role": "user", "content": "Explain in two sentences what a neural network is."}]
}'
Run it: python first.py or node first.mjs. On Windows use Python or Node.js — PowerShell breaks the quotes in curl.
The answer text is in choices[0].message.content, token usage in usage. For another model, change model; nothing else changes.
If it doesn't work
What you see | What to do
--- | ---
401 | Copy the key again from the dashboard (/app/api-keys)
KeyError: 'ZUKKO_API_KEY' or OPENAI_API_KEY ... missing | The variable isn't set in this window — run export again
402 | Top up (/app/payments)
403 email_unverified | Verify your email using the link in the email
404 model_not_found | Take the name from Models and pricing (/docs/introduction/models-pricing)
503 model_disabled | Pick another model, for example deepseek-v4.1-flash
Let AI write the code
Download the docs as one file and give it to ChatGPT, Claude or Cursor — they'll write code with the right addresses and models.
----------
How to choose a model
The short answer: start with a cheap model, test it on your own examples, and move to a stronger one only if the quality isn't enough. Below is what to use for specific tasks, and why.
The main rule: cheap first, then stronger
Prices differ between models by tens of times, while on simple tasks the quality gap is often invisible. claude-haiku-5-5 answers "what time do you open?" just as well as claude-opus-5-5 — for a twentieth of the price.
Diagram: Your task → Cheap model (claude-haiku-5-5 or deepseek-v4.1-flash) → Test (10–20 of your real examples) → Good enough? (yes — keep it)
Diagram: Not good enough → Mid-range model (claude-sonnet-5-5) → Test again → Still not enough? (claude-opus-5-5)
Test on real customer questions or real data, not made-up ones. Switching models is easy: you change one word in the model field and the rest of the code stays the same.
What to use for each task
Prices are in US dollars per 1M tokens (/docs/introduction/for-beginners#token), input / output.
Task | Model | Why | Price
--- | --- | --- | ---
Chatbot, simple questions | claude-haiku-5-5 | fast and cheap, understands images | 0.05 / 0.20
Support bot over a knowledge base | claude-haiku-5-5; if answers are off — claude-sonnet-5 | Sonnet follows long instructions better, and a repeated knowledge base is read from the cache 10× cheaper | 0.4 / 2
Code, Claude Code, Cursor | claude-sonnet-5-5; for hard tasks — claude-opus-5-5 | strength index 56 and 58, the strongest in the catalog | 0.4 / 2 and 0.8 / 4
Extracting data to JSON | deepseek-v4.1-flash or claude-haiku-5-5 | verified: both follow a JSON schema, both are very cheap | 0.03 / 0.10
Long documents: contracts, reports | claude-sonnet-5 | 1M-token window; follow-up questions on the same document go through the cache | 0.4 / 2
Understanding an image: screenshot, photo, receipt | claude-haiku-5-5; for hard cases — claude-sonnet-5 | every Claude model accepts images | 0.05 / 0.20
Agent with tools | deepseek-v4.1-flash or claude-haiku-5-5; for complex agents — claude-sonnet-5-5 | tool calling is verified on these models | 0.03 / 0.10
Grading answers (judge) | claude-opus-5-5, or the cheaper gpt-6.1-sol | a judge should be stronger than the model it grades; index 58 and 52 | 0.8 / 4 and 0.27 / 1.32
Hard reasoning, planning | claude-opus-5-5 or kimi-k3.1-pro | index 58 and 55; Kimi is about three times cheaper | 0.8 / 4 and 0.26 / 1.29
Cheap bulk processing | qwen3.8-flash or deepseek-v4.1-flash | the cheapest in the catalog | 0.03 / 0.07 and 0.03 / 0.10
Generating a picture from a description is a separate job — see Images: understand and generate (/docs/guides/images). The full table of every model is in Models & pricing (/docs/introduction/models-pricing).
Careful: Don't use gpt-6-luna for JSON
In testing, gpt-6-luna ignored the JSON schema and took about a minute to answer. For strict JSON use deepseek-v4.1-flash or claude-haiku-5-5 — and still validate the JSON in your code.
Strength index versus price
In the model catalog (/app/models) every model has a "strength index" — a combined score across a set of benchmarks from Artificial Analysis. A higher number means a smarter model on average. The catalog is sorted by it by default.
Model | Strength index | Output price per 1M tokens
--- | --- | ---
claude-opus-5-5 | 58 | $4
claude-sonnet-5-5 | 56 | $2
kimi-k3.1-pro | 55 | $1.29
gpt-6-astra | 53 | $3.05
deepseek-v4.1-flash-1017 | 45 | $0.10
claude-haiku-5-5 | 43 | $0.20
How to read it: Opus costs 20 times more than Haiku and scores 15 points higher. For hard code and multi-step reasoning those points show. For "where is my order?" they don't. And at a similar index the price can differ several times over: kimi-k3.1-pro and claude-sonnet-5-5 are nearly equal in strength, but Kimi is cheaper.
The index is a general guide, not a score for your task. Testing on your own examples is what decides.
Slow reasoning models
Tip: Models that "think" take longer to answer
Strong reasoning (/docs/introduction/for-beginners#reasoning) models (Kimi K3, Opus) can think for 30–60 seconds before the first word. The thinking is tokens too, and you pay for it. For customer chat, where speed matters, use fast models. If you do need a strong one, set the client timeout to 120–300 seconds and turn on streaming (/docs/introduction/for-beginners#streaming).
More ways to save
- Prompt cache. Put a long, unchanging instruction or knowledge base at the start of the request. With claude-sonnet-5 and claude-opus-5-5 the repeated part is read from the cache 10 times cheaper.
- The Standard channel. Claude models with the -standard suffix cost the same for any token: claude-sonnet-5-standard is $0.2, Opus is $0.3. There is no cache discount, so it pays off in pipelines where the context doesn't repeat.
- Different models for different steps. A cheap one sorts and extracts; a strong one writes the final result or checks it.
More in How to pay less (/docs/guides/save-money).
What next
- Models & pricing (/docs/introduction/models-pricing) — the full table of models, prices, and context sizes
- Payments and costs (/docs/introduction/billing) — cost worked out on examples
- LLM as a judge (/docs/guides/llm-as-judge) — how to compare models on your own data
- Your first request in 3 minutes (/docs/introduction/quick-start) — try a model right now
----------
Models & pricing
Zukko gives you text models from Claude, GPT, Grok, Gemini, Qwen, DeepSeek, GLM, Kimi, and MiniMax. Prices are in US dollars per 1M tokens. Input and output are priced differently, and prompt caching is billed separately: cache reads and cache writes have their own prices.
Available models
Model ID | Model | Input | Output | Cache read | Cache write | Context | Image input
--- | --- | --- | --- | --- | --- | --- | ---
claude-opus-5-5 | Claude Opus 5.5 | 0.8 | 4 | 0.08 | 1 | 1M | yes
claude-fable-5-1 | Claude Fable 5.1 | 7 | 35 | 0.7 | 8.75 | 1M | yes
claude-fable-5 | Claude Fable 5 | 7 | 35 | 0.7 | 8.75 | 1M | yes
claude-opus-5 | Claude Opus 5 | 0.8 | 4 | 0.08 | 1 | 1M | yes
claude-opus-4.8 | Claude Opus 4.8 | 0.8 | 4 | 0.08 | 1 | 1M | yes
claude-opus-4.7 | Claude Opus 4.7 | 0.8 | 4 | 0.08 | 1 | 1M | yes
claude-opus-4.6 | Claude Opus 4.6 | 0.8 | 4 | 0.08 | 1 | 1M | yes
claude-sonnet-5-5 | Claude Sonnet 5.5 | 0.4 | 2 | 0.04 | 0.5 | 1M | yes
claude-sonnet-5 | Claude Sonnet 5 | 0.4 | 2 | 0.04 | 0.5 | 1M | yes
claude-sonnet-4-6 | Claude Sonnet 4.6 | 0.5 | 2.5 | 0.05 | 0.6 | 1M | yes
claude-haiku-5-5 | Claude Haiku 5.5 | 0.05 | 0.2 | 0.01 | 0.06 | 1M | yes
claude-haiku-4.5 | Claude Haiku 4.5 | 0.18 | 0.9 | 0.02 | 0.23 | 200K | yes
gpt-6-astra | GPT-6 Astra | 0.62 | 3.05 | 0.05 | 0.61 | 1M | yes
gpt-6.1-sol | GPT-6.1 Sol | 0.27 | 1.32 | 0.01 | 0.22 | 1M | yes
gpt-6-sol | GPT-6 Sol | 0.13 | 0.63 | 0.01 | 0.13 | 1M | yes
gpt-6-luna | GPT-6 Luna | 0.04 | 0.21 | 0.03 | 0.14 | 1M | no
gpt-5.6-sol | GPT-5.6 Sol | 0.22 | 1.07 | 0.02 | 0.22 | 1M | yes
gpt-5.6-terra | GPT-5.6 Terra | 0.13 | 0.63 | 0.01 | 0.13 | 1M | yes
gpt-5.6-luna | GPT-5.6 Luna | 0.06 | 0.3 | 0.01 | 0.06 | 1M | no
grok-4.7 | Grok 4.7 | 0.18 | 0.53 | 0.05 | 0.18 | 256K | yes
grok-4.6 | Grok 4.6 | 0.17 | 0.49 | 0.05 | 0.17 | 256K | yes
grok-4.5 | Grok 4.5 | 0.18 | 0.54 | 0.03 | 0.18 | 256K | yes
gemini-3.1-pro | Gemini 3.1 Pro | 0.15 | 0.9 | 0.03 | 0.15 | 1M | yes
gemini-3.8-flash | Gemini 3.8 Flash | 0.12 | 0.6 | 0.01 | 0.12 | 1M | no
gemini-3.7-flash | Gemini 3.7 Flash | 0.12 | 0.6 | 0.01 | 0.12 | 1M | no
qwen3.8-max | Qwen 3.8 Max | 0.1 | 0.28 | 0.1 | 0.1 | 1M | yes
qwen3.8-flash | Qwen 3.8 Flash | 0.03 | 0.07 | 0.03 | 0.03 | 1M | no
qwen3.8-omni-flash | Qwen 3.8 Omni Flash | 0.03 | 0.08 | 0.01 | 0.03 | 1M | yes
deepseek-v4-pro | DeepSeek V4 Pro | 0.07 | 0.21 | 0.07 | 0.07 | 1M | yes
deepseek-v4.1-flash | DeepSeek V4.1 Flash | 0.03 | 0.1 | 0.03 | 0.03 | 1M | no
deepseek-v4.1-flash-1017 | DeepSeek V4.1 Flash 1017 | 0.03 | 0.1 | 0.03 | 0.03 | 1M | yes
deepseek-v4-flash | DeepSeek V4 Flash | 0.05 | 0.14 | 0.02 | 0.05 | 1M | no
glm-5.2 | GLM 5.2 | 0.08 | 0.2 | 0.04 | 0.08 | 1M | yes
kimi-k3.1-pro | Kimi K3.1 Pro | 0.26 | 1.29 | 0.03 | 0.26 | 1M | no
kimi-k3 | Kimi K3 | 0.39 | 1.93 | 0.04 | 0.39 | 1M | yes
kimi-k2.7-code | Kimi K2.7 Code | 0.12 | 0.5 | 0.02 | 0.12 | 256K | yes
minimax-m3.1-flash | MiniMax M3.1 Flash | 0.16 | 0.6 | 0.02 | 0.16 | 1M | yes
glm-5.3 | GLM 5.3 | 0.1 | 0.1 | 0.1 | 0.1 | 128K | no
glm-5.3-flash | GLM 5.3 Flash | 0.04 | 0.04 | 0.04 | 0.04 | 128K | no
Original API Standard
The same Claude models through another provider channel: one price for any token — input, output, and cache. There is no cache discount, so Standard pays off where context does not repeat: pipelines, chatbots, batch jobs. The model code ends with -standard.
Model ID | Model | Price per any token | Context | Image input
--- | --- | --- | --- | ---
claude-opus-5-5-standard | Claude Opus 5.5 | 0.3 | 1M | yes
claude-opus-5-standard | Claude Opus 5 | 0.3 | 1M | yes
claude-opus-4.8-standard | Claude Opus 4.8 | 0.3 | 1M | yes
claude-opus-4.7-standard | Claude Opus 4.7 | 0.3 | 1M | yes
claude-opus-4.6-standard | Claude Opus 4.6 | 0.3 | 1M | yes
claude-sonnet-5-standard | Claude Sonnet 5 | 0.2 | 1M | yes
claude-sonnet-4-6-standard | Claude Sonnet 4.6 | 0.2 | 1M | yes
All prices are $ per 1M tokens. Image models are currently unavailable (see below).
The list and prices change: models get added and temporarily switched off. Before you put a model id into a config, check the live catalog — it is public, no key required:
curl https://api.zukko.pro/v1/models/info
/v1/models/info returns prices (cache prices included) and limits and is the source of truth if the table above ever disagrees with it. The same catalog with prices is on the Models (/app/models) page in the dashboard.
Strength index
In the dashboard catalog each model has a "Strength index" — the Intelligence Index from Artificial Analysis (https://artificialanalysis.ai/leaderboards/models), a composite score across a set of benchmarks. By default the models are sorted by it, strongest first. Current values:
Model | Strength index
--- | ---
Claude Opus 5.5 | 58
Claude Sonnet 5.5 | 56
Kimi K3.1 Pro | 55
Claude Fable 5.1 | 53
GPT-6 Astra | 53
GPT-6.1 Sol | 52
Grok 4.7 | 46
Qwen 3.8 Max | 45
DeepSeek V4.1 Flash 1017 | 45
GLM 5.3 | 45
Kimi K3 | 44
Claude Haiku 5.5 | 43
MiniMax M3.1 Flash | 43
GPT-5.6 Terra | 42
GLM 5.3 Flash | 42
Gemini 3.8 Flash | 41
Qwen 3.8 Flash | 40
DeepSeek V4.1 Flash | 39
GPT-6 Luna | 38
DeepSeek V4 Pro | 36
Gemini 3.1 Pro | 30
Kimi K2.7 Code | 26
Claude Haiku 4.5 | 17
The index is a general guide, not a score for your particular task. Before moving work to another model, test it on your own requests.
Which model to pick
- Highest strength index — claude-opus-5-5 (58) at $0.8 / $4.
- Index 52–53 — gpt-6.1-sol (52, $0.27 / $1.32), gpt-6-astra (53, $0.62 / $3.05), and claude-fable-5-1 (53, $7 / $35). The index is close, the price differs several times over — compare them on your own volume using the table.
- Default Claude — claude-sonnet-5 ($0.4 / $2): the main model in the examples throughout these docs. The new claude-sonnet-5-5 costs the same.
- Cheaper Claude without a cache discount — Standard: claude-sonnet-5-standard at $0.2 and Opus at $0.3 per any token.
- Index 45 at $0.1 input or less — qwen3.8-max ($0.1 / $0.28, accepts images) and glm-5.3 ($0.1 / $0.1, 128K context, no images).
- Cheap bulk jobs — deepseek-v4.1-flash ($0.03 / $0.1, index 39), glm-5.3-flash ($0.04 / $0.04, index 42, 128K context).
- You need to send images — pick a model with "yes" in the last column: every Claude and Grok, gpt-6-astra, gpt-6.1-sol, gpt-6-sol, gpt-5.6-sol, gpt-5.6-terra, gemini-3.1-pro, qwen3.8-max, deepseek-v4-pro, glm-5.2.
- Long context — most models have a 1M-token window; Grok has 256K, glm-5.3 and glm-5.3-flash have 128K.
claude-opus-5, claude-opus-4.8, claude-opus-4.7, claude-opus-4.6, and claude-fable-5 are previous versions; they keep working for configs that already use them.
How to write the model name
The canonical name is the id from the table above, and that is what you should put in configs. On top of that the gateway recognizes a few common variants on its own:
- with a vendor or router prefix: anthropic/claude-sonnet-5, zhipu/glm-5.3, deepseek/deepseek-v4-pro;
- with a version date at the end: deepseek-v4-flash-0731, claude-sonnet-5-20260101;
- Bedrock style: us.anthropic.claude-sonnet-5;
- a dot or a dash in the version number: claude-opus-5.5 and claude-opus-5-5 are the same model;
- case does not matter.
Old Qwen names work as second names: qwen3.5-flash and qwen3.7-plus point to qwen3.8-flash, qwen3.7-max and qwen3.8-max-preview point to qwen3.8-max.
What the gateway answers when something is wrong with the model:
- 404 model_not_found — there is no such model in the catalog. The request does not reach a provider and is not charged.
- 503 model_disabled — the model is temporarily switched off; pick another one from GET /v1/models.
- 400 model_retired — the model was retired; the message names the one to switch to.
Endpoints per model family
Family | /v1/chat/completions | /v1/messages | /v1/responses | /v1/messages/count_tokens
--- | --- | --- | --- | ---
Claude (claude-*) | yes | yes, native | yes | yes
GPT (gpt-*) | yes, native | yes | yes | yes
Gemini, Qwen, DeepSeek, Kimi, MiniMax | yes | yes | yes | yes
GLM (glm-*) | yes, native | yes, Zukko converts the format | no | no
The safest choice is the model's native format. For GPT use /v1/chat/completions or /v1/responses. For Gemini, Qwen, and DeepSeek use Chat Completions or Messages. GLM does not understand the Responses API input and instructions fields — use Chat Completions or Messages for it.
GLM specifics
- Text only: image input is not accepted.
- By default GLM answers without extended reasoning, so you do not pay for hidden thinking. To enable reasoning, send "reasoning_effort": "high" or "thinking": {"type": "enabled"}.
- Zukko raises max_tokens to at least 256, or to at least 4096 when reasoning is on; with less the answer comes back empty.
Names for Cursor
Cursor refuses to add a custom model whose name it already knows, so there are second names for it:
Name for Cursor | Model
--- | ---
cursor-opus-55 | claude-opus-5-5
cursor-opus-5 | claude-opus-5
cursor-opus-48 | claude-opus-4.8
cursor-opus-47 | claude-opus-4.7
cursor-opus-46 | claude-opus-4.6
cursor-sonnet-5 | claude-sonnet-5
cursor-gpt-sol | gpt-5.6-sol
cursor-gpt-terra | gpt-5.6-terra
cursor-gpt-luna | gpt-5.6-luna
cursor-qwen | qwen3.8-max
See the Cursor IDE (/docs/ide-integrations/cursor-ide) guide.
Image generation
Image models are temporarily unavailable: requests to them get 503 model_disabled. The request format is described on POST /v1/images/generations (/docs/api-reference/images-generations).
How cost is calculated
Input is split into three parts, each with its own price:
+ cache read × cache read price
+ cache write × cache write price
+ output × output price
Charging happens in two steps:
1. Right after the response — from the usage in the provider's response.
2. About a minute later — from the provider's actual count for that request: input, cache reads, cache writes, and output. The charge is recalculated at our prices, and the difference is refunded to your balance or charged. Request history keeps the final numbers.
The second step exists because usage in the response is not always complete. For example, for Claude in the OpenAI format (/v1/chat/completions) the provider sends no cache breakdown, yet caching still works; after recalculation the cache is billed at cache prices in any format.
- For models whose cache prices equal the input price (Qwen, DeepSeek, GLM 5.3), cached input costs the same as regular input.
Example for claude-sonnet-5 via /v1/messages: 100,000 input tokens, 90,000 of them read from the cache, and 2,000 output tokens.
90,000 × $0.04 / 1M = $0.0036 cache read
2,000 × $2 / 1M = $0.004 output
total $0.0116
The same request via /v1/chat/completions ends up costing the same. This format's response carries no cache breakdown, so $0.044 (100,000 × $0.4 / 1M + $0.004) is charged at first, and about a minute later the charge is recalculated from the provider's actual usage to $0.0116, with the difference refunded. More in Billing (/docs/introduction/billing).
----------
Payment and costs
Your balance is in US dollars. You top up in advance, and each request deducts the tokens (/docs/introduction/for-beginners#token) it used. No subscriptions.
How to top up
Dashboard → Payments (/app/payments), minimum $1:
Method | Fee on top
--- | ---
SBP (in rubles at the market rate) | ≈ 8.5%
Crypto | —
Foreign card ($2–$594 per payment) | ≈ 13%
Russian cards are temporarily unavailable — use SBP. You see the final amount before paying.
Bonuses: $0.50 for subscribing to @zukkopro (https://t.me/zukkopro) (once per Telegram account, claim it in the dashboard). 10% of top-ups by people you invite, via the referral link in the dashboard. There's no sign-up bonus.
What a request costs
Cost = input tokens × input price + output tokens × output price. Prices per 1M tokens are in Models and pricing (/docs/introduction/models-pricing). Input read from the cache (/docs/introduction/for-beginners#cache) costs less.
Rule of thumb: a page of English text is about 700 tokens.
What it costs: A one-page answer (150 tokens in, 1,000 out)
- claude-haiku-5-5 ≈ $0.0002 — about 5,000 answers per $1;
- claude-sonnet-5 ≈ $0.002 — about 500 per $1;
- claude-opus-5-5 ≈ $0.004 — about 250 per $1.
An hour in Claude Code usually costs $0.8–1.6. To spend less, see How to pay less (/docs/guides/save-money).
When a request is refused
- Email not verified — 403 email_unverified (not needed if you signed in with Telegram).
- Balance below $0.01 — 402 insufficient_balance.
Charges happen after the answer, for what was used. A long request can take the balance negative; then the next ones get 402, and a top-up clears the negative first.
What's free
- Requests refused before the model: no money, key limit, unknown model, invalid key.
- Error responses.
- Answers from the Zukko cache (X-Zukko-Cache: true) and repeats with the same Idempotency-Key.
- /v1/balance and /v1/usage.
What's paid, though not obvious
- An interrupted stream — the answer is read to the end and charged in full.
- POST /v1/messages/count_tokens — at the input price.
- POST /v1/web-search — as a small token charge.
- Reasoning by "thinking" models — that's tokens too.
Where to see spending
- Dashboard → Usage (/app/usage) — each request with its model, tokens and cost.
- API: GET /v1/usage (/docs/api-reference/usage) and GET /v1/balance (/docs/api-reference/balance).
- The X-Zukko-Cost-Micros header on a regular (non-streaming) response — the request's cost.
The balance is shared: all keys, the web chat and the Telegram bot spend the same money.
About a minute after a request, its cost is recalculated with exact cache data — the difference is refunded or charged.
Per-key limits
In the dashboard (/app/api-keys) you can set a daily and monthly limit and a list of allowed models for each key.
Situation | Response
--- | ---
Model not in the key's list | 403 model_not_allowed_for_key
Daily limit reached | 429 key_daily_limit_reached
Monthly limit reached | 429 key_monthly_limit_reached
Resets at 00:00 UTC and on the 1st. An empty limit or 0 means no limit. The last request may go slightly over. More: Key limits and cache (/docs/api-reference/key-limits-and-cache).
Units in the API
Where | Unit | Example
--- | --- | ---
GET /v1/balance → balance_cents | cents, rounded down | 1234 = $12.34
GET /v1/usage → cost_cents | fractional cents | 0.0525 = $0.000525
X-Zukko-Cost-Micros | microdollars | 525 = $0.000525
X-Fetch-Balance-Cents | microdollars | 12340000 = $12.34
Dashboard /api/, _cents fields | microdollars | 12340000 = $12.34
----------
Migrating to Zukko
You change three things: the address, the key and the model name. Your request code stays the same.
What | Before | After
--- | --- | ---
Key | sk-..., sk-ant-..., sk-or-... | fetch_... (create one (/app/api-keys))
OpenAI / OpenRouter address | https://api.openai.com/v1, https://openrouter.ai/api/v1 | https://api.zukko.pro/v1
Anthropic address | https://api.anthropic.com | https://api.zukko.pro
Model | the provider's name | the name from the catalog (/docs/introduction/models-pricing)
Supported: /v1/chat/completions, /v1/responses, /v1/messages, /v1/messages/count_tokens, /v1/images/generations, /v1/models, /v1/balance, /v1/usage, /v1/web-search. Everything else (embeddings, audio, files, batches, fine-tuning) returns 404.
From OpenAI
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key="fetch_...",
)
r = client.chat.completions.create(
model="gpt-5.6-sol",
messages=[{"role": "user", "content": "Hi!"}],
)
- GPT models: gpt-6.1-sol, gpt-6-astra, gpt-6-sol, gpt-5.6-sol, gpt-5.6-terra; cheaper: gpt-6-luna, gpt-5.6-luna. The same client works with Claude, Gemini, Qwen, DeepSeek and GLM.
- The OpenAI-Organization and OpenAI-Project headers aren't needed.
- With environment variables: OPENAI_BASE_URL=https://api.zukko.pro/v1, OPENAI_API_KEY=fetch_....
From Anthropic
client = Anthropic(
base_url="https://api.zukko.pro",
api_key="fetch_...",
)
msg = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hi!"}],
)
- The address goes without /v1. The x-api-key header is accepted.
- Names with a date (claude-sonnet-5-20260101) or a dot (claude-opus-5.5) are recognized, but use the catalog name.
- Prompt caching works and is billed at cache prices. count_tokens is paid, at the input price.
- Claude Code has its own guide (/docs/ide-integrations/claude-code).
From OpenRouter
client = OpenAI(
base_url="https://api.zukko.pro/v1", # was https://openrouter.ai/api/v1
api_key="fetch_...", # was sk-or-...
)
r = client.chat.completions.create(
model="claude-sonnet-5", # was anthropic/claude-sonnet-5
messages=[{"role": "user", "content": "Hi!"}],
)
- A vendor prefix (anthropic/claude-sonnet-5) is recognized, but drop it.
- Remove the models, provider, route, transforms fields and the HTTP-Referer, X-Title headers.
- Balance: GET /v1/balance (/docs/api-reference/balance); history: GET /v1/usage (/docs/api-reference/usage).
Check
1. Your model is in the list (https://api.zukko.pro/v1/models).
2. A short request goes through. 404 model_not_found means the name isn't in the catalog. Other codes: API errors (/docs/api-reference/errors).
----------
Use cases
Telegram bot with AI
In 15 minutes you'll have a Telegram bot that answers with an AI model and remembers the last 10 messages of each chat.
You need: a Telegram account, a fetch_... key (create one (/app/api-keys)), a balance from $0.01 (top up (/app/payments)), Python 3.10+ or Node.js 18+ (not sure — pick Python).
Diagram: User (writes in Telegram) → Your bot (a program on your computer or server) → Zukko API (one key) → Model (e.g. Claude Haiku)
Create the bot
In @BotFather (https://t.me/BotFather) send /newbot, pick a name and a username ending in bot (e.g. my_helper_bot). You'll get a token 123456789:AA... — never share it.
Create a key for the bot
In Dashboard → API keys (/app/api-keys) create a separate key. Set a limit, say $1 a day, and allow only claude-haiku-5-5 — how (/docs/api-reference/key-limits-and-cache).
Install the libraries
Create an ai-bot folder and open a terminal in it.
Python:
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "python-telegram-bot>=21,<22" openai
Node.js:
npm install grammy @grammyjs/runner openai
Save the code
Copy the code from "Full code" below into bot.py (Python) or bot.mjs (Node.js) in the ai-bot folder.
Start it
macOS and Linux:
export ZUKKO_API_KEY="fetch_..."
python bot.py # or: node bot.mjs
Windows (PowerShell):
$env:ZUKKO_API_KEY="fetch_..."
python bot.py
You'll see a "bot started" line. Keep the window open: the bot answers only while the program runs.
Check
Find the bot in Telegram, tap Start and ask something.
Tip: Personality and model
Personality — the SYSTEM_PROMPT text at the top of the code. Model — export ZUKKO_MODEL=claude-sonnet-5; allow it in the key settings.
Full code
The 10-message memory lives in RAM and is wiped on restart. /reset clears it manually.
import asyncio
import logging
import os
from collections import defaultdict, deque
from openai import APIConnectionError, APIStatusError, AsyncOpenAI
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # otherwise the log shows URLs containing the bot token
log = logging.getLogger("bot")
MODEL = os.environ.get("ZUKKO_MODEL", "claude-haiku-5-5")
HISTORY_SIZE = 10 # how many recent messages to remember (questions and answers)
TELEGRAM_LIMIT = 4000 # Telegram allows up to 4096 characters; keep a margin
SYSTEM_PROMPT = (
"You are a friendly Telegram assistant. Reply in the user's language, "
"briefly and in plain words. If you don't know the answer, say so. "
"Do not use Markdown: Telegram would show asterisks and hashes as is."
)
client = AsyncOpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=120,
max_retries=2,
)
Bot memory: the last HISTORY_SIZE messages of each chat.
Kept in RAM and wiped on restart.
history: dict[int, deque] = defaultdict(lambda: deque(maxlen=HISTORY_SIZE))
def split_text(text: str, limit: int = TELEGRAM_LIMIT) -> list[str]:
"""Split a long answer into chunks, at line breaks where possible."""
parts = []
while len(text) > limit:
cut = text.rfind("\n", 0, limit)
if cut < limit // 2:
cut = limit
parts.append(text[:cut])
text = text[cut:].lstrip("\n")
if text:
parts.append(text)
return parts
async def keep_typing(bot, chat_id: int) -> None:
"""The "typing…" status lasts about 5 seconds, so refresh it while waiting for the model."""
while True:
try:
await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
except Exception as err:
log.debug("typing failed: %r", err)
await asyncio.sleep(4)
def friendly_error(err: Exception) -> str:
if isinstance(err, APIStatusError) and err.status_code == 429:
return "Too many requests. Please wait a minute and try again."
if isinstance(err, APIStatusError) and err.status_code in (401, 402, 403):
return "The bot can't answer right now. The owner has been notified."
return "Couldn't get an answer. Please try again in a minute."
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
history[update.effective_chat.id].clear()
await update.message.reply_text(
"Hi! I'm an AI bot. Ask me anything.\n/reset — start the conversation over."
)
async def reset(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
history[update.effective_chat.id].clear()
await update.message.reply_text("Done, starting with a clean slate.")
async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id
text = update.message.text
memory = history[chat_id]
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*memory,
{"role": "user", "content": text},
]
typing = asyncio.create_task(keep_typing(context.bot, chat_id))
try:
response = await client.chat.completions.create(
model=MODEL, messages=messages, max_tokens=1500
)
answer = (response.choices[0].message.content or "").strip()
except (APIStatusError, APIConnectionError) as err:
log.warning("Zukko API error: %r", err)
await update.message.reply_text(friendly_error(err))
return
finally:
typing.cancel()
if not answer:
answer = "The model returned an empty answer. Try rephrasing your question."
memory.append({"role": "user", "content": text})
memory.append({"role": "assistant", "content": answer})
for part in split_text(answer):
await update.message.reply_text(part)
async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
log.error("Unexpected error", exc_info=context.error)
def main() -> None:
app = (
Application.builder()
.token(os.environ["TELEGRAM_BOT_TOKEN"])
.concurrent_updates(True) # don't make everyone wait while the model answers one user
.build()
)
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("reset", reset))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_message))
app.add_error_handler(on_error)
log.info("Bot started, model %s", MODEL)
app.run_polling()
if __name__ == "__main__":
main()
import { Bot } from "grammy";
import { run } from "@grammyjs/runner";
import OpenAI from "openai";
const MODEL = process.env.ZUKKO_MODEL ?? "claude-haiku-5-5";
const HISTORY_SIZE = 10; // how many recent messages to remember
const TELEGRAM_LIMIT = 4000; // Telegram allows up to 4096 characters
const SYSTEM_PROMPT =
"You are a friendly Telegram assistant. Reply in the user's language, " +
"briefly and in plain words. If you don't know the answer, say so. " +
"Do not use Markdown: Telegram would show asterisks and hashes as is.";
const client = new OpenAI({
baseURL: "https://api.zukko.pro/v1",
apiKey: process.env.ZUKKO_API_KEY,
timeout: 120_000,
maxRetries: 2,
});
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN);
// Memory: chatId -> recent messages. Wiped on restart.
const history = new Map();
function getMemory(chatId) {
if (!history.has(chatId)) history.set(chatId, []);
return history.get(chatId);
}
function splitText(text, limit = TELEGRAM_LIMIT) {
const parts = [];
while (text.length > limit) {
let cut = text.lastIndexOf("\n", limit);
if (cut < limit / 2) cut = limit;
parts.push(text.slice(0, cut));
text = text.slice(cut).replace(/^\n+/, "");
}
if (text) parts.push(text);
return parts;
}
function friendlyError(err) {
const status = err instanceof OpenAI.APIError ? err.status : undefined;
if (status === 429) return "Too many requests. Please wait a minute and try again.";
if ([401, 402, 403].includes(status)) return "The bot can't answer right now. The owner has been notified.";
return "Couldn't get an answer. Please try again in a minute.";
}
bot.command("start", async (ctx) => {
history.delete(ctx.chat.id);
await ctx.reply("Hi! I'm an AI bot. Ask me anything.\n/reset — start the conversation over.");
});
bot.command("reset", async (ctx) => {
history.delete(ctx.chat.id);
await ctx.reply("Done, starting with a clean slate.");
});
bot.on("message:text", async (ctx) => {
const text = ctx.message.text;
if (text.startsWith("/")) return; // don't send unknown commands to the model
const memory = getMemory(ctx.chat.id);
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
...memory,
{ role: "user", content: text },
];
// "typing…" lasts about 5 seconds, so refresh it while waiting for the model
const sendTyping = () => ctx.replyWithChatAction("typing").catch(() => {});
sendTyping();
const typing = setInterval(sendTyping, 4000);
let answer;
try {
const response = await client.chat.completions.create({
model: MODEL,
messages,
max_tokens: 1500,
});
answer = response.choices[0].message.content?.trim();
} catch (err) {
console.warn("Zukko API error:", err.status ?? "", err.message);
await ctx.reply(friendlyError(err));
return;
} finally {
clearInterval(typing);
}
if (!answer) answer = "The model returned an empty answer. Try rephrasing your question.";
memory.push({ role: "user", content: text }, { role: "assistant", content: answer });
if (memory.length > HISTORY_SIZE) memory.splice(0, memory.length - HISTORY_SIZE);
for (const part of splitText(answer)) await ctx.reply(part);
});
bot.catch((err) => console.error("Bot error:", err.error ?? err));
// run() from @grammyjs/runner handles messages in parallel:
// while the model answers one person, others don't have to wait.
run(bot);
console.log(Bot started, model ${MODEL});
Running 24/7
Copy the ai-bot folder to a Linux VPS, install the libraries and set up autostart.
/etc/systemd/system/ai-bot.service
[Unit]
Description=Telegram AI bot
After=network-online.target
Wants=network-online.target
[Service]
User=bot
WorkingDirectory=/home/bot/ai-bot
EnvironmentFile=/home/bot/ai-bot/.env
ExecStart=/home/bot/ai-bot/.venv/bin/python bot.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
npm install -g pm2
export TELEGRAM_BOT_TOKEN="123456789:AA..."
export ZUKKO_API_KEY="fetch_..."
pm2 start bot.mjs --name ai-bot
pm2 save # remember the process list
pm2 startup # prints the command that starts pm2 after a reboot
pm2 logs ai-bot # watch the log
For systemd, create a .env file next to the bot with TELEGRAM_BOT_TOKEN=... and ZUKKO_API_KEY=... (no export, no quotes), run chmod 600 .env and enable the service:
sudo systemctl enable --now ai-bot
journalctl -u ai-bot -f # live bot log
Careful: Keep the bot token and the key out of the code and out of Git. If the token leaks, revoke it with /revoke in @BotFather.
What it costs: 1,000 answers a day
- claude-haiku-5-5: ≈ $0.00014 per answer, about $4 a month.
- deepseek-v4.1-flash: ≈ $0.000075 per answer, about $2.30 a month.
- claude-sonnet-5: ≈ $0.0012 per answer, about $36 a month.
If it doesn't work
What you see | What to do
--- | ---
KeyError: 'TELEGRAM_BOT_TOKEN' | Run the export ... lines in the same window where you start the bot
ModuleNotFoundError: No module named 'telegram' | source .venv/bin/activate, then pip install again
Conflict: terminated by other getUpdates request | The bot runs twice — keep one copy
"Can't answer right now", the log shows 401 / 402 / 403 | 401 — check ZUKKO_API_KEY. 402 — top up (/app/payments). 403 model_not_allowed_for_key — allow the model in the key or change ZUKKO_MODEL
429 key_daily_limit_reached | Raise the key limit or wait until 00:00 UTC
The bot is slow | Use a fast model: claude-haiku-5-5 or deepseek-v4.1-flash
Other codes: API errors (/docs/api-reference/errors).
----------
Support bot with a knowledge base
You'll have a bot that answers customers only from your knowledge base, and when it doesn't know, says "I'm passing this to a colleague" and sends the question to an operator chat.
You need: a fetch_... key with a spend limit (create one (/app/api-keys), limits (/docs/api-reference/key-limits-and-cache)), a balance (top up (/app/payments)), Python 3.10+. For Telegram — the bot from Telegram bot with AI (/docs/guides/telegram-bot).
Diagram: Customer (asks in Telegram) → Bot (code on a server) → Zukko API (rules + knowledge base + question) → Model (answer as JSON)
Diagram: Answer is in the knowledge base → Bot replies to the customer
Diagram: No answer, or a person is needed → Bot: "passing to a colleague" → Operator (message in the team chat)
Write the knowledge base
Create knowledge_base.md: delivery, payment, returns, warranty, opening hours, contacts. Be specific, with numbers. Sample below.
Save the code
Copy the code from "Full code" below into support.py, next to the knowledge base.
Try it in the terminal
export ZUKKO_API_KEY="fetch_..."
python support.py
Ask questions covered by the knowledge base and some that aren't. Each returns JSON: the reply, needs_human and the reason.
Test on 20 real questions
Use real customer questions, awkward ones included. The bot must not invent prices or delivery times. Automated check: LLM as a judge (/docs/guides/llm-as-judge).
Sample knowledge base
Delivery
- Within the city by courier: 1–2 days, $5, free over $80.
- Nationwide: 3–7 days, the price is shown at checkout.
- Pickup: 1 Example St., Mon–Fri 10:00–20:00.
Payment
- Card on the website, bank transfer, cash to the courier.
Returns
- Within 14 days of delivery if the item hasn't been used.
- Refunds go back to the original payment method within 10 business days.
Contacts
- Support hours: Mon–Fri 9:00–21:00.
Full code
The model is claude-haiku-5-5 (or deepseek-v4.1-flash): both stick to the JSON schema. Unanswered questions go to unanswered.jsonl — add them to the knowledge base once a week. Changed the knowledge base? Restart the bot.
import asyncio
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from openai import APIConnectionError, APIStatusError, AsyncOpenAI
log = logging.getLogger("support")
MODEL = os.environ.get("ZUKKO_SUPPORT_MODEL", "claude-haiku-5-5")
COMPANY = "Example Store"
KNOWLEDGE_BASE = Path(os.environ.get("KB_FILE", "knowledge_base.md")).read_text(encoding="utf-8")
UNANSWERED_LOG = Path("unanswered.jsonl") # questions the bot couldn't answer on its own
The part that never changes (rules and knowledge base) goes first,
so the prompt cache can pick it up and repeats cost less.
SYSTEM_PROMPT = f"""You are a support agent at {COMPANY}. Be polite and brief, and reply in the customer's language.
Rules:
1. Answer only from the knowledge base below. Take prices, delivery times, terms, addresses and phone numbers only from it. Never make anything up.
2. Set needs_human = true if the answer is not in the knowledge base; the customer is unhappy or asks for a person; or the topic is a refund, a defect or a complaint. In that case, say in answer that you are passing the question to a colleague who will reply during business hours.
3. In reason, briefly tell the colleague why a person is needed. If no person is needed, use an empty string.
4. answer is the ready-to-send text for the customer: no Markdown and no JSON inside it.
5. If the answer is not in the knowledge base, set needs_human = true even if you could reply in general terms.
{KNOWLEDGE_BASE}
"""
RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {
"name": "support_answer",
"strict": True,
"schema": {
"type": "object",
"properties": {
"answer": {"type": "string"},
"needs_human": {"type": "boolean"},
"reason": {"type": "string"},
},
"required": ["answer", "needs_human", "reason"],
"additionalProperties": False,
},
},
}
FALLBACK = {
"answer": "I'm passing your question to a colleague who will reply soon.",
"needs_human": True,
"reason": "the bot could not answer",
}
client = AsyncOpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=60,
max_retries=2,
)
def parse_reply(content: str | None) -> dict | None:
"""Check that the model returned JSON of the right shape. Otherwise return None."""
text = (content or "").strip()
if text.startswith(""): # in case the model wrapped the JSON in a code block
text = text.strip("").removeprefix("json").strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
if not isinstance(data.get("answer"), str) or not data["answer"].strip():
return None
if not isinstance(data.get("needs_human"), bool):
return None
reason = data.get("reason") if isinstance(data.get("reason"), str) else ""
return {"answer": data["answer"].strip(), "needs_human": data["needs_human"], "reason": reason}
def log_unanswered(question: str, reason: str) -> None:
record = {"time": datetime.now(timezone.utc).isoformat(), "question": question, "reason": reason}
with UNANSWERED_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
async def answer(question: str, history: list[dict]) -> dict:
"""Return {"answer": str, "needs_human": bool, "reason": str}.
history is the earlier messages of the conversation:
[{"role": "user", "content": ...}, {"role": "assistant", "content": ...}].
"""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*history,
{"role": "user", "content": question},
]
result = None
for attempt in range(2): # retry once if the JSON is invalid
try:
response = await client.chat.completions.create(
model=MODEL,
messages=messages,
response_format=RESPONSE_FORMAT,
max_tokens=800,
)
except (APIStatusError, APIConnectionError) as err:
log.warning("Zukko API error: %r", err)
result = {**FALLBACK, "reason": f"API error: {err}"}
break
result = parse_reply(response.choices[0].message.content)
if result:
break
log.warning("The model returned invalid JSON, attempt %d", attempt + 1)
result = result or dict(FALLBACK)
if result["needs_human"]:
log_unanswered(question, result["reason"])
return result
async def main() -> None:
"""Try it in the terminal: ask questions, an empty line exits."""
history: list[dict] = []
while True:
question = input("Question: ").strip()
if not question:
break
result = await answer(question, history)
print(json.dumps(result, ensure_ascii=False, indent=2))
history += [
{"role": "user", "content": question},
{"role": "assistant", "content": result["answer"]},
]
history = history[-10:]
if __name__ == "__main__":
asyncio.run(main())
Connect to Telegram
Put the files together
support.py and knowledge_base.md go in the folder with bot.py from Telegram bot with AI (/docs/guides/telegram-bot).
Create an operator chat
Create a Telegram group and add the bot to it.
Paste the code into bot.py
It replaces on_message and adds an /id command. In main(), add app.add_handler(CommandHandler("id", show_id)).
python
from support import answer as support_answer
ADMIN_CHAT_ID = int(os.environ["ADMIN_CHAT_ID"]) # the chat where operator requests arrive
async def show_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""/id command: shows the chat id you need for ADMIN_CHAT_ID."""
await update.message.reply_text(f"chat id: {update.effective_chat.id}")
async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id
if chat_id == ADMIN_CHAT_ID:
return # stay silent in the operators' chat
text = update.message.text
memory = history[chat_id]
typing = asyncio.create_task(keep_typing(context.bot, chat_id))
try:
result = await support_answer(text, list(memory))
finally:
typing.cancel()
memory.append({"role": "user", "content": text})
memory.append({"role": "assistant", "content": result["answer"]})
for part in split_text(result["answer"]):
await update.message.reply_text(part)
if result["needs_human"]:
user = update.effective_user
who = f"@{user.username}" if user.username else user.full_name
await context.bot.send_message(
chat_id=ADMIN_CHAT_ID,
text=(
"Operator needed\n"
f"Customer: {who} (chat id {chat_id})\n"
f"Question: {text}\n"
f"Reason: {result['reason']}"
),
)
Get the chat id
Send /id in the operators' group. The bot replies with the id (group ids start with a minus).
Start
Set export ADMIN_CHAT_ID="-100..." and start the bot. Questions that need a person arrive in the operator chat; the operator contacts the customer directly.
Careful: Don't put purchase prices, employees' personal phone numbers or passwords into the knowledge base: anything in it can end up in an answer.
What it costs: 2,000 questions a day, ~13,000 input tokens each
- claude-haiku-5-5: ≈ $0.00068 per question, about $41 a month.
- deepseek-v4.1-flash: ≈ $0.0004 per question, about $24 a month.
- claude-sonnet-5: ~$330 a month without the cache, ~$160 if 60% of the prompt is read from the cache.
If it doesn't work
What you see | What to do
--- | ---
FileNotFoundError: knowledge_base.md | Put it next to support.py or set the path in KB_FILE
The bot invents a price or a delivery time | Add the fact. If it keeps happening, use a stronger model
The bot calls a person for almost everything | Add customers' wording to the knowledge base, check unanswered.jsonl
"invalid JSON" in the log | Use claude-haiku-5-5 or deepseek-v4.1-flash
Operators get nothing | Send /id in the operators' group and copy the id, minus included, into ADMIN_CHAT_ID. The bot must be in the group
401, 402, 403, 429 | See Telegram bot (/docs/guides/telegram-bot) and API errors (/docs/api-reference/errors)
----------
Answers from your own documents
You'll have a program that answers from your documents and cites the source [1] [2].
You need: a fetch_... key (create one (/app/api-keys)), a balance (top up (/app/payments)), Python 3.10+, documents as .txt or .md (convert PDF and Word first, e.g. with pypdf).
Diagram: Question → Search (finds 5 matching chunks in your files) → Zukko API (question + chunks) → Model → Answer with references [1] [2]
Search runs on your machine and costs nothing: Zukko has no embeddings endpoint. Zukko writes the answer.
Put the documents in a folder
Create a docs folder and put .txt or .md files there. Subfolders are fine.
Install the libraries
bash
Save the code
Copy the code from "Full code" below into rag.py, next to the docs folder.
Run it
bash
python rag.py
Ask a question. The answer has source numbers and the files the chunks came from. Added a document? Restart the program.
Full code
python
import os
import re
import sys
from pathlib import Path
import snowballstemmer
from openai import APIConnectionError, APIStatusError, OpenAI
from rank_bm25 import BM25Plus
DOCS_DIR = Path(os.environ.get("DOCS_DIR", "docs")) # folder with .txt and .md files
MODEL = os.environ.get("ZUKKO_MODEL", "claude-haiku-5-5")
CHUNK_SIZE = 1200 # rough chunk size in characters
TOP_K = 5 # how many chunks to send to the model
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=120,
)
stem_ru = snowballstemmer.stemmer("russian")
stem_en = snowballstemmer.stemmer("english")
def tokenize(text: str) -> list[str]:
"""Lowercase words reduced to their stem: "delivered", "delivery" -> "deliv"."""
words = re.findall(r"\w+", text.lower())
return [stem_ru.stemWord(w) if re.search("[а-яё]", w) else stem_en.stemWord(w) for w in words]
def split_into_chunks(text: str, size: int = CHUNK_SIZE) -> list[str]:
"""Split text into chunks along paragraphs, about size characters each."""
chunks, current = [], ""
for paragraph in re.split(r"\n\s*\n", text):
paragraph = paragraph.strip()
if not paragraph:
continue
if current and len(current) + len(paragraph) > size:
chunks.append(current)
current = ""
current = f"{current}\n\n{paragraph}" if current else paragraph
if current:
chunks.append(current)
return chunks
def load_documents(folder: Path) -> list[dict]:
chunks = []
for path in sorted(folder.rglob("*")):
if path.suffix.lower() not in {".txt", ".md"}:
continue
text = path.read_text(encoding="utf-8", errors="ignore")
for number, chunk in enumerate(split_into_chunks(text), start=1):
chunks.append({"source": f"{path.name}, chunk {number}", "text": chunk})
return chunks
class Index:
"""Keyword search (BM25): the more rare question words a chunk contains, the higher it ranks."""
def __init__(self, chunks: list[dict]):
self.chunks = chunks
self.bm25 = BM25Plus([tokenize(c["text"]) for c in chunks])
def search(self, query: str, k: int = TOP_K) -> list[dict]:
scores = self.bm25.get_scores(tokenize(query))
best = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
return [self.chunks[i] for i in best if scores[i] > 0]
SYSTEM_PROMPT = """You answer questions about the company's documents.
Rules:
- Use only the chunks inside . Do not add outside knowledge.
- After each statement, put the chunk number in square brackets: [1], [2].
- If the chunks don't contain the answer, say "The documents don't cover this." Don't guess.
- Answer briefly, in the language of the question, without Markdown."""
def ask(index: Index, question: str) -> tuple[str, list[str]]:
found = index.search(question)
if not found:
return "Nothing in the documents matches this question.", []
sources = "\n\n".join(
f"[{n}] ({c['source']})\n{c['text']}" for n, c in enumerate(found, start=1)
)
try:
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"\n{sources}\n\n\nQuestion: {question}"},
],
max_tokens=1000,
)
except (APIStatusError, APIConnectionError) as err:
return f"Model request failed: {err}", []
text = response.choices[0].message.content or ""
used = sorted({int(n) for n in re.findall(r"\[(\d+)\]", text) if 1 <= int(n) <= len(found)})
return text, [f"[{n}] {found[n - 1]['source']}" for n in used]
def main() -> None:
chunks = load_documents(DOCS_DIR)
if not chunks:
sys.exit(f"No .txt or .md files in {DOCS_DIR}")
index = Index(chunks)
print(f"Loaded {len(chunks)} chunks. Ask a question (empty line to quit).")
while True:
question = input("\nQuestion: ").strip()
if not question:
break
answer, sources = ask(index, question)
print("\n" + answer)
if sources:
print("\nSources:\n" + "\n".join(sources))
if __name__ == "__main__":
main()
In a bot: build index once at startup and call ask from the message handler, as in Telegram bot (/docs/guides/telegram-bot). ask is synchronous — in an async bot use await asyncio.to_thread(ask, index, text).
Other approaches
Everything in the prompt — up to ~200–300 pages
No search: join the documents and send them with the question. Every question pays for the whole base and takes longer.
python
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.zukko.pro/v1", api_key=os.environ["ZUKKO_API_KEY"], timeout=300)
docs = "\n\n".join(
f"=== {p.name} ===\n{p.read_text(encoding='utf-8')}" for p in sorted(Path("docs").glob("*.md"))
)
question = "How many vacation days does a new employee get?"
response = client.chat.completions.create(
model="claude-sonnet-5",
messages=[
# documents go first: this part never changes and lands in the prompt cache
{"role": "system", "content": "Answer only from the documents below and name the source file. "
"If the answer isn't there, say so.\n\n" + docs},
{"role": "user", "content": question},
],
)
print(response.choices[0].message.content)
Meaning-based search — when questions use different words than the documents
For example, "quit my job" vs. "termination of employment". Replace the Index class in rag.py with this code (runs locally, no GPU):
python
from sentence_transformers import SentenceTransformer
encoder = SentenceTransformer("intfloat/multilingual-e5-small") # downloads once, handles 100 languages
class Index:
def __init__(self, chunks: list[dict]):
self.chunks = chunks
# e5 models expect "passage: " before texts and "query: " before questions
self.vectors = encoder.encode(
["passage: " + c["text"] for c in chunks], normalize_embeddings=True
)
def search(self, query: str, k: int = TOP_K) -> list[dict]:
q = encoder.encode(["query: " + query], normalize_embeddings=True)[0]
scores = self.vectors @ q # higher means closer in meaning
best = scores.argsort()[::-1][:k]
return [self.chunks[i] for i in best]
Vectors for thousands of chunks take a few minutes — save them to a file (numpy.save).
What it costs: 1,000 questions a day, keyword search
- claude-haiku-5-5: ≈ $0.0002 per question, about $6 a month.
- deepseek-v4.1-flash: ≈ $0.00012 per question, about $3.50 a month.
- claude-sonnet-5: ≈ $0.0018 per question, about $54 a month. For comparison, "everything in the prompt" with a 200-page base costs $300 (Haiku) and $2,400 (Sonnet) a month.
If it doesn't work
What you see | What to do
--- | ---
"No .txt or .md files in docs" | Convert files to .txt/.md, check the path or set DOCS_DIR
"Nothing in the documents matches", but the answer is there | Rephrase. If it happens a lot, switch to meaning-based search
The answer is incomplete | Raise TOP_K to 8–10 or lower CHUNK_SIZE
The model answers beyond the documents | Keep the sources rule, try claude-sonnet-5
"Everything in the prompt" is very slow | Switch to keyword search, set a 300-second client timeout
401, 402, 429 | See API errors (/docs/api-reference/errors)
----------
Pipelines: processing data in bulk
You'll end up with a script that runs 10,000 reviews from a CSV through an AI model and writes each one's tone, topic and summary to a new spreadsheet.
You need: a separate fetch_... key (create one (/app/api-keys)), a balance — $1 covers 10,000 reviews on DeepSeek (top up (/app/payments)), Python 3.10+, a CSV with the columns id and text.
Diagram: CSV file (10,000 reviews) → Your script (10 rows at a time) → Zukko API (one key) → Model (DeepSeek V4.1 Flash) → New CSV (tone, topic, summary)
Put a limit on the key
Dashboard → API keys (/app/api-keys): set a daily spend limit (say, $3) and allow only the models you need. The limit stops the script if it goes in circles.
Install the library and set the key
bash
export ZUKKO_API_KEY="fetch_your_key"
Windows (PowerShell): $env:ZUKKO_API_KEY="fetch_your_key".
Save the script
File classify_reviews.py, next to reviews.csv:
python
"""
Classify customer reviews from a CSV file through the Zukko API.
Run:
pip install openai
export ZUKKO_API_KEY="fetch_..."
python classify_reviews.py reviews.csv results.csv --limit 50 # a trial run first
python classify_reviews.py reviews.csv results.csv # then everything
The input CSV needs the columns id and text. You can stop the script and run it
again: rows already present in results.csv are skipped.
"""
import argparse
import asyncio
import csv
import json
import os
import random
import sys
import time
from openai import APIConnectionError, APIStatusError, AsyncOpenAI
MODEL = "deepseek-v4.1-flash" # or "claude-haiku-5-5"
CONCURRENCY = 10 # how many requests run at the same time
MAX_ATTEMPTS = 6 # attempts per row
MAX_SPEND_USD = 2.00 # safety net in the script; the second one is the key limit
PROMPT_VERSION = "v1" # changed the prompt? bump the version
SYSTEM_PROMPT = """You classify customer reviews for an online store.
Reply with a JSON object only, no explanations and no markdown:
{"sentiment": "positive" | "neutral" | "negative",
"topic": "delivery" | "quality" | "price" | "support" | "other",
"summary": "the gist of the review in one sentence, up to 15 words"}"""
SENTIMENTS = {"positive", "neutral", "negative"}
TOPICS = {"delivery", "quality", "price", "support", "other"}
OUT_FIELDS = ["id", "sentiment", "topic", "summary", "cost_usd"]
RETRY_STATUSES = {429, 500, 502, 503, 504}
FATAL_CODES = {"key_daily_limit_reached", "key_monthly_limit_reached", "insufficient_balance"}
client = AsyncOpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=90, # seconds per request
max_retries=0, # we retry ourselves, so it is clear what happens
)
class StopPipeline(Exception):
"""An error after which there is no point going on: key, balance, limit."""
def validate(raw: str) -> dict:
"""Check the model's answer. Anything off raises ValueError."""
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("the answer is not a JSON object")
if data.get("sentiment") not in SENTIMENTS:
raise ValueError(f"bad sentiment: {data.get('sentiment')!r}")
if data.get("topic") not in TOPICS:
raise ValueError(f"bad topic: {data.get('topic')!r}")
summary = data.get("summary")
if not isinstance(summary, str) or not summary.strip():
raise ValueError("empty summary")
return {"sentiment": data["sentiment"], "topic": data["topic"], "summary": summary.strip()}
async def classify(row_id: str, text: str) -> dict:
json_try = 0 # grows only when the model sent unusable JSON
for attempt in range(1, MAX_ATTEMPTS + 1):
# One idempotency key = one request body. A retry after a network failure
# with the same key is not charged twice. The model and prompt version
# are part of the key: otherwise Zukko answers 422 after you change them.
idem_key = f"reviews-{PROMPT_VERSION}-{MODEL}-{row_id}-{json_try}"
delay = min(60.0, 2.0 ** attempt) + random.random()
try:
raw = await client.chat.completions.with_raw_response.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": text[:4000]},
],
response_format={"type": "json_object"},
max_tokens=200,
extra_headers={"Idempotency-Key": idem_key},
)
completion = raw.parse()
cost = int(raw.headers.get("x-zukko-cost-micros") or 0) / 1_000_000
try:
result = validate(completion.choices[0].message.content or "")
except ValueError as e:
# The successful answer is stored under this key, so a new
# attempt needs a new key: bump json_try.
json_try += 1
print(f" row {row_id}: bad JSON ({e}), retrying", file=sys.stderr)
continue
result["cost_usd"] = cost
return result
except APIStatusError as e:
if e.status_code in (401, 402, 403) or e.code in FATAL_CODES:
raise StopPipeline(f"{e.status_code} {e.code}: {e.message}") from e
if e.status_code not in RETRY_STATUSES or e.code == "model_disabled":
raise # 400, 404, 422 and so on: a retry will not help
retry_after = e.response.headers.get("retry-after")
if retry_after and retry_after.isdigit():
delay = float(retry_after)
print(f" row {row_id}: {e.status_code}, waiting {delay:.0f} s", file=sys.stderr)
except APIConnectionError as e: # network drop or timeout
print(f" row {row_id}: {type(e).__name__}, waiting {delay:.0f} s", file=sys.stderr)
await asyncio.sleep(delay)
raise RuntimeError(f"failed after {MAX_ATTEMPTS} attempts")
async def process_row(row, sem, writer, out_file, stats, stop):
async with sem: # at most CONCURRENCY rows at a time
if stop.is_set():
return
try:
result = await classify(row["id"], row["text"])
except StopPipeline as e:
stop.set()
print(f"\nStopping: {e}", file=sys.stderr)
return
except Exception as e:
stats["failed"] += 1
print(f" row {row['id']} skipped: {e}", file=sys.stderr)
return
writer.writerow({"id": row["id"], **result})
out_file.flush() # the row is on disk right away, progress is never lost
stats["done"] += 1
stats["cost"] += result["cost_usd"]
if stats["cost"] > MAX_SPEND_USD and not stop.is_set():
stop.set()
print(f"\nStopping: spent more than ${MAX_SPEND_USD}", file=sys.stderr)
if stats["done"] % 50 == 0 or stats["done"] == stats["total"]:
speed = stats["done"] / (time.monotonic() - stats["started"])
print(f"Done {stats['done']}/{stats['total']}, "
f"${stats['cost']:.4f}, {speed:.1f} rows/s")
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("input")
parser.add_argument("output")
parser.add_argument("--limit", type=int, default=0, help="take only the first N rows")
args = parser.parse_args()
with open(args.input, newline="", encoding="utf-8-sig") as f: # -sig: also reads CSV saved by Excel
rows = list(csv.DictReader(f))
if args.limit:
rows = rows[: args.limit]
done_ids = set()
if os.path.exists(args.output):
with open(args.output, newline="", encoding="utf-8") as f:
done_ids = {r["id"] for r in csv.DictReader(f)}
todo = [r for r in rows if r["id"] not in done_ids]
print(f"Rows: {len(rows)}, already done: {len(rows) - len(todo)}, left: {len(todo)}")
is_new = not os.path.exists(args.output) or os.path.getsize(args.output) == 0
with open(args.output, "a", newline="", encoding="utf-8") as out_file:
writer = csv.DictWriter(out_file, fieldnames=OUT_FIELDS)
if is_new:
writer.writeheader()
sem = asyncio.Semaphore(CONCURRENCY)
stop = asyncio.Event()
stats = {"done": 0, "failed": 0, "cost": 0.0, "total": len(todo),
"started": time.monotonic()}
await asyncio.gather(*(process_row(r, sem, writer, out_file, stats, stop) for r in todo))
print(f"\nProcessed: {stats['done']}, failed: {stats['failed']}, "
f"spent: ${stats['cost']:.4f}")
if stats["failed"] or stop.is_set():
print("Run the script again: it will continue with the unprocessed rows.")
if __name__ == "__main__":
asyncio.run(main())
Test on 50 rows
bash
Check results.csv by eye. Multiply the amount spent by 200 to get the price of all 10,000 rows.
Run the whole file
bash
Finished rows are skipped. If it stops, run the same command again.
Which model to use
Change the model in MODEL. Pick by trial: the same 50 rows through two candidates.
Model | Price per 1M tokens, input / output | When
--- | --- | ---
deepseek-v4.1-flash | $0.03 / $0.10 | the default
claude-haiku-5-5 | $0.05 / $0.20 | DeepSeek failed the trial
claude-sonnet-5-standard | $0.2 per any token | you need Sonnet's strength; no cache discount, but cheaper than claude-sonnet-5-5 here
claude-sonnet-5-5 | $0.4 / $2 | tricky labeling
Careful: Timeout
A retry with the same Idempotency-Key while the first request is still running is also charged. Set a generous timeout: 60–90 s for fast models, 120–300 s for reasoning models.
Tip: Running the same file several times?
Add "X-Zukko-Cache": "true" to extra_headers: an identical request is served from the cache for free for 7 days (an Idempotency-Key answer lives only 24 hours).
What it costs: - 10,000 reviews ≈ 4.5M input tokens and 1M output tokens.
- deepseek-v4.1-flash — about $0.24, claude-haiku-5-5 — $0.43, claude-sonnet-5-standard — $1.10, claude-sonnet-5-5 — $3.80, claude-opus-5-5 — $7.60.
- Add 5–10% for retries caused by unusable JSON.
No code: n8n and Make
Set up the HTTP request
n8n: HTTP Request node, Make: HTTP → Make a request. POST to https://api.zukko.pro/v1/chat/completions. Headers: Authorization: Bearer fetch_your_key, Content-Type: application/json, Idempotency-Key — for example reviews-v1- plus the row id.
Set the request body
json
"model": "deepseek-v4.1-flash",
"response_format": { "type": "json_object" },
"max_tokens": 200,
"messages": [
{ "role": "system", "content": "You classify customer reviews. Reply with JSON only: {\"sentiment\": \"positive|neutral|negative\", \"topic\": \"delivery|quality|price|support|other\", \"summary\": \"gist in up to 15 words\"}" },
{ "role": "user", "content": "REVIEW TEXT" }
]
}
Replace REVIEW TEXT with the sheet field. In n8n use JSON.stringify(...), or a quote mark in a review breaks the JSON.
Parse the answer
choices[0].message.content is a string with JSON. Make: JSON → Parse JSON, n8n: a Code or Edit Fields node.
Turn on retries
Retry on error in the node settings, batches of 5–10 rows, a spend limit on the key.
If it doesn't work
What you see | What to do
--- | ---
402 insufficient_balance | Top up (/app/payments) and run again — finished rows are skipped
429 key_daily_limit_reached | Your key limit kicked in. Raise it or wait for 00:00 UTC
Many 429 without a key-limit code | Lower CONCURRENCY to 5
422 idempotency_conflict | You changed the prompt — bump PROMPT_VERSION
Frequent "bad JSON" | Use claude-haiku-5-5 or a strict schema (/docs/guides/structured-output)
KeyError: 'id' or 'text' | Rename the columns in the first row of the CSV
403 model_not_allowed_for_key | Add the model in the key settings
----------
Structured JSON output for automation
You'll end up with code that gets JSON with the fields you need from a model and validates it before passing it to a spreadsheet, CRM, accounting software or backend.
You need: a fetch_... key (create one (/app/api-keys)), a balance of at least $0.01 (top up (/app/payments)), Python 3.10+ with pip install openai pydantic or Node.js 18+ with npm install openai zod.
Diagram: Email or invoice text → Model (fills in the form) → JSON (supplier, total, date) → Check in code → Spreadsheet, CRM or accounting
Describe the fields
Types: string, number, yes/no, a choice from a list. For a list, always set enum, or you'll get "Delivery", "delivery" and "shipping".
Send a request with the schema
response_format with json_schema and strict: true — example 1. Few fields? json_object with the fields in the prompt is enough — example 2.
Validate the answer in code
pydantic or zod. Even a strict schema doesn't guarantee a correct answer.
Give a second chance
If validation fails, show the model its answer and the error and ask for corrected JSON. Fails again — hand the document to a person.
Modes and models
Mode | What it guarantees
--- | ---
response_format: {"type": "json_object"} | valid JSON, fields are not checked
response_format: {"type": "json_schema", ...} with strict: true | JSON that follows your schema: the right fields of the right types
Model | Strict schema | json_object
--- | --- | ---
deepseek-v4.1-flash | yes | yes
claude-haiku-5-5 | yes | yes
gpt-6-luna | no | —
Test other models on 20–50 of your own examples. Prices: Models and pricing (/docs/introduction/models-pricing).
Example 1. Invoice data, strict schema
python
"""
Invoice data as JSON that follows a strict schema.
pip install openai pydantic
"""
import os
from typing import Literal
from openai import OpenAI
from pydantic import BaseModel, ConfigDict, Field, ValidationError
MODEL = "deepseek-v4.1-flash" # or "claude-haiku-5-5"
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=60,
)
class Invoice(BaseModel):
"""The form the model has to fill in."""
model_config = ConfigDict(extra="forbid") # extra fields are an error
supplier: str = Field(description="supplier name")
invoice_number: str = Field(description="invoice number; empty string if missing")
date: str = Field(description="invoice date as YYYY-MM-DD; empty string if missing")
total: float = Field(description="total amount due, as a number")
currency: Literal["RUB", "USD", "EUR", "OTHER"]
vat_included: bool = Field(description="whether VAT is included in the total")
SCHEMA = Invoice.model_json_schema() # JSON Schema from the same model: fields are described once
SYSTEM = (
"You extract data from invoices. Reply with JSON that follows the given schema only. "
"Do not make anything up: if a field is not in the text, leave an empty string."
)
def clean(raw: str) -> str:
"""Sometimes the model wraps JSON in markdown; strip it."""
raw = raw.strip()
if raw.startswith("" * 3):
raw = raw.split("\n", 1)[-1].rsplit("" * 3, 1)[0]
return raw
def ask(messages: list) -> str:
response = client.chat.completions.create(
model=MODEL,
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "invoice", "strict": True, "schema": SCHEMA},
},
max_tokens=500,
)
return response.choices[0].message.content or ""
def extract_invoice(text: str) -> Invoice:
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": text},
]
raw = ask(messages)
try:
return Invoice.model_validate_json(clean(raw))
except ValidationError as error:
# One chance to fix it: show the model its answer and the error.
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"The answer failed validation:\n{error}\n"
"Send the corrected JSON in full, with no explanations."},
]
# If it fails again, raise: a person should look at this document.
return Invoice.model_validate_json(clean(ask(messages)))
if __name__ == "__main__":
text = """Invoice No. 4512, dated 03 Oct 2026
Supplier: Greenfield Logistics Ltd, VAT ID GB123456789
Delivery services for September: EUR 1,283.38
Total due: EUR 1,540.05 incl. VAT 20% (EUR 256.67)"""
invoice = extract_invoice(text)
print(invoice.model_dump_json(indent=2))
print("Due:", invoice.total, invoice.currency)
javascript
// Invoice data as JSON that follows a strict schema.
// npm install openai zod
// Run: node extract_invoice.mjs
import OpenAI from "openai";
import { z } from "zod";
const MODEL = "deepseek-v4.1-flash"; // or "claude-haiku-5-5"
const client = new OpenAI({
baseURL: "https://api.zukko.pro/v1",
apiKey: process.env.ZUKKO_API_KEY,
timeout: 60_000,
});
// Schema for the model (JSON Schema): which fields to fill in.
const schema = {
type: "object",
properties: {
supplier: { type: "string", description: "supplier name" },
invoice_number: { type: "string", description: "invoice number; empty string if missing" },
date: { type: "string", description: "invoice date as YYYY-MM-DD; empty string if missing" },
total: { type: "number", description: "total amount due, as a number" },
currency: { type: "string", enum: ["RUB", "USD", "EUR", "OTHER"] },
vat_included: { type: "boolean", description: "whether VAT is included in the total" },
},
required: ["supplier", "invoice_number", "date", "total", "currency", "vat_included"],
additionalProperties: false,
};
// The same form for validation in code (zod).
const Invoice = z
.object({
supplier: z.string(),
invoice_number: z.string(),
date: z.string(),
total: z.number(),
currency: z.enum(["RUB", "USD", "EUR", "OTHER"]),
vat_included: z.boolean(),
})
.strict();
const SYSTEM =
"You extract data from invoices. Reply with JSON that follows the given schema only. " +
"Do not make anything up: if a field is not in the text, leave an empty string.";
// Sometimes the model wraps JSON in markdown; strip it.
function clean(raw) {
const fence = "".repeat(3);
let text = raw.trim();
if (text.startsWith(fence)) text = text.slice(text.indexOf("\n") + 1, text.lastIndexOf(fence));
return text;
}
function check(raw) {
try {
return Invoice.safeParse(JSON.parse(clean(raw)));
} catch (error) {
return { success: false, error: { issues: [{ message: "not JSON: " + error.message }] } };
}
}
async function ask(messages) {
const response = await client.chat.completions.create({
model: MODEL,
messages,
response_format: {
type: "json_schema",
json_schema: { name: "invoice", strict: true, schema },
},
max_tokens: 500,
});
return response.choices[0].message.content ?? "";
}
async function extractInvoice(text) {
const messages = [
{ role: "system", content: SYSTEM },
{ role: "user", content: text },
];
const raw = await ask(messages);
const first = check(raw);
if (first.success) return first.data;
// One chance to fix it: show the model its answer and the error.
messages.push(
{ role: "assistant", content: raw },
{
role: "user",
content: The answer failed validation: ${JSON.stringify(first.error.issues)}. +
"Send the corrected JSON in full, with no explanations.",
},
);
const second = check(await ask(messages));
if (second.success) return second.data;
// Failed again: a person should look at this document.
throw new Error("The model could not fill in the form: " + JSON.stringify(second.error.issues));
}
const text = Invoice No. 4512, dated 03 Oct 2026
Supplier: Greenfield Logistics Ltd, VAT ID GB123456789
Delivery services for September: EUR 1,283.38
Total due: EUR 1,540.05 incl. VAT 20% (EUR 256.67);
const invoice = await extractInvoice(text);
console.log(invoice);
console.log("Due:", invoice.total, invoice.currency);
In Node.js the schema for the model and the zod schema are written separately — keep them in sync. In Python both come from the Invoice class.
Example 2. Support ticket triage, json_object mode
python
"""
Triage a support ticket: category, priority, whether a human is needed.
pip install openai pydantic
"""
import os
from typing import Literal
from openai import OpenAI
from pydantic import BaseModel, ValidationError
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=60,
)
class Ticket(BaseModel):
category: Literal["billing", "technical", "delivery", "other"]
priority: Literal["low", "normal", "high"]
needs_human: bool
reply_draft: str
json_object mode: the model must return JSON, and the fields are described in the prompt.
SYSTEM = """You triage support tickets for an online store.
Reply with a JSON object only:
{"category": "billing" | "technical" | "delivery" | "other",
"priority": "low" | "normal" | "high",
"needs_human": true | false,
"reply_draft": "a draft reply to the customer, 1-3 sentences"}
needs_human = true if the customer asks for a refund, threatens to leave, or mentions legal action."""
def ask(messages: list) -> str:
response = client.chat.completions.create(
model="claude-haiku-5-5",
messages=messages,
response_format={"type": "json_object"},
max_tokens=400,
)
return response.choices[0].message.content or ""
def classify_ticket(text: str) -> Ticket:
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": text},
]
raw = ask(messages)
try:
return Ticket.model_validate_json(raw)
except ValidationError as error:
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"The answer failed validation:\n{error}\n"
"Send the corrected JSON in full, with no explanations."},
]
return Ticket.model_validate_json(ask(messages))
if __name__ == "__main__":
ticket = classify_ticket(
"Hi! You charged me for the same order 18734 for the third time. "
"Please refund the extra, or I will dispute it with my bank."
)
print(ticket)
if ticket.needs_human:
print("-> to the human queue, priority:", ticket.priority)
else:
print("-> auto-reply:", ticket.reply_draft)
javascript
// Triage a support ticket: category, priority, whether a human is needed.
// npm install openai zod
import OpenAI from "openai";
import { z } from "zod";
const client = new OpenAI({
baseURL: "https://api.zukko.pro/v1",
apiKey: process.env.ZUKKO_API_KEY,
timeout: 60_000,
});
const Ticket = z.object({
category: z.enum(["billing", "technical", "delivery", "other"]),
priority: z.enum(["low", "normal", "high"]),
needs_human: z.boolean(),
reply_draft: z.string(),
});
// json_object mode: the model must return JSON, and the fields are described in the prompt.
const SYSTEM = You triage support tickets for an online store.
Reply with a JSON object only:
{"category": "billing" | "technical" | "delivery" | "other",
"priority": "low" | "normal" | "high",
"needs_human": true | false,
"reply_draft": "a draft reply to the customer, 1-3 sentences"}
needs_human = true if the customer asks for a refund, threatens to leave, or mentions legal action.;
async function ask(messages) {
const response = await client.chat.completions.create({
model: "claude-haiku-5-5",
messages,
response_format: { type: "json_object" },
max_tokens: 400,
});
return response.choices[0].message.content ?? "";
}
function check(raw) {
try {
return Ticket.safeParse(JSON.parse(raw));
} catch (error) {
return { success: false, error: { issues: [{ message: "not JSON: " + error.message }] } };
}
}
async function classifyTicket(text) {
const messages = [
{ role: "system", content: SYSTEM },
{ role: "user", content: text },
];
const raw = await ask(messages);
const first = check(raw);
if (first.success) return first.data;
messages.push(
{ role: "assistant", content: raw },
{
role: "user",
content: The answer failed validation: ${JSON.stringify(first.error.issues)}. +
"Send the corrected JSON in full, with no explanations.",
},
);
const second = check(await ask(messages));
if (!second.success) throw new Error("Could not triage the ticket");
return second.data;
}
const ticket = await classifyTicket(
"Hi! You charged me for the same order 18734 for the third time. " +
"Please refund the extra, or I will dispute it with my bank.",
);
console.log(ticket);
if (ticket.needs_human) console.log("-> to the human queue, priority:", ticket.priority);
else console.log("-> auto-reply:", ticket.reply_draft);
Schema rules
- Numbers as numbers (1540.05, not "EUR 1,540.05"), currency in its own field.
- Dates as strings, format in the field description: YYYY-MM-DD.
- 5–10 fields, no deep nesting.
- Every field in required, additionalProperties: false. pydantic with extra="forbid" does this for you.
What it costs: - 1,000 emails or invoices a day, ~300 words each: 33M input tokens and 3.6M output tokens a month.
- deepseek-v4.1-flash ≈ $1.35 a month, claude-haiku-5-5 ≈ $2.40 a month.
- Retries after a failed check are a few percent at most.
If it doesn't work
What you see | What to do
--- | ---
The answer starts with three backticks and the word json | Strip the wrapper with clean() from the example
"Shipping" instead of delivery | Set an enum on the field
The JSON is cut off halfway | Raise max_tokens
The model invents a number or date | Allow an empty string in the field description and say "do not make anything up" in the instruction
The answer takes about a minute and ignores the schema | Use deepseek-v4.1-flash or claude-haiku-5-5
400 mentioning response_format | Pick a model from the table above or use json_object mode
----------
Agent with tools
You'll end up with an agent: the model decides on its own when to check the weather, calculate or search the web, and your code runs those actions and hands the results back.
You need: a separate fetch_... key with a spend limit and an allowed-models list (create one (/app/api-keys)), Python 3.10+ and pip install openai.
Diagram: User question → Model (decides what it needs) → Your code (runs the tool) → Model (reads the result) → Answer
Save the code
File agent.py. Tools: get_weather (a stub instead of a weather service) and calculate (a calculator).
python
"""
Agent with tools: the model decides which function to call.
pip install openai
python agent.py "What is the weather in London and in Madrid? How much warmer is Madrid?"
"""
import ast
import json
import operator
import os
import sys
from openai import OpenAI
MODEL = "deepseek-v4.1-flash" # or "claude-haiku-5-5", "gpt-6-luna"
MAX_STEPS = 6 # more steps than this means the agent is going in circles
MAX_COST_USD = 0.05 # spend cap for one question
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=120,
)
---------- Tools: plain Python functions ----------
FAKE_WEATHER = { # a stub instead of a real weather service
"london": {"temp_c": 9, "conditions": "rain"},
"madrid": {"temp_c": 18, "conditions": "sunny"},
"oslo": {"temp_c": -2, "conditions": "snow"},
}
def get_weather(city: str) -> dict:
data = FAKE_WEATHER.get(city.strip().lower())
if data is None:
return {"error": f"no weather data for {city}"}
return {"city": city, **data}
OPERATORS = {
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos,
}
def _eval(node):
"""Only numbers and + - / % *. No names, calls, or imports."""
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPERATORS:
left, right = _eval(node.left), _eval(node.right)
if isinstance(node.op, ast.Pow) and abs(right) > 100:
raise ValueError("exponent too large")
return OPERATORStype(node.op) (left, right)
if isinstance(node, ast.UnaryOp) and type(node.op) in OPERATORS:
return OPERATORStype(node.op) (_eval(node.operand))
raise ValueError("only numbers and + - / % * are allowed")
def calculate(expression: str) -> dict:
if len(expression) > 200:
return {"error": "expression too long"}
try:
return {"result": _eval(ast.parse(expression, mode="eval").body)}
except (ValueError, SyntaxError, ZeroDivisionError, OverflowError) as e:
return {"error": str(e)}
Tool descriptions for the model: name, purpose, parameters.
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather in a city: temperature in °C and conditions.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "city, for example London"}},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "calculate",
"description": "Exactly evaluate an arithmetic expression, for example (18 - 9) * 1.8.",
"parameters": {
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"],
},
},
},
]
TOOL_FUNCTIONS = {"get_weather": get_weather, "calculate": calculate}
NEEDS_CONFIRMATION = set() # tools that change something go here: {"send_email", "refund"}
SYSTEM = (
"You are an assistant. Call get_weather for weather facts and calculate for any arithmetic. "
"Do not make up data the tools did not return. Answer briefly."
)
def run_tool(name: str, arguments: str) -> dict:
"""Run the call the model asked for. Errors go back to the model instead of crashing."""
func = TOOL_FUNCTIONS.get(name)
if func is None:
return {"error": f"no tool named {name}"}
try:
args = json.loads(arguments or "{}")
except json.JSONDecodeError:
return {"error": "arguments are not JSON"}
if name in NEEDS_CONFIRMATION:
answer = input(f"The agent wants to run {name}({args}). Allow? [y/N] ")
if answer.strip().lower() != "y":
return {"error": "a human denied this action"}
try:
return func(**args)
except TypeError as e: # the model passed the wrong parameters
return {"error": f"bad parameters: {e}"}
def run_agent(question: str) -> str:
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": question},
]
spent = 0.0
for step in range(1, MAX_STEPS + 1):
raw = client.chat.completions.with_raw_response.create(
model=MODEL, messages=messages, tools=TOOLS, max_tokens=1000,
)
spent += int(raw.headers.get("x-zukko-cost-micros") or 0) / 1_000_000
message = raw.parse().choices[0].message
if not message.tool_calls: # no more tools needed: this is the final answer
print(f"[steps: {step}, spent: ${spent:.5f}]")
return message.content or ""
# Record the model's request and answer every call; there can be several at once.
messages.append({
"role": "assistant",
"content": message.content,
"tool_calls": [
{"id": c.id, "type": "function",
"function": {"name": c.function.name, "arguments": c.function.arguments}}
for c in message.tool_calls
],
})
for call in message.tool_calls:
result = run_tool(call.function.name, call.function.arguments)
print(f" step {step}: {call.function.name}({call.function.arguments}) -> {result}")
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
if spent > MAX_COST_USD:
return f"Stopped: spent ${spent:.4f}, over the ${MAX_COST_USD} cap."
return f"Stopped: the agent did not finish within {MAX_STEPS} steps."
if __name__ == "__main__":
question = " ".join(sys.argv[1:]) or "What is the weather in London and in Madrid? How much warmer is Madrid?"
print(run_agent(question))
Run it
bash
python agent.py "What is the weather in London and in Madrid? How much warmer is Madrid?"
The model asks for the weather in both cities, then for the difference, then answers. Every call is printed.
Add web search (optional)
Paste the code into agent.py before if __name__ == "__main__":, and add "call web_search for fresh facts" to SYSTEM.
python
import urllib.request
def web_search(query: str) -> dict:
request = urllib.request.Request(
"https://api.zukko.pro/v1/web-search",
data=json.dumps({"query": query, "max_results": 5}).encode(),
headers={
"Authorization": f"Bearer {os.environ['ZUKKO_API_KEY']}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = json.load(response)
except (urllib.error.URLError, TimeoutError) as e:
return {"error": f"search failed: {e}"}
return {"results": [
{"title": r.get("title"), "url": r.get("url"), "snippet": r.get("snippet")}
for r in data.get("results", [])
]}
TOOLS.append({
"type": "function",
"function": {
"name": "web_search",
"description": "Web search. For fresh facts, news, prices, and anything you may not know.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "search query"}},
"required": ["query"],
},
},
})
TOOL_FUNCTIONS["web_search"] = web_search
Up to 10 results, about $0.0003–0.0005 per search, counts toward the key's limits. Parameters: POST /v1/web-search (/docs/api-reference/web-search).
The agent loop
1. Send the conversation with the tools parameter.
2. Got tool_calls? Run each call and return the result as a message with the tool role and the matching tool_call_id. Go back to step 1.
3. No tool_calls — content holds the final answer.
MAX_STEPS and MAX_COST_USD stop the agent if it goes in circles. To add your own tool: a function in TOOL_FUNCTIONS and a description in TOOLS.
Models
Model | Price per 1M tokens, input / output
--- | ---
deepseek-v4.1-flash | $0.03 / $0.10
claude-haiku-5-5 | $0.05 / $0.20
gpt-6-luna | $0.04 / $0.21
Tested through /v1/chat/completions. With the Anthropic SDK and /v1/messages, tools are passed the same way as in the Anthropic API.
Careful: Safety
- Don't provide tools like run_shell or exec_python, and never call eval() on text from the model.
- Actions that change something (an email, a refund, a deletion) run only after a human confirms: add them to NEEDS_CONFIRMATION.
- Check parameters inside the function: the model may pass someone else's order number.
- Text from the web is data, not commands.
What it costs: - A typical question takes 2–3 steps, about 2,000 input tokens and 250 output tokens.
- 1,000 questions a day: deepseek-v4.1-flash ≈ $2.55, gpt-6-luna ≈ $4.00, claude-haiku-5-5 ≈ $4.50 a month.
- Web search: a fraction of a cent plus roughly 1,000–1,500 tokens on the next step's input.
If it doesn't work
What you see | What to do
--- | ---
The model answers with text and never calls a tool | Sharpen the description and the instruction; use a model from the table above
400 after answering a call | Answer every call in tool_calls with a tool message carrying its tool_call_id
The agent calls the same thing in circles | Return a clear error message from the tool
The model invents arguments | Validate them in the function and return an error — the model will fix it
429 key_daily_limit_reached | The key limit kicked in — check whether the agent got stuck in a loop
----------
Orchestrating multiple agents
You'll build a script where a strong model makes a plan, cheap models do the parts in parallel, and an editor merges everything into a report, all on one key.
Diagram: Task (report topic) → Planner (Claude Sonnet 5.5) → [Worker 1 (DeepSeek) / Worker 2 (DeepSeek) / Worker 3 (DeepSeek)] → Editor (Claude Sonnet 5) → Finished report
You need: a fetch_... key (create one (/app/api-keys)), a balance (one report costs about a cent), Python 3.10+.
Pick the models
Planner and editor: claude-sonnet-5-5 or claude-sonnet-5; for the hardest tasks give the planner claude-opus-5-5. Workers: deepseek-v4.1-flash or claude-haiku-5-5.
Save the code
Copy the code below into a file named research.py.
Run it
bash
export ZUKKO_API_KEY="fetch_your_key"
python research.py "The e-scooter market in Europe in 2026"
The report is printed and saved to report.md; the step log goes to the console.
Read the log
It shows the cost and time of each step. Workers write poorly: give them claude-haiku-5-5. Report is shallow: use a stronger planner.
Code
python
"""
A team of agents: planner -> workers in parallel -> editor.
pip install openai
python research.py "The e-scooter market in Europe in 2026"
"""
import asyncio
import json
import os
import sys
import time
import urllib.request
from openai import AsyncOpenAI
PLANNER_MODEL = "claude-sonnet-5-5" # or "claude-opus-5-5": stronger and pricier
WORKER_MODEL = "deepseek-v4.1-flash" # or "claude-haiku-5-5"
EDITOR_MODEL = "claude-sonnet-5"
MAX_SUBTASKS = 5
USE_WEB_SEARCH = True # workers look up fresh data via /v1/web-search
BUDGET_USD = 0.20 # spend cap per report
API_KEY = os.environ["ZUKKO_API_KEY"]
client = AsyncOpenAI(
base_url="https://api.zukko.pro/v1",
api_key=API_KEY,
timeout=300, # reasoning models can think for a minute or longer
max_retries=2, # the library retries 429 and 5xx itself, with backoff
)
spent = {"usd": 0.0}
def log(message: str) -> None:
print(f"{time.strftime('%H:%M:%S')} {message}", file=sys.stderr)
async def ask(role: str, model: str, system: str, user: str, max_tokens: int) -> str:
"""One model request plus a log line: who, how many tokens, how much money, how many seconds."""
if spent["usd"] >= BUDGET_USD:
raise RuntimeError(f"budget of ${BUDGET_USD} used up")
started = time.monotonic()
raw = await client.chat.completions.with_raw_response.create(
model=model,
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
max_tokens=max_tokens,
)
response = raw.parse()
cost = int(raw.headers.get("x-zukko-cost-micros") or 0) / 1_000_000
spent["usd"] += cost
usage = response.usage
log(f"[{role}] {model}: {usage.prompt_tokens} in / {usage.completion_tokens} out, "
f"${cost:.5f}, {time.monotonic() - started:.1f} s")
return response.choices[0].message.content or ""
def parse_json(raw: str) -> dict:
"""Pull the JSON object out of the answer, even if the model added text around it."""
start, end = raw.find("{"), raw.rfind("}")
if start == -1 or end == -1:
raise ValueError("no JSON object in the answer")
return json.loads(raw[start:end + 1])
---------- 1. Planner: splits the task into subtasks ----------
PLANNER_SYSTEM = f"""You lead a research project. Split the topic into 3-{MAX_SUBTASKS} independent subtasks
that can run in parallel and together cover the topic.
Reply with JSON only, no explanations:
{{"subtasks": [{{"title": "short name", "question": "what to find out, 1-2 sentences",
"search_query": "a web search query"}}]}}"""
async def plan(topic: str) -> list[dict]:
raw = await ask("planner", PLANNER_MODEL, PLANNER_SYSTEM, f"Topic: {topic}", 1500)
for attempt in range(2):
try:
subtasks = parse_json(raw)["subtasks"]
if not 1 <= len(subtasks) <= MAX_SUBTASKS:
raise ValueError(f"{len(subtasks)} subtasks, need 1 to {MAX_SUBTASKS}")
for s in subtasks:
if not (isinstance(s.get("title"), str) and isinstance(s.get("question"), str)):
raise ValueError("a subtask has no title or question")
return subtasks
except (ValueError, KeyError, TypeError) as error:
if attempt == 1:
raise RuntimeError(f"the planner returned an unusable plan: {error}")
log(f"the plan failed validation ({error}), asking for a redo")
raw = await ask("planner", PLANNER_MODEL, PLANNER_SYSTEM,
f"Topic: {topic}\n\nThe previous answer failed validation: {error}. "
"Send only JSON in the required format.", 1500)
---------- 2. Workers: run in parallel ----------
def web_search_sync(query: str) -> list[dict]:
request = urllib.request.Request(
"https://api.zukko.pro/v1/web-search",
data=json.dumps({"query": query, "max_results": 5}).encode(),
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response).get("results", [])
WORKER_SYSTEM = """You are an analyst. Answer your question to the point, 120-200 words.
Rely on the sources found, if any, and put the source URL next to each fact.
If data is missing, say so; do not make up numbers."""
async def work(subtask: dict) -> str:
sources = ""
if USE_WEB_SEARCH and subtask.get("search_query"):
try:
results = await asyncio.to_thread(web_search_sync, subtask["search_query"])
sources = "\n".join(f"- {r.get('title')} ({r.get('url')}): {r.get('snippet')}"
for r in results)
except Exception as error: # search is optional: the worker can answer without it
log(f"search for \"{subtask['title']}\" failed: {error}")
user = f"Question: {subtask['question']}\n\nSources found:\n{sources or 'none'}"
# Leave room: for reasoning models (DeepSeek, Kimi) the thinking also counts
# toward max_tokens, and with 800 the notes were cut off mid-sentence.
return await ask(f"worker: {subtask['title']}", WORKER_MODEL, WORKER_SYSTEM, user, 2500)
---------- 3. Editor: merges and checks ----------
EDITOR_SYSTEM = """You are an editor. Turn the analysts' notes into a coherent Markdown report of 400-600 words:
a short summary, a section per subtask, a conclusion. Remove repetition. If the notes contradict
each other or contain numbers without a source, flag it right in the text.
Add nothing of your own without the label "editor's estimate"."""
async def research(topic: str) -> str:
subtasks = await plan(topic)
log(f"plan: {[s['title'] for s in subtasks]}")
results = await asyncio.gather(*(work(s) for s in subtasks), return_exceptions=True)
notes = []
for subtask, result in zip(subtasks, results):
if isinstance(result, Exception): # one failed worker does not sink the whole report
log(f"worker \"{subtask['title']}\" failed: {result}")
result = "This part failed because of an error; flag the gap in the report."
notes.append(f"## {subtask['title']}\n{result}")
return await ask("editor", EDITOR_MODEL, EDITOR_SYSTEM,
f"Topic: {topic}\n\nAnalysts' notes:\n\n" + "\n\n".join(notes), 2500)
if __name__ == "__main__":
topic = " ".join(sys.argv[1:]) or "The e-scooter market in Europe in 2026"
report = asyncio.run(research(topic))
with open("report.md", "w", encoding="utf-8") as f:
f.write(report)
print(report)
log(f"done, total spent ${spent['usd']:.4f}, report saved to report.md")
The code reads the cost of each request from the X-Zukko-Cost-Micros header. Before every request it checks the total against BUDGET_USD and stops when the cap is reached.
Tip: A key per role
Create three keys: allow Claude for the planner and the editor, and only cheap models with a small daily cap for the workers. If the workers loop, the cap stops them. Create a client per role and pass the right one to ask():
python
# ZUKKO_KEY_PLANNER, ZUKKO_KEY_WORKER, ZUKKO_KEY_EDITOR; falls back to the shared key
key = os.environ.get(f"ZUKKO_KEY_{role.upper()}") or os.environ["ZUKKO_API_KEY"]
return AsyncOpenAI(base_url="https://api.zukko.pro/v1", api_key=key, timeout=300)
What it costs: - Mixed team (claude-sonnet-5-5 → deepseek-v4.1-flash → claude-sonnet-5): ≈ $0.0055 per report, ≈ $5.50 per 1,000.
- Every role on claude-opus-5-5: ≈ $0.029 per report, ≈ $29 per 1,000, about 5 times more.
- Plus web search: 5 searches ≈ $0.0015 per report.
Ready-made frameworks
Set base_url and your Zukko key. Exact parameter names depend on the library version.
Library | Setup
--- | ---
OpenAI Agents SDK | set_default_openai_client(AsyncOpenAI(base_url="https://api.zukko.pro/v1", api_key=...)) and set_default_openai_api("chat_completions")
LangGraph (via LangChain) | ChatOpenAI(model="claude-sonnet-5", base_url="https://api.zukko.pro/v1", api_key=...)
CrewAI | LLM(model="openai/claude-sonnet-5", base_url="https://api.zukko.pro/v1", api_key=...)
AutoGen | OpenAIChatCompletionClient(model="claude-sonnet-5", base_url="https://api.zukko.pro/v1", api_key=..., model_info={...}); for non-OpenAI models describe their capabilities in model_info
If it doesn't work
What you see | What to do
--- | ---
"the planner returned an unusable plan" | Tighten the format in PLANNER_SYSTEM or use a stronger model
APITimeoutError | Raise timeout to 300 seconds
"budget used up" | Find the expensive step in the log; raise BUDGET_USD or trim the context
429 key_daily_limit_reached | Make sure nothing is looping, then raise the key limit
402 | Top up (/app/payments); retrying won't help
Made-up numbers in the report | Keep USE_WEB_SEARCH = True and the requirement to cite sources
Other errors: API errors (/docs/api-reference/errors).
----------
LLM as a judge: evaluating answers
You'll build a script that compares two models (or two prompts) on your own questions: a strong judge model grades the answers, and the script sums them up in a table.
Diagram: Question set (20–100 items) → [Candidate 1 (DeepSeek) / Candidate 2 (Claude Haiku)] → Judge (Claude Sonnet 5.5) → Score table
You need: a fetch_... key (create one (/app/api-keys)), a balance (50 questions ≈ 10 cents), Python 3.10+, 20–100 real customer questions with references, short correct answers.
Collect the questions
Take real questions from your support chat or inbox, easy and tricky ones. For each, write a reference: what the answer must contain.
Pick the judge
The judge is stronger than the candidates and not one of them: claude-sonnet-5-5 or gpt-6.1-sol.
Check the rubric
It lives in GRADE_SYSTEM: correctness, completeness, clarity on a 1–5 scale. If "never promise what the rules don't say" matters to your business, add it as a separate criterion.
Run it
Copy the code into judge.py and replace RULES and DATASET with your own. Run pip install openai pydantic, then python judge.py.
Check the judge against yourself
Open judge_results.jsonl and grade 10–20 answers yourself. If you often disagree with the judge, refine the rubric or the references instead of trusting the table.
Code
The example compares deepseek-v4.1-flash and claude-haiku-5-5 as a store support agent. The judge is claude-sonnet-5-5.
python
"""
LLM as a judge: which of two models answers store customers better.
pip install openai pydantic
python judge.py
"""
import asyncio
import json
import os
import statistics
from typing import Literal
from openai import AsyncOpenAI
from pydantic import BaseModel, Field, ValidationError
CANDIDATES = ["deepseek-v4.1-flash", "claude-haiku-5-5"] # the models being compared
JUDGE_MODEL = "claude-sonnet-5-5" # or "gpt-6.1-sol"; stronger than the candidates and not one of them
CONCURRENCY = 5
client = AsyncOpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=180,
max_retries=3, # the library retries 429 and 5xx itself
)
Zukko cache: re-running with the same questions and prompts costs nothing.
CACHE = {"X-Zukko-Cache": "true"}
sem = asyncio.Semaphore(CONCURRENCY)
---------- What we test ----------
RULES = """Store rules:
- Returns: within 14 days of purchase, item unused. Request: account -> "My orders" -> "Return".
Refunds take 3-10 business days, to the same card.
- Courier delivery in London: 1-2 days, £5, free on orders over £50. Pickup points across the UK: 3-7 days.
- Payment: by card or Apple Pay on the website. Payment on delivery is possible only to the courier, not at a pickup point.
- Electronics warranty: 12 months. Repairs at a service center, request through support.
- Support chat is open 9:00-21:00 UK time."""
CANDIDATE_SYSTEM = "You are a support agent for an online store. Answer the customer by the rules below, briefly and politely.\n\n" + RULES
Test set: a question and a reference, i.e. what a good answer must contain.
For a real evaluation use 20-100 real customer questions.
DATASET = [
{"question": "How do I return an item I bought 10 days ago?",
"reference": "You can: the window is 14 days. The item must be unused. Request it in your account: "
"\"My orders\" -> \"Return\". The refund takes 3-10 business days, to the same card."},
{"question": "How much is courier delivery in London?",
"reference": "£5, free on orders over £50, takes 1-2 days."},
{"question": "Can I pay cash at the pickup point?",
"reference": "No. You cannot pay at a pickup point, only in advance by card or Apple Pay. "
"Payment on delivery is possible only to the courier."},
{"question": "My headphones broke after 8 months. What should I do?",
"reference": "They are under the 12-month warranty. Submit a request through support; "
"the repair is done at a service center."},
{"question": "Do you answer at night?",
"reference": "No, the chat is open 9:00-21:00 UK time."},
]
---------- Rubric and the judge's answer format ----------
class Grade(BaseModel):
correctness: int = Field(ge=1, le=5)
completeness: int = Field(ge=1, le=5)
clarity: int = Field(ge=1, le=5)
score: int = Field(ge=1, le=5)
reasons: str
class Verdict(BaseModel):
winner: Literal["A", "B", "tie"]
reasons: str
GRADE_SYSTEM = """You are a strict examiner. Grade a support agent's answer against the reference.
Criteria, each from 1 to 5:
- correctness: everything said is true and does not contradict the reference; any made-up fact caps it at 2;
- completeness: all the important points of the reference are present;
- clarity: clear and to the point. Length is not a virtue in itself: extra paragraphs lower the grade.
score is the overall grade from 1 to 5. reasons is 1-2 sentences on why.
Reply with JSON only: {"correctness": 1-5, "completeness": 1-5, "clarity": 1-5, "score": 1-5, "reasons": "..."}"""
PAIR_SYSTEM = """You are a strict examiner. Compare two support answers to the same question against the reference.
The better one is correct and complete; if equal, the clearer one wins. Length and the order of
the answers do not matter. If there is no real difference, answer tie.
Reply with JSON only: {"winner": "A" | "B" | "tie", "reasons": "1-2 sentences"}"""
def parse_json(raw: str) -> str:
"""Cut out the JSON object, even if the model wrapped it in text or markdown."""
start, end = raw.find("{"), raw.rfind("}")
return raw[start:end + 1] if start != -1 and end != -1 else raw
async def chat(model: str, system: str, user: str, max_tokens: int) -> str:
async with sem:
response = await client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
max_tokens=max_tokens,
extra_headers=CACHE,
)
return response.choices[0].message.content or ""
async def ask_judge(system: str, user: str, schema: type[BaseModel]):
raw = await chat(JUDGE_MODEL, system, user, 600)
try:
return schema.model_validate_json(parse_json(raw))
except ValidationError as error: # one chance to fix it
raw = await chat(JUDGE_MODEL, system,
f"{user}\n\nYour previous answer failed validation: {error}. Send only JSON.", 600)
return schema.model_validate_json(parse_json(raw))
async def grade(item: dict, answer: str) -> Grade:
user = f"Question: {item['question']}\nReference: {item['reference']}\nAgent's answer: {answer}"
return await ask_judge(GRADE_SYSTEM, user, Grade)
async def compare(item: dict, answer_a: str, answer_b: str) -> str:
user = (f"Question: {item['question']}\nReference: {item['reference']}\n\n"
f"Answer A:\n{answer_a}\n\nAnswer B:\n{answer_b}")
return (await ask_judge(PAIR_SYSTEM, user, Verdict)).winner
async def evaluate(item: dict) -> dict:
first, second = CANDIDATES
answers = await asyncio.gather(*(chat(m, CANDIDATE_SYSTEM, item["question"], 500) for m in CANDIDATES))
answer = dict(zip(CANDIDATES, answers))
# Rubric grades + the pair compared twice: first as A, then second as A.
grades = await asyncio.gather(*(grade(item, answer[m]) for m in CANDIDATES))
v1, v2 = await asyncio.gather(compare(item, answer[first], answer[second]),
compare(item, answer[second], answer[first]))
winner_1 = {"A": first, "B": second}.get(v1, "tie")
winner_2 = {"A": second, "B": first}.get(v2, "tie")
# A win counts only if the judge picked the same model in both orders.
winner = winner_1 if winner_1 == winner_2 else "tie"
return {"question": item["question"], "answers": answer,
"grades": {m: g.model_dump() for m, g in zip(CANDIDATES, grades)},
"winner": winner, "consistent": winner_1 == winner_2}
async def main():
results = await asyncio.gather(*(evaluate(item) for item in DATASET))
with open("judge_results.jsonl", "w", encoding="utf-8") as f: # for manual review
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"\nQuestions: {len(results)}, judge: {JUDGE_MODEL}\n")
print(f"{'Model':<22}{'Score':>6}{'Corr':>7}{'Compl':>7}{'Clar':>6}{'Wins':>8}{'Losses':>11}{'Ties':>7}")
for m in CANDIDATES:
g = [r["grades"][m] for r in results]
avg = lambda key: statistics.mean(x[key] for x in g)
wins = sum(r["winner"] == m for r in results)
losses = sum(r["winner"] not in (m, "tie") for r in results)
ties = sum(r["winner"] == "tie" for r in results)
print(f"{m:<22}{avg('score'):>6.2f}{avg('correctness'):>7.2f}"
f"{avg('completeness'):>7.2f}{avg('clarity'):>6.2f}{wins:>8}{losses:>11}{ties:>7}")
inconsistent = sum(not r["consistent"] for r in results)
print(f"\nThe judge changed its mind when the answers were swapped: {inconsistent} of {len(results)}")
print("Per-question details are in judge_results.jsonl")
if __name__ == "__main__":
asyncio.run(main())
You'll get a table like this (the numbers depend on the models and questions):
text
deepseek-v4.1-flash 4.20 4.40 4.00 4.60 1 2 2
claude-haiku-5-5 4.40 4.60 4.20 4.60 2 1 2
The judge changed its mind when the answers were swapped: 1 of 5
Reading the table
- The judge compares each pair twice, with the answers swapped. A win counts only if it picked the same candidate both times.
- If the judge changed its mind on more than a third of the questions, the candidates are too close or the rubric is vague.
- On 5 questions a 0.2-point gap is noise. For a decision use 30–50 questions and look at pairwise wins.
Keep in mind
- X-Zukko-Cache: true: identical requests are served from the cache for free for 7 days. Change the rubric and you pay only for the new judge requests. Need fresh candidate answers? Set CACHE = {}.
- Don't set temperature: newer Claude models may reject non-default values.
What it costs: - 50 questions, judge claude-sonnet-5-5: ≈ $0.09 per run; 100 questions ≈ $0.18.
- Judge gpt-6.1-sol: about $0.06 for 50 questions.
- Re-running without changes is almost free thanks to the cache.
If it doesn't work
What you see | What to do
--- | ---
ValidationError from the judge even after the retry | Tighten the format in the judge's prompt or use a stronger model
Both candidates get nearly the same scores | Add tricky questions and grow the set to 30–50
The judge often changes its mind after the swap | Sharpen the criteria; look at the rubric scores
A re-run returns the same candidate answers | That's the cache. Need fresh ones? Set CACHE = {}
A made-up answer gets a high score | Complete the reference; add a "nothing made up" criterion
----------
Images: understanding and generation
You'll get a model to answer questions about photos and screenshots, and to draw pictures from a description once generation is back on.
You need: a fetch_... key (create one (/app/api-keys)), a balance (top up (/app/payments)), Python and pip install openai.
Diagram: Photo, screenshot, receipt → Your code (image + question) → Zukko API → Model that accepts images → Answer as text or JSON
Diagram: Picture description → Your code → Zukko API (/v1/images/generations) → Image model → Finished picture
Asking about an image
Install the SDK
pip install openai
Save the code
Copy the code below into vision.py.
Ask a question
bash
python vision.py screenshot.png "What does this error mean and how do I fix it?"
python vision.py https://example.com/photo.jpg "Describe this product for a marketplace listing"
Pass the image as a file or a public link. The model can't download private or expiring links.
python
import base64
import mimetypes
import os
import sys
from openai import OpenAI
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=120,
)
MODEL = os.environ.get("ZUKKO_MODEL", "claude-haiku-5-5") # the model must accept images
def image_to_data_url(path: str) -> str:
"""Turn an image file into a data URL, so the picture travels inside the request."""
mime = mimetypes.guess_type(path)[0] or "image/jpeg"
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode()
return f"data:{mime};base64,{data}"
def ask_about_image(image: str, question: str) -> str:
"""image is a file path on disk or a public https://... link."""
url = image if image.startswith(("http://", "https://")) else image_to_data_url(image)
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": question},
{"type": "image_url", "image_url": {"url": url}},
],
}
],
max_tokens=1000,
)
return response.choices[0].message.content or ""
if __name__ == "__main__":
# python vision.py screenshot.png "What does this error mean and how do I fix it?"
image = sys.argv[1]
question = sys.argv[2] if len(sys.argv) > 2 else "Describe what is in the picture."
print(ask_about_image(image, question))
Which model to use
The model must accept images — see the "Image input" column in Models & pricing (/docs/introduction/models-pricing).
Model | Input / output, $ per 1M tokens | When to use
--- | --- | ---
claude-haiku-5-5 | 0.05 / 0.2 | Receipts, screenshots, simple photos
gpt-6-sol | 0.13 / 0.63 | All-rounder
claude-sonnet-5 | 0.4 / 2 | Small print, tables, diagrams
qwen3.8-omni-flash | 0.03 / 0.08 | Cheapest
These don't accept images: deepseek-v4.1-flash, gemini-3.8-flash, kimi-k3.1-pro, glm-5.3, gpt-6-luna. Live list: GET /v1/models/info, field supports_vision.
A receipt into JSON
Put receipt.py next to vision.py and run python receipt.py receipt.jpg. The code checks that the lines add up to the total — review mismatches by hand.
python
import json
import sys
from vision import client, image_to_data_url
RECEIPT_FORMAT = {
"type": "json_schema",
"json_schema": {
"name": "receipt",
"strict": True,
"schema": {
"type": "object",
"properties": {
"store": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD"},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"quantity": {"type": "number"},
"sum": {"type": "number"},
},
"required": ["name", "quantity", "sum"],
"additionalProperties": False,
},
},
"total": {"type": "number"},
},
"required": ["store", "date", "items", "total"],
"additionalProperties": False,
},
},
}
def read_receipt(path: str) -> dict:
response = client.chat.completions.create(
model="claude-haiku-5-5",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Read this receipt. sum is the line total. "
"If a field isn't visible, return an empty string or 0."},
{"type": "image_url", "image_url": {"url": image_to_data_url(path)}},
],
}
],
response_format=RECEIPT_FORMAT,
max_tokens=2000,
)
data = json.loads(response.choices[0].message.content) # if parsing fails, retry the request
# Check the model with arithmetic: the lines must add up to the total
items_total = sum(item["sum"] for item in data["items"])
if abs(items_total - data["total"]) > 0.01:
print(f"Warning: lines add up to {items_total}, but the total is {data['total']}. Check by hand.")
return data
if __name__ == "__main__":
print(json.dumps(read_receipt(sys.argv[1]), ensure_ascii=False, indent=2))
In a Telegram bot
Add the handler to the bot from Telegram bot with AI (/docs/guides/telegram-bot): the user sends a photo with a question in the caption. The model in ZUKKO_MODEL must accept images.
python
import base64
async def on_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
photo = update.message.photo[-1] # Telegram sends several sizes; take the largest
file = await photo.get_file()
data = base64.b64encode(await file.download_as_bytearray()).decode()
question = update.message.caption or "What is in this picture?"
response = await client.chat.completions.create(
model=MODEL, # the model must accept images, e.g. claude-haiku-5-5
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": question},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{data}"}},
],
}
],
max_tokens=1000,
)
for part in split_text(response.choices[0].message.content or "Empty answer."):
await update.message.reply_text(part)
in main():
app.add_handler(MessageHandler(filters.PHOTO, on_photo))
Generating images
Careful: Temporarily unavailable
Image models are switched off; requests get 503 model_disabled. The code will work once they're back. Watch GET /v1/models and the Zukko channel (https://t.me/zukkopro?direct).
The endpoint is POST /v1/images/generations, OpenAI format. Generation takes up to 3 minutes, so set a 300-second client timeout. The curl version needs jq. All parameters: POST /v1/images/generations (/docs/api-reference/images-generations).
python
import base64
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key=os.environ["ZUKKO_API_KEY"],
timeout=300, # generation can take up to 3 minutes
)
result = client.images.generate(
model="openai-gpt-image-2-text-to-image",
prompt="A cozy coffee shop on an autumn evening, warm light from the windows, watercolor",
size="1024x1024",
quality="high",
response_format="b64_json",
)
with open("picture.png", "wb") as f:
f.write(base64.b64decode(result.data[0].b64_json))
print("Saved: picture.png")
bash
curl https://api.zukko.pro/v1/images/generations \
-H "Authorization: Bearer $ZUKKO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "openai-gpt-image-2-text-to-image",
"prompt": "A cozy coffee shop on an autumn evening, warm light from the windows, watercolor",
"size": "1024x1024",
"quality": "high",
"response_format": "b64_json"
}' | jq -r '.data[0].b64_json' | base64 --decode > picture.png
What it costs: - A receipt on claude-haiku-5-5: ≈ $0.00014, 1,000 receipts a day is about $4 a month.
- A receipt on claude-sonnet-5: ≈ $0.0012, about $37 a month.
- Generation is billed per image at the model's price from the Models (/app/models) catalog. Your balance must cover at least one image.
If it doesn't work
What you see | What to do
--- | ---
400, or the model "can't see" the image | Use a model that accepts images, e.g. claude-haiku-5-5
An error about the image URL | Download the image and send it as a file (data URL)
The model misreads receipt numbers | Retake the photo closer, use claude-sonnet-5
json.JSONDecodeError in receipt.py | Retry; raise max_tokens for long receipts
503 model_disabled when generating | Image models are temporarily off — watch GET /v1/models
402 insufficient_balance or 504 image_generation_timeout | 402 — top up (/app/payments). 504 — retry, simplify the prompt
Other codes: API errors (/docs/api-reference/errors).
----------
How to pay less
Ten techniques: the first ones cut costs several times over, the last ones protect you from surprises. You pay for input and output tokens; output is usually 4–5 times more expensive. Prices are in Models & pricing (/docs/introduction/models-pricing).
1. A cheap model for simple tasks
Give sorting requests, pulling a phone number from an email, short translations and spelling fixes to deepseek-v4.1-flash, qwen3.8-flash or claude-haiku-5-5. Keep claude-sonnet-5 and claude-opus-5-5 for code, long reasoning and important texts. Run 20–30 of your real requests through a cheap and a strong model: no visible difference, take the cheap one. More in How to choose a model (/docs/introduction/choose-model).
Example: sorting 10,000 support tickets by topic: claude-opus-5-5 $4.80, claude-haiku-5-5 $0.29, deepseek-v4.1-flash $0.17.
2. A cheap worker, a strong reviewer
A cheap model writes the draft; a strong one only reviews it and gives a short verdict. How to wire it up: Agent orchestration (/docs/guides/multi-agent).
Example: 1,000 product descriptions: all on claude-opus-5-5 $5.60; deepseek-v4.1-flash writes, claude-opus-5-5 reviews: $2.96.
3. Prompt cache: the unchanging part first
Put the instruction and knowledge base at the start of the request, and the question, date or name at the end. The cache works by itself, no cache_control needed. The current time in the first line of the instruction breaks the cache.
- claude-sonnet-5 and claude-opus-5-5: in our test 40–70% of the repeated part was read from the cache. A cache read on Opus 5.5 costs $0.08 instead of $0.8 per 1M tokens.
- The first cache write costs a little more than regular input (Sonnet: $0.5 instead of $0.4 per 1M); it pays off from the second or third repeat.
- On DeepSeek a cache read costs the same as regular input. claude-haiku-5-5 did not cache in our test.
Example: a bot on claude-sonnet-5 with a 20,000-token instruction, 1,000 questions a day: $8.64 a day without the cache, $3.60–5.76 with it.
4. The Standard channel for requests without repeats
claude-sonnet-5-standard and claude-opus-5-5-standard charge one price for any token, input and output alike: $0.2 for Sonnet and $0.3 for Opus per 1M. No cache discount. Worth it when every request is new and the model writes a lot: translations, forms, descriptions, emails. Not worth it for a bot with a large fixed instruction: a cache read on regular Sonnet ($0.04) is cheaper.
Example: translating 1,000 documents: claude-sonnet-5 $7.20, claude-sonnet-5-standard $1.20.
5. Response cache X-Zukko-Cache: true
With this header Zukko returns the saved answer to an exactly identical request (same model, text and settings) for free. Useful when you restart a crashed pipeline.
- Kept for 7 days, only successful answers up to 2 MB. Each account has its own cache.
- The response carries X-Zukko-Cache: hit and X-Zukko-Cost-Micros: 0 and does not appear in your spending history.
- For Claude use the header, not temperature: 0: newer Claude models may reject non-default temperature values.
Example: a 1,000-row pipeline crashed on row 600: restarting costs $2.00 without the cache, $0.80 with it. Details: Key limits, idempotency and caching (/docs/api-reference/key-limits-and-cache).
6. Idempotency-Key: no double charge on retries
Send a unique value; a retry with the same value returns the saved answer and is not charged again.
- Works for /v1/chat/completions, /v1/messages and /v1/responses. The answer is kept for 24 hours.
- The same value with a different request body gets 422 idempotency_conflict.
- Retry after the first request has finished or failed. Two simultaneous requests are both charged.
python
model="deepseek-v4.1-flash",
messages=[{"role": "user", "content": f"Classify the sentiment of this review: {review_text}"}],
extra_headers={"Idempotency-Key": f"reviews-2026-10-26-row-{row_id}"},
)
Example: 10,000 requests, 3% resent after network drops: an extra $0.90 per run without the key, $0 with it.
7. Short answers
Ask in the instruction: "Answer in 2–3 sentences", "JSON only, no explanations". max_tokens is only a safety net: the model stops mid-sentence, so leave headroom. Don't turn on reasoning for simple tasks.
Example: a bot on claude-sonnet-5, 1,000 answers a day, answers cut from ~600 to ~200 tokens: ~$24 a month saved.
8. Short chat history
The model gets the whole conversation every time. Send only the last 10–20 messages, or every 20 messages replace the old ones with a summary from a cheap model. Keep the instruction first so it stays in the cache.
python
def build_messages(system_prompt, history):
# The instruction always comes first, then only the latest messages
return [{"role": "system", "content": system_prompt}] + history[-MAX_MESSAGES:]
Example: 1,000 conversations of 40 messages on claude-sonnet-5: $49 with the full history, $21 with a 10-message window.
9. Key limits
In the dashboard (/app/api-keys) give each key a daily and monthly spend cap (once reached, requests get 429 and cost nothing) and a list of allowed models. Create a separate key for every bot, agent or contractor. The limits are soft: the last request let through can go slightly over the cap.
Example: an agent looping on claude-opus-5-5 burns ~$53 an hour. With a $2 daily key limit the damage is about $2.
10. Watch your spending
- Dashboard: the Usage (/app/usage) page.
- API: GET /v1/usage (/docs/api-reference/usage) and GET /v1/balance (/docs/api-reference/balance).
- The X-Zukko-Cost-Micros header in every non-streaming response: the cost in millionths of a dollar (1500 = $0.0015).
Run the example twice; the second answer comes from the cache and costs 0:
python
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.zukko.pro/v1", api_key=os.environ["ZUKKO_API_KEY"])
raw = client.chat.completions.with_raw_response.create(
model="deepseek-v4.1-flash",
messages=[{"role": "user", "content": "Capital of France? One word."}],
max_tokens=100,
extra_headers={"X-Zukko-Cache": "true"},
)
resp = raw.parse()
print(resp.choices[0].message.content)
print("Cache:", raw.headers.get("X-Zukko-Cache", "none"))
print("Cost, $:", int(raw.headers.get("X-Zukko-Cost-Micros", "0")) / 1_000_000)
javascript
// Save as cost.mjs and run: node cost.mjs
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api.zukko.pro/v1", apiKey: process.env.ZUKKO_API_KEY });
const { data, response } = await client.chat.completions
.create(
{
model: "deepseek-v4.1-flash",
messages: [{ role: "user", content: "Capital of France? One word." }],
max_tokens: 100,
},
{ headers: { "X-Zukko-Cache": "true" } },
)
.withResponse();
console.log(data.choices[0].message.content);
console.log("Cache:", response.headers.get("x-zukko-cache") ?? "none");
console.log("Cost, $:", Number(response.headers.get("x-zukko-cost-micros") ?? 0) / 1e6);
bash
curl -s -D - -o /dev/null https://api.zukko.pro/v1/chat/completions \
-H "Authorization: Bearer $ZUKKO_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Zukko-Cache: true" \
-d '{"model":"deepseek-v4.1-flash","max_tokens":100,"messages":[{"role":"user","content":"Capital of France? One word."}]}' \
| grep -i "x-zukko"
Note: Streaming responses (stream: true) carry no X-Zukko-Cost-Micros header; check their cost in the spending history. The history covers the whole account: every key, the web chat and the Telegram bot.
----------
Connect your tools
Choose provider
What to pick in your tool's settings so it runs through Zukko.
You need: a fetch_... key (create one (/app/api-keys)) and a balance from $1 (top up (/app/payments)). One key works for both modes.
The rule
Provider in the tool | Base URL | Models
--- | --- | ---
OpenAI / OpenAI Compatible | https://api.zukko.pro/v1 — with /v1 | Any
Anthropic | https://api.zukko.pro — without /v1 | Claude
Get /v1 wrong and every request returns 404. The exception is OpenCode (Anthropic, but with /v1).
Popular tools
Tool | Provider | Base URL
--- | --- | ---
Claude Code (/docs/ide-integrations/claude-code) | Anthropic | https://api.zukko.pro
Cursor (/docs/ide-integrations/cursor-ide) | OpenAI (Override OpenAI Base URL) | https://api.zukko.pro/v1
Cline (/docs/ide-integrations/vscode-extensions) | OpenAI Compatible | https://api.zukko.pro/v1
Roo Code (/docs/ide-integrations/vscode-extensions) | Anthropic | https://api.zukko.pro
Kilo Code (/docs/ide-integrations/vscode-extensions) | OpenAI Compatible | https://api.zukko.pro/v1
Codex (/docs/ide-integrations/codex) | OpenAI | https://api.zukko.pro/v1
OpenCode (/docs/ide-integrations/opencode) | Anthropic | https://api.zukko.pro/v1
Model names are the id field in the model list (https://api.zukko.pro/v1/models).
Check your key
The balance request is free. Put in your key:
bash
curl https://api.zukko.pro/v1/balance -H "Authorization: Bearer fetch_..."
powershell
curl.exe https://api.zukko.pro/v1/balance -H "Authorization: Bearer fetch_..."
If the response has balance_cents, the key works.
If it doesn't work
What you see | What to do
--- | ---
404 on every request | Check /v1 against the table above
404 model_not_found | Take the name from the model list (https://api.zukko.pro/v1/models)
401 | Typo in the key or it was revoked — create a new one (/app/api-keys)
402 | Top up (/app/payments)
403 email_unverified | Verify your email in the dashboard
----------
Claude Code
Run Claude Code through Zukko: no Anthropic login, no subscription, billed from your balance.
You need: a fetch_... key (create one (/app/api-keys)) and a balance from $1 (top up (/app/payments)).
Install Claude Code
bash
curl -fsSL https://claude.ai/install.sh | bash
powershell
irm https://claude.ai/install.ps1 | iex
bash
npm install -g @anthropic-ai/claude-code
Already installed if claude --version works. If claude.ai returns 403, install with npm.
Open the settings file
bash
mkdir -p ~/.claude && touch ~/.claude/settings.json && open -e ~/.claude/settings.json
bash
mkdir -p ~/.claude && nano ~/.claude/settings.json
powershell
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude" | Out-Null
notepad "$env:USERPROFILE\.claude\settings.json"
Paste the settings
Replace fetch_... with your key and save the file:
json
"env": {
"ANTHROPIC_BASE_URL": "https://api.zukko.pro",
"ANTHROPIC_AUTH_TOKEN": "fetch_...",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-5-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-5-5"
}
}
Careful: File wasn't empty?
Add only the env block and leave the rest alone. A trailing comma breaks the whole file.
Run it
Open a new terminal window:
bash
claude
Type "Hi". If you get an answer, it works.
Models
Slot in /model | Model | When
--- | --- | ---
Opus | claude-opus-5-5 | Hard tasks
Sonnet | claude-sonnet-5 | Everyday work
Haiku | claude-haiku-5-5 | Small background tasks
Switch slots with /model. To use another model, change the name in settings.json and restart claude. All models: Models and pricing (/docs/introduction/models-pricing).
Tip: Spend less
Starting a new task? Type /clear. A long conversation makes every request more expensive.
VS Code extension
1. Install the Claude Code extension from the Marketplace.
2. The ~/.claude/settings.json file from the steps above must exist — the extension reads the same file.
3. In VS Code settings, turn on Claude Code: Disable Login Prompt.
4. Ctrl/Cmd + Shift + P → Developer: Reload Window.
If it doesn't work
What you see | What to do
--- | ---
Asks you to log in to Anthropic | The key goes in ANTHROPIC_AUTH_TOKEN, not ANTHROPIC_API_KEY. Check the commas in settings.json. Logged in to Anthropic before? Run /logout
401 | Typo in the key or it was revoked — create a new one (/app/api-keys)
402 | Out of balance — top up (/app/payments), no restart needed
403 email_unverified | Verify your email in the dashboard
404 on every request | Remove /v1: the address is exactly https://api.zukko.pro
404 model_not_found or 503 model_disabled | Pick a model from the list (https://api.zukko.pro/v1/models)
Windows: claude is not recognized | Close PowerShell and open it again
Other errors: Something went wrong: what to do (/docs/help/troubleshooting).
----------
Cursor IDE
Run Cursor on Zukko models, billed from your balance.
You need: paid Cursor Pro (the free plan can't set a custom address), a fetch_... key (create one (/app/api-keys)) and a balance from $1 (top up (/app/payments)).
Open settings
Cursor Settings → Models.
Paste the key
Turn on OpenAI API Key and paste fetch_....
Set the address
Turn on Override OpenAI Base URL:
text
Add a model
Use a name from the table below, for example cursor-sonnet-5.
Check
Press Verify, pick the model in chat and type "Hi".
Model names
Cursor sends standard names like claude-sonnet-5 past Zukko. Use these instead:
Name for Cursor | Model
--- | ---
cursor-opus-55 | Claude Opus 5.5
cursor-opus-5 | Claude Opus 5
cursor-opus-48 | Claude Opus 4.8
cursor-opus-47 | Claude Opus 4.7
cursor-opus-46 | Claude Opus 4.6
cursor-sonnet-5 | Claude Sonnet 5
cursor-gpt-sol | GPT-5.6 Sol
cursor-gpt-terra | GPT-5.6 Terra
cursor-gpt-luna | GPT-5.6 Luna
cursor-qwen | Qwen 3.8 Max
For models not in the table, use the standard name if Cursor accepts it: gpt-6.1-sol, gemini-3.8-flash, deepseek-v4.1-flash, glm-5.3. Old names (cursor47, cursor46, cursor46s, cursorgpt54, cursorgpt55) still work.
If it doesn't work
What you see | What to do
--- | ---
"Model already exists" | Use a name from the table
Answers bypass Zukko | The address is exactly https://api.zukko.pro/v1 and Override is on
404 model_not_found or 503 model_disabled | Pick another name from the table
401 | Typo in the key — create a new one (/app/api-keys)
402 | Top up (/app/payments)
403 email_unverified | Verify your email in the dashboard
----------
VS Code Extensions
Run Cline, Roo Code and Kilo Code on Zukko models, billed from your balance.
You need: the extension from the Marketplace, a fetch_... key (create one (/app/api-keys)) and a balance from $1 (top up (/app/payments)).
Extension | Provider | Base URL | Model
--- | --- | --- | ---
Cline | OpenAI Compatible | https://api.zukko.pro/v1 | claude-sonnet-5
Roo Code | Anthropic | https://api.zukko.pro | claude-sonnet-5
Kilo Code | OpenAI Compatible | https://api.zukko.pro/v1 | claude-sonnet-5
Other models: the id field in the model list (https://api.zukko.pro/v1/models).
Cline
1. Cline panel → gear icon Settings.
2. API Provider → OpenAI Compatible.
3. Base URL: https://api.zukko.pro/v1, API Key: fetch_....
4. Model ID: claude-sonnet-5. If it asks for the context size: 1000000 for Claude and GPT, 128000 for GLM.
5. Save and type "Hi".
Roo Code
1. Roo Code panel → Settings.
2. API Provider → Anthropic.
3. Anthropic API Key: fetch_....
4. Turn on Use custom base URL: https://api.zukko.pro — no /v1.
5. Model: claude-sonnet-5 or claude-opus-5-5. Save and type "Hi".
Need a non-Claude model? Pick OpenAI Compatible with https://api.zukko.pro/v1, as in Cline.
Kilo Code
1. Kilo Code panel → Settings.
2. API Provider → OpenAI Compatible.
3. Base URL: https://api.zukko.pro/v1, API Key: fetch_....
4. Model: claude-sonnet-5.
5. Save and type "Hi".
If it doesn't work
What you see | What to do
--- | ---
404 on every request | OpenAI Compatible takes /v1, Anthropic doesn't
404 model_not_found or 503 model_disabled | Pick a model from the list (https://api.zukko.pro/v1/models)
401 | Typo in the key — create a new one (/app/api-keys)
402 | Top up (/app/payments)
403 email_unverified | Verify your email in the dashboard
----------
Codex
Run Codex CLI through Zukko, billed from your balance. GPT and Claude models work; GLM models don't work in Codex.
You need: Codex CLI installed, a fetch_... key (create one (/app/api-keys)) and a balance from $1 (top up (/app/payments)).
Installer
{{CODEX_DOWNLOADS}}
1. Run the file: on macOS unzip it and double-click codex-setup-macos.command; on Windows open codex-setup-windows.vbs.
2. Paste your fetch_... key.
3. When you see Codex configured for Zukko, you're done. Your old settings are backed up.
macOS won't open the file? Right-click → Open. Windows shows SmartScreen? More info → Run anyway.
Manually
~/.codex/config.toml (Windows: %USERPROFILE%\.codex\config.toml):
toml
model_provider = "zukko"
model_reasoning_effort = "high"
[model_providers.zukko]
name = "zukko"
base_url = "https://api.zukko.pro/v1"
wire_api = "responses"
requires_openai_auth = true
~/.codex/auth.json:
json
Run codex and type "Hi". To use another model, change it in config.toml or at launch: codex -m gpt-5.6-luna.
If it doesn't work
What you see | What to do
--- | ---
401 | Check the key in auth.json — create a new one (/app/api-keys)
402 | Top up (/app/payments)
404 on every request | base_url is exactly https://api.zukko.pro/v1
404 model_not_found or 503 model_disabled | Pick a GPT or Claude model from the list (https://api.zukko.pro/v1/models)
----------
OpenCode
Run OpenCode on Claude models through Zukko, billed from your balance.
You need: OpenCode (install (https://opencode.ai)), a fetch_... key (create one (/app/api-keys)) and a balance from $1 (top up (/app/payments)).
Open the settings file
bash
mkdir -p ~/.config/opencode && touch ~/.config/opencode/opencode.json && open -e ~/.config/opencode/opencode.json
bash
mkdir -p ~/.config/opencode && nano ~/.config/opencode/opencode.json
For a single project only, use opencode.json in its root.
Paste the settings
Replace fetch_... with your key and save:
json
"$schema": "https://opencode.ai/config.json",
"provider": {
"anthropic": {
"options": {
"baseURL": "https://api.zukko.pro/v1",
"apiKey": "fetch_..."
},
"models": {
"claude-opus-5-5": {},
"claude-sonnet-5": {}
}
}
}
}
Careful: /v1 is required here
Unlike other tools with the Anthropic provider, OpenCode needs the address with /v1.
Pick a model
Run opencode in your project folder, type /models → Anthropic → claude-sonnet-5. Type "Hi".
Add other Claude models to the models block — names are in the model list (https://api.zukko.pro/v1/models).
If it doesn't work
What you see | What to do
--- | ---
404 on every request | baseURL needs /v1: https://api.zukko.pro/v1
No Zukko models in /models | Check the file path and commas, restart opencode
404 model_not_found | Take the name from the model list (https://api.zukko.pro/v1/models)
401 | Typo in the key — create a new one (/app/api-keys)
402 | Top up (/app/payments)
403 email_unverified | Verify your email in the dashboard
----------
Ready-made agents
Hermes
Run Hermes Agent (https://hermes-agent.nousresearch.com/) on Zukko models, billed from your balance.
You need: a fetch_... key (create one (/app/api-keys)) and a balance from $1 (top up (/app/payments)).
Leave the chat (Ctrl+C or /quit) and start the wizard:
bash
Pick one of the two options. Codex CLI and Claude Code OAuth are subscriptions and don't work with Zukko.
Option 1: Anthropic
1. Anthropic → API key → paste fetch_....
2. Model: claude-sonnet-5.
3. In ~/.hermes/config.yaml, the address goes without /v1:
yaml
provider: anthropic
default: claude-sonnet-5
base_url: https://api.zukko.pro
The key is in ~/.hermes/.env: ANTHROPIC_API_KEY=fetch_....
Option 2: OpenAI
Works with any model, not just Claude.
1. OpenAI → direct OpenAI API → paste fetch_....
2. Model: claude-sonnet-5 or gpt-5.6-sol.
3. In ~/.hermes/.env, the address goes with /v1:
bash
OPENAI_BASE_URL=https://api.zukko.pro/v1
Or in ~/.hermes/config.yaml:
yaml
provider: openai-api
default: claude-sonnet-5
base_url: https://api.zukko.pro/v1
Run hermes and type "Hi". Switch models in chat with /model.
If it doesn't work
What you see | What to do
--- | ---
401 | Check the fetch_... key, no spaces
402 | Top up (/app/payments)
404 with Anthropic | Remove /v1 from base_url
OpenAI option goes elsewhere | Address https://api.zukko.pro/v1, provider openai-api
404 model_not_found or 503 model_disabled | Pick a model from the list (https://api.zukko.pro/v1/models)
----------
OpenClaw
OpenClaw (https://docs.openclaw.ai/) connects to Zukko as a custom provider. Config: ~/.openclaw/openclaw.json. Key: fetch_... (API keys (/app/api-keys)).
Option | api | baseUrl
--- | --- | ---
OpenAI Compatible | openai-completions | https://api.zukko.pro/v1
Anthropic Messages | anthropic-messages | https://api.zukko.pro
With onboard
bash
openclaw onboard --non-interactive --accept-risk \
--auth-choice custom-api-key \
--custom-base-url "https://api.zukko.pro/v1" \
--custom-model-id "claude-sonnet-5" \
--custom-api-key "fetch_..." \
--secret-input-mode plaintext \
--custom-compatibility openai
For the Anthropic option use --custom-base-url "https://api.zukko.pro" (no /v1) and --custom-compatibility anthropic. Onboard flags change between versions — check openclaw onboard --help.
Manual: OpenAI Compatible
A custom provider's model must be listed in models.providers..models[], otherwise OpenClaw does not know about it.
json5
models: {
mode: "merge",
providers: {
zukko: {
baseUrl: "https://api.zukko.pro/v1",
apiKey: "fetch_...",
api: "openai-completions",
models: [
{
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
input: ["text", "image"],
contextWindow: 1000000,
maxTokens: 8192,
},
],
},
},
},
agents: {
defaults: {
model: { primary: "zukko/claude-sonnet-5" },
models: {
"zukko/claude-sonnet-5": { alias: "sonnet" },
},
},
},
}
agents.defaults.models only sets aliases and per-model settings — it does not restrict anything.
Manual: Anthropic Messages
Same file, but in the provider:
json5
api: "anthropic-messages",
Base URL without /v1.
Apply and check
The Gateway picks up changes to openclaw.json by itself. If OpenClaw says Restart the gateway to apply, restart the Gateway. To check:
bash
openclaw models list
If it fails
- Model not listed — make sure it is in models.providers.zukko.models[] and the id matches GET /v1/models.
- Model not allowed — if you set agents.defaults.modelPolicy.allow, add zukko/ (or zukko/*) to it.
- 401 — check the fetch_... key and the URL from the table (with or without /v1).
- 404 model_not_found — there is no such model in the catalog: check the name against GET /v1/models.
- 503 model_disabled — the model is temporarily switched off; pick another from GET /v1/models.
----------
API reference
Authentication
Requests to the Zukko API are signed with a fetch_... key. Create it on the API keys page (/app/api-keys); it is shown only once.
How to send the key
Either of two headers works:
bash
x-api-key: fetch_YOUR_KEY
Authorization: Bearer is what the OpenAI SDK and most IDEs use. x-api-key is sent by the Anthropic SDK, Roo Code, and other Anthropic clients — no need to change their settings. Passing the key in the query string (?api_key=...) is not supported: such a request gets 401.
anthropic-version
Zukko neither checks nor requires this header. If your client sends it (the Anthropic SDK does so automatically), it is passed to the provider as is — keep it.
What needs a key
Endpoint | Key
--- | ---
GET /v1/models, GET /v1/models/{id}, GET /v1/models/info | not needed, the key is ignored
GET /api/status | not needed
GET /v1/balance, GET /v1/usage | required
POST /v1/chat/completions, /v1/messages, /v1/messages/count_tokens, /v1/responses, /v1/images/generations, /v1/web-search | required, plus a verified email and a balance of at least $0.01
One key works with every endpoint and every model, unless the key's settings restrict the model list.
Authentication errors
Missing key, typo, revoked key — 401:
json
"type": "error",
"error": {
"type": "authentication_error",
"message": "Invalid API key. Use Authorization: Bearer fetch_*. Поддержка — https://t.me/zukkopro?direct, напишите, пожалуйста."
}
}
The support line at the end of the message is added to every Zukko error. Unverified email — 403 with code email_unverified. All other codes are in API errors (/docs/api-reference/errors).
Example
bash
-H "Authorization: Bearer fetch_YOUR_KEY"
Security rules
- use HTTPS only: https://api.zukko.pro;
- keep the key out of code and repositories — read it from environment variables;
- create a separate key per app and give it limits (/docs/api-reference/key-limits-and-cache): a leaked key can be revoked without touching the others.
----------
API errors
Zukko reports errors with a regular HTTP status and a JSON body in the OpenAI format. Handle errors by status and error.code, not by text: Zukko's own error messages are in Russian and may change.
Error format
json
"error": {
"message": "Модель 'gpt-5.4' снята с обслуживания. Используйте 'gpt-5.6-sol' — актуальный список: GET /v1/models. Поддержка — https://t.me/zukkopro?direct, напишите, пожалуйста.",
"type": "invalid_request_error",
"code": "model_retired",
"param": "model"
}
}
- error.message is always present;
- error.code is set on every Zukko error except 401;
- error.type and error.param appear where they apply.
Every error message ends with a support line: "Поддержка — https://t.me/zukkopro?direct, напишите, пожалуйста." (Support — write to us on Telegram.) A 401 also has a top-level "type": "error" and error.type: "authentication_error". A 403 email_unverified duplicates message and code at the top level.
Error codes
Status | Code | When | Retry?
--- | --- | --- | ---
400 | model_retired | the model was retired; the message names its replacement | no, change the model
400 | invalid_json | the body is not JSON (GLM, web search, images) | no, fix the request
400 | invalid_request | web search without query | no
400 | invalid_request_error | images: no prompt, or no images for an edit model | no
400 | unknown_glm_model | unknown GLM model | no
401 | — (authentication_error) | the key is missing, mistyped, or revoked | no
402 | insufficient_balance | balance below $0.01 (for images — below the price of one image) | after a top-up
403 | email_unverified | email not verified | after verification
403 | model_not_allowed_for_key | the model is not in this key's allowed list | no
404 | model_not_found | the model is not in the catalog; the request does not reach a provider and is not charged | no, fix the name using GET /v1/models
404 | not_found | unknown path | no
405 | method_not_allowed | wrong HTTP method | no
413 | payload_too_large | request body larger than 32 MB | no, make it smaller
422 | idempotency_conflict | the Idempotency-Key was already used with a different body | no, use a new key
429 | key_daily_limit_reached | the key's daily cap is used up | after 00:00 UTC, or raise the cap
429 | key_monthly_limit_reached | the key's monthly cap is used up | next month (UTC), or raise the cap
429 | provider's code | rate limit on the model provider's side | yes, with backoff
502 | upstream_unreachable | the model provider did not respond | yes, with backoff
502 | web_search_failed | web search failed | yes
502 | image_generation_failed | image generation failed | yes
503 | model_disabled | the model is temporarily switched off | no, pick another from GET /v1/models
503 | provider_unavailable | the model provider is temporarily unavailable | yes, with backoff or another model
503 | maintenance | maintenance; comes with Retry-After: 600 | yes, after Retry-After seconds
503 | image_provider_unavailable, image_provider_busy | the image service is down or overloaded | yes, later
504 | image_generation_timeout | image generation took longer than 3 minutes | yes
Zukko has no request rate limiting of its own. A 429 from Zukko only means a key limit that you set yourself. Any other 429 comes from the model provider.
Provider errors
Errors returned by the model provider are passed through with the same status and text — for example, context length exceeded or an invalid parameter. Exceptions:
- if the provider answered 402, you get 503 provider_unavailable — it is not about your balance;
- if the error text explains nothing, Zukko replaces it with its own: for 5xx — "the model provider is temporarily unavailable, retry later", for 4xx — "the model provider rejected the request, check the parameters";
- for 5xx errors Zukko appends the list of models that are working right now.
A 500 with the body {"error": "Internal server error"} means a failure on Zukko's side — retry, and if it persists, contact support.
Errors inside a stream
With stream: true the 200 status is sent before the model's answer. If an error happens after that, it arrives inside the stream as an event with an error field. Check the stream events, not just the HTTP status. A stream that carried only an error — no text and no usage — is not billed. See Streaming (/docs/api-reference/streaming).
Retrying
- retry provider 429, 500, 502, 503 (except model_disabled), and 504 with exponential backoff;
- on 503 maintenance wait the number of seconds in Retry-After;
- do not retry other 4xx — fix the request, key, or balance;
- add an Idempotency-Key header: a retry of a request that already completed returns the stored response and is not charged again. Send the retry after the first request has finished — see idempotency (/docs/api-reference/key-limits-and-cache).
----------
Streaming
Send "stream": true and the answer arrives in pieces as Server-Sent Events (SSE). The event format depends on the endpoint and matches the original API, so official SDKs parse it for you.
Chat Completions
POST /v1/chat/completions sends data: lines with answer fragments and ends with data: [DONE].
text
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}
data: [DONE]
For Claude and GPT models Zukko adds stream_options: {"include_usage": true} to the request itself. So the last chunk before [DONE] carries usage and an empty choices array. Code that reads chunk.choices[0] without a check will crash on it:
python
if chunk.choices:
print(chunk.choices[0].delta.content or "", end="", flush=True)
elif chunk.usage:
print(f"\n[tokens: {chunk.usage.prompt_tokens} in, {chunk.usage.completion_tokens} out]")
Messages
POST /v1/messages sends Anthropic events: message_start, content_block_start, content_block_delta, content_block_stop, message_delta, and message_stop.
text
data: {"type":"message_start","message":{"id":"msg_...","role":"assistant","model":"claude-sonnet-5","content":[]}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}
event: message_stop
data: {"type":"message_stop"}
GLM models called through /v1/messages stream in the same format: Zukko converts their output into Anthropic events.
Responses
POST /v1/responses sends named events: response.created, response.output_item.added, response.output_text.delta, response.output_item.done, response.completed, and others. The full list is on POST /v1/responses (/docs/api-reference/responses).
Errors inside a stream
The 200 HTTP status is sent before the model starts answering. If an error happens later, it arrives as an event inside the stream — a data: line with an error field. Check the events, not just the response status.
A stream that carried only an error — no text and no usage — is not billed.
Cost and interruption
- A streamed response has no X-Zukko-Cost-Micros or X-Fetch-Balance-Cents headers: the cost is known only at the end. Look it up in GET /v1/usage (/docs/api-reference/usage) or the dashboard.
- If the client closes the connection early, Zukko still reads the model's answer to the end and charges for everything generated. Aborting a stream does not save money — use max_tokens to cap spend.
- Streamed responses are also covered by the cache and Idempotency-Key if they are no larger than 2 MB; the streamed and non-streamed versions of a request are stored separately.
SDK examples
Ready-made streaming code is on the Python (/docs/api-reference/python) and Node.js (/docs/api-reference/nodejs) pages.
----------
Python
Zukko works with the official SDKs — no code changes, just base_url and the key.
SDK | Base URL | Key
--- | --- | ---
OpenAI (openai) | https://api.zukko.pro/v1 | fetch_...
Anthropic (anthropic) | https://api.zukko.pro | fetch_...
OpenAI SDK
bash
python
client = OpenAI(base_url="https://api.zukko.pro/v1", api_key="fetch_...")
r = client.chat.completions.create(
model="claude-sonnet-5",
messages=[{"role": "user", "content": "Hello!"}],
)
print(r.choices[0].message.content)
model also accepts GPT models such as gpt-5.6-sol and the cheap GLM model glm-5.3-flash.
Streaming
python
model="claude-sonnet-5",
messages=[{"role": "user", "content": "Write a haiku about code"}],
stream=True,
)
for chunk in stream:
if chunk.choices:
print(chunk.choices[0].delta.content or "", end="", flush=True)
elif chunk.usage:
print(f"\n[tokens: {chunk.usage.prompt_tokens} in, {chunk.usage.completion_tokens} out]")
The if chunk.choices check is required. Zukko enables stream_options.include_usage, so the stream ends with a separate chunk that has usage and an empty choices — without the check chunk.choices[0] raises IndexError.
Request cost
The X-Zukko-Cost-Micros header (in microdollars) is available via with_raw_response:
python
model="glm-5.3-flash",
messages=[{"role": "user", "content": "Hello!"}],
)
print(raw.headers.get("x-zukko-cost-micros"))
r = raw.parse()
The header is present only on regular (non-streaming) responses.
Anthropic SDK
bash
python
client = Anthropic(base_url="https://api.zukko.pro", api_key="fetch_...")
msg = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(msg.content[0].text)
Streaming
python
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Write a haiku about code"}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
Key from environment variables
Keep the key out of your code:
bash
export OPENAI_API_KEY="fetch_..."
python
client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY
Any catalog model works in model — see GET /v1/models. Error codes are in API errors (/docs/api-reference/errors).
----------
Node.js
The official openai and @anthropic-ai/sdk packages work with Zukko without code changes — only the base URL and the key change. The examples below are ES modules (Node.js 18+, a .mjs file or "type": "module" in package.json).
SDK | Base URL | Key
--- | --- | ---
OpenAI (openai) | https://api.zukko.pro/v1 | fetch_...
Anthropic (@anthropic-ai/sdk) | https://api.zukko.pro | fetch_...
Keep the key in an environment variable:
bash
OpenAI SDK
bash
js
const client = new OpenAI({
baseURL: "https://api.zukko.pro/v1",
apiKey: process.env.ZUKKO_API_KEY,
});
const r = await client.chat.completions.create({
model: "gpt-5.6-sol",
messages: [{ role: "user", content: "Hello!" }],
});
console.log(r.choices[0].message.content);
Streaming
js
model: "claude-sonnet-5",
messages: [{ role: "user", content: "Write a haiku about code" }],
stream: true,
});
for await (const chunk of stream) {
if (chunk.choices.length > 0) {
process.stdout.write(chunk.choices[0].delta?.content ?? "");
} else if (chunk.usage) {
console.log("\ntokens:", chunk.usage.prompt_tokens, "in,", chunk.usage.completion_tokens, "out");
}
}
The chunk.choices.length check is required: Zukko enables stream_options.include_usage, and the last chunk of the stream comes with usage and an empty choices.
Request cost
js
.create({ model: "glm-5.3-flash", messages: [{ role: "user", content: "Hello!" }] })
.withResponse();
console.log(response.headers.get("x-zukko-cost-micros")); // microdollars
console.log(data.choices[0].message.content);
The header is present only on regular (non-streaming) responses.
Anthropic SDK
bash
js
const client = new Anthropic({
baseURL: "https://api.zukko.pro",
apiKey: process.env.ZUKKO_API_KEY,
});
const msg = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello!" }],
});
console.log(msg.content[0].text);
Base URL without /v1: the SDK appends /v1/messages itself.
Streaming
js
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Write a haiku about code" }],
});
stream.on("text", (text) => process.stdout.write(text));
const final = await stream.finalMessage();
console.log("\noutput tokens:", final.usage.output_tokens);
If something is off
Error codes and what to do about them are in API errors (/docs/api-reference/errors). Stream formats are in Streaming (/docs/api-reference/streaming).
----------
POST /v1/chat/completions
OpenAI-compatible endpoint for chat and text generation. Works with every text model: Claude, GPT, Gemini, Qwen, DeepSeek, and GLM.
Request example
bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"Hello"}]}'
Parameters
Parameter | Type | Required | Description
--- | --- | --- | ---
model | string | yes | model id from GET /v1/models
messages | array | yes | array of messages
max_tokens | integer | no | max tokens in the response
temperature | float | no | sampling temperature (0–2); 0 turns on the Zukko cache (/docs/api-reference/key-limits-and-cache). Newer Claude models may reject non-default values — omit it for them
stream | boolean | no | streamed response (SSE)
top_p | float | no | nucleus sampling
reasoning_effort | string | no | for GLM: high enables reasoning; for other models it is passed to the provider as is
Other OpenAI Chat Completions fields are passed to the model provider unchanged.
Notes
- For GLM models Zukko raises max_tokens to at least 256, or to at least 4096 with reasoning on: with a smaller budget the answer comes back empty.
- With stream: true on Claude and GPT the stream ends with a chunk that has usage and an empty choices — see Streaming (/docs/api-reference/streaming).
- A regular (non-streaming) response carries the X-Zukko-Cost-Micros and X-Fetch-Balance-Cents headers.
- Caching: for GPT, tokens read from the cache (prompt_tokens_details.cached_tokens) are billed at the cache read price. For Claude this format carries no cache breakdown — all input is billed at the input price; the Claude cache discount comes through POST /v1/messages (/docs/api-reference/messages). Prices: Models & pricing (/docs/introduction/models-pricing).
----------
POST /v1/images/generations
OpenAI-compatible endpoint for generating and editing images (Nano Banana, GPT Image).
Image models are temporarily unavailable: requests to them get 503 model_disabled. The format below will work again once the models are switched back on — watch GET /v1/models.
Request example
bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "openai-gpt-image-2-text-to-image",
"prompt": "A cinematic travel poster for Kyoto in autumn",
"size": "1024x1024",
"quality": "high"
}'
Parameters
- model — image model id, required;
- prompt — description of the image or the edit, required;
- images — array of URLs or data URLs of input images: required for edit models; reference models need images or video_clips;
- response_format — "b64_json" to get the image as base64 instead of a link;
- size, quality, output_format, moderation — for GPT Image;
- aspect_ratio, resolution — for Nano Banana;
- n, seed — if the model supports them.
Response
json
"created": 1735689600,
"model": "openai-gpt-image-2-text-to-image",
"data": [{ "url": "https://cdn.example.com/outputs/result.png" }]
}
With response_format: "b64_json" you get b64_json instead of url.
Cost
Billed per generated image at the model's price. Before generation the balance must cover the price of one image, otherwise 402 insufficient_balance.
Errors
- 400 invalid_request_error — no prompt, or no images for an edit model;
- 402 insufficient_balance — not enough for an image;
- 404 model_not_found — unknown image model;
- 503 model_disabled — the model is temporarily switched off;
- 503 image_provider_unavailable / image_provider_busy — the image service is down or overloaded;
- 502 image_generation_failed, 504 image_generation_timeout — generation failed or took longer than 3 minutes.
----------
POST /v1/messages
Anthropic-compatible endpoint — the native format for Claude models. Claude Code, the Anthropic SDK, Roo Code, and other Anthropic clients use it.
Request example
bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"claude-sonnet-5","max_tokens":256,"messages":[{"role":"user","content":"Hi"}]}'
The key can also go in the x-api-key header. Zukko does not require anthropic-version, but passes it to the provider if present.
What to send
- model — model id: claude-sonnet-5, claude-opus-5-5, or any other from the catalog (/docs/introduction/models-pricing);
- messages — array of messages;
- max_tokens — generation limit, required;
- system, stream, temperature, top_p, top_k, tools, tool_choice, stop_sequences, metadata — optional, as in the Anthropic Messages API.
Prompt caching
For Claude this is the only format in which the cache breakdown is visible right in the response: usage.cache_read_input_tokens is billed at the cache read price, usage.cache_creation_input_tokens at the cache write price, and the rest of the input at the input price. In any format, the final charge is recalculated from the provider's actual count about a minute later. Prices: Models & pricing (/docs/introduction/models-pricing).
GLM models
glm-5.3 and glm-5.3-flash can also be called through /v1/messages: Zukko converts the request to the OpenAI format and the response and stream back to the Anthropic format. A thinking field with "type": "enabled" turns on GLM reasoning.
Stream event format: see Streaming (/docs/api-reference/streaming).
----------
POST /v1/messages/count_tokens
Counts the input tokens of an Anthropic Messages request without running generation. Works with Claude models only.
Request example
bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"claude-sonnet-5","system":"You are an assistant","messages":[{"role":"user","content":"Hello!"}]}'
The body is the same as for POST /v1/messages; max_tokens is not needed.
Response
json
"input_tokens": 12
}
Cost and requirements
- The request is billed: the counted tokens are charged at the model's input price. 12 tokens on claude-sonnet-5 ($0.2 per 1M) cost 2.4 microdollars, rounded to a whole microdollar — $0.000002.
- As with generation, you need a verified email and a balance of at least $0.01; key limits apply.
- The Zukko cache and Idempotency-Key do not apply to this endpoint.
- Works for Claude, GPT, Gemini, Qwen, and DeepSeek. Counting is not supported for GLM.
----------
GET /v1/models
List of available models in the OpenAI format. The endpoint is public: no key needed, and a key, if sent, is ignored. The request is free.
Request example
bash
Response
json
"object": "list",
"data": [
{ "id": "claude-opus-5-5", "object": "model", "created": 1700000000, "owned_by": "zukko" },
{ "id": "claude-sonnet-5", "object": "model", "created": 1700000000, "owned_by": "zukko" },
{ "id": "gpt-6.1-sol", "object": "model", "created": 1700000000, "owned_by": "zukko" },
{ "id": "gemini-3.8-flash", "object": "model", "created": 1700000000, "owned_by": "zukko" },
{ "id": "deepseek-v4.1-flash", "object": "model", "created": 1700000000, "owned_by": "zukko" },
{ "id": "glm-5.3-flash", "object": "model", "created": 1700000000, "owned_by": "zukko" }
]
}
What is listed
- Enabled models only. Disabled ones are not listed and answer 503 model_disabled.
- Models that are failing the health check right now are temporarily hidden from the list. They can still be called by explicit id — the request goes to the provider.
- The created field is nominal and the same for every model.
- The example above is shortened: the real response lists every available text model.
Model prices and limits: GET /v1/models/info (/docs/api-reference/models-info).
GET /v1/models/{id}
A single model by id. Also public.
bash
json
- Second names and other spellings resolve: GET /v1/models/cursor-sonnet-5 and GET /v1/models/anthropic%2Fclaude-sonnet-5 return "id": "claude-sonnet-5".
- Returns 200 for any catalog model, including disabled ones. Whether a model is available right now — check the GET /v1/models list.
- Unknown id — 404 model_not_found; a retired model — 404 model_retired naming its replacement.
----------
GET /v1/models/info
Extended catalog: names, context, capabilities, and prices. The endpoint is public: no key needed, and a key, if sent, is ignored. Prices are the same for every account.
Request example
bash
Response
json
"data": [
{
"id": "claude-sonnet-5",
"display_name": "Claude Sonnet 5",
"max_input_tokens": 1000000,
"max_output_tokens": null,
"supports_thinking": true,
"supports_streaming": true,
"supports_vision": true,
"input_price_per_1m": 0.4,
"output_price_per_1m": 2,
"cache_write_price_per_1m": 0.5,
"cache_read_price_per_1m": 0.05
}
]
}
Response fields
Field | Type | Description
--- | --- | ---
id | string | model id for the model field
display_name | string | human-readable name
max_input_tokens | int | context window
max_output_tokens | int or null | max output tokens; null means Zukko sets no limit of its own
supports_thinking | bool | reasoning support
supports_streaming | bool | SSE streaming support
supports_vision | bool | image input support
input_price_per_1m | float | USD per 1M input tokens
output_price_per_1m | float | USD per 1M output tokens
cache_write_price_per_1m | float | USD per 1M cache-write tokens
cache_read_price_per_1m | float | USD per 1M cache-read tokens
Caching is billed separately from regular input — see Models & pricing (/docs/introduction/models-pricing) for how. For models whose cache prices both equal the input price there is no separate cache discount.
The list contains the same models as GET /v1/models (/docs/api-reference/models): disabled models and models currently failing the health check are not shown.
----------
GET /v1/balance
Current account balance and total token usage. Requires a key; the request is free.
Request example
bash
-H "Authorization: Bearer fetch_YOUR_KEY"
Response
json
"balance_cents": 1234,
"total_input_tokens": 5000000,
"total_output_tokens": 1200000,
"total_tokens_used": 6200000
}
Field | Type | Description
--- | --- | ---
balance_cents | int | balance in US cents, rounded down: 1234 = $12.34. Can be negative
total_input_tokens | int | input tokens, all time
total_output_tokens | int | output tokens, all time
total_tokens_used | int | input plus output
How the balance works
- The balance belongs to the account: all keys spend the same money. Token totals are also account-wide and include usage in the web chat and the Telegram bot, not just the API.
- There is no reservation: money is charged after the model answers. A request is allowed if the balance is at least $0.01, otherwise 402 insufficient_balance. An expensive request started with a small balance can take it below zero — then further requests get 402 until you top up.
- Right after a request the remaining balance is also in the X-Fetch-Balance-Cents header — in microdollars (1,000,000 = $1); see Billing (/docs/introduction/billing).
----------
GET /v1/usage
Account charge history, newest first, with cursor pagination. Requires a key; the request is free.
Request example
bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
--data-urlencode "limit=50"
Parameters
Parameter | Type | Description
--- | --- | ---
limit | int | records per page, 1 to 100, default 20
cursor | string | the next_cursor value from the previous response
Response
json
"data": [
{
"created_at": "2026-10-01T12:00:00+00:00",
"model": "Claude Sonnet 5",
"input_tokens": 1500,
"output_tokens": 320,
"cache_read_tokens": 0,
"cache_write_tokens": 0,
"total_tokens": 1820,
"cost_cents": 0.0546
}
],
"next_cursor": "2026-10-01T11:59:00+00:00|48213"
}
Field | Type | Description
--- | --- | ---
created_at | string | charge time, ISO 8601 in UTC
model | string | model display name (e.g. Claude Sonnet 5) or a service name such as web-search
input_tokens | int | input tokens, cache included
output_tokens | int | output tokens (for images — the number of images)
cache_read_tokens | int | part of the input read from the cache
cache_write_tokens | int | part of the input written to the cache
total_tokens | int | input plus output
cost_cents | float | cost in cents with a fractional part: 0.0546 = $0.000546
Pagination
The cursor looks like