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