Generated from https://zukko.pro/docs — upload to an AI or Save as PDF via Print.
# 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
```bash
export ZUKKO_API_KEY="fetch_..."
```
```powershell
$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.
```python
# 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)
```
```javascript
// 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);
```
```bash
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:
```bash
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:
```text
+ 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.
```text
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
```python
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
```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": "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
```python
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:
```bash
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "python-telegram-bot>=21,<22" openai
```
Node.js:
```bash
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:
```bash
export ZUKKO_API_KEY="fetch_..."
python bot.py # or: node bot.mjs
```
Windows (PowerShell):
```bash
$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.
```python
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()
```
```javascript
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.
```ini
# /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
```
```bash
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:
```bash
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
```bash
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
```markdown
# 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.
```python
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>
{KNOWLEDGE_BASE}
</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 <sources>. 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"<sources>\n{sources}\n</sources>\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 OPERATORS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and type(node.op) in OPERATORS:
return OPERATORS[type(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.<provider>.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/<id>` (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 `<time>|<numeric record id>`. Pass `next_cursor` into `cursor` as is, but URL-encode it: it contains `+` and `|`, and an unencoded `+` turns into a space — then you get the first page again. `curl -G --data-urlencode` is the easiest way:
```bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
--data-urlencode "limit=50" \
--data-urlencode "cursor=2026-10-01T11:59:00+00:00|48213"
```
`next_cursor: null` means there are no more records.
## What is included
- Every charge on the account, not just this key: other keys, the web chat, the Telegram bot, web search.
- Responses from the Zukko cache and `Idempotency-Key` replays are free and do not appear in the history.
---
# Key limits, idempotency and caching
Three things agents and pipelines need more than a discount: spend control, protection from paying twice, and free replays.
## Per-key limits
Every key in the dashboard can have a daily and monthly spend cap and a list of allowed models. An experiment key — no more than $2 a day; a support-bot key — only `glm-5.3-flash`.
The check runs before the request reaches the model, so a rejected request costs nothing.
| Situation | Response |
| --- | --- |
| Model not in the key's list | `403`, code `model_not_allowed_for_key` |
| Daily cap reached | `429`, code `key_daily_limit_reached` |
| Monthly cap reached | `429`, code `key_monthly_limit_reached` |
How limits are counted:
- Limits are soft. Before a request, the amount already charged is compared with the cap; the cost of the request itself is not known in advance. So the last request let through can overshoot the cap, and several parallel requests started before the cap was reached will all pass.
- Daily spend resets at 00:00 UTC, monthly spend at the start of the month (UTC).
- An empty limit or `0` means no limit.
- Requests to `/v1/web-search` count toward the key's limits; the allowed-models list does not affect web search.
- Other keys on the account keep working: the limit is per key, not per balance.
## Idempotency-Key
A pipeline with retries can send the same request twice — and pay twice. Send an `Idempotency-Key` header with any unique value (up to 200 characters): a repeat with the same key returns the stored response and is **not charged again**.
```bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-2026-10-04-row-4812" \
-d '{"model":"glm-5.3-flash","messages":[{"role":"user","content":"Classify: ..."}]}'
```
- Works only for `POST /v1/chat/completions`, `/v1/messages`, and `/v1/responses`.
- The same key with a different body — `422`, code `idempotency_conflict`: one key, one request.
- Only a successful (2xx) response up to 2 MB is stored, for 24 hours. After an error, a retry with the same key goes to the model again.
- There is no in-flight lock: if two identical requests with the same key arrive at the same time, both go to the model and both are charged. Send the retry after the first request has finished or failed.
- Idempotency keys are shared across the whole account, not scoped to one API key.
## Content cache
An identical request — same endpoint, same model, same body — is served for free the second time when the cache is on. A pipeline restarted halfway does not pay for work already done.
- The reliable way to turn the cache on is the `X-Zukko-Cache: true` header (also `yes`, `on`). It works with any temperature or none.
- Without the header the cache turns on by itself only if the body has `temperature: 0`.
- Turn it off regardless: `X-Zukko-Cache: false` (also `no`, `off`, `no-cache`).
For Claude models use the header: newer Claude models may reject non-default `temperature` and `top_p` values, so do not send `temperature: 0` just to get caching.
Details:
- The cache covers the same three endpoints: `POST /v1/chat/completions`, `/v1/messages`, `/v1/responses`.
- Requests are compared by body; the `user`, `metadata`, and `stream_options` fields are ignored, and so is JSON key order. The `stream` field counts: streaming and non-streaming responses are stored separately.
- Only successful responses up to 2 MB are stored, for 7 days. Each account has its own cache.
- A cached response still passes the balance and key-limit checks, but costs nothing and does not appear in the charge history.
A hit is visible in the response headers `X-Zukko-Cache: hit` (or `idempotent-replay` for an `Idempotency-Key` replay) and `X-Zukko-Cost-Micros: 0`.
## Cost in the response
A successful regular (non-streaming) JSON response carries two headers:
- `X-Zukko-Cost-Micros` — the cost of this request in microdollars (1,000,000 = $1);
- `X-Fetch-Balance-Cents` — the balance after the charge. Despite the name, it is also in **microdollars**: `12340000` = $12.34.
Streamed responses have neither header — check the cost in [GET /v1/usage](/docs/api-reference/usage). A cached or `Idempotency-Key` replay comes with `X-Zukko-Cost-Micros: 0`. An agent can watch its own spend and stop before zero rather than after.
## Auto-replace a stuck model
For API key requests. The request goes to a similar model if the selected one:
- is overloaded or returns a 5xx error;
- doesn't start streaming an answer within 40 seconds.
Without streaming there's no time limit: the answer arrives all at once at the end, so long work can't be told apart from a hang. Nothing is sent to the client before the switch.
- The replacement costs no more than the selected model (input, output and cache read) and has the same capabilities: images, tools, JSON schema. Through `/v1/messages` it's always Claude.
- While the model keeps failing, requests go to the replacement right away, with no wait. As soon as the model answers again, requests go back to it.
- Only the model that answered is charged, and it shows in your [usage history](/app/usage).
Off by default. Turn it on in the [dashboard](/app/overview) for all keys or for a single [key](/app/api-keys). For one request, send `X-Zukko-Fallback: on` or `off`.
---
# POST /v1/responses
Endpoint in the OpenAI Responses API format. Codex CLI and the OpenAI Agents SDK use it.
## Request example
```bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6-sol","input":"Write a short hello world example"}'
```
## Supported models
The Responses API works with `claude-*` and `gpt-*` models. GLM models do not understand the `input` and `instructions` fields — use `/v1/chat/completions` or `/v1/messages` for them.
## Main parameters
- `model` — model id, required;
- `input` — a string or an array of messages and tool calls;
- `instructions` — system prompt;
- `stream` — streamed response (SSE);
- `max_output_tokens`, `temperature`, `top_p`, `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning`.
## Authentication
`Authorization: Bearer fetch_...` or `x-api-key: fetch_...`. A key in the query string is not accepted.
## Codex
```bash
export OPENAI_API_KEY=fetch_...
codex -m gpt-5.6-sol
```
For a permanent setup, configure the provider in `~/.codex/config.toml` — see the [Codex](/docs/ide-integrations/codex) guide.
Claude Code does not use the Responses API; it works through `/v1/messages` — see the [Claude Code](/docs/ide-integrations/claude-code) guide.
## Errors
Shape: `{"error": {"message", "type", "code", "param"}}`. `401` — invalid key, `402` — balance below $0.01, `403` — email not verified or the model is not allowed for the key, `404 model_not_found` — the model is not in the catalog, a `429` from Zukko is only a key limit (`key_daily_limit_reached`, `key_monthly_limit_reached`); any other `429` comes from the provider. Full list: [API errors](/docs/api-reference/errors).
---
# POST /v1/web-search
Web search through the API: returns a list of found pages. Handy for feeding fresh data into a model prompt.
## Request example
```bash
-H "Authorization: Bearer fetch_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"latest AI news","max_results":5}'
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| `query` | string | search query, required |
| `max_results` | int | how many results to return: 1 to 10, default 5. Out-of-range values are clamped |
## Response
```json
"results": [
{
"title": "...",
"url": "https://...",
"snippet": "..."
}
]
}
```
Individual result fields may be missing.
## Cost and requirements
- Search is billed as a small token charge: the query and the result text are estimated at $0.30 per 1M tokens. In the charge history the line is called `web-search`.
- Requires a verified email and a balance of at least $0.01. The key's daily and monthly limits apply; the allowed-models list does not.
- The Zukko cache and `Idempotency-Key` do not apply to search.
## Errors
- `400 invalid_json` — the body is not JSON;
- `400 invalid_request` — no `query`;
- `401` — invalid key;
- `402 insufficient_balance` — balance below $0.01;
- `403 email_unverified` — email not verified;
- `429 key_daily_limit_reached` / `key_monthly_limit_reached` — the key limit is used up;
- `503 provider_unavailable` — search is temporarily unavailable;
- `502 web_search_failed` or `upstream_error` — search failed, retry later.
---
# GET /api/status
Public status of the service and its models. No key needed, the request is free. Zukko has no separate `GET /health` — use this endpoint.
## Request example
```bash
curl "https://api.zukko.pro/api/status?hours=24"
```
`hours` is the statistics window in hours, 1 to 24, default 6.
## Response
```json
"overall": "ok",
"maintenance": null,
"window_hours": 6,
"checked_at": "2026-10-04T12:00:00.000Z",
"models": [
{
"id": "claude-sonnet-5",
"name": "Claude Sonnet 5",
"enabled": true,
"state": "ok",
"latency_ms": 1840
}
]
}
```
| Field | Description |
| --- | --- |
| `overall` | `ok`, `degraded` (some models failing), `down` (no enabled model works), or `maintenance` |
| `maintenance` | maintenance message or `null` |
| `window_hours` | the window the statistics cover |
| `checked_at` | response time, ISO 8601 |
| `models[].id`, `models[].name` | text model id and name |
| `models[].enabled` | whether the model is enabled |
| `models[].state` | `ok`, `degraded`, `down`, `unknown` (not enough data), or `disabled` |
| `models[].latency_ms` | average response time over the window; may be `null` |
During maintenance, model requests get `503` with code `maintenance` and a `Retry-After: 600` header, and `overall` is `maintenance`.
## GET /api/gateway-status
A summary for the whole gateway — what the status page shows:
```bash
```
`timeframe` is `1h` (default), `24h`, or `7d`. The response has `healthy`, `avg_success_rate` (share of successful requests, %), `avg_latency_ms`, `requests_total`, `timeseries` (request counts per interval), plus `status_override` and `admin_message_ru` / `admin_message_en` during maintenance.
## For monitoring
- Availability: `GET /api/status`, expect `200` and `overall` other than `down` or `maintenance`.
- A specific model: the `state` field in `models`.
- Your key: `GET /v1/balance` with the key — free and does not call a model.
---
# Help
## Something went wrong: what to do
Find what you see in the table — the fix is next to it.
## One-minute check
### Is the service up?
Open [api.zukko.pro/api/status](https://api.zukko.pro/api/status): `ok` — yes, `degraded` — some models have issues, `maintenance` — maintenance.
### Does the key work?
Free check:
```bash
```
You get the balance — the key is fine; `401` — it isn't.
### Does the model exist?
Match the name against [api.zukko.pro/v1/models](https://api.zukko.pro/v1/models) letter for letter.
## Errors
| What you see | What to do |
| --- | --- |
| `401`, "Invalid API key" | Typo in the key or it was revoked. Copy it again or [create a new one](/app/api-keys) |
| `402`, `insufficient_balance` | [Top up](/app/payments) — no restart needed |
| `403`, `email_unverified` | Verify your email using the link in the email |
| `403`, `model_not_allowed_for_key` | Allow the model in the [key](/app/api-keys) settings |
| `429`, `key_daily_limit_reached` / `key_monthly_limit_reached` | Your own key limit kicked in. Raise it or wait for the reset: 00:00 UTC or the 1st |
| `429` without a Zukko code, "rate limit" | The model is overloaded. Retry in 10–30 seconds or pick another |
| `404`, `model_not_found` | Typo in the name. Check the [model list](/app/models) |
| `400`, `model_retired` | The model was retired — the error says which one to use |
| `503`, `model_disabled` | The model is off — pick another |
| `502`, `503`, other 5xx | The model isn't responding. Retry in 10–30 seconds or use a model listed in the error text |
| `503`, `maintenance` | Maintenance, retry in about 10 minutes |
| No answer for a long time, timeout | Strong models think for 30–60 seconds. Set the timeout to 120–300 seconds and turn on streaming |
| `413`, `payload_too_large` | The request is over 32 MB — shrink images or files |
| The answer is cut off | Raise `max_tokens` |
Errors aren't charged. All codes: [API errors](/docs/api-reference/errors).
## Claude Code, Cursor and other tools
| Mode | Address |
| --- | --- |
| Anthropic (Claude Code, Roo Code) | `https://api.zukko.pro` — **without** `/v1` |
| OpenAI (Cursor, Cline, Codex) | `https://api.zukko.pro/v1` — **with** `/v1` |
Get it wrong and every request returns `404`.
| What you see | What to do |
| --- | --- |
| Claude Code asks you to log in to Anthropic | The key goes in `ANTHROPIC_AUTH_TOKEN`, not `ANTHROPIC_API_KEY`. Check the commas in `settings.json`. Run `/logout` |
| Cursor bypasses Zukko | Turn on **Override OpenAI Base URL** with `https://api.zukko.pro/v1`. Requires Cursor Pro |
| Cursor won't add the model | Use the Cursor names: `cursor-sonnet-5`, `cursor-opus-55` |
## The site won't open
Try another network (mobile data instead of Wi-Fi) or use Zukko in Telegram: [@zukkopro_bot](https://t.me/zukkopro_bot) has top-ups and chat with models.
## Paid, but the balance didn't change
1. Wait 2–5 minutes and refresh the [payments page](/app/payments).
2. Save the order number from the result page ("Order: …") and the receipt.
3. Still nothing after 10–15 minutes? Contact support with the order number, amount, method and time. Per the [terms](/terms), contact us within 24 hours.
## Support
[Telegram](https://t.me/zukkopro?direct) is fastest, or email zukkopro@rambler.ru. Include:
- the time of the error with your time zone;
- where: Claude Code, Cursor, your code, web chat, bot;
- the model and the full error text (a screenshot is fine);
- the response id (`chatcmpl-…` or `msg_…`), if any;
- the last 4 characters of the key.
**Careful: Never send the whole key**
The last 4 characters are enough. If the key leaked, delete it on the [keys page](/app/api-keys) and create a new one.
---
## FAQ
Short answers to the questions we get most often. Every question has its own link, so it is easy to share.
## What is Zukko in plain words?
One key to many AI models: Claude, GPT, Gemini, DeepSeek, Qwen, Kimi and others. You top up one balance and pay only for what you use — no subscriptions, no monthly fee. You can use it from your own code, from editors such as Claude Code and Cursor, in the [browser chat](/app/chat), or through the Telegram bot [@zukkopro_bot](https://t.me/zukkopro_bot). Where to start: [First request in 3 minutes](/docs/introduction/quick-start).
## Is it legal and safe?
Yes, it is legal. Zukko works under a public offer — the [Terms of Service](/terms). Zukko is an intermediary: it takes your request, passes it to the model you chose and returns the answer. Zukko does not build or train models itself and is not affiliated with Anthropic, OpenAI or Google.
As for safety: connections are HTTPS only, and Zukko does not record the content of your requests (see the next question). Your part is to keep the key secret and never publish it in code, chats or screenshots.
## Do you store my data and prompts?
No. Zukko does not store or log the content of your requests to models or their answers — this is also stated in the [Privacy Policy](/privacy). Only service data about each request is kept — which model, how many tokens, what it cost — and that is what your spending history is built from. Account data is stored too: email, Telegram ID if you sign in with Telegram, payment history.
The request text does, of course, go to the model you chose — otherwise it could not answer.
There are two exceptions, both of which you turn on yourself: the response cache (the `X-Zukko-Cache` header, the answer is kept for 7 days) and `Idempotency-Key` (the answer is kept for 24 hours). They save the answer so that a repeat is free. More in [Key limits and caching](/docs/api-reference/key-limits-and-cache).
## How do I pay?
Top up on the [Balance & payments](/app/payments) page in the dashboard:
- SBP — in rubles, at the market USD rate; the payment service adds about 8.5%, and the total is shown before you pay;
- crypto;
- foreign cards — the payment service adds about 13% on top.
Russian bank cards are temporarily not accepted — please use SBP.
The minimum top-up is $1. The balance is kept in dollars and each request is charged by actual usage. More in [Billing](/docs/introduction/billing).
## Can I get a refund?
Under the [terms](/terms), money for requests already served is not refunded. A refund is possible if:
- the payment went through but the balance was not credited;
- the service was not provided because of a technical fault on Zukko's side.
Contact support within 24 hours of the payment — each case is decided individually.
Note that failed requests are not charged anyway: if the model returned an error or the request was rejected before reaching the model, you pay nothing.
## Why is Zukko cheaper than the official websites?
Zukko buys capacity in bulk and shares it among users, so the price per token is lower than buying directly at retail. Easy to compare: every model's price is in [Models & pricing](/docs/introduction/models-pricing).
## Which model should I pick?
In short:
- simple bulk tasks (sorting, data extraction, short answers) — `deepseek-v4.1-flash`, `qwen3.8-flash`, `claude-haiku-5-5`;
- an everyday all-rounder — `claude-sonnet-5`;
- the hardest work (large code bases, long reasoning) — `claude-opus-5-5`.
In detail, with examples: [How to choose a model](/docs/introduction/choose-model).
## Does Zukko work in Russia?
Yes, and no VPN is needed. You pay in rubles via SBP, and support speaks Russian. Zukko is also available through the Telegram bot [@zukkopro_bot](https://t.me/zukkopro_bot).
## Can I use it without programming?
Yes. There is a [browser chat](/app/chat) and the Telegram bot [@zukkopro_bot](https://t.me/zukkopro_bot) with a mini app — just type your questions. They all spend the same balance.
## Can I use it in Claude Code or Cursor?
Yes, that is one of the most common uses. The key thing is the right address:
- Claude Code: `https://api.zukko.pro` (without `/v1`), the key in `ANTHROPIC_AUTH_TOKEN` — [guide](/docs/ide-integrations/claude-code);
- Cursor: `https://api.zukko.pro/v1` in the Override OpenAI Base URL field, paid Cursor Pro required — [guide](/docs/ide-integrations/cursor-ide).
Other editors and agents: [Choosing a provider](/docs/ide-integrations/choose-provider).
## Are there rate limits?
Zukko has no request-rate limit of its own. Limits come in two kinds:
- limits you set on a key yourself (daily or monthly spend, allowed models) — [details](/docs/api-reference/key-limits-and-cache);
- temporary overload of the model itself — you get a `429` and should retry in 10–30 seconds.
A request also needs a verified email and a balance of at least $0.01, and a single request can be up to 32 MB.
## How do I give a key to a contractor or a bot safely?
Create a separate key for them on the [API keys](/app/api-keys) page and limit it right away:
- set a daily and a monthly spend cap — the key will not spend more than that (the cap is soft: the last request may go slightly over);
- allow only the models they need — for example, block the expensive Opus.
When the work is done or the key leaks, delete it: your other keys keep working. The balance is shared across the account — all keys spend the same money.
## How do I see what I spent?
- In the dashboard, on the [Usage](/app/usage) page — every charge with the model and token counts.
- Via the API — [GET /v1/usage](/docs/api-reference/usage) and [GET /v1/balance](/docs/api-reference/balance).
- Every regular API response has an `X-Zukko-Cost-Micros` header — the cost of that request in millionths of a dollar.
The history covers the whole account: every key, the web chat and the Telegram bot. To spend less, see [How to pay less](/docs/guides/save-money).
## Does unused balance expire?
There is no monthly fee: money is charged only for requests. The [terms](/terms) do not set an expiry date for the balance. If the account is closed, the remaining balance is not refunded.
## Is there a free trial?
There is no trial period and no sign-up bonus. You can get $0.50 once for subscribing to the [@zukkopro](https://t.me/zukkopro) Telegram channel — claim it in the dashboard. The minimum top-up is just $1, and on cheap models that covers thousands of short requests.
## What happens if the money runs out mid-request?
A request that has already started runs to the end, even if the balance goes slightly negative. Further requests get a `402` error until you top up — the top-up covers the negative part first.
## What can't Zukko do?
Zukko works with text models: chat, JSON answers that follow a schema, tool calling, image understanding on models that accept images, and web search. There are currently no embeddings, no speech recognition or synthesis, no file uploads, no batch API and no fine-tuning.
## How do I contact support?
- Telegram: [t.me/zukkopro](https://t.me/zukkopro?direct) — the fastest way.
- Email: zukkopro@rambler.ru.
Include the time, the model and the error text, and only the last 4 characters of your key. What else to attach: [Something went wrong](/docs/help/troubleshooting#how-to-contact-support).
## What's next
- [First request in 3 minutes](/docs/introduction/quick-start) — from key to model answer
- [How to choose a model](/docs/introduction/choose-model) — which model for which job
- [How to pay less](/docs/guides/save-money) — ten ways to cut costs
- [Something went wrong](/docs/help/troubleshooting) — when things break
---
_End of Zukko API knowledge base. Source: https://zukko.pro/docs_