# Zukko API — полная база знаний

> Этот файл — полная документация Zukko API. Загрузите его в ИИ (ChatGPT, Claude, Cursor и т.д.), чтобы ассистент знал endpoints, auth, модели и примеры.

Сайт: 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 (или x-api-key: fetch_YOUR_KEY)
Модели и цены: GET https://api.zukko.pro/v1/models/info (публично, без ключа)

---

# Начало

# Что такое Zukko

Zukko — это один вход ко многим нейросетям. Вы заводите один аккаунт, пополняете один баланс и получаете один ключ. С этим ключом можно обращаться к Claude, GPT, Gemini, DeepSeek, Qwen, Kimi и другим моделям. Платите только за то, чем воспользовались, без подписок и абонентской платы.

## Простая аналогия

Представьте поездку по разным странам. В каждой своя розетка, и приходится возить с собой пачку переходников. Zukko — это один универсальный переходник: вилка у вас одна, а подходит она к любой «розетке»-нейросети.

Без Zukko, чтобы работать с Claude, GPT и DeepSeek, нужны три аккаунта в трёх компаниях, три ключа, три способа оплаты (часто только иностранной картой) и три разных счёта. С Zukko всё проще:

- **один ключ** вида `fetch_…` подходит ко всем моделям;
- **один баланс** в долларах, а пополнять его можно в рублях через СБП или криптовалютой;
- **оплата по факту**: за каждый запрос списываются доли цента, абонентской платы нет;
- **сменить модель — значит поменять одно слово** в запросе, остальной код остаётся прежним.

## Как проходит запрос

Схема: Вы или ваша программа (вопрос и ключ fetch_) → Zukko (проверяет ключ и баланс) → [Claude / GPT / DeepSeek / Qwen / Gemini] → Zukko (считает токены и списывает деньги) → Ответ (возвращается вам)

Модель читает ваш текст и пишет ответ. Объём текста измеряется в [токенах](/docs/introduction/for-beginners#токен) — это кусочки слов. За токены Zukko и берёт деньги: за прочитанные моделью (вход) и за написанные ею (выход).

## Что можно сделать

- [Чат в браузере](/app/chat) — спросить любую модель без кода, как в обычном мессенджере
- [Claude Code](/docs/ide-integrations/claude-code) — ИИ-программист в терминале, оплата с баланса Zukko
- [Cursor и другие редакторы](/docs/ide-integrations/cursor-ide) — подключить модели к редактору кода
- [Telegram-бот](/docs/guides/telegram-bot) — бот, который отвечает в чате с помощью нейросети
- [Бот поддержки](/docs/guides/support-bot) — отвечает клиентам по вашей базе знаний
- [Агент с инструментами](/docs/guides/tool-agent) — модель сама вызывает ваши функции: ищет, считает, записывает
- [Пайплайны](/docs/guides/pipelines) — обработать тысячи строк: отзывы, заявки, карточки товаров
- [Ответ в JSON](/docs/guides/structured-output) — данные в виде, удобном для программы

## Для кого это

### Если вы не программист

Откройте [чат в кабинете](/app/chat): там можно выбрать любую модель и переписываться с ней, как в мессенджере. Удобно сравнить, как на один и тот же вопрос отвечают Claude, GPT и DeepSeek. В Telegram то же самое доступно через бота @zukkopro_bot. Расход идёт с того же баланса.

Многие готовые программы (редакторы текста, конструкторы ботов, расширения браузера) просят «API-ключ» и «адрес API». Туда можно вписать ключ Zukko и [адрес Zukko](/docs/introduction/for-beginners#base-url) — и программа заработает через Zukko. Если не получается, напишите в [поддержку](https://t.me/zukkopro?direct).

### Если вы разработчик

Zukko понимает два самых распространённых формата запросов, поэтому существующий код и SDK работают без переписывания — меняются только адрес и ключ:

| Формат | Base URL | Чем пользоваться |
| --- | --- | --- |
| OpenAI-совместимый | `https://api.zukko.pro/v1` | OpenAI SDK, Cursor, большинство программ |
| Anthropic-совместимый | `https://api.zukko.pro` | Anthropic SDK, Claude Code (без `/v1`) |

Есть эндпоинты `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/images/generations`, `/v1/models`, `/v1/models/info`, `/v1/balance`, `/v1/usage` и `/v1/web-search`. Эмбеддингов, распознавания и синтеза речи, файлов, batch API и дообучения нет — такие запросы получат `404`. Если вы переезжаете с OpenAI или Anthropic, начните со статьи [Переезд](/docs/introduction/migration).

### Если у вас бизнес или агенты

- Каждому боту, агенту или подрядчику можно выдать свой ключ с лимитом расхода и списком разрешённых моделей — никто не потратит больше, чем вы разрешили. Подробнее — [Лимиты ключа](/docs/api-reference/key-limits-and-cache).
- Модели можно смешивать: дешёвая обрабатывает поток простых задач, сильная берёт сложные, ещё одна проверяет результат.
- Весь расход виден по запросам: какая модель, сколько токенов, сколько стоило.

Схема: Задача → Планировщик (Claude Opus) → [Исполнитель 1 (DeepSeek) / Исполнитель 2 (Qwen)] → Проверяющий (Claude Sonnet) → Результат

## Сколько это стоит

Цены указаны в долларах за 1 млн токенов, отдельно за вход и за выход. В русском тексте одно слово — примерно 2–3 токена, так что ответ на страницу — около 1 000 токенов.

**Сколько стоит: Ответ на страницу текста стоит примерно:**
- `claude-haiku-5-5` — $0.0002, то есть на $1 около 5 000 таких ответов;
- `claude-sonnet-5` — $0.002, около 500 ответов на $1;
- `claude-opus-5-5` — $0.004, около 250 ответов на $1.

Минимальное пополнение — $1, а для запроса на балансе должно быть хотя бы $0.01. Подробные расчёты — в статье [Оплата и сколько это стоит](/docs/introduction/billing), все цены — в [Модели и цены](/docs/introduction/models-pricing).

## С чего начать

- [Первый запрос за 3 минуты](/docs/introduction/quick-start) — ключ, пополнение и первый ответ модели
- [Словарь простыми словами](/docs/introduction/for-beginners) — токен, контекст, промпт и другие слова без тумана
- [Как выбрать модель](/docs/introduction/choose-model) — какая модель подходит под вашу задачу и бюджет
- [Оплата и сколько это стоит](/docs/introduction/billing) — как пополнить и сколько уйдёт на ваш сценарий

---

# Словарь простыми словами

Здесь собраны слова, которые встречаются во всей документации. Каждое объяснено так, чтобы было понятно без опыта в программировании.

## Вся идея на одной картинке

Схема: Вы (пишете промпт) → Программа или чат (отправляет запрос с API-ключом) → Zukko API (base URL) → Модель (читает контекст)
Схема: Модель (пишет ответ токенами) → Zukko API (считает токены) → Баланс (списываются доли цента) → Вы (получаете ответ)

Дальше — каждое слово по отдельности.

## Токен

Токен — кусочек текста, которым модель читает и пишет. Это не всегда целое слово: длинное слово разбивается на несколько частей. В русском тексте одно слово — примерно 2–3 токена, в английском — 1,3–1,5.

Аналогия: такси считает не поездки, а километры. Модели тоже считают не сообщения, а токены — и деньги берут за них.

Пример: вопрос из 10 русских слов — примерно 20–30 токенов, ответ на страницу (около 400 слов) — примерно 1 000 токенов. Цены указывают за 1 млн токенов: у `claude-haiku-5-5` миллион токенов ответа стоит $0.20.

## Модель

Модель — конкретная нейросеть, которая пишет ответ: Claude Sonnet, GPT-6, DeepSeek и другие. У каждой есть имя (id), которое пишется в запросе в поле `model`, например `claude-sonnet-5-5`.

Аналогия: модели — как специалисты в одной конторе. Один быстрый и недорогой, другой думает дольше, зато справляется со сложным.

Список и цены — в статье [Модели и цены](/docs/introduction/models-pricing), подсказки по выбору — в [Как выбрать модель](/docs/introduction/choose-model).

## API

API — способ, которым одна программа общается с другой. Вместо того чтобы нажимать кнопки на сайте, программа отправляет запрос по адресу и получает ответ.

Аналогия: окошко выдачи. Вы просовываете записку с заказом — вам выдают готовое блюдо. Zukko API — такое окошко: внутрь отдаёте вопрос и имя модели, обратно получаете ответ.

Пример: запрос `POST https://api.zukko.pro/v1/chat/completions` означает «отправить сообщение модели и получить ответ».

## API-ключ

API-ключ — секретная строка вида `fetch_…`, по которой Zukko понимает, что запрос от вас и с какого баланса списывать деньги. Создаётся в [кабинете → API-ключи](/app/api-keys).

Аналогия: ключ от квартиры. Кто его держит, тот и заходит — то есть тратит ваш баланс. Поэтому ключ не публикуют в коде на GitHub, в чатах и на скриншотах.

Пример: в программах ключ хранят в переменной окружения `ZUKKO_API_KEY`, а не прямо в тексте кода. Каждому боту или подрядчику удобно выдать свой ключ с лимитом расхода. Если ключ утёк — удалите его в кабинете и создайте новый.

## Base URL

Base URL — адрес, на который программа отправляет запросы. Многие программы по умолчанию шлют запросы в OpenAI или Anthropic. Если вписать в настройку base URL адрес Zukko, они пойдут в Zukko.

Аналогия: адрес на конверте. Письмо то же самое, но придёт туда, куда вы написали.

| Чем пользуетесь | Base URL |
| --- | --- |
| OpenAI SDK, Cursor, большинство программ | `https://api.zukko.pro/v1` |
| Claude Code, Anthropic SDK | `https://api.zukko.pro` (без `/v1`) |

Оба адреса ведут в один и тот же Zukko, разница только в формате запроса.

## Промпт

Промпт — текст, который вы отправляете модели: вопрос, задание, пример того, что нужно получить.

Аналогия: техническое задание подрядчику. Чем понятнее задание, тем лучше результат.

Пример: «напиши про кофе» — слабый промпт. «Напиши три предложения для карточки товара: зерновой кофе, Колумбия, мягкий вкус с нотами шоколада, для покупателя, который выбирает подарок» — сильный.

## Системный промпт

Системный промпт — постоянная инструкция «кто ты и как отвечать». Она идёт перед сообщениями пользователя и действует на весь разговор.

Аналогия: должностная инструкция новому сотруднику. Её дают один раз, а не повторяют перед каждым клиентом.

Пример: `{"role": "system", "content": "Ты — консультант магазина обуви. Отвечай коротко и только про наш магазин."}`. Держите длинную неизменную инструкцию в начале запроса — так срабатывает [кэш](/docs/introduction/for-beginners#кэш) и она обходится дешевле.

## Контекст

Контекст — всё, что модель видит в одном запросе: системный промпт, историю переписки, документы и сам вопрос. Важно: модель сама ничего не помнит между запросами. Чтобы она «помнила» разговор, программа каждый раз отправляет историю заново.

Аналогия: консультант, который перед каждым ответом заново читает всю папку с делом клиента.

Пример: у большинства моделей окно контекста — 1 млн токенов, это примерно 400 000 русских слов. У Grok — 256 тыс. токенов, у `glm-5.3` — 128 тыс. Чем длиннее контекст, тем больше входных токенов и тем дороже запрос.

## Кэш

Кэш — сохранённый результат, который не нужно считать заново. В Zukko их два:

- **Кэш промпта.** Если начало запроса повторяется (длинная инструкция, база знаний), модель читает его из кэша. У `claude-sonnet-5` и `claude-opus-5-5` эта часть стоит в 10 раз дешевле обычного входа. Включается сам.
- **Кэш ответов Zukko.** Если отправить точно такой же запрос ещё раз с заголовком `X-Zukko-Cache: true`, Zukko вернёт сохранённый ответ бесплатно.

Аналогия: повар, у которого овощи для супа уже нарезаны с утра, — блюдо готово быстрее и дешевле. Подробнее — [Лимиты ключа, идемпотентность и кэш](/docs/api-reference/key-limits-and-cache).

## Стриминг

Стриминг — ответ приходит по кусочкам, пока модель пишет, а не целиком в конце. Так работают чаты, где текст «печатается» на глазах.

Аналогия: репортаж в прямом эфире вместо записи, которую покажут вечером.

Пример: в запросе ставят `"stream": true`. Человек видит начало ответа сразу, а с медленными моделями соединение не обрывается по таймауту. Учтите: если закрыть поток на середине, сгенерированный ответ всё равно оплачивается целиком. Подробнее — [Стриминг](/docs/api-reference/streaming).

## Агент

Агент — модель, которая не просто отвечает, а сама выполняет задачу по шагам: решает, что сделать, вызывает [инструменты](/docs/introduction/for-beginners#инструменты), смотрит на результат и продолжает, пока не закончит.

Аналогия: помощник с доступом к календарю и почте. Вы говорите «назначь встречу с Иваном на четверг», а он сам смотрит свободное время и отправляет приглашение.

Пример: Claude Code — агент, который читает файлы проекта, запускает команды и правит код. Как сделать своего — [Агент с инструментами](/docs/guides/tool-agent) и [Оркестрация агентов](/docs/guides/multi-agent).

## Инструменты

Инструменты — функции, которые ваша программа предлагает модели: «найти заказ по номеру», «узнать курс валют», «записать строку в таблицу». Модель сама их не выполняет. Она отвечает: «вызови find_order с номером 123». Ваш код выполняет функцию и возвращает модели результат.

Аналогия: руководитель просит секретаря посмотреть в базе, а дальше решает сам.

Пример: в запросе это поле `tools`. Проверено, что инструменты работают у `deepseek-v4.1-flash`, `claude-haiku-5-5` и `gpt-6-luna`.

## Пайплайн

Пайплайн — конвейер: данные автоматически проходят через несколько шагов, часто тысячи раз подряд.

Аналогия: конвейер на фабрике — каждая деталь проходит одни и те же станки.

Пример: 10 000 отзывов → модель определяет настроение и тему → ответ в JSON → строки в таблице. Для таких задач берут дешёвые модели и заголовок `Idempotency-Key`, чтобы повтор после сбоя не оплачивался второй раз. Подробнее — [Пайплайны](/docs/guides/pipelines).

## RAG

RAG (Retrieval-Augmented Generation) — «ответ с подглядыванием в документы». Перед вопросом программа находит в ваших документах подходящие куски и вставляет их в промпт, а модель отвечает по ним.

Аналогия: экзамен с открытой книгой. Студент не помнит всё наизусть, зато быстро находит нужную страницу.

Пример: клиент спрашивает про доставку — бот находит в FAQ три абзаца о доставке и отдаёт их модели вместе с вопросом. Эмбеддингов в Zukko нет, поэтому поиск по документам делается в вашем коде. Подробнее — [Ответы по своим документам](/docs/guides/knowledge-base).

## JSON

JSON — текстовый формат данных, который легко читают программы: `{"имя": "Анна", "город": "Казань"}`.

Аналогия: анкета с полями вместо письма в свободной форме. Анкету легко разложить по столбцам таблицы.

Пример: модель можно попросить отвечать строго в JSON через параметр `response_format`. Проверено, что схему соблюдают `deepseek-v4.1-flash` и `claude-haiku-5-5`. Всё равно проверяйте JSON в коде и при ошибке повторяйте запрос. Подробнее — [Ответ в JSON](/docs/guides/structured-output).

## Температура

Температура (`temperature`) — сколько случайности в ответах. При 0 ответы предсказуемые и почти одинаковые, при 0.7–1 — разнообразнее и «творческее».

Аналогия: строгий бухгалтер против писателя, который любит импровизировать.

Пример: для извлечения данных ставят низкую температуру, для рекламных слоганов — повыше. Новые модели Claude могут не принимать нестандартную температуру — если получили ошибку, просто уберите этот параметр.

## Рассуждение (reasoning)

Рассуждение — режим, в котором модель сначала «думает»: пишет скрытый черновик, проверяет себя и только потом отвечает. Так лучше получаются математика, код и планирование, но выходит медленнее и дороже: размышления — это тоже токены, и они оплачиваются.

Аналогия: решить задачу на черновике, прежде чем переписать начисто.

Пример: сильные модели с рассуждением (Kimi K3, Opus) могут думать 30–60 секунд до первого слова. Ставьте таймаут клиента 120–300 секунд и используйте [стриминг](/docs/introduction/for-beginners#стриминг).

## Что дальше

- [Первый запрос за 3 минуты](/docs/introduction/quick-start) — применить слова на практике
- [Как выбрать модель](/docs/introduction/choose-model) — какую модель брать под задачу
- [Оплата и сколько это стоит](/docs/introduction/billing) — как токены превращаются в деньги

---

# Первый запрос за 3 минуты

## Без кода

1. [Зарегистрируйтесь](/register) и [пополните баланс](/app/payments) от $1.
2. Откройте [Чат](/app/chat), выберите модель и задайте вопрос.

Не знаете, какую модель взять, — начните с `claude-sonnet-5-5`.

## Через API

### Создайте ключ
[Кабинет → API-ключи](/app/api-keys) → «Создать». Ключ `fetch_...` показывается один раз — сохраните его. Нужна подтверждённая почта (при входе через Telegram — не нужна).
### Пополните баланс
[Оплата](/app/payments), от $1.
### Сохраните ключ в переменную

```bash
export ZUKKO_API_KEY="fetch_..."
```
```powershell
$env:ZUKKO_API_KEY = "fetch_..."
```

Переменная живёт до закрытия терминала. Навсегда: добавьте строку в `~/.zshrc` или `~/.bashrc`, на Windows — `setx ZUKKO_API_KEY "fetch_..."`.
### Запустите пример
Python: `pip install openai`, Node.js: `npm install openai`, curl ставить не нужно.

```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": "Объясни в двух предложениях, что такое нейросеть."}],
)

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: "Объясни в двух предложениях, что такое нейросеть." }],
});

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": "Объясни в двух предложениях, что такое нейросеть."}]
  }'
```

Запуск: `python first.py` или `node first.mjs`. На Windows берите Python или Node.js — в PowerShell у curl ломаются кавычки.

Текст ответа — в `choices[0].message.content`, расход токенов — в `usage`. Другая модель — поменяйте `model`, остальное то же.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| 401 | Скопируйте ключ заново из [кабинета](/app/api-keys) |
| `KeyError: 'ZUKKO_API_KEY'` или `OPENAI_API_KEY ... missing` | Переменная не задана в этом окне — выполните `export` ещё раз |
| 402 | [Пополните баланс](/app/payments) |
| 403 `email_unverified` | Подтвердите почту по ссылке из письма |
| 404 `model_not_found` | Возьмите имя из [Модели и цены](/docs/introduction/models-pricing) |
| 503 `model_disabled` | Возьмите другую модель, например `deepseek-v4.1-flash` |

## Пусть код пишет нейросеть

Скачайте документацию одним файлом и отдайте её ChatGPT, Claude или Cursor — они напишут код с правильными адресами и моделями.

---

# Как выбрать модель

Короткий ответ: начните с дешёвой модели, проверьте её на своих примерах и переходите на более сильную, только если качества не хватает. Ниже — что брать под конкретные задачи и почему.

## Главное правило: от дешёвой к сильной

Разница в цене между моделями — в десятки раз, а разница в качестве на простых задачах часто незаметна. Ответ `claude-haiku-5-5` на вопрос «во сколько вы открываетесь?» будет не хуже, чем у `claude-opus-5-5`, а стоить будет в 20 раз меньше.

Схема: Ваша задача → Дешёвая модель (claude-haiku-5-5 или deepseek-v4.1-flash) → Проверка (10–20 ваших реальных примеров) → Качество устраивает? (да — оставляйте)
Схема: Не хватает качества → Средняя модель (claude-sonnet-5-5) → Снова проверка → Всё ещё мало? (claude-opus-5-5)

Проверяйте на реальных вопросах ваших клиентов или реальных данных, а не на придуманных. Модель сменить просто: меняется одно слово в поле `model`, весь остальной код остаётся прежним.

## Что брать под задачу

Цены — в долларах за 1 млн [токенов](/docs/introduction/for-beginners#токен), вход / выход.

| Задача | Модель | Почему | Цена |
| --- | --- | --- | --- |
| Чат-бот, ответы на простые вопросы | `claude-haiku-5-5` | быстрая и дешёвая, понимает картинки | 0.05 / 0.20 |
| Бот поддержки по базе знаний | `claude-haiku-5-5`, при неточных ответах — `claude-sonnet-5` | Sonnet лучше держит длинные инструкции, а повторяющаяся база знаний читается из кэша в 10 раз дешевле | 0.4 / 2 |
| Код, Claude Code, Cursor | `claude-sonnet-5-5`, для сложного — `claude-opus-5-5` | индекс силы 56 и 58 — самые сильные в каталоге | 0.4 / 2 и 0.8 / 4 |
| Извлечь данные в JSON | `deepseek-v4.1-flash` или `claude-haiku-5-5` | проверено: обе соблюдают JSON-схему, обе очень дешёвые | 0.03 / 0.10 |
| Длинные документы: договоры, отчёты | `claude-sonnet-5` | окно 1 млн токенов, повторные вопросы по тому же документу идут через кэш | 0.4 / 2 |
| Понять картинку: скриншот, фото, чек | `claude-haiku-5-5`, для сложного — `claude-sonnet-5` | все модели Claude принимают картинки | 0.05 / 0.20 |
| Агент с инструментами | `deepseek-v4.1-flash` или `claude-haiku-5-5`, для сложного — `claude-sonnet-5-5` | вызов инструментов проверен на этих моделях | 0.03 / 0.10 |
| Оценить качество ответов (судья) | `claude-opus-5-5` или дешевле `gpt-6.1-sol` | судья должен быть сильнее проверяемой модели; индекс 58 и 52 | 0.8 / 4 и 0.27 / 1.32 |
| Сложные рассуждения, планирование | `claude-opus-5-5` или `kimi-k3.1-pro` | индекс 58 и 55; Kimi втрое дешевле | 0.8 / 4 и 0.26 / 1.29 |
| Массовая дешёвая обработка | `qwen3.8-flash` или `deepseek-v4.1-flash` | самые дешёвые в каталоге | 0.03 / 0.07 и 0.03 / 0.10 |

Нарисовать картинку по описанию — отдельная задача, о ней статья [Картинки: распознать и нарисовать](/docs/guides/images). Полная таблица всех моделей — в [Модели и цены](/docs/introduction/models-pricing).

**Осторожно: Не берите gpt-6-luna для JSON**
В проверке `gpt-6-luna` проигнорировал JSON-схему и отвечал около минуты. Для ответов строго в JSON используйте `deepseek-v4.1-flash` или `claude-haiku-5-5` — и всё равно проверяйте JSON в коде.

## Индекс силы и цена

В [каталоге моделей](/app/models) у каждой модели есть «индекс силы» — сводная оценка по набору тестов от Artificial Analysis. Чем выше число, тем модель в среднем умнее. Каталог по умолчанию отсортирован по нему.

| Модель | Индекс силы | Цена выхода за 1 млн токенов |
| --- | --- | --- |
| `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 |

Как это читать: Opus дороже Haiku в 20 раз, а индекс выше на 15 пунктов. Для сложного кода и многошаговых рассуждений эти пункты заметны. Для вопроса «где мой заказ?» — нет. А при близком индексе цена может отличаться в разы: `kimi-k3.1-pro` и `claude-sonnet-5-5` почти равны по силе, но Kimi дешевле.

Индекс — общий ориентир, а не оценка под вашу задачу. Решает проверка на ваших примерах.

## Медленные модели с рассуждением

**Совет: Модели, которые «думают», отвечают дольше**
Сильные модели с [рассуждением](/docs/introduction/for-beginners#рассуждение-reasoning) (Kimi K3, Opus) могут думать 30–60 секунд до первого слова. Размышления — тоже токены, и они оплачиваются. Для чата с клиентом, где важна скорость, берите быстрые модели. Если нужна сильная, ставьте таймаут клиента 120–300 секунд и включайте [стриминг](/docs/introduction/for-beginners#стриминг).

## Ещё способы сэкономить

- **Кэш промпта.** Длинную неизменную инструкцию или базу знаний ставьте в начало запроса. У `claude-sonnet-5` и `claude-opus-5-5` повторяющаяся часть читается из кэша в 10 раз дешевле.
- **Канал Standard.** Модели Claude с суффиксом `-standard` стоят одинаково за любой токен: `claude-sonnet-5-standard` — $0.2, Opus — $0.3. Скидки за кэш нет, поэтому это выгодно для пайплайнов, где контекст не повторяется.
- **Разные модели для разных шагов.** Дешёвая сортирует и извлекает, сильная пишет итог или проверяет.

Подробнее — [Как платить меньше](/docs/guides/save-money).

## Что дальше

- [Модели и цены](/docs/introduction/models-pricing) — полная таблица моделей, цен и размеров контекста
- [Оплата и сколько это стоит](/docs/introduction/billing) — расчёт стоимости на примерах
- [LLM как судья](/docs/guides/llm-as-judge) — как сравнить модели на своих данных
- [Первый запрос за 3 минуты](/docs/introduction/quick-start) — попробовать модель прямо сейчас

---

# Модели и цены

Через Zukko доступны текстовые модели Claude, GPT, Grok, Gemini, Qwen, DeepSeek, GLM, Kimi и MiniMax. Цены указаны в долларах за 1 млн токенов. У входа и выхода цены разные, кэш промптов тарифицируется отдельно: у чтения из кэша и записи в кэш своя цена.

## Доступные модели

| ID модели | Модель | Вход | Выход | Чтение кэша | Запись кэша | Контекст | Картинки на вход |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `claude-opus-5-5` | Claude Opus 5.5 | 0.8 | 4 | 0.08 | 1 | 1M | да |
| `claude-fable-5-1` | Claude Fable 5.1 | 7 | 35 | 0.7 | 8.75 | 1M | да |
| `claude-fable-5` | Claude Fable 5 | 7 | 35 | 0.7 | 8.75 | 1M | да |
| `claude-opus-5` | Claude Opus 5 | 0.8 | 4 | 0.08 | 1 | 1M | да |
| `claude-opus-4.8` | Claude Opus 4.8 | 0.8 | 4 | 0.08 | 1 | 1M | да |
| `claude-opus-4.7` | Claude Opus 4.7 | 0.8 | 4 | 0.08 | 1 | 1M | да |
| `claude-opus-4.6` | Claude Opus 4.6 | 0.8 | 4 | 0.08 | 1 | 1M | да |
| `claude-sonnet-5-5` | Claude Sonnet 5.5 | 0.4 | 2 | 0.04 | 0.5 | 1M | да |
| `claude-sonnet-5` | Claude Sonnet 5 | 0.4 | 2 | 0.04 | 0.5 | 1M | да |
| `claude-sonnet-4-6` | Claude Sonnet 4.6 | 0.5 | 2.5 | 0.05 | 0.6 | 1M | да |
| `claude-haiku-5-5` | Claude Haiku 5.5 | 0.05 | 0.2 | 0.01 | 0.06 | 1M | да |
| `claude-haiku-4.5` | Claude Haiku 4.5 | 0.18 | 0.9 | 0.02 | 0.23 | 200K | да |
| `gpt-6-astra` | GPT-6 Astra | 0.62 | 3.05 | 0.05 | 0.61 | 1M | да |
| `gpt-6.1-sol` | GPT-6.1 Sol | 0.27 | 1.32 | 0.01 | 0.22 | 1M | да |
| `gpt-6-sol` | GPT-6 Sol | 0.13 | 0.63 | 0.01 | 0.13 | 1M | да |
| `gpt-6-luna` | GPT-6 Luna | 0.04 | 0.21 | 0.03 | 0.14 | 1M | нет |
| `gpt-5.6-sol` | GPT-5.6 Sol | 0.22 | 1.07 | 0.02 | 0.22 | 1M | да |
| `gpt-5.6-terra` | GPT-5.6 Terra | 0.13 | 0.63 | 0.01 | 0.13 | 1M | да |
| `gpt-5.6-luna` | GPT-5.6 Luna | 0.06 | 0.3 | 0.01 | 0.06 | 1M | нет |
| `grok-4.7` | Grok 4.7 | 0.18 | 0.53 | 0.05 | 0.18 | 256K | да |
| `grok-4.6` | Grok 4.6 | 0.17 | 0.49 | 0.05 | 0.17 | 256K | да |
| `grok-4.5` | Grok 4.5 | 0.18 | 0.54 | 0.03 | 0.18 | 256K | да |
| `gemini-3.1-pro` | Gemini 3.1 Pro | 0.15 | 0.9 | 0.03 | 0.15 | 1M | да |
| `gemini-3.8-flash` | Gemini 3.8 Flash | 0.12 | 0.6 | 0.01 | 0.12 | 1M | нет |
| `gemini-3.7-flash` | Gemini 3.7 Flash | 0.12 | 0.6 | 0.01 | 0.12 | 1M | нет |
| `qwen3.8-max` | Qwen 3.8 Max | 0.1 | 0.28 | 0.1 | 0.1 | 1M | да |
| `qwen3.8-flash` | Qwen 3.8 Flash | 0.03 | 0.07 | 0.03 | 0.03 | 1M | нет |
| `qwen3.8-omni-flash` | Qwen 3.8 Omni Flash | 0.03 | 0.08 | 0.01 | 0.03 | 1M | да |
| `deepseek-v4-pro` | DeepSeek V4 Pro | 0.07 | 0.21 | 0.07 | 0.07 | 1M | да |
| `deepseek-v4.1-flash` | DeepSeek V4.1 Flash | 0.03 | 0.1 | 0.03 | 0.03 | 1M | нет |
| `deepseek-v4.1-flash-1017` | DeepSeek V4.1 Flash 1017 | 0.03 | 0.1 | 0.03 | 0.03 | 1M | да |
| `deepseek-v4-flash` | DeepSeek V4 Flash | 0.05 | 0.14 | 0.02 | 0.05 | 1M | нет |
| `glm-5.2` | GLM 5.2 | 0.08 | 0.2 | 0.04 | 0.08 | 1M | да |
| `kimi-k3.1-pro` | Kimi K3.1 Pro | 0.26 | 1.29 | 0.03 | 0.26 | 1M | нет |
| `kimi-k3` | Kimi K3 | 0.39 | 1.93 | 0.04 | 0.39 | 1M | да |
| `kimi-k2.7-code` | Kimi K2.7 Code | 0.12 | 0.5 | 0.02 | 0.12 | 256K | да |
| `minimax-m3.1-flash` | MiniMax M3.1 Flash | 0.16 | 0.6 | 0.02 | 0.16 | 1M | да |
| `glm-5.3` | GLM 5.3 | 0.1 | 0.1 | 0.1 | 0.1 | 128K | нет |
| `glm-5.3-flash` | GLM 5.3 Flash | 0.04 | 0.04 | 0.04 | 0.04 | 128K | нет |

### Original API Standard

Те же модели Claude через другой канал поставщика: одна цена за любой токен — вход, выход и кэш. Скидки за кэш нет, поэтому Standard выгоден там, где контекст не повторяется: пайплайны, чат-боты, пакетная обработка. Код модели — с `-standard` на конце.

| ID модели | Модель | Цена за любой токен | Контекст | Картинки на вход |
| --- | --- | --- | --- | --- |
| `claude-opus-5-5-standard` | Claude Opus 5.5 | 0.3 | 1M | да |
| `claude-opus-5-standard` | Claude Opus 5 | 0.3 | 1M | да |
| `claude-opus-4.8-standard` | Claude Opus 4.8 | 0.3 | 1M | да |
| `claude-opus-4.7-standard` | Claude Opus 4.7 | 0.3 | 1M | да |
| `claude-opus-4.6-standard` | Claude Opus 4.6 | 0.3 | 1M | да |
| `claude-sonnet-5-standard` | Claude Sonnet 5 | 0.2 | 1M | да |
| `claude-sonnet-4-6-standard` | Claude Sonnet 4.6 | 0.2 | 1M | да |

Все цены — $ за 1M токенов. Модели изображений сейчас недоступны (см. ниже).

Список и цены меняются: модели добавляются и временно выключаются. Перед тем как прописать модель в конфиг, сверьтесь с живым каталогом — он публичный, ключ не нужен:

```bash
curl https://api.zukko.pro/v1/models/info
```

`/v1/models/info` отдаёт цены (включая цены кэша) и лимиты — это главный источник, если таблица выше разойдётся с ним. Тот же каталог с ценами — на странице [«Модели»](/app/models) в кабинете.

## Индекс силы

В каталоге кабинета у моделей есть «Индекс силы» — Intelligence Index от [Artificial Analysis](https://artificialanalysis.ai/leaderboards/models), сводный балл по набору бенчмарков. По умолчанию модели отсортированы по нему, от сильных к слабым. Значения на сейчас:

| Модель | Индекс силы |
| --- | --- |
| 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 |

Индекс — общий ориентир, а не оценка под вашу задачу. Перед тем как переводить работу на другую модель, проверьте её на своих запросах.

## Какую модель выбрать

- **Максимальный индекс силы** — `claude-opus-5-5` (58) за $0.8 / $4.
- **Индекс 52–53** — `gpt-6.1-sol` (52, $0.27 / $1.32), `gpt-6-astra` (53, $0.62 / $3.05) и `claude-fable-5-1` (53, $7 / $35). При близком индексе цена у них отличается в разы — сравните по таблице на своём объёме.
- **Claude по умолчанию** — `claude-sonnet-5` ($0.4 / $2): основная модель в примерах этой документации. Новый `claude-sonnet-5-5` стоит столько же.
- **Claude без скидки за кэш, но дешевле** — Standard: `claude-sonnet-5-standard` за $0.2 и Opus за $0.3 за любой токен.
- **Индекс 45 за $0.1 входа и дешевле** — `qwen3.8-max` ($0.1 / $0.28, принимает картинки) и `glm-5.3` ($0.1 / $0.1, контекст 128K, без картинок).
- **Массовые дешёвые задачи** — `deepseek-v4.1-flash` ($0.03 / $0.1, индекс 39), `glm-5.3-flash` ($0.04 / $0.04, индекс 42, контекст 128K).
- **Нужно передавать картинки** — берите модель с «да» в последнем столбце таблицы: все Claude и 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`.
- **Длинный контекст** — у большинства моделей окно 1M токенов; у Grok — 256K, у `glm-5.3` и `glm-5.3-flash` — 128K.

`claude-opus-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6` и `claude-fable-5` — прошлые версии; они работают для тех, у кого уже прописаны в конфигах.

## Как писать имя модели

Каноническое имя — ID из таблицы выше. Его и стоит указывать в конфигах. Кроме того, шлюз сам распознаёт несколько привычных вариантов:

- с префиксом вендора или роутера: `anthropic/claude-sonnet-5`, `zhipu/glm-5.3`, `deepseek/deepseek-v4-pro`;
- с датой версии в конце: `deepseek-v4-flash-0731`, `claude-sonnet-5-20260101`;
- в стиле Bedrock: `us.anthropic.claude-sonnet-5`;
- с точкой или дефисом в номере версии: `claude-opus-5.5` и `claude-opus-5-5` — одна и та же модель;
- регистр не важен.

Старые имена Qwen работают как вторые имена: `qwen3.5-flash` и `qwen3.7-plus` ведут на `qwen3.8-flash`, `qwen3.7-max` и `qwen3.8-max-preview` — на `qwen3.8-max`.

Что отвечает шлюз, если с моделью что-то не так:

- **404 `model_not_found`** — такой модели в каталоге нет. Запрос не уходит к поставщику и не оплачивается.
- **503 `model_disabled`** — модель временно выключена; возьмите другую из `GET /v1/models`.
- **400 `model_retired`** — модель снята с обслуживания; в сообщении — на какую перейти.

## Какие endpoint'ы поддерживает семейство

| Семейство | `/v1/chat/completions` | `/v1/messages` | `/v1/responses` | `/v1/messages/count_tokens` |
| --- | --- | --- | --- | --- |
| Claude (`claude-*`) | да | да, родной | да | да |
| GPT (`gpt-*`) | да, родной | да | да | да |
| Gemini, Qwen, DeepSeek, Kimi, MiniMax | да | да | да | да |
| GLM (`glm-*`) | да, родной | да, Zukko переводит формат | нет | нет |

Надёжнее всего обращаться к модели в её родном формате. Для GPT используйте `/v1/chat/completions` или `/v1/responses`. Для Gemini, Qwen и DeepSeek — Chat Completions или Messages. GLM не понимает поля `input` и `instructions` из Responses API — для него только Chat Completions или Messages.

## Особенности GLM

- Модели только текстовые: картинки на вход не принимаются.
- По умолчанию GLM отвечает без развёрнутого рассуждения, чтобы вы не платили за скрытые размышления. Включить рассуждение: `"reasoning_effort": "high"` или `"thinking": {"type": "enabled"}`.
- Zukko поднимает `max_tokens` минимум до 256, а с включённым рассуждением — до 4096: иначе ответ приходит пустым.

## Имена для Cursor

Cursor не даёт завести модель с именем, которое он уже знает. Для него есть вторые имена:

| Имя для Cursor | Модель |
| --- | --- |
| `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` |

Подробнее — в гайде [Cursor IDE](/docs/ide-integrations/cursor-ide).

## Генерация изображений

Модели изображений сейчас временно недоступны: запросы к ним получают `503 model_disabled`. Формат запроса описан на странице [POST /v1/images/generations](/docs/api-reference/images-generations).

## Как считается стоимость

Вход делится на три части, у каждой своя цена:

```text
          + чтение кэша × цена чтения кэша
          + запись кэша × цена записи кэша
          + выход × цена выхода
```

Списание идёт в два шага:

1. Сразу после ответа — по `usage` из ответа поставщика.
2. Примерно через минуту — по фактическому подсчёту поставщика: сколько токенов входа, чтения и записи кэша и выхода он насчитал за этот запрос. Списание пересчитывается по нашему прайсу, разница возвращается на баланс или досписывается. В истории запросов остаются итоговые цифры.

Второй шаг нужен потому, что `usage` в ответе не всегда полный. Например, для Claude в формате OpenAI (`/v1/chat/completions`) поставщик не присылает разбивку на кэш, но кэш при этом работает, и после пересчёта он оплачивается по ценам кэша в любом формате.
- У моделей, где цены кэша равны цене входа (Qwen, DeepSeek, GLM 5.3), кэш стоит столько же, сколько обычный вход.

Пример для `claude-sonnet-5` через `/v1/messages`: 100 000 токенов входа, из них 90 000 прочитаны из кэша, и 2 000 токенов выхода.

```text
90 000 × $0.04 / 1M = $0.0036   чтение кэша
 2 000 × $2 / 1M    = $0.004    выход
итого                 $0.0116
```

Тот же запрос через `/v1/chat/completions` в итоге стоит столько же. В ответе этого формата разбивки на кэш нет, поэтому сразу спишется 100 000 × $0.4 / 1M + $0.004 = $0.044, а примерно через минуту списание пересчитается по фактическим данным поставщика до $0.0116 и разница вернётся на баланс. Подробнее о списаниях — в разделе [Оплата и списания](/docs/introduction/billing).

---

# Оплата и сколько это стоит

Баланс в долларах. Пополняете заранее, за каждый запрос списываются использованные [токены](/docs/introduction/for-beginners#токен). Подписок нет.

## Как пополнить

[Кабинет → Оплата](/app/payments), минимум **$1**:

| Способ | Комиссия сверху |
| --- | --- |
| СБП (в рублях по курсу) | ≈ 8,5% |
| Криптовалюта | — |
| Иностранная карта ($2–$594 за платёж) | ≈ 13% |

Российские карты временно не принимаются — платите через СБП. Итоговая сумма видна до оплаты.

**Бонусы:** $0.50 за подписку на [@zukkopro](https://t.me/zukkopro) (один раз на Telegram-аккаунт, забрать в кабинете). 10% с пополнений приглашённых — по реферальной ссылке из кабинета. Стартового бонуса нет.

## Сколько стоит запрос

**Стоимость = входные токены × цена входа + выходные × цена выхода.** Цены за 1 млн токенов — в [Модели и цены](/docs/introduction/models-pricing). Часть входа из [кэша](/docs/introduction/for-beginners#кэш) стоит дешевле.

Ориентир: страница русского текста — около 1 000 токенов.

**Сколько стоит: Ответ на страницу (150 токенов вопроса, 1 000 ответа)**
- `claude-haiku-5-5` ≈ **$0.0002** — около 5 000 ответов на $1;
- `claude-sonnet-5` ≈ **$0.002** — около 500 на $1;
- `claude-opus-5-5` ≈ **$0.004** — около 250 на $1.

Час работы в Claude Code обычно стоит $0.8–1.6. Как тратить меньше — [Как платить меньше](/docs/guides/save-money).

## Когда запрос не пройдёт

- Почта не подтверждена — `403 email_unverified` (при входе через Telegram не нужно).
- На балансе меньше $0.01 — `402 insufficient_balance`.

Списание идёт после ответа, по факту. Длинный запрос может увести баланс в минус — тогда следующие получат `402`, а пополнение сначала закроет минус.

## Что бесплатно

- Запросы, отклонённые до модели: нет денег, лимит ключа, нет такой модели, неверный ключ.
- Ответы с ошибкой.
- Ответ из кэша Zukko (`X-Zukko-Cache: true`) и повтор с тем же `Idempotency-Key`.
- `/v1/balance` и `/v1/usage`.

## Что платно, хотя не очевидно

- Прерванный стриминг — ответ дочитывается и списывается целиком.
- `POST /v1/messages/count_tokens` — по цене входа.
- `POST /v1/web-search` — как небольшой расход токенов.
- Рассуждения «думающих» моделей — это тоже токены.

## Где смотреть расходы

- [Кабинет → Расходы](/app/usage) — каждый запрос с моделью, токенами и ценой.
- API: [GET /v1/usage](/docs/api-reference/usage) и [GET /v1/balance](/docs/api-reference/balance).
- Заголовок `X-Zukko-Cost-Micros` в обычном (не потоковом) ответе — цена запроса.

Баланс общий: все ключи, веб-чат и Telegram-бот тратят одни деньги.

Примерно через минуту после запроса стоимость пересчитывается по точным данным о кэше — разница возвращается или досписывается.

## Лимиты на ключ

В [кабинете](/app/api-keys) на каждый ключ можно поставить лимит в день и в месяц и список разрешённых моделей.

| Ситуация | Ответ |
| --- | --- |
| Модели нет в списке ключа | `403 model_not_allowed_for_key` |
| Кончился дневной лимит | `429 key_daily_limit_reached` |
| Кончился месячный лимит | `429 key_monthly_limit_reached` |

Сброс — в 00:00 UTC и 1-го числа. Пустой лимит или `0` — без ограничений. Последний запрос может немного превысить лимит. Подробнее — [Лимиты ключа и кэш](/docs/api-reference/key-limits-and-cache).

## Единицы в API

| Где | Единица | Пример |
| --- | --- | --- |
| `GET /v1/balance` → `balance_cents` | центы, вниз | `1234` = $12.34 |
| `GET /v1/usage` → `cost_cents` | центы с дробью | `0.0525` = $0.000525 |
| `X-Zukko-Cost-Micros` | микродоллары | `525` = $0.000525 |
| `X-Fetch-Balance-Cents` | микродоллары | `12340000` = $12.34 |
| `/api/*` кабинета, поля `*_cents` | микродоллары | `12340000` = $12.34 |

---

# Миграция на Zukko

Меняете три вещи: адрес, ключ и имя модели. Код запросов остаётся прежним.

| Что | Было | Стало |
| --- | --- | --- |
| Ключ | `sk-...`, `sk-ant-...`, `sk-or-...` | `fetch_...` ([создать](/app/api-keys)) |
| Адрес OpenAI / OpenRouter | `https://api.openai.com/v1`, `https://openrouter.ai/api/v1` | `https://api.zukko.pro/v1` |
| Адрес Anthropic | `https://api.anthropic.com` | `https://api.zukko.pro` |
| Модель | имя у провайдера | имя из [каталога](/docs/introduction/models-pricing) |

Работают: `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/images/generations`, `/v1/models`, `/v1/balance`, `/v1/usage`, `/v1/web-search`. Остальное (embeddings, audio, files, batches, fine-tuning) отвечает `404`.

## С 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": "Привет!"}],
)
```

- Модели GPT: `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-5.6-sol`, `gpt-5.6-terra`, дешевле — `gpt-6-luna`, `gpt-5.6-luna`. Тот же клиент работает с Claude, Gemini, Qwen, DeepSeek, GLM.
- Заголовки `OpenAI-Organization` и `OpenAI-Project` не нужны.
- Через переменные: `OPENAI_BASE_URL=https://api.zukko.pro/v1`, `OPENAI_API_KEY=fetch_...`.

## С 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": "Привет!"}],
)
```

- Адрес **без** `/v1`. Заголовок `x-api-key` принимается.
- Имена с датой (`claude-sonnet-5-20260101`) и с точкой (`claude-opus-5.5`) распознаются, но лучше писать как в каталоге.
- Кэш промптов работает и оплачивается по ценам кэша. `count_tokens` платный — по цене входа.
- Claude Code — [отдельная инструкция](/docs/ide-integrations/claude-code).

## С OpenRouter

```python

client = OpenAI(
    base_url="https://api.zukko.pro/v1",  # было https://openrouter.ai/api/v1
    api_key="fetch_...",                    # было sk-or-...
)

r = client.chat.completions.create(
    model="claude-sonnet-5",                # было anthropic/claude-sonnet-5
    messages=[{"role": "user", "content": "Привет!"}],
)
```

- Префикс вендора (`anthropic/claude-sonnet-5`) распознаётся, но лучше без него.
- Поля `models`, `provider`, `route`, `transforms` и заголовки `HTTP-Referer`, `X-Title` уберите.
- Баланс — [GET /v1/balance](/docs/api-reference/balance), история — [GET /v1/usage](/docs/api-reference/usage).

## Проверка

1. Нужная модель есть в [списке](https://api.zukko.pro/v1/models).
2. Короткий запрос проходит. `404 model_not_found` — имени нет в каталоге. Другие коды — [Ошибки API](/docs/api-reference/errors).

---

# Сценарии

# Telegram-бот с нейросетью

За 15 минут у вас будет Telegram-бот, который отвечает через нейросеть и помнит последние 10 сообщений чата.

**Нужно:** аккаунт Telegram, ключ `fetch_...` ([создать](/app/api-keys)), баланс от $0.01 ([пополнить](/app/payments)), Python 3.10+ или Node.js 18+ (не знаете — берите Python).

Схема: Пользователь (пишет в Telegram) → Ваш бот (программа на компьютере или сервере) → Zukko API (один ключ) → Модель (например, Claude Haiku)

### Создайте бота
В [@BotFather](https://t.me/BotFather) отправьте `/newbot`, задайте имя и адрес на `bot` (например, `my_helper_bot`). Придёт токен `123456789:AA...` — никому его не показывайте.
### Создайте ключ для бота
В [кабинете → API-ключи](/app/api-keys) создайте отдельный ключ. Поставьте лимит, например $1 в день, и разрешите только `claude-haiku-5-5` — [как](/docs/api-reference/key-limits-and-cache).
### Установите библиотеки
Создайте папку `ai-bot` и откройте в ней терминал.

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
```
### Сохраните код
Скопируйте код из раздела «Готовый код» ниже в `bot.py` (Python) или `bot.mjs` (Node.js) в папке `ai-bot`.
### Запустите
macOS и Linux:
```bash
export ZUKKO_API_KEY="fetch_..."
python bot.py        # или: node bot.mjs
```
Windows (PowerShell):
```bash
$env:ZUKKO_API_KEY="fetch_..."
python bot.py
```
Появится «Бот запущен». Не закрывайте окно: бот отвечает, пока работает программа.
### Проверьте
Найдите бота в Telegram, нажмите «Старт» и задайте вопрос.

**Совет: Характер и модель**
Характер — текст `SYSTEM_PROMPT` в начале кода. Модель — `export ZUKKO_MODEL=claude-sonnet-5`; разрешите её в настройках ключа.

## Готовый код

Память на 10 сообщений хранится в оперативной памяти и стирается при перезапуске. Команда /reset очищает её вручную.

```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)  # иначе в лог попадает адрес с токеном бота
log = logging.getLogger("bot")

MODEL = os.environ.get("ZUKKO_MODEL", "claude-haiku-5-5")
HISTORY_SIZE = 10      # сколько последних сообщений помнить (вопросы и ответы вместе)
TELEGRAM_LIMIT = 4000  # Telegram принимает до 4096 символов, оставляем запас

SYSTEM_PROMPT = (
    "Ты — дружелюбный помощник в Telegram. Отвечай на языке собеседника, "
    "коротко и простыми словами. Если не знаешь ответа — честно скажи. "
    "Не используй Markdown: звёздочки и решётки Telegram покажет как есть."
)

client = AsyncOpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["ZUKKO_API_KEY"],
    timeout=120,
    max_retries=2,
)

# Память бота: для каждого чата — последние HISTORY_SIZE сообщений.
# Хранится в оперативной памяти и стирается при перезапуске.
history: dict[int, deque] = defaultdict(lambda: deque(maxlen=HISTORY_SIZE))

def split_text(text: str, limit: int = TELEGRAM_LIMIT) -> list[str]:
    """Режет длинный ответ на куски, по возможности — по переносу строки."""
    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:
    """Статус «печатает…» живёт около 5 секунд, поэтому обновляем его, пока ждём модель."""
    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 "Слишком много запросов. Подождите минуту и напишите снова."
    if isinstance(err, APIStatusError) and err.status_code in (401, 402, 403):
        return "Бот временно не может ответить. Владелец уже получил сигнал."
    return "Не получилось получить ответ. Попробуйте ещё раз через минуту."

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    history[update.effective_chat.id].clear()
    await update.message.reply_text(
        "Привет! Я бот с нейросетью. Спросите что угодно.\n/reset — начать разговор заново."
    )

async def reset(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    history[update.effective_chat.id].clear()
    await update.message.reply_text("Готово, начинаем с чистого листа.")

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 = "Модель вернула пустой ответ. Попробуйте переформулировать вопрос."

    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)  # не заставлять всех ждать, пока модель отвечает одному
        .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("Бот запущен, модель %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; // сколько последних сообщений помнить
const TELEGRAM_LIMIT = 4000; // Telegram принимает до 4096 символов

const SYSTEM_PROMPT =
  "Ты — дружелюбный помощник в Telegram. Отвечай на языке собеседника, " +
  "коротко и простыми словами. Если не знаешь ответа — честно скажи. " +
  "Не используй Markdown: звёздочки и решётки Telegram покажет как есть.";

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);

// Память: chatId -> последние сообщения. Стирается при перезапуске.
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 "Слишком много запросов. Подождите минуту и напишите снова.";
  if ([401, 402, 403].includes(status)) return "Бот временно не может ответить. Владелец уже получил сигнал.";
  return "Не получилось получить ответ. Попробуйте ещё раз через минуту.";
}

bot.command("start", async (ctx) => {
  history.delete(ctx.chat.id);
  await ctx.reply("Привет! Я бот с нейросетью. Спросите что угодно.\n/reset — начать разговор заново.");
});

bot.command("reset", async (ctx) => {
  history.delete(ctx.chat.id);
  await ctx.reply("Готово, начинаем с чистого листа.");
});

bot.on("message:text", async (ctx) => {
  const text = ctx.message.text;
  if (text.startsWith("/")) return; // незнакомые команды не отправляем модели

  const memory = getMemory(ctx.chat.id);
  const messages = [
    { role: "system", content: SYSTEM_PROMPT },
    ...memory,
    { role: "user", content: text },
  ];

  // «печатает…» живёт около 5 секунд — обновляем, пока ждём модель
  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 = "Модель вернула пустой ответ. Попробуйте переформулировать вопрос.";

  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() из @grammyjs/runner обрабатывает сообщения параллельно:
// пока модель думает над ответом одному человеку, остальные не ждут.
run(bot);
console.log(`Бот запущен, модель ${MODEL}`);
```

## Работа 24/7

Скопируйте папку `ai-bot` на Linux-VPS, установите библиотеки и настройте автозапуск.

```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          # запомнить список программ
pm2 startup       # покажет команду для автозапуска после перезагрузки сервера
pm2 logs ai-bot   # смотреть лог
```

Для systemd создайте рядом с ботом `.env` со строками `TELEGRAM_BOT_TOKEN=...` и `ZUKKO_API_KEY=...` (без `export` и кавычек), выполните `chmod 600 .env` и включите сервис:

```bash
sudo systemctl enable --now ai-bot
journalctl -u ai-bot -f      # лог бота в реальном времени
```

**Осторожно: Не храните токен бота и ключ в коде и Git. Токен утёк — отзовите его у @BotFather командой `/revoke`.**

**Сколько стоит: 1000 ответов в день**
- `claude-haiku-5-5`: ≈ $0.00014 за ответ, около **$4 в месяц**.
- `deepseek-v4.1-flash`: ≈ $0.000075 за ответ, около **$2.3 в месяц**.
- `claude-sonnet-5`: ≈ $0.0012 за ответ, около **$36 в месяц**.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| `KeyError: 'TELEGRAM_BOT_TOKEN'` | Выполните `export ...` в том же окне, где запускаете бота |
| `ModuleNotFoundError: No module named 'telegram'` | `source .venv/bin/activate`, затем снова `pip install` |
| `Conflict: terminated by other getUpdates request` | Бот запущен дважды — оставьте одну копию |
| «Временно не может ответить», в логе 401 / 402 / 403 | 401 — проверьте `ZUKKO_API_KEY`. 402 — [пополните баланс](/app/payments). 403 `model_not_allowed_for_key` — разрешите модель в ключе или смените `ZUKKO_MODEL` |
| 429 `key_daily_limit_reached` | Поднимите лимит ключа или дождитесь 00:00 UTC |
| Бот долго думает | Возьмите быструю модель: `claude-haiku-5-5` или `deepseek-v4.1-flash` |

Другие коды — [Ошибки API](/docs/api-reference/errors).

---

# Бот поддержки по базе знаний

У вас будет бот, который отвечает клиентам только по вашей базе знаний, а если ответа нет — пишет «передаю сотруднику» и шлёт вопрос в чат операторов.

**Нужно:** ключ `fetch_...` с лимитом расхода ([создать](/app/api-keys), [лимиты](/docs/api-reference/key-limits-and-cache)), баланс ([пополнить](/app/payments)), Python 3.10+. Для Telegram — бот из статьи [Telegram-бот с нейросетью](/docs/guides/telegram-bot).

Схема: Клиент (вопрос в Telegram) → Бот (код на сервере) → Zukko API (правила + база знаний + вопрос) → Модель (ответ в JSON)
Схема: Ответ есть в базе → Бот отвечает клиенту
Схема: Ответа нет или нужен человек → Бот: «передаю сотруднику» → Оператор (сообщение в рабочий чат)

### Соберите базу знаний
Создайте `knowledge_base.md`: доставка, оплата, возврат, гарантия, часы работы, контакты. Пишите конкретно, с цифрами. Пример — ниже.
### Сохраните код
Скопируйте код из раздела «Готовый код» ниже в `support.py` рядом с базой.
### Проверьте в терминале
```bash
export ZUKKO_API_KEY="fetch_..."
python support.py
```
Задайте вопросы из базы и вне её. На каждый придёт JSON: ответ, `needs_human` и причина.
### Проверьте на 20 реальных вопросах
Возьмите вопросы клиентов, включая неудобные. Бот не должен выдумывать цены и сроки. Автоматическая проверка — [LLM как судья](/docs/guides/llm-as-judge).

## Пример базы знаний

```markdown
# Доставка
- По Москве курьером: 1–2 дня, 300 ₽, бесплатно от 5000 ₽.
- По России: СДЭК, 3–7 дней, стоимость считается при оформлении заказа.
- Самовывоз: Москва, ул. Примерная, 1, пн–пт 10:00–20:00.

# Оплата
- Картой на сайте, через СБП, наличными курьеру.

# Возврат
- 14 дней с момента получения, если товар не был в использовании.
- Деньги возвращаем в течение 10 рабочих дней тем же способом оплаты.

# Контакты
- Поддержка работает пн–пт 9:00–21:00 по Москве.
```

## Готовый код

Модель — `claude-haiku-5-5` (или `deepseek-v4.1-flash`): обе строго соблюдают схему JSON. Вопросы без ответа пишутся в `unanswered.jsonl` — раз в неделю дописывайте их в базу. Изменили базу — перезапустите бота.

```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 = "Магазин «Пример»"
KNOWLEDGE_BASE = Path(os.environ.get("KB_FILE", "knowledge_base.md")).read_text(encoding="utf-8")
UNANSWERED_LOG = Path("unanswered.jsonl")  # вопросы, на которые бот не ответил сам

# Неизменная часть (правила и база знаний) стоит в начале запроса:
# так её подхватывает кэш промпта, и повторы стоят дешевле.
SYSTEM_PROMPT = f"""Ты — сотрудник поддержки компании {COMPANY}. Отвечай вежливо, коротко, на «вы», на языке клиента.

Правила:
1. Отвечай только по базе знаний ниже. Цены, сроки, условия, адреса и телефоны бери только из неё. Ничего не придумывай.
2. Поставь needs_human = true, если: ответа нет в базе; клиент недоволен или просит человека; речь о возврате денег, браке или жалобе. Тогда в answer напиши, что передаёшь вопрос сотруднику и он ответит в рабочее время.
3. В reason коротко напиши для сотрудника, почему нужен человек. Если человек не нужен — пустая строка.
4. В answer — готовый текст для клиента: без Markdown и без JSON внутри.
5. Если ответа в базе нет — needs_human = true, даже если можешь ответить общими словами.

<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": "Передаю ваш вопрос сотруднику — он скоро ответит.",
    "needs_human": True,
    "reason": "бот не смог ответить",
}

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:
    """Проверяет, что модель вернула JSON нужного вида. Иначе — None."""
    text = (content or "").strip()
    if text.startswith("```"):  # на случай, если модель обернула JSON в блок кода
        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:
    """Возвращает {"answer": str, "needs_human": bool, "reason": str}.

    history — прошлые сообщения диалога в формате
    [{"role": "user", "content": ...}, {"role": "assistant", "content": ...}].
    """
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        *history,
        {"role": "user", "content": question},
    ]
    result = None
    for attempt in range(2):  # при неверном JSON пробуем ещё раз
        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: {err}"}
            break
        result = parse_reply(response.choices[0].message.content)
        if result:
            break
        log.warning("Модель вернула неверный JSON, попытка %d", attempt + 1)

    result = result or dict(FALLBACK)
    if result["needs_human"]:
        log_unanswered(question, result["reason"])
    return result

async def main() -> None:
    """Проверка в терминале: задавайте вопросы, пустая строка — выход."""
    history: list[dict] = []
    while True:
        question = input("Вопрос: ").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())
```

## Подключите к Telegram

### Положите файлы рядом
`support.py` и `knowledge_base.md` — в папку с `bot.py` из статьи [Telegram-бот с нейросетью](/docs/guides/telegram-bot).
### Создайте чат операторов
Создайте группу в Telegram и добавьте в неё бота.
### Вставьте код в bot.py
Код заменяет `on_message` и добавляет команду /id. В `main()` добавьте строку `app.add_handler(CommandHandler("id", show_id))`.

```python
from support import answer as support_answer

ADMIN_CHAT_ID = int(os.environ["ADMIN_CHAT_ID"])  # чат, куда приходят вопросы для оператора

async def show_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Команда /id: подсказывает номер чата — нужен для 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  # в рабочем чате операторов бот молчит
    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=(
                "Нужен оператор\n"
                f"Клиент: {who} (chat id {chat_id})\n"
                f"Вопрос: {text}\n"
                f"Причина: {result['reason']}"
            ),
        )
```
### Узнайте номер чата
Напишите `/id` в группе операторов. Бот пришлёт номер (у групп он начинается с минуса).
### Запустите
Задайте `export ADMIN_CHAT_ID="-100..."` и запустите бота. Вопросы для человека придут в чат операторов, клиенту оператор пишет сам.

**Осторожно: Не кладите в базу закупочные цены, личные телефоны сотрудников и пароли: всё, что есть в базе, бот может пересказать.**

**Сколько стоит: 2000 вопросов в день, ≈ 13 000 токенов на вход каждый**
- `claude-haiku-5-5`: ≈ $0.00068 за вопрос, около **$41 в месяц**.
- `deepseek-v4.1-flash`: ≈ $0.0004 за вопрос, около **$24 в месяц**.
- `claude-sonnet-5`: ~$330 в месяц без кэша, ~$160 — если 60% промпта читается из кэша.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| `FileNotFoundError: knowledge_base.md` | Положите файл рядом с `support.py` или укажите путь в `KB_FILE` |
| Бот выдумывает цену или срок | Добавьте факт в базу. Повторяется — возьмите модель сильнее |
| Бот зовёт человека почти всегда | Добавьте в базу формулировки клиентов, посмотрите `unanswered.jsonl` |
| В логе «неверный JSON» | Возьмите `claude-haiku-5-5` или `deepseek-v4.1-flash` |
| Операторам ничего не приходит | Отправьте `/id` в группе операторов и скопируйте номер с минусом в `ADMIN_CHAT_ID`. Бот должен быть в группе |
| 401, 402, 403, 429 | См. [Telegram-бот](/docs/guides/telegram-bot) и [Ошибки API](/docs/api-reference/errors) |

---

# Ответы по своим документам

У вас будет программа, которая отвечает по вашим документам и ставит ссылки на источник [1] [2].

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)), баланс ([пополнить](/app/payments)), Python 3.10+, документы в `.txt` или `.md` (PDF и Word сначала переведите в текст, например через `pypdf`).

Схема: Вопрос → Поиск (находит 5 подходящих фрагментов в ваших файлах) → Zukko API (вопрос + фрагменты) → Модель → Ответ со ссылками [1] [2]

Поиск работает у вас на компьютере и ничего не стоит: эндпоинта эмбеддингов в Zukko нет. Zukko пишет сам ответ.

### Сложите документы
Создайте папку `docs` и положите туда `.txt` или `.md`. Подпапки можно.
### Установите библиотеки
```bash
```
### Сохраните код
Скопируйте код из раздела «Готовый код» ниже в `rag.py` рядом с папкой `docs`.
### Запустите
```bash
python rag.py
```
Задайте вопрос. В ответе будут номера источников и файлы, откуда взяты фрагменты. Добавили документ — перезапустите программу.

## Готовый код

```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"))  # папка с файлами .txt и .md
MODEL = os.environ.get("ZUKKO_MODEL", "claude-haiku-5-5")
CHUNK_SIZE = 1200  # примерный размер фрагмента в символах
TOP_K = 5          # сколько фрагментов отправлять модели

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]:
    """Слова в нижнем регистре, приведённые к основе: «доставки», «доставкой» → «доставк»."""
    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]:
    """Режет текст на фрагменты по абзацам, примерно по size символов."""
    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}, фрагмент {number}", "text": chunk})
    return chunks

class Index:
    """Поиск по ключевым словам (BM25): чем больше редких слов вопроса во фрагменте, тем выше он в выдаче."""

    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 = """Ты отвечаешь на вопросы по документам компании.
Правила:
- Используй только фрагменты из блока <sources>. Не добавляй знаний со стороны.
- После каждого утверждения ставь номер фрагмента в квадратных скобках: [1], [2].
- Если во фрагментах нет ответа, так и скажи: «В документах этого нет». Не угадывай.
- Отвечай коротко, на языке вопроса, без Markdown."""

def ask(index: Index, question: str) -> tuple[str, list[str]]:
    found = index.search(question)
    if not found:
        return "В документах ничего не нашлось по этому вопросу.", []

    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\nВопрос: {question}"},
            ],
            max_tokens=1000,
        )
    except (APIStatusError, APIConnectionError) as err:
        return f"Ошибка запроса к модели: {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"В папке {DOCS_DIR} нет файлов .txt или .md")
    index = Index(chunks)
    print(f"Загружено фрагментов: {len(chunks)}. Задайте вопрос (пустая строка — выход).")
    while True:
        question = input("\nВопрос: ").strip()
        if not question:
            break
        answer, sources = ask(index, question)
        print("\n" + answer)
        if sources:
            print("\nИсточники:\n" + "\n".join(sources))

if __name__ == "__main__":
    main()
```

В бота: создайте `index` один раз при запуске и вызывайте `ask` в обработчике сообщений, как в статье [Telegram-бот](/docs/guides/telegram-bot). `ask` синхронная — в асинхронном боте: `await asyncio.to_thread(ask, index, text)`.

## Другие способы

### Всё в запрос — до ~200–300 страниц
Без поиска: склейте документы и отправьте с вопросом. Каждый вопрос оплачивает всю базу и отвечается дольше.

```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 = "Сколько дней отпуска положено новому сотруднику?"

response = client.chat.completions.create(
    model="claude-sonnet-5",
    messages=[
        # документы — в начало: эта часть одинаковая и попадает в кэш промпта
        {"role": "system", "content": "Отвечай только по документам ниже и называй файл-источник. "
                                      "Если ответа нет — скажи об этом.\n\n" + docs},
        {"role": "user", "content": question},
    ],
)
print(response.choices[0].message.content)
```

### Поиск по смыслу — когда в вопросах другие слова, чем в документах
Например, «уволиться» и «расторжение трудового договора». Замените класс `Index` в `rag.py` на этот код (работает локально, без видеокарты):

```python
from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("intfloat/multilingual-e5-small")  # скачается один раз, понимает русский

class Index:
    def __init__(self, chunks: list[dict]):
        self.chunks = chunks
        # у моделей e5 тексты помечают «passage: », а вопросы — «query: »
        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  # чем больше число, тем ближе смысл
        best = scores.argsort()[::-1][:k]
        return [self.chunks[i] for i in best]
```

Векторы для тысяч фрагментов считаются несколько минут — сохраните их в файл (`numpy.save`).

**Сколько стоит: 1000 вопросов в день, поиск по словам**
- `claude-haiku-5-5`: ≈ $0.0002 за вопрос, около **$6 в месяц**.
- `deepseek-v4.1-flash`: ≈ $0.00012 за вопрос, около **$3.5 в месяц**.
- `claude-sonnet-5`: ≈ $0.0018 за вопрос, около **$54 в месяц**. Для сравнения, «всё в запрос» на базе в 200 страниц — $300 (Haiku) и $2400 (Sonnet) в месяц.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| «В папке docs нет файлов» | Переведите файлы в `.txt`/`.md`, проверьте путь или задайте `DOCS_DIR` |
| «В документах ничего не нашлось», хотя ответ есть | Перефразируйте вопрос. Часто — перейдите на поиск по смыслу |
| Ответ неполный | Увеличьте `TOP_K` до 8–10 или уменьшите `CHUNK_SIZE` |
| Модель отвечает не по документам | Не убирайте правило про источники, попробуйте `claude-sonnet-5` |
| «Всё в запрос» отвечает очень долго | Перейдите на поиск по словам, поставьте таймаут клиента 300 с |
| 401, 402, 429 | См. [Ошибки API](/docs/api-reference/errors) |

---

# Пайплайны: обработка данных потоком

В конце у вас будет скрипт, который прогоняет 10 000 отзывов из CSV через нейросеть и пишет в новую таблицу тон, тему и суть каждого.

**Нужно:** отдельный ключ `fetch_...` ([создать](/app/api-keys)), баланс — на 10 000 отзывов на DeepSeek хватит $1 ([пополнить](/app/payments)), Python 3.10+, CSV с колонками `id` и `text`.

Схема: Таблица CSV (10 000 отзывов) → Ваш скрипт (по 10 строк одновременно) → Zukko API (один ключ) → Модель (DeepSeek V4.1 Flash) → Новая таблица (тон, тема, суть)

### Поставьте лимит на ключ
[Кабинет → API-ключи](/app/api-keys): дневной лимит расхода (например, $3) и только нужные модели. Лимит остановит скрипт, если тот пойдёт по кругу.
### Установите библиотеку и задайте ключ
```bash
export ZUKKO_API_KEY="fetch_ваш_ключ"
```
Windows (PowerShell): `$env:ZUKKO_API_KEY="fetch_ваш_ключ"`.
### Сохраните скрипт
Файл `classify_reviews.py` рядом с `reviews.csv`:

```python
"""
Классификация отзывов из CSV через Zukko API.

Запуск:
    pip install openai
    export ZUKKO_API_KEY="fetch_..."
    python classify_reviews.py reviews.csv results.csv --limit 50   # сначала проба
    python classify_reviews.py reviews.csv results.csv              # потом всё

Во входном CSV нужны колонки id и text. Скрипт можно прервать и запустить
снова: строки, которые уже есть в results.csv, он пропустит.
"""
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"  # или "claude-haiku-5-5"
CONCURRENCY = 10               # сколько запросов идут одновременно
MAX_ATTEMPTS = 6               # попыток на одну строку
MAX_SPEND_USD = 2.00           # страховка в самом скрипте; вторая — лимит на ключе
PROMPT_VERSION = "v1"          # поменяли промпт — поменяйте и версию

SYSTEM_PROMPT = """Ты классифицируешь отзывы покупателей интернет-магазина.
Ответь только JSON-объектом, без пояснений и без markdown:
{"sentiment": "positive" | "neutral" | "negative",
 "topic": "delivery" | "quality" | "price" | "support" | "other",
 "summary": "суть отзыва одним предложением, до 15 слов"}"""

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,      # секунд на один запрос
    max_retries=0,   # повторы делаем сами — так понятнее, что происходит
)

class StopPipeline(Exception):
    """Ошибка, после которой продолжать нет смысла: ключ, баланс, лимит."""

def validate(raw: str) -> dict:
    """Проверяем ответ модели. Любая неточность — ValueError."""
    data = json.loads(raw)
    if not isinstance(data, dict):
        raise ValueError("ответ не JSON-объект")
    if data.get("sentiment") not in SENTIMENTS:
        raise ValueError(f"неверный sentiment: {data.get('sentiment')!r}")
    if data.get("topic") not in TOPICS:
        raise ValueError(f"неверный topic: {data.get('topic')!r}")
    summary = data.get("summary")
    if not isinstance(summary, str) or not summary.strip():
        raise ValueError("пустой summary")
    return {"sentiment": data["sentiment"], "topic": data["topic"], "summary": summary.strip()}

async def classify(row_id: str, text: str) -> dict:
    json_try = 0  # растёт, только если модель прислала негодный JSON
    for attempt in range(1, MAX_ATTEMPTS + 1):
        # Один ключ идемпотентности = одно тело запроса. Повтор после сбоя сети
        # с тем же ключом не спишет деньги второй раз. Модель и версия промпта
        # входят в ключ: иначе после их смены Zukko ответит 422.
        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:
                # Успешный ответ сохранён под этим ключом, поэтому для новой
                # попытки нужен новый ключ — меняем json_try.
                json_try += 1
                print(f"  строка {row_id}: плохой JSON ({e}), повторяю", 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 и т. п.: повтор не поможет
            retry_after = e.response.headers.get("retry-after")
            if retry_after and retry_after.isdigit():
                delay = float(retry_after)
            print(f"  строка {row_id}: {e.status_code}, пауза {delay:.0f} с", file=sys.stderr)
        except APIConnectionError as e:  # обрыв сети или таймаут
            print(f"  строка {row_id}: {type(e).__name__}, пауза {delay:.0f} с", file=sys.stderr)
        await asyncio.sleep(delay)
    raise RuntimeError(f"не удалось за {MAX_ATTEMPTS} попыток")

async def process_row(row, sem, writer, out_file, stats, stop):
    async with sem:  # не больше CONCURRENCY строк одновременно
        if stop.is_set():
            return
        try:
            result = await classify(row["id"], row["text"])
        except StopPipeline as e:
            stop.set()
            print(f"\nОстановка: {e}", file=sys.stderr)
            return
        except Exception as e:
            stats["failed"] += 1
            print(f"  строка {row['id']} пропущена: {e}", file=sys.stderr)
            return
        writer.writerow({"id": row["id"], **result})
        out_file.flush()  # строка сразу на диске — прогресс не потеряется
        stats["done"] += 1
        stats["cost"] += result["cost_usd"]
        if stats["cost"] > MAX_SPEND_USD and not stop.is_set():
            stop.set()
            print(f"\nОстановка: потрачено больше ${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"Готово {stats['done']}/{stats['total']}, "
                  f"${stats['cost']:.4f}, {speed:.1f} строк/с")

async def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("input")
    parser.add_argument("output")
    parser.add_argument("--limit", type=int, default=0, help="взять только первые N строк")
    args = parser.parse_args()

    with open(args.input, newline="", encoding="utf-8-sig") as f:  # -sig: CSV из 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"Всего строк: {len(rows)}, уже готово: {len(rows) - len(todo)}, осталось: {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"\nОбработано: {stats['done']}, с ошибкой: {stats['failed']}, "
          f"потрачено: ${stats['cost']:.4f}")
    if stats["failed"] or stop.is_set():
        print("Запустите скрипт ещё раз — он продолжит с необработанных строк.")

if __name__ == "__main__":
    asyncio.run(main())
```
### Проверьте на 50 строках
```bash
```
Посмотрите `results.csv` глазами. Потраченную сумму умножьте на 200 — это цена всех 10 000 строк.
### Запустите на всём файле
```bash
```
Готовые строки скрипт пропускает. Прервалось — запустите ту же команду снова.

## Какую модель взять

Модель меняется в `MODEL`. Выбирайте на пробе: одни и те же 50 строк через двух кандидатов.

| Модель | Цена за 1 млн токенов, вход / выход | Когда |
| --- | --- | --- |
| `deepseek-v4.1-flash` | $0.03 / $0.10 | по умолчанию |
| `claude-haiku-5-5` | $0.05 / $0.20 | DeepSeek не устроил на пробе |
| `claude-sonnet-5-standard` | $0.2 за любой токен | нужна сила Sonnet; без скидки за кэш, но здесь дешевле `claude-sonnet-5-5` |
| `claude-sonnet-5-5` | $0.4 / $2 | сложная разметка |

**Осторожно: Таймаут**
Повтор с тем же `Idempotency-Key`, пока первый запрос ещё выполняется, тоже оплачивается. Ставьте таймаут с запасом: 60–90 с для быстрых моделей, 120–300 с для моделей с рассуждением.

**Совет: Гоняете один файл несколько раз?**
Добавьте в `extra_headers` `"X-Zukko-Cache": "true"`: одинаковый запрос 7 дней отдаётся из кэша бесплатно (ответ по `Idempotency-Key` живёт только 24 часа).

**Сколько стоит: - 10 000 отзывов ≈ 4,5 млн токенов входа и 1 млн выхода.**
- `deepseek-v4.1-flash` — около $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.
- Заложите 5–10% на повторы из-за негодного JSON.

## Без кода: n8n и Make

### Настройте HTTP-запрос
n8n — узел **HTTP Request**, Make — **HTTP → Make a request**. `POST` на `https://api.zukko.pro/v1/chat/completions`. Заголовки: `Authorization: Bearer fetch_ваш_ключ`, `Content-Type: application/json`, `Idempotency-Key` — например, `reviews-v1-` плюс id строки.
### Задайте тело запроса
```json
  "model": "deepseek-v4.1-flash",
  "response_format": { "type": "json_object" },
  "max_tokens": 200,
  "messages": [
    { "role": "system", "content": "Ты классифицируешь отзывы покупателей. Ответь только JSON: {\"sentiment\": \"positive|neutral|negative\", \"topic\": \"delivery|quality|price|support|other\", \"summary\": \"суть до 15 слов\"}" },
    { "role": "user", "content": "ТЕКСТ ОТЗЫВА" }
  ]
}
```
Вместо `ТЕКСТ ОТЗЫВА` подставьте поле таблицы. В n8n — через `JSON.stringify(...)`, иначе кавычка в отзыве сломает JSON.
### Разберите ответ
`choices[0].message.content` — строка с JSON. Make: **JSON → Parse JSON**, n8n: узел **Code** или **Edit Fields**.
### Включите повторы
Повтор при ошибке в настройках узла, пачки по 5–10 строк, лимит расхода на ключе.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| `402 insufficient_balance` | [Пополните баланс](/app/payments) и запустите снова — готовое пропустится |
| `429 key_daily_limit_reached` | Сработал ваш лимит ключа. Поднимите его или дождитесь 00:00 UTC |
| Много `429` без кода лимита | Уменьшите `CONCURRENCY` до 5 |
| `422 idempotency_conflict` | Поменяли промпт — увеличьте `PROMPT_VERSION` |
| Часто «плохой JSON» | Возьмите `claude-haiku-5-5` или [строгую схему](/docs/guides/structured-output) |
| `KeyError: 'id'` или `'text'` | Переименуйте колонки в первой строке CSV |
| `403 model_not_allowed_for_key` | Добавьте модель в настройках ключа |

---

# Ответ в JSON для автоматизации

В конце у вас будет код, который получает от модели JSON с нужными полями и проверяет его, прежде чем отдать в таблицу, CRM, 1С или бэкенд.

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)), баланс от $0.01 ([пополнить](/app/payments)), Python 3.10+ с `pip install openai pydantic` или Node.js 18+ с `npm install openai zod`.

Схема: Текст письма или счёта → Модель (заполняет бланк) → JSON (поставщик, сумма, дата) → Проверка в коде → Таблица, CRM или 1С

### Опишите поля
Типы: строка, число, да/нет, выбор из списка. Для списка всегда задавайте `enum` — иначе получите «Доставка», «доставка» и «delivery».
### Отправьте запрос со схемой
`response_format` с `json_schema` и `strict: true` — пример 1. Полей мало — хватит `json_object` с полями в промпте, пример 2.
### Проверьте ответ кодом
`pydantic` или `zod`. Даже строгая схема не гарантирует верный ответ.
### Дайте второй шанс
Не прошло — покажите модели её ответ и ошибку, попросите исправленный JSON. Второй отказ — отдайте документ человеку.

## Режимы и модели

| Режим | Что гарантирует |
| --- | --- |
| `response_format: {"type": "json_object"}` | корректный JSON, поля не проверяются |
| `response_format: {"type": "json_schema", ...}` со `strict: true` | JSON по вашей схеме: нужные поля нужных типов |

| Модель | Строгая схема | json_object |
| --- | --- | --- |
| `deepseek-v4.1-flash` | да | да |
| `claude-haiku-5-5` | да | да |
| `gpt-6-luna` | нет | — |

Другие модели проверьте на 20–50 своих примерах. Цены — [Модели и цены](/docs/introduction/models-pricing).

## Пример 1. Данные из счёта, строгая схема

```python
"""
Данные из счёта — в JSON по строгой схеме.
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"  # или "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):
    """Форма, которую должна заполнить модель."""
    model_config = ConfigDict(extra="forbid")  # лишние поля — ошибка

    supplier: str = Field(description="название поставщика")
    invoice_number: str = Field(description="номер счёта; пустая строка, если его нет")
    date: str = Field(description="дата счёта в формате ГГГГ-ММ-ДД; пустая строка, если её нет")
    total: float = Field(description="итоговая сумма к оплате, числом")
    currency: Literal["RUB", "USD", "EUR", "OTHER"]
    vat_included: bool = Field(description="входит ли НДС в итоговую сумму")

SCHEMA = Invoice.model_json_schema()  # JSON Schema из той же модели — описываем поля один раз

SYSTEM = (
    "Ты извлекаешь данные из счетов. Отвечай только JSON по заданной схеме. "
    "Ничего не придумывай: если в тексте нет поля, оставь пустую строку."
)

def clean(raw: str) -> str:
    """Иногда модель оборачивает JSON в markdown-разметку — снимаем её."""
    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:
        # Одна попытка исправиться: показываем модели её ответ и текст ошибки.
        messages += [
            {"role": "assistant", "content": raw},
            {"role": "user", "content": f"Ответ не прошёл проверку:\n{error}\n"
                                        "Пришли исправленный JSON целиком, без пояснений."},
        ]
        # Если и сейчас не прошло — исключение: такой документ лучше отдать человеку.
        return Invoice.model_validate_json(clean(ask(messages)))

if __name__ == "__main__":
    text = """Счёт № 4512 от 03.10.2026
Поставщик: ООО «Ромашка», ИНН 7701234567
Услуги доставки за сентябрь — 12 833,75 руб.
Итого к оплате: 15 400,50 руб., в т. ч. НДС 20% — 2 566,75 руб."""

    invoice = extract_invoice(text)
    print(invoice.model_dump_json(indent=2))
    print("К оплате:", invoice.total, invoice.currency)
```
```javascript
// Данные из счёта — в JSON по строгой схеме.
// npm install openai zod
// Запуск: node extract_invoice.mjs
import OpenAI from "openai";
import { z } from "zod";

const MODEL = "deepseek-v4.1-flash"; // или "claude-haiku-5-5"

const client = new OpenAI({
  baseURL: "https://api.zukko.pro/v1",
  apiKey: process.env.ZUKKO_API_KEY,
  timeout: 60_000,
});

// Схема для модели (JSON Schema): какие поля заполнить.
const schema = {
  type: "object",
  properties: {
    supplier: { type: "string", description: "название поставщика" },
    invoice_number: { type: "string", description: "номер счёта; пустая строка, если его нет" },
    date: { type: "string", description: "дата счёта в формате ГГГГ-ММ-ДД; пустая строка, если её нет" },
    total: { type: "number", description: "итоговая сумма к оплате, числом" },
    currency: { type: "string", enum: ["RUB", "USD", "EUR", "OTHER"] },
    vat_included: { type: "boolean", description: "входит ли НДС в итоговую сумму" },
  },
  required: ["supplier", "invoice_number", "date", "total", "currency", "vat_included"],
  additionalProperties: false,
};

// Та же форма для проверки в коде (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 =
  "Ты извлекаешь данные из счетов. Отвечай только JSON по заданной схеме. " +
  "Ничего не придумывай: если в тексте нет поля, оставь пустую строку.";

// Иногда модель оборачивает JSON в markdown-разметку — снимаем её.
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: "не 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;

  // Одна попытка исправиться: показываем модели её ответ и текст ошибки.
  messages.push(
    { role: "assistant", content: raw },
    {
      role: "user",
      content: `Ответ не прошёл проверку: ${JSON.stringify(first.error.issues)}. ` +
        "Пришли исправленный JSON целиком, без пояснений.",
    },
  );
  const second = check(await ask(messages));
  if (second.success) return second.data;
  // Не прошло и сейчас — такой документ лучше отдать человеку.
  throw new Error("Модель не смогла заполнить форму: " + JSON.stringify(second.error.issues));
}

const text = `Счёт № 4512 от 03.10.2026
Поставщик: ООО «Ромашка», ИНН 7701234567
Услуги доставки за сентябрь — 12 833,75 руб.
Итого к оплате: 15 400,50 руб., в т. ч. НДС 20% — 2 566,75 руб.`;

const invoice = await extractInvoice(text);
console.log(invoice);
console.log("К оплате:", invoice.total, invoice.currency);
```

В Node.js схема для модели и схема `zod` написаны отдельно — держите их одинаковыми. В Python обе берутся из класса `Invoice`.

## Пример 2. Обращение в поддержку, режим json_object

```python
"""
Разбор обращения в поддержку: категория, срочность, нужен ли человек.
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: модель обязана вернуть JSON, а поля описываем словами в промпте.
SYSTEM = """Ты разбираешь обращения в поддержку интернет-магазина.
Ответь только JSON-объектом:
{"category": "billing" | "technical" | "delivery" | "other",
 "priority": "low" | "normal" | "high",
 "needs_human": true | false,
 "reply_draft": "черновик ответа клиенту, 1–3 предложения"}
needs_human = true, если клиент просит вернуть деньги, грозит уйти или пишет о суде."""

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"Ответ не прошёл проверку:\n{error}\n"
                                        "Пришли исправленный JSON целиком, без пояснений."},
        ]
        return Ticket.model_validate_json(ask(messages))

if __name__ == "__main__":
    ticket = classify_ticket(
        "Здравствуйте! Третий раз списали деньги за один и тот же заказ 18734. "
        "Верните, пожалуйста, лишнее, иначе пойду в банк."
    )
    print(ticket)
    if ticket.needs_human:
        print("-> в очередь оператору, срочность:", ticket.priority)
    else:
        print("-> автоответ:", ticket.reply_draft)
```
```javascript
// Разбор обращения в поддержку: категория, срочность, нужен ли человек.
// 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: модель обязана вернуть JSON, а поля описываем словами в промпте.
const SYSTEM = `Ты разбираешь обращения в поддержку интернет-магазина.
Ответь только JSON-объектом:
{"category": "billing" | "technical" | "delivery" | "other",
 "priority": "low" | "normal" | "high",
 "needs_human": true | false,
 "reply_draft": "черновик ответа клиенту, 1–3 предложения"}
needs_human = true, если клиент просит вернуть деньги, грозит уйти или пишет о суде.`;

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: "не 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: `Ответ не прошёл проверку: ${JSON.stringify(first.error.issues)}. ` +
        "Пришли исправленный JSON целиком, без пояснений.",
    },
  );
  const second = check(await ask(messages));
  if (!second.success) throw new Error("Не удалось разобрать обращение");
  return second.data;
}

const ticket = await classifyTicket(
  "Здравствуйте! Третий раз списали деньги за один и тот же заказ 18734. " +
    "Верните, пожалуйста, лишнее, иначе пойду в банк.",
);
console.log(ticket);
if (ticket.needs_human) console.log("-> в очередь оператору, срочность:", ticket.priority);
else console.log("-> автоответ:", ticket.reply_draft);
```

## Правила схемы

- Числа — числами (`15400.5`, а не «15 400,50 руб.»), валюта — отдельным полем.
- Даты — строкой, формат в описании поля: `ГГГГ-ММ-ДД`.
- 5–10 полей без глубокой вложенности.
- Все поля в `required`, `additionalProperties: false`. `pydantic` с `extra="forbid"` делает это сам.

**Сколько стоит: - 1000 писем или счетов в день по ~300 слов: 33 млн токенов входа и 3,6 млн выхода в месяц.**
- `deepseek-v4.1-flash` ≈ $1.35 в месяц, `claude-haiku-5-5` ≈ $2.40 в месяц.
- Повторы после ошибки проверки — единицы процентов.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| Ответ начинается с трёх обратных кавычек и слова json | Снимите обёртку функцией `clean()` из примера |
| «Доставка» вместо `delivery` | Задайте у поля `enum` |
| JSON обрывается на середине | Увеличьте `max_tokens` |
| Модель выдумывает номер или дату | Разрешите пустую строку в описании поля и напишите «ничего не придумывай» в инструкции |
| Ответ идёт около минуты, схема не соблюдается | Возьмите `deepseek-v4.1-flash` или `claude-haiku-5-5` |
| `400` с упоминанием `response_format` | Возьмите модель из таблицы выше или режим `json_object` |

---

# Агент с инструментами

В конце у вас будет агент: модель сама решает, когда узнать погоду, посчитать или поискать в интернете, а ваш код выполняет эти действия и возвращает ей результат.

**Нужно:** отдельный ключ `fetch_...` с лимитом расхода и списком разрешённых моделей ([создать](/app/api-keys)), Python 3.10+ и `pip install openai`.

Схема: Вопрос пользователя → Модель (решает, что нужно) → Ваш код (выполняет инструмент) → Модель (читает результат) → Ответ

### Сохраните код
Файл `agent.py`. Инструменты: `get_weather` (заглушка вместо сервиса погоды) и `calculate` (калькулятор).

```python
"""
Агент с инструментами: модель сама решает, какую функцию вызвать.
pip install openai
python agent.py "Какая погода в Москве и в Сочи? На сколько градусов в Сочи теплее?"
"""
import ast
import json
import operator
import os
import sys

from openai import OpenAI

MODEL = "deepseek-v4.1-flash"  # или "claude-haiku-5-5", "gpt-6-luna"
MAX_STEPS = 6                  # больше шагов — значит, агент ходит по кругу
MAX_COST_USD = 0.05            # потолок расхода на один вопрос

client = OpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["ZUKKO_API_KEY"],
    timeout=120,
)

# ---------- Инструменты: обычные функции Python ----------

FAKE_WEATHER = {  # вместо настоящего сервиса погоды — заглушка
    "москва": {"temp_c": 7, "conditions": "облачно"},
    "сочи": {"temp_c": 16, "conditions": "солнечно"},
    "новосибирск": {"temp_c": -3, "conditions": "снег"},
}

def get_weather(city: str) -> dict:
    data = FAKE_WEATHER.get(city.strip().lower())
    if data is None:
        return {"error": f"нет данных о погоде для города {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):
    """Считаем только числа и + - * / % **. Никаких имён, вызовов и импортов."""
    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("слишком большая степень")
        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("разрешены только числа и знаки + - * / % **")

def calculate(expression: str) -> dict:
    if len(expression) > 200:
        return {"error": "слишком длинное выражение"}
    try:
        return {"result": _eval(ast.parse(expression, mode="eval").body)}
    except (ValueError, SyntaxError, ZeroDivisionError, OverflowError) as e:
        return {"error": str(e)}

# Описание инструментов для модели: имя, зачем нужен, какие параметры.
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Текущая погода в городе: температура в °C и осадки.",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string", "description": "город, например «Москва»"}},
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "Точно посчитать арифметическое выражение, например (16 - 7) * 1.8.",
            "parameters": {
                "type": "object",
                "properties": {"expression": {"type": "string"}},
                "required": ["expression"],
            },
        },
    },
]

TOOL_FUNCTIONS = {"get_weather": get_weather, "calculate": calculate}
NEEDS_CONFIRMATION = set()  # сюда — инструменты, которые что-то меняют: {"send_email", "refund"}

SYSTEM = (
    "Ты помощник. Для фактов о погоде вызывай get_weather, для любых вычислений — calculate. "
    "Не придумывай данные, которых не вернули инструменты. Отвечай кратко, по-русски."
)

def run_tool(name: str, arguments: str) -> dict:
    """Выполняем вызов, который попросила модель. Ошибку возвращаем модели, а не падаем."""
    func = TOOL_FUNCTIONS.get(name)
    if func is None:
        return {"error": f"нет инструмента {name}"}
    try:
        args = json.loads(arguments or "{}")
    except json.JSONDecodeError:
        return {"error": "аргументы — не JSON"}
    if name in NEEDS_CONFIRMATION:
        answer = input(f"Агент хочет выполнить {name}({args}). Разрешить? [y/N] ")
        if answer.strip().lower() != "y":
            return {"error": "человек запретил это действие"}
    try:
        return func(**args)
    except TypeError as e:  # модель передала не те параметры
        return {"error": f"неверные параметры: {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:  # инструменты больше не нужны — это окончательный ответ
            print(f"[шагов: {step}, потрачено: ${spent:.5f}]")
            return message.content or ""

        # Запоминаем просьбу модели и отвечаем на каждый вызов — их может быть несколько сразу.
        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}: {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"Остановлено: потрачено ${spent:.4f}, это больше лимита ${MAX_COST_USD}."
    return f"Остановлено: агент не уложился в {MAX_STEPS} шагов."

if __name__ == "__main__":
    question = " ".join(sys.argv[1:]) or "Какая погода в Москве и в Сочи? На сколько градусов в Сочи теплее?"
    print(run_agent(question))
```
### Запустите
```bash
python agent.py "Какая погода в Москве и в Сочи? На сколько градусов в Сочи теплее?"
```
Модель попросит погоду в двух городах, затем разницу, затем ответит. Каждый вызов печатается.
### Добавьте веб-поиск (по желанию)
Вставьте код в `agent.py` перед `if __name__ == "__main__":`, а в `SYSTEM` добавьте «для свежих фактов вызывай web_search».

```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"поиск не удался: {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": "Поиск в интернете. Для свежих фактов, новостей, цен и всего, чего ты можешь не знать.",
        "parameters": {
            "type": "object",
            "properties": {"query": {"type": "string", "description": "поисковый запрос"}},
            "required": ["query"],
        },
    },
})
TOOL_FUNCTIONS["web_search"] = web_search
```
До 10 результатов, около $0.0003–0.0005 за поиск, входит в лимиты ключа. Параметры — [POST /v1/web-search](/docs/api-reference/web-search).

## Цикл агента

1. Отправьте переписку с параметром `tools`.
2. Есть `tool_calls` — выполните каждый вызов и верните результат сообщением с ролью `tool` и тем же `tool_call_id`. Повторите шаг 1.
3. Нет `tool_calls` — в `content` окончательный ответ.

`MAX_STEPS` и `MAX_COST_USD` останавливают агента, если он ходит по кругу. Свой инструмент: функция в `TOOL_FUNCTIONS` и описание в `TOOLS`.

## Модели

| Модель | Цена за 1 млн токенов, вход / выход |
| --- | --- |
| `deepseek-v4.1-flash` | $0.03 / $0.10 |
| `claude-haiku-5-5` | $0.05 / $0.20 |
| `gpt-6-luna` | $0.04 / $0.21 |

Проверены через `/v1/chat/completions`. В Anthropic SDK и `/v1/messages` инструменты передаются как в Anthropic API.

**Осторожно: Безопасность**
- Не давайте инструменты вроде `run_shell` или `exec_python` и не вызывайте `eval()` на тексте от модели.
- Действия, которые что-то меняют (письмо, возврат денег, удаление), — только после подтверждения человеком: добавьте их в `NEEDS_CONFIRMATION`.
- Проверяйте параметры в функции: модель может передать чужой номер заказа.
- Текст из интернета — данные, а не команды.

**Сколько стоит: - Типичный вопрос — 2–3 шага, около 2000 токенов входа и 250 выхода.**
- 1000 вопросов в день: `deepseek-v4.1-flash` ≈ $2.55, `gpt-6-luna` ≈ $4.00, `claude-haiku-5-5` ≈ $4.50 в месяц.
- Веб-поиск: доли цента плюс примерно 1000–1500 токенов на входе следующего шага.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| Модель отвечает текстом и не вызывает инструмент | Уточните `description` и инструкцию, возьмите модель из таблицы выше |
| `400` после ответа на вызов | Ответьте на каждый вызов из `tool_calls` сообщением `tool` с его `tool_call_id` |
| Агент вызывает одно и то же по кругу | Возвращайте из инструмента понятный текст ошибки |
| Модель выдумывает аргументы | Проверяйте их в функции и возвращайте ошибку — модель поправится |
| `429 key_daily_limit_reached` | Сработал лимит ключа — проверьте, не зациклился ли агент |

---

# Оркестрация агентов

Соберёте скрипт, где сильная модель составляет план, дешёвые параллельно выполняют части, а редактор сводит всё в отчёт — через один ключ.

Схема: Задача (тема отчёта) → Планировщик (Claude Sonnet 5.5) → [Исполнитель 1 (DeepSeek) / Исполнитель 2 (DeepSeek) / Исполнитель 3 (DeepSeek)] → Редактор (Claude Sonnet 5) → Готовый отчёт

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)), баланс (отчёт стоит около цента), Python 3.10+.

### Выберите модели
Планировщик и редактор — `claude-sonnet-5-5` или `claude-sonnet-5`, для самых сложных задач планировщику — `claude-opus-5-5`. Исполнители — `deepseek-v4.1-flash` или `claude-haiku-5-5`.
### Сохраните код
Скопируйте код ниже в файл `research.py`.
### Запустите
```bash
export ZUKKO_API_KEY="fetch_ваш_ключ"
python research.py "Рынок электросамокатов в России в 2026 году"
```
Отчёт печатается и сохраняется в `report.md`, журнал шагов — в консоли.
### Посмотрите журнал
Там цена и время каждого шага. Исполнители пишут слабо — дайте им `claude-haiku-5-5`. Отчёт поверхностный — усильте планировщика.

## Код

```python
"""
Команда агентов: планировщик -> исполнители параллельно -> редактор.
pip install openai
python research.py "Рынок электросамокатов в России в 2026 году"
"""
import asyncio
import json
import os
import sys
import time
import urllib.request

from openai import AsyncOpenAI

PLANNER_MODEL = "claude-sonnet-5-5"   # или "claude-opus-5-5": сильнее и дороже
WORKER_MODEL = "deepseek-v4.1-flash"  # или "claude-haiku-5-5"
EDITOR_MODEL = "claude-sonnet-5"
MAX_SUBTASKS = 5
USE_WEB_SEARCH = True                 # исполнители ищут свежие данные через /v1/web-search
BUDGET_USD = 0.20                     # потолок на один отчёт

API_KEY = os.environ["ZUKKO_API_KEY"]
client = AsyncOpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key=API_KEY,
    timeout=300,     # модели с рассуждением могут думать минуту и дольше
    max_retries=2,   # 429 и 5xx библиотека повторит сама, с паузой
)
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:
    """Один запрос к модели + журнал: кто, сколько токенов, сколько денег, сколько секунд."""
    if spent["usd"] >= BUDGET_USD:
        raise RuntimeError(f"бюджет ${BUDGET_USD} исчерпан")
    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} вход / {usage.completion_tokens} выход, "
        f"${cost:.5f}, {time.monotonic() - started:.1f} с")
    return response.choices[0].message.content or ""

def parse_json(raw: str) -> dict:
    """Достаём JSON-объект из ответа, даже если модель добавила текст вокруг."""
    start, end = raw.find("{"), raw.rfind("}")
    if start == -1 or end == -1:
        raise ValueError("в ответе нет JSON-объекта")
    return json.loads(raw[start:end + 1])

# ---------- 1. Планировщик: делит задачу на подзадачи ----------

PLANNER_SYSTEM = f"""Ты руководитель исследования. Раздели тему на 3–{MAX_SUBTASKS} независимых подзадач,
которые можно выполнять параллельно и которые вместе покрывают тему.
Ответь только JSON без пояснений:
{{"subtasks": [{{"title": "короткое название", "question": "что выяснить, 1–2 предложения",
"search_query": "запрос для поиска в интернете"}}]}}"""

async def plan(topic: str) -> list[dict]:
    raw = await ask("планировщик", PLANNER_MODEL, PLANNER_SYSTEM, f"Тема: {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)}, нужно от 1 до {MAX_SUBTASKS}")
            for s in subtasks:
                if not (isinstance(s.get("title"), str) and isinstance(s.get("question"), str)):
                    raise ValueError("у подзадачи нет title или question")
            return subtasks
        except (ValueError, KeyError, TypeError) as error:
            if attempt == 1:
                raise RuntimeError(f"планировщик вернул негодный план: {error}")
            log(f"план не прошёл проверку ({error}), прошу переделать")
            raw = await ask("планировщик", PLANNER_MODEL, PLANNER_SYSTEM,
                            f"Тема: {topic}\n\nПрошлый ответ не прошёл проверку: {error}. "
                            "Пришли только JSON по формату.", 1500)

# ---------- 2. Исполнители: работают параллельно ----------

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 = """Ты аналитик. Ответь на свой вопрос по существу, 120–200 слов.
Опирайся на найденные источники, если они есть, и указывай адрес источника рядом с фактом.
Если данных не хватает — так и напиши, не выдумывай цифры."""

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:  # поиск не обязателен: без него исполнитель тоже ответит
            log(f"поиск для «{subtask['title']}» не удался: {error}")
    user = f"Вопрос: {subtask['question']}\n\nНайденные источники:\n{sources or 'нет'}"
    # Запас по длине: у моделей с рассуждением (DeepSeek, Kimi) размышления тоже
    # входят в max_tokens, и при 800 заметка обрывалась на середине фразы.
    return await ask(f"исполнитель: {subtask['title']}", WORKER_MODEL, WORKER_SYSTEM, user, 2500)

# ---------- 3. Редактор: собирает и проверяет ----------

EDITOR_SYSTEM = """Ты редактор. Из заметок аналитиков собери связный отчёт в Markdown на 400–600 слов:
короткое резюме, разделы по подзадачам, вывод. Убери повторы. Если заметки противоречат
друг другу или в них есть цифры без источника — отметь это прямо в тексте.
Ничего не добавляй от себя без пометки «оценка редактора»."""

async def research(topic: str) -> str:
    subtasks = await plan(topic)
    log(f"план: {[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):  # один упавший исполнитель не роняет весь отчёт
            log(f"исполнитель «{subtask['title']}» упал: {result}")
            result = "Эта часть не выполнена из-за ошибки — отметь пробел в отчёте."
        notes.append(f"## {subtask['title']}\n{result}")

    return await ask("редактор", EDITOR_MODEL, EDITOR_SYSTEM,
                     f"Тема: {topic}\n\nЗаметки аналитиков:\n\n" + "\n\n".join(notes), 2500)

if __name__ == "__main__":
    topic = " ".join(sys.argv[1:]) or "Рынок электросамокатов в России в 2026 году"
    report = asyncio.run(research(topic))
    with open("report.md", "w", encoding="utf-8") as f:
        f.write(report)
    print(report)
    log(f"готово, всего потрачено ${spent['usd']:.4f}, отчёт в report.md")
```

Стоимость каждого запроса код берёт из заголовка `X-Zukko-Cost-Micros`. Перед каждым запросом он сверяет потраченное с `BUDGET_USD` и останавливается, если потолок достигнут.

**Совет: Свой ключ на каждую роль**
Создайте три ключа: планировщику и редактору разрешите Claude, исполнителям — только дешёвые модели и маленький дневной лимит. Если исполнители зациклятся, лимит их остановит. Заведите клиента на каждую роль и передавайте нужного в `ask()`:

```python
    # ZUKKO_KEY_PLANNER, ZUKKO_KEY_WORKER, ZUKKO_KEY_EDITOR; если ключа роли нет — общий
    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)
```

**Сколько стоит: - Смешанная команда (`claude-sonnet-5-5` → `deepseek-v4.1-flash` → `claude-sonnet-5`): ≈ $0.0055 за отчёт, ≈ $5.50 за 1000.**
- Все роли на `claude-opus-5-5`: ≈ $0.029 за отчёт, ≈ $29 за 1000 — примерно в 5 раз дороже.
- Плюс веб-поиск: 5 поисков ≈ $0.0015 на отчёт.

## Готовые фреймворки

Укажите `base_url` и ключ Zukko. Точные имена параметров зависят от версии библиотеки.

| Библиотека | Настройка |
| --- | --- |
| OpenAI Agents SDK | `set_default_openai_client(AsyncOpenAI(base_url="https://api.zukko.pro/v1", api_key=...))` и `set_default_openai_api("chat_completions")` |
| LangGraph (через 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={...})` — для моделей не от OpenAI опишите возможности в `model_info` |

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| «планировщик вернул негодный план» | Уточните формат в `PLANNER_SYSTEM` или возьмите модель сильнее |
| `APITimeoutError` | Поднимите `timeout` до 300 секунд |
| «бюджет исчерпан» | Найдите в журнале дорогой шаг; поднимите `BUDGET_USD` или урежьте контекст |
| `429 key_daily_limit_reached` | Проверьте, нет ли зацикливания, затем поднимите лимит ключа |
| 402 | [Пополните баланс](/app/payments) — повтор не поможет |
| В отчёте выдуманные цифры | Оставьте `USE_WEB_SEARCH = True` и требование указывать источник |

Другие ошибки — [Ошибки API](/docs/api-reference/errors).

---

# LLM как судья: оценка ответов

Соберёте скрипт, который сравнивает две модели (или два промпта) на ваших вопросах: сильная модель-судья ставит оценки, скрипт сводит их в таблицу.

Схема: Набор вопросов (20–100 штук) → [Кандидат 1 (DeepSeek) / Кандидат 2 (Claude Haiku)] → Судья (Claude Sonnet 5.5) → Таблица оценок

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)), баланс (50 вопросов ≈ 10 центов), Python 3.10+, 20–100 настоящих вопросов клиентов с эталонами — короткими правильными ответами.

### Соберите вопросы
Возьмите реальные вопросы из чата поддержки или почты, простые и каверзные. К каждому напишите эталон: что обязательно должно прозвучать.
### Выберите судью
Судья сильнее кандидатов и не один из них: `claude-sonnet-5-5` или `gpt-6.1-sol`.
### Проверьте рубрику
Она в `GRADE_SYSTEM`: верность, полнота, ясность по шкале 1–5. Важно бизнесу «не обещать того, чего нет в правилах» — добавьте отдельный критерий.
### Запустите
Скопируйте код в `judge.py`, замените `RULES` и `DATASET` своими. Выполните `pip install openai pydantic`, затем `python judge.py`.
### Сверьте судью с собой
Откройте `judge_results.jsonl` и оцените 10–20 ответов сами. Часто расходитесь с судьёй — уточните рубрику или эталоны, а не верьте таблице.

## Код

Пример сравнивает `deepseek-v4.1-flash` и `claude-haiku-5-5` в роли поддержки магазина. Судья — `claude-sonnet-5-5`.

```python
"""
LLM как судья: какая из двух моделей лучше отвечает клиентам магазина.
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"]  # кого сравниваем
JUDGE_MODEL = "claude-sonnet-5-5"  # или "gpt-6.1-sol"; сильнее кандидатов и не один из них
CONCURRENCY = 5

client = AsyncOpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["ZUKKO_API_KEY"],
    timeout=180,
    max_retries=3,  # 429 и 5xx библиотека повторит сама
)
# Кэш Zukko: повторный запуск на тех же вопросах и тех же промптах ничего не стоит.
CACHE = {"X-Zukko-Cache": "true"}
sem = asyncio.Semaphore(CONCURRENCY)

# ---------- Что проверяем ----------

RULES = """Правила магазина:
- Возврат: 14 дней с покупки, товар без следов использования. Заявка: личный кабинет -> «Мои заказы» -> «Вернуть».
  Деньги возвращаются за 3–10 рабочих дней на ту же карту.
- Доставка курьером по Москве: 1–2 дня, 300 ₽, бесплатно от 5000 ₽. Пункты выдачи по России: 3–7 дней.
- Оплата: картой или через СБП на сайте. При получении можно оплатить только курьеру, в пункте выдачи — нельзя.
- Гарантия на электронику — 12 месяцев. Ремонт в сервисном центре, заявка через поддержку.
- Чат поддержки работает с 9:00 до 21:00 по Москве."""

CANDIDATE_SYSTEM = "Ты оператор поддержки интернет-магазина. Отвечай клиенту по правилам ниже, коротко и вежливо.\n\n" + RULES

# Тестовый набор: вопрос и эталон — что обязательно должно быть в хорошем ответе.
# Для настоящей проверки возьмите 20–100 реальных вопросов клиентов.
DATASET = [
    {"question": "Как вернуть товар, купленный 10 дней назад?",
     "reference": "Можно: срок 14 дней. Товар без следов использования. Заявка в личном кабинете: "
                  "«Мои заказы» -> «Вернуть». Деньги — за 3–10 рабочих дней на ту же карту."},
    {"question": "Сколько стоит доставка курьером по Москве?",
     "reference": "300 ₽, бесплатно при заказе от 5000 ₽, срок 1–2 дня."},
    {"question": "Можно оплатить наличными в пункте выдачи?",
     "reference": "Нет. В пункте выдачи оплатить нельзя — только заранее картой или СБП. "
                  "При получении можно оплатить только курьеру."},
    {"question": "Наушники сломались через 8 месяцев. Что делать?",
     "reference": "Действует гарантия 12 месяцев. Нужно оставить заявку через поддержку, "
                  "ремонт сделают в сервисном центре."},
    {"question": "Вы ночью отвечаете?",
     "reference": "Нет, чат работает с 9:00 до 21:00 по Москве."},
]

# ---------- Рубрика и формат ответа судьи ----------

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 = """Ты строгий экзаменатор. Оцени ответ оператора поддержки по эталону.
Критерии, каждый от 1 до 5:
- correctness: всё сказанное верно и не противоречит эталону; выдуманные факты — максимум 2;
- completeness: есть все важные пункты эталона;
- clarity: понятно и по делу. Длина сама по себе не достоинство: лишние абзацы снижают оценку.
score — общая оценка от 1 до 5. reasons — 1–2 предложения, почему.
Ответь только JSON: {"correctness": 1-5, "completeness": 1-5, "clarity": 1-5, "score": 1-5, "reasons": "..."}"""

PAIR_SYSTEM = """Ты строгий экзаменатор. Сравни два ответа оператора поддержки на один вопрос по эталону.
Лучше тот, который верен и полон; при равенстве — тот, что понятнее. Длина и порядок ответов
не важны. Если разницы по существу нет — tie.
Ответь только JSON: {"winner": "A" | "B" | "tie", "reasons": "1–2 предложения"}"""

def parse_json(raw: str) -> str:
    """Вырезаем JSON-объект, даже если модель обернула его в текст или 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:  # одна попытка исправиться
        raw = await chat(JUDGE_MODEL, system,
                         f"{user}\n\nТвой прошлый ответ не прошёл проверку: {error}. Пришли только JSON.", 600)
        return schema.model_validate_json(parse_json(raw))

async def grade(item: dict, answer: str) -> Grade:
    user = f"Вопрос: {item['question']}\nЭталон: {item['reference']}\nОтвет оператора: {answer}"
    return await ask_judge(GRADE_SYSTEM, user, Grade)

async def compare(item: dict, answer_a: str, answer_b: str) -> str:
    user = (f"Вопрос: {item['question']}\nЭталон: {item['reference']}\n\n"
            f"Ответ A:\n{answer_a}\n\nОтвет 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))

    # Оценки по рубрике + сравнение пары дважды: сначала first как A, потом second как 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")
    # Победа засчитывается, только если судья выбрал одно и то же при обоих порядках.
    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 r in results:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")

    print(f"\nВопросов: {len(results)}, судья: {JUDGE_MODEL}\n")
    print(f"{'Модель':<22}{'Балл':>6}{'Верно':>7}{'Полно':>7}{'Ясно':>6}{'Победы':>8}{'Поражения':>11}{'Ничьи':>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"\nСудья поменял мнение при перестановке ответов: {inconsistent} из {len(results)}")
    print("Подробности по каждому вопросу — в judge_results.jsonl")

if __name__ == "__main__":
    asyncio.run(main())
```

Результат — таблица такого вида (цифры зависят от моделей и вопросов):

```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

Судья поменял мнение при перестановке ответов: 1 из 5
```

## Как читать таблицу

- Каждую пару судья сравнивает дважды, поменяв ответы местами. Победа засчитывается, только если оба раза выбран один кандидат.
- Судья поменял мнение больше чем в трети вопросов — кандидаты почти равны или рубрика размыта.
- На 5 вопросах разница в 0,2 балла — случайность. Для решения берите 30–50 вопросов и смотрите на победы в парах.

## Что учесть

- `X-Zukko-Cache: true`: одинаковые запросы 7 дней отдаются из кэша бесплатно. Поменяли рубрику — платите только за новые запросы судьи. Нужны свежие ответы кандидатов — поставьте `CACHE = {}`.
- Не задавайте `temperature`: новые модели Claude могут отклонять нестандартные значения.

**Сколько стоит: - 50 вопросов, судья `claude-sonnet-5-5`: ≈ $0.09 за прогон; 100 вопросов ≈ $0.18.**
- Судья `gpt-6.1-sol`: около $0.06 за 50 вопросов.
- Повторный прогон без изменений почти бесплатен благодаря кэшу.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| `ValidationError` у судьи и после повтора | Уточните формат в промпте судьи или возьмите модель сильнее |
| У кандидатов почти одинаковые баллы | Добавьте каверзные вопросы, доведите набор до 30–50 |
| Судья часто меняет мнение при перестановке | Уточните критерии, смотрите на баллы по рубрике |
| Повторный запуск даёт те же ответы кандидатов | Это кэш. Нужны свежие — поставьте `CACHE = {}` |
| Высокий балл у ответа с выдумками | Дополните эталон, добавьте критерий «нет выдуманного» |

---

# Картинки: распознавание и генерация

Вы научите модель отвечать на вопросы по фото и скриншотам, а когда генерацию включат — рисовать картинки по описанию.

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)), баланс ([пополнить](/app/payments)), Python и `pip install openai`.

Схема: Фото, скриншот, чек → Ваш код (картинка + вопрос) → Zukko API → Модель с поддержкой картинок → Ответ текстом или JSON
Схема: Описание картинки → Ваш код → Zukko API (/v1/images/generations) → Модель изображений → Готовая картинка

## Вопрос по картинке

### Установите SDK
`pip install openai`
### Сохраните код
Скопируйте код ниже в `vision.py`.
### Задайте вопрос
```bash
python vision.py screenshot.png "Что означает эта ошибка и как её исправить?"
python vision.py https://example.com/photo.jpg "Опиши товар для карточки маркетплейса"
```
Картинку можно передать файлом или публичной ссылкой. Ссылки за логином и временные модель не скачает.

```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")  # модель должна принимать картинки

def image_to_data_url(path: str) -> str:
    """Превращает файл с картинкой в data URL: картинка едет прямо внутри запроса."""
    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 — путь к файлу на диске или публичная ссылка https://..."""
    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 "Что означает эта ошибка и как её исправить?"
    image = sys.argv[1]
    question = sys.argv[2] if len(sys.argv) > 2 else "Опиши, что на картинке."
    print(ask_about_image(image, question))
```

## Какую модель взять

Модель должна принимать картинки — см. столбец «Картинки на вход» в [Модели и цены](/docs/introduction/models-pricing).

| Модель | Вход / выход, $ за 1M токенов | Когда брать |
| --- | --- | --- |
| `claude-haiku-5-5` | 0.05 / 0.2 | Чеки, скриншоты, простые фото |
| `gpt-6-sol` | 0.13 / 0.63 | Универсальный вариант |
| `claude-sonnet-5` | 0.4 / 2 | Мелкий текст, таблицы, схемы |
| `qwen3.8-omni-flash` | 0.03 / 0.08 | Самый дешёвый |

Не принимают картинки: `deepseek-v4.1-flash`, `gemini-3.8-flash`, `kimi-k3.1-pro`, `glm-5.3`, `gpt-6-luna`. Актуальный список — `GET /v1/models/info`, поле `supports_vision`.

## Чек в JSON

Положите `receipt.py` рядом с `vision.py` и запустите: `python receipt.py check.jpg`. Код сверяет сумму строк с итогом — расхождения проверяйте вручную.

```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": "Распознай кассовый чек. sum — сумма по строке. "
                                             "Если поле не видно, верни пустую строку или 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)  # при ошибке разбора повторите запрос

    # Проверяем модель арифметикой: сумма строк должна сходиться с итогом
    items_total = sum(item["sum"] for item in data["items"])
    if abs(items_total - data["total"]) > 0.01:
        print(f"Внимание: строки дают {items_total}, а итог {data['total']} — проверьте вручную")
    return data

if __name__ == "__main__":
    print(json.dumps(read_receipt(sys.argv[1]), ensure_ascii=False, indent=2))
```

## В Telegram-боте

Добавьте обработчик в бота из статьи [Telegram-бот с нейросетью](/docs/guides/telegram-bot): пользователь шлёт фото с вопросом в подписи. Модель в `ZUKKO_MODEL` должна принимать картинки.

```python
import base64

async def on_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    photo = update.message.photo[-1]  # Telegram присылает несколько размеров, берём самый большой
    file = await photo.get_file()
    data = base64.b64encode(await file.download_as_bytearray()).decode()
    question = update.message.caption or "Что на этой картинке?"

    response = await client.chat.completions.create(
        model=MODEL,  # модель должна принимать картинки, например 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 "Пустой ответ."):
        await update.message.reply_text(part)

# в main():
# app.add_handler(MessageHandler(filters.PHOTO, on_photo))
```

## Генерация картинок

**Осторожно: Временно недоступна**
Модели изображений выключены, запросы получают `503 model_disabled`. Код заработает, когда их включат. Следите за `GET /v1/models` и [каналом Zukko](https://t.me/zukkopro?direct).

Эндпоинт — `POST /v1/images/generations` в формате OpenAI. Генерация идёт до 3 минут, поэтому таймаут клиента — 300 с. Для curl нужен `jq`. Все параметры — [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,  # генерация может идти до 3 минут
)

result = client.images.generate(
    model="openai-gpt-image-2-text-to-image",
    prompt="Уютная кофейня осенним вечером, тёплый свет из окон, акварель",
    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("Готово: 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": "Уютная кофейня осенним вечером, тёплый свет из окон, акварель",
    "size": "1024x1024",
    "quality": "high",
    "response_format": "b64_json"
  }' | jq -r '.data[0].b64_json' | base64 --decode > picture.png
```

**Сколько стоит: - Чек на `claude-haiku-5-5`: ≈ $0.00014, 1000 чеков в день — около **$4 в месяц**.**
- Чек на `claude-sonnet-5`: ≈ $0.0012, около **$37 в месяц**.
- Генерация — за каждую картинку по цене модели из каталога [«Модели»](/app/models). На балансе должна быть цена хотя бы одной картинки.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| 400 или модель «не видит» картинку | Возьмите модель с поддержкой картинок, например `claude-haiku-5-5` |
| Ошибка про ссылку на картинку | Скачайте картинку и отправьте файлом (data URL) |
| Модель путает цифры в чеке | Переснимите ближе, возьмите `claude-sonnet-5` |
| `json.JSONDecodeError` в `receipt.py` | Повторите запрос, для длинных чеков увеличьте `max_tokens` |
| `503 model_disabled` при генерации | Модели изображений временно выключены — следите за `GET /v1/models` |
| `402 insufficient_balance` или `504 image_generation_timeout` | 402 — [пополните баланс](/app/payments). 504 — повторите, упростите промпт |

Остальные коды — [Ошибки API](/docs/api-reference/errors).

---

# Как платить меньше

Десять приёмов: первые экономят в разы, последние страхуют от сюрпризов. Вы платите за токены входа и выхода, выход обычно в 4–5 раз дороже. Цены — в [Модели и цены](/docs/introduction/models-pricing).

## 1. Дешёвая модель для простых задач

Сортировку заявок, поиск телефона в письме, короткий перевод, орфографию отдайте `deepseek-v4.1-flash`, `qwen3.8-flash` или `claude-haiku-5-5`. `claude-sonnet-5` и `claude-opus-5-5` оставьте для кода, длинных рассуждений и важных текстов. Прогоните 20–30 своих запросов через дешёвую и сильную модель: разницы нет — берите дешёвую. Подробнее — [Как выбрать модель](/docs/introduction/choose-model).

**Пример:** разобрать 10 000 обращений по темам — `claude-opus-5-5` **$4.80**, `claude-haiku-5-5` **$0.29**, `deepseek-v4.1-flash` **$0.17**.

## 2. Дешёвый исполнитель, сильный проверяющий

Черновик пишет дешёвая модель, сильная только проверяет и выносит короткий вердикт. Как собрать — [Оркестрация агентов](/docs/guides/multi-agent).

**Пример:** 1000 описаний товаров — всё на `claude-opus-5-5` **$5.60**; пишет `deepseek-v4.1-flash`, проверяет `claude-opus-5-5` — **$2.96**.

## 3. Кэш промпта: неизменное — в начало

Инструкцию и базу знаний ставьте в начало запроса, а вопрос, дату, имя — в конец. Кэш работает сам, `cache_control` не нужен. Время в первой строке инструкции ломает кэш.

- `claude-sonnet-5` и `claude-opus-5-5`: в тесте из кэша читалось 40–70% повторяющейся части. Чтение кэша у Opus 5.5 — $0.08 вместо $0.8 за 1 млн токенов.
- Первая запись чуть дороже обычного входа (у Sonnet $0.5 вместо $0.4 за 1 млн), окупается со второго-третьего повтора.
- У DeepSeek кэш стоит как обычный вход. У `claude-haiku-5-5` кэш в тесте не срабатывал.

**Пример:** бот на `claude-sonnet-5` с инструкцией на 20 000 токенов, 1000 вопросов в день — **$8.64** в день без кэша, **$3.60–5.76** с кэшем.

## 4. Канал Standard для запросов без повторов

`claude-sonnet-5-standard` и `claude-opus-5-5-standard` берут одну цену за любой токен, вход и выход: $0.2 у Sonnet и $0.3 у Opus за 1 млн. Скидки за кэш нет. Выгодно, когда каждый запрос новый и модель много пишет: перевод, анкеты, описания, письма. Для бота с большой постоянной инструкцией невыгодно: чтение кэша у обычного Sonnet ($0.04) дешевле.

**Пример:** перевести 1000 документов — `claude-sonnet-5` **$7.20**, `claude-sonnet-5-standard` **$1.20**.

## 5. Кэш ответов `X-Zukko-Cache: true`

С этим заголовком точно такой же запрос (модель, текст, настройки) Zukko отдаст из сохранённого ответа бесплатно. Пригодится при перезапуске упавшего пайплайна.

- Хранится 7 дней, только успешный ответ до 2 МБ. Кэш свой у каждого аккаунта.
- В ответе будут `X-Zukko-Cache: hit` и `X-Zukko-Cost-Micros: 0`, в историю расходов он не попадает.
- Для Claude используйте заголовок, а не `temperature: 0`: новые модели Claude могут отклонять нестандартные `temperature`.

**Пример:** пайплайн на 1000 строк упал на 600-й — перезапуск без кэша **$2.00**, с кэшем **$0.80**. Подробнее — [Лимиты ключа, идемпотентность и кэш](/docs/api-reference/key-limits-and-cache).

## 6. `Idempotency-Key`: повтор не спишет дважды

Передайте уникальное значение — повтор с тем же значением вернёт сохранённый ответ без второго списания.

- Работает для `/v1/chat/completions`, `/v1/messages` и `/v1/responses`. Ответ хранится 24 часа.
- То же значение с другим текстом запроса получит `422 idempotency_conflict`.
- Повторяйте после того, как первый запрос завершился или упал. Два одновременных запроса оплачиваются оба.

```python
    model="deepseek-v4.1-flash",
    messages=[{"role": "user", "content": f"Определи тональность отзыва: {review_text}"}],
    extra_headers={"Idempotency-Key": f"reviews-2026-10-26-row-{row_id}"},
)
```

**Пример:** 10 000 запросов, 3% повторов из-за обрывов сети — без ключа лишние **$0.90** за прогон, с ключом **$0**.

## 7. Короткие ответы

Попросите в инструкции: «Отвечай в 2–3 предложениях», «Только JSON, без пояснений». `max_tokens` — только страховка: модель оборвётся на полуслове, ставьте с запасом. Рассуждение для простых задач не включайте.

**Пример:** бот на `claude-sonnet-5`, 1000 ответов в день, ответ с ~600 до ~200 токенов — экономия **~$24 в месяц**.

## 8. Короткая история переписки

Модель каждый раз получает всю переписку заново. Отправляйте последние 10–20 сообщений или раз в 20 сообщений заменяйте старые выжимкой от дешёвой модели. Инструкцию держите первой — она останется в кэше.

```python

def build_messages(system_prompt, history):
    # Инструкция всегда первая, из переписки — только последние сообщения
    return [{"role": "system", "content": system_prompt}] + history[-MAX_MESSAGES:]
```

**Пример:** 1000 диалогов по 40 сообщений на `claude-sonnet-5` — **$49** с полной историей, **$21** с окном в 10 сообщений.

## 9. Лимиты на ключ

В [кабинете](/app/api-keys) задайте ключу потолок расхода в день и месяц (после него запросы получают `429` и ничего не стоят) и список разрешённых моделей. Заведите отдельный ключ на каждого бота, агента или подрядчика. Лимиты мягкие: последний запрос может немного превысить потолок.

**Пример:** агент в цикле на `claude-opus-5-5` тратит **~$53 в час**. С дневным лимитом $2 потери — **около $2**.

## 10. Следите за расходами

- Кабинет: страница [«Использование»](/app/usage).
- API: [GET /v1/usage](/docs/api-reference/usage) и [GET /v1/balance](/docs/api-reference/balance).
- Заголовок `X-Zukko-Cost-Micros` в каждом непотоковом ответе: стоимость в миллионных долях доллара (`1500` = $0.0015).

Запустите пример дважды — второй ответ придёт из кэша, стоимость будет 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": "Столица Франции? Ответь одним словом."}],
    max_tokens=100,
    extra_headers={"X-Zukko-Cache": "true"},
)
resp = raw.parse()
print(resp.choices[0].message.content)
print("Кэш:", raw.headers.get("X-Zukko-Cache", "нет"))
print("Стоимость, $:", int(raw.headers.get("X-Zukko-Cost-Micros", "0")) / 1_000_000)
```
```javascript
// Сохраните как cost.mjs и запустите: 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: "Столица Франции? Ответь одним словом." }],
      max_tokens: 100,
    },
    { headers: { "X-Zukko-Cache": "true" } },
  )
  .withResponse();

console.log(data.choices[0].message.content);
console.log("Кэш:", response.headers.get("x-zukko-cache") ?? "нет");
console.log("Стоимость, $:", 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":"Столица Франции? Ответь одним словом."}]}' \
  | grep -i "x-zukko"
```

**Важно: У потоковых ответов (`stream: true`) заголовка `X-Zukko-Cost-Micros` нет — смотрите историю расходов. История общая для аккаунта: все ключи, веб-чат и Telegram-бот.**

---

# Подключение к программам

# Выбор провайдера

Что выбрать в настройках программы, чтобы она работала через Zukko.

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)) и баланс от $1 ([пополнить](/app/payments)). Ключ один для обоих режимов.

## Правило

| Провайдер в программе | Base URL | Модели |
| --- | --- | --- |
| **OpenAI** / OpenAI Compatible | `https://api.zukko.pro/v1` — **с** `/v1` | Любые |
| **Anthropic** | `https://api.zukko.pro` — **без** `/v1` | Claude |

Перепутали `/v1` — будет 404 на каждый запрос. Исключение: OpenCode (Anthropic, но с `/v1`).

## Популярные программы

| Программа | Провайдер | 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` |

Названия моделей — поле `id` в [списке моделей](https://api.zukko.pro/v1/models).

## Проверить ключ

Запрос баланса бесплатный. Подставьте свой ключ:

```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_..."
```

В ответе есть `balance_cents` — ключ рабочий.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| 404 на каждый запрос | Проверьте `/v1` по таблице выше |
| 404 `model_not_found` | Возьмите имя из [списка моделей](https://api.zukko.pro/v1/models) |
| 401 | Ключ с опечаткой или отозван — [создайте новый](/app/api-keys) |
| 402 | [Пополните баланс](/app/payments) |
| 403 `email_unverified` | Подтвердите почту в кабинете |

---

# Claude Code

Claude Code заработает через Zukko: без входа в Anthropic и без подписки, оплата — с баланса.

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)) и баланс от $1 ([пополнить](/app/payments)).

### Установите 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
```

Уже установлен, если работает `claude --version`. Если `claude.ai` отвечает 403 — ставьте через npm.
### Откройте файл настроек

```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"
```

### Вставьте настройки
Замените `fetch_...` на свой ключ и сохраните файл:

```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"
  }
}
```

**Осторожно: Файл был не пустой?**
Добавьте только блок `env`, остальное не трогайте. Лишняя запятая в конце ломает весь файл.

### Запустите
Откройте **новое** окно терминала:

```bash
claude
```

Напишите «Привет». Пришёл ответ — всё работает.

## Модели

| Слот в `/model` | Модель | Когда |
| --- | --- | --- |
| Opus | `claude-opus-5-5` | Сложные задачи |
| Sonnet | `claude-sonnet-5` | Каждый день |
| Haiku | `claude-haiku-5-5` | Фоновые мелочи |

Переключить слот — команда `/model`. Поставить другую модель — поменяйте имя в `settings.json` и перезапустите `claude`. Все модели — [Модели и цены](/docs/introduction/models-pricing).

**Совет: Дешевле**
Начинаете новую задачу — наберите `/clear`. Длинная переписка делает каждый запрос дороже.

## Расширение VS Code

1. Установите расширение **Claude Code** из Marketplace.
2. Файл `~/.claude/settings.json` из шагов выше уже должен быть — расширение читает его же.
3. В настройках VS Code включите **Claude Code: Disable Login Prompt**.
4. `Ctrl/Cmd + Shift + P` → **Developer: Reload Window**.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| Просит войти в Anthropic | Ключ должен быть в `ANTHROPIC_AUTH_TOKEN`, а не `ANTHROPIC_API_KEY`. Проверьте запятые в `settings.json`. Входили раньше в Anthropic — выполните `/logout` |
| 401 | Ключ с опечаткой или отозван — [создайте новый](/app/api-keys) |
| 402 | Закончился баланс — [пополните](/app/payments), перезапуск не нужен |
| 403 `email_unverified` | Подтвердите почту в кабинете |
| 404 на каждый запрос | Уберите `/v1`: адрес ровно `https://api.zukko.pro` |
| 404 `model_not_found` или 503 `model_disabled` | Поставьте модель из [списка](https://api.zukko.pro/v1/models) |
| Windows: `claude` is not recognized | Закройте PowerShell и откройте заново |

Другие ошибки — [Ошибка: что делать](/docs/help/troubleshooting).

---

# Cursor IDE

Cursor заработает с моделями Zukko, оплата — с баланса.

**Нужно:** платный **Cursor Pro** (на бесплатном тарифе свой адрес не задать), ключ `fetch_...` ([создать](/app/api-keys)) и баланс от $1 ([пополнить](/app/payments)).

### Откройте настройки
**Cursor Settings → Models**.
### Вставьте ключ
Включите **OpenAI API Key** и вставьте `fetch_...`.
### Впишите адрес
Включите **Override OpenAI Base URL**:

```text
```
### Добавьте модель
Имя — из таблицы ниже, например `cursor-sonnet-5`.
### Проверьте
Нажмите **Verify**, выберите модель в чате и напишите «Привет».

## Имена моделей

Обычные имена вроде `claude-sonnet-5` Cursor отправляет мимо Zukko. Поэтому пишите эти:

| Имя для Cursor | Модель |
| --- | --- |
| `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 |

Других моделей в таблице нет — для них подойдёт обычное имя, если Cursor его принимает: `gpt-6.1-sol`, `gemini-3.8-flash`, `deepseek-v4.1-flash`, `glm-5.3`. Старые имена (`cursor47`, `cursor46`, `cursor46s`, `cursorgpt54`, `cursorgpt55`) тоже работают.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| «Такая модель уже есть» | Возьмите имя из таблицы |
| Ответы идут мимо Zukko | Адрес ровно `https://api.zukko.pro/v1`, переключатель Override включён |
| 404 `model_not_found` или 503 `model_disabled` | Возьмите другое имя из таблицы |
| 401 | Ключ с опечаткой — [создайте новый](/app/api-keys) |
| 402 | [Пополните баланс](/app/payments) |
| 403 `email_unverified` | Подтвердите почту в кабинете |

---

# Расширения VS Code

Cline, Roo Code и Kilo Code заработают с моделями Zukko, оплата — с баланса.

**Нужно:** расширение из Marketplace, ключ `fetch_...` ([создать](/app/api-keys)) и баланс от $1 ([пополнить](/app/payments)).

| Расширение | Провайдер | Base URL | Модель |
| --- | --- | --- | --- |
| 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` |

Другие модели — поле `id` в [списке моделей](https://api.zukko.pro/v1/models).

## Cline

1. Панель Cline → шестерёнка **Settings**.
2. **API Provider** → **OpenAI Compatible**.
3. **Base URL**: `https://api.zukko.pro/v1`, **API Key**: `fetch_...`.
4. **Model ID**: `claude-sonnet-5`. Если просит размер контекста — `1000000` для Claude и GPT, `128000` для GLM.
5. Сохраните и напишите «Привет».

## Roo Code

1. Панель Roo Code → **Settings**.
2. **API Provider** → **Anthropic**.
3. **Anthropic API Key**: `fetch_...`.
4. Включите **Use custom base URL**: `https://api.zukko.pro` — без `/v1`.
5. **Model**: `claude-sonnet-5` или `claude-opus-5-5`. Сохраните и напишите «Привет».

Нужна не-Claude модель — выберите **OpenAI Compatible** с адресом `https://api.zukko.pro/v1`, как в Cline.

## Kilo Code

1. Панель Kilo Code → **Settings**.
2. **API Provider** → **OpenAI Compatible**.
3. **Base URL**: `https://api.zukko.pro/v1`, **API Key**: `fetch_...`.
4. **Model**: `claude-sonnet-5`.
5. Сохраните и напишите «Привет».

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| 404 на каждый запрос | OpenAI Compatible — с `/v1`, Anthropic — без |
| 404 `model_not_found` или 503 `model_disabled` | Возьмите модель из [списка](https://api.zukko.pro/v1/models) |
| 401 | Ключ с опечаткой — [создайте новый](/app/api-keys) |
| 402 | [Пополните баланс](/app/payments) |
| 403 `email_unverified` | Подтвердите почту в кабинете |

---

# Codex

Codex CLI заработает через Zukko, оплата — с баланса. Подходят модели GPT и Claude, GLM в Codex не работают.

**Нужно:** установленный Codex CLI, ключ `fetch_...` ([создать](/app/api-keys)) и баланс от $1 ([пополнить](/app/payments)).

## Установщик

{{CODEX_DOWNLOADS}}

1. Запустите файл: на macOS распакуйте архив и дважды кликните `codex-setup-macos.command`, на Windows откройте `codex-setup-windows.vbs`.
2. Вставьте ключ `fetch_...`.
3. Появилось `Codex configured for Zukko` — готово. Старые настройки сохранятся в резервную копию.

macOS не открывает файл — правой кнопкой → **Open**. Windows показывает SmartScreen — **More info** → **Run anyway**.

## Вручную

`~/.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
```

Запустите `codex` и напишите «Привет». Другая модель — в `config.toml` или при запуске: `codex -m gpt-5.6-luna`.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| 401 | Проверьте ключ в `auth.json` — [создайте новый](/app/api-keys) |
| 402 | [Пополните баланс](/app/payments) |
| 404 на каждый запрос | `base_url` ровно `https://api.zukko.pro/v1` |
| 404 `model_not_found` или 503 `model_disabled` | Возьмите модель GPT или Claude из [списка](https://api.zukko.pro/v1/models) |

---

# OpenCode

OpenCode заработает с моделями Claude через Zukko, оплата — с баланса.

**Нужно:** OpenCode ([установка](https://opencode.ai)), ключ `fetch_...` ([создать](/app/api-keys)) и баланс от $1 ([пополнить](/app/payments)).

### Откройте файл настроек

```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
```

Только для одного проекта — `opencode.json` в его корне.
### Вставьте настройки
Замените `fetch_...` на свой ключ и сохраните:

```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": {}
      }
    }
  }
}
```

**Осторожно: Здесь /v1 нужен**
В отличие от других программ с провайдером Anthropic, в OpenCode адрес — **с** `/v1`.

### Выберите модель
В папке проекта запустите `opencode`, наберите `/models` → **Anthropic → claude-sonnet-5**. Напишите «Привет».

Другие модели Claude добавляйте в блок `models` — имена из [списка моделей](https://api.zukko.pro/v1/models).

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| 404 на каждый запрос | В `baseURL` нужен `/v1`: `https://api.zukko.pro/v1` |
| Моделей Zukko нет в `/models` | Проверьте путь к файлу и запятые, перезапустите `opencode` |
| 404 `model_not_found` | Возьмите имя из [списка моделей](https://api.zukko.pro/v1/models) |
| 401 | Ключ с опечаткой — [создайте новый](/app/api-keys) |
| 402 | [Пополните баланс](/app/payments) |
| 403 `email_unverified` | Подтвердите почту в кабинете |

---

# Готовые агенты

# Hermes

[Hermes Agent](https://hermes-agent.nousresearch.com/) заработает с моделями Zukko, оплата — с баланса.

**Нужно:** ключ `fetch_...` ([создать](/app/api-keys)) и баланс от $1 ([пополнить](/app/payments)).

Выйдите из чата (`Ctrl+C` или `/quit`) и запустите мастер:

```bash
```

Выберите один из двух вариантов. Codex CLI и Claude Code OAuth — это подписки, с Zukko они не работают.

## Вариант 1: Anthropic

1. **Anthropic** → **API key** → вставьте `fetch_...`.
2. Модель: `claude-sonnet-5`.
3. В `~/.hermes/config.yaml` адрес **без** `/v1`:

```yaml
  provider: anthropic
  default: claude-sonnet-5
  base_url: https://api.zukko.pro
```

Ключ лежит в `~/.hermes/.env`: `ANTHROPIC_API_KEY=fetch_...`.

## Вариант 2: OpenAI

Подходит для любых моделей, не только Claude.

1. **OpenAI** → **direct OpenAI API** → вставьте `fetch_...`.
2. Модель: `claude-sonnet-5` или `gpt-5.6-sol`.
3. В `~/.hermes/.env` адрес **с** `/v1`:

```bash
OPENAI_BASE_URL=https://api.zukko.pro/v1
```

Или в `~/.hermes/config.yaml`:

```yaml
  provider: openai-api
  default: claude-sonnet-5
  base_url: https://api.zukko.pro/v1
```

Запустите `hermes` и напишите «Привет». Сменить модель в чате — `/model`.

## Если не работает

| Что видите | Что сделать |
| --- | --- |
| 401 | Проверьте ключ `fetch_...`, без пробелов |
| 402 | [Пополните баланс](/app/payments) |
| 404 в варианте Anthropic | Уберите `/v1` из `base_url` |
| Вариант OpenAI ходит не туда | Адрес `https://api.zukko.pro/v1`, провайдер `openai-api` |
| 404 `model_not_found` или 503 `model_disabled` | Возьмите модель из [списка](https://api.zukko.pro/v1/models) |

---

# OpenClaw

[OpenClaw](https://docs.openclaw.ai/) подключает Zukko как custom provider. Конфиг: `~/.openclaw/openclaw.json`. Ключ: `fetch_...` ([API-ключи](/app/api-keys)).

| Вариант | api | baseUrl |
| --- | --- | --- |
| OpenAI Compatible | `openai-completions` | `https://api.zukko.pro/v1` |
| Anthropic Messages | `anthropic-messages` | `https://api.zukko.pro` |

## Через 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
```

Для варианта Anthropic: `--custom-base-url "https://api.zukko.pro"` (без `/v1`) и `--custom-compatibility anthropic`. Флаги onboard меняются от версии к версии — сверьтесь с `openclaw onboard --help`.

## Вручную: OpenAI Compatible

Модель custom-провайдера должна быть описана в `models.providers.<провайдер>.models[]` — иначе OpenClaw о ней не знает.

```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` задаёт только короткие имена (alias) и настройки моделей — он ничего не ограничивает.

## Вручную: Anthropic Messages

Тот же файл, но в провайдере:

```json5
api: "anthropic-messages",
```

Base URL — без `/v1`.

## Применить и проверить

Gateway подхватывает изменения `openclaw.json` сам. Если OpenClaw пишет `Restart the gateway to apply`, перезапустите Gateway. Проверка:

```bash
openclaw models list
```

## Если не работает

- **Модели нет в списке** — проверьте, что она описана в `models.providers.zukko.models[]`, а ID совпадает с `GET /v1/models`.
- **Модель запрещена** — если у вас задан `agents.defaults.modelPolicy.allow`, добавьте туда `zukko/<id>` (или `zukko/*`).
- **401** — ключ `fetch_...` и URL по таблице выше (с `/v1` или без).
- **404 `model_not_found`** — такой модели нет в каталоге: проверьте имя по `GET /v1/models`.
- **503 `model_disabled`** — модель временно выключена, возьмите другую из `GET /v1/models`.

---

# Справочник API

# Аутентификация

Запросы к Zukko API подписываются ключом `fetch_...`. Ключ создаётся на [странице API-ключей](/app/api-keys) и показывается один раз.

## Как передать ключ

Подходит любой из двух заголовков:

```bash
x-api-key: fetch_YOUR_KEY
```

`Authorization: Bearer` используют OpenAI SDK и большинство IDE. `x-api-key` отправляют Anthropic SDK, Roo Code и другие Anthropic-клиенты — менять их настройки не нужно. Передавать ключ в query string (`?api_key=...`) нельзя: такой запрос получит `401`.

## anthropic-version

Zukko этот заголовок не проверяет и не требует. Если клиент его отправляет (Anthropic SDK делает это сам), он передаётся провайдеру как есть — оставляйте.

## Что требует ключа

| Endpoint | Ключ |
| --- | --- |
| `GET /v1/models`, `GET /v1/models/{id}`, `GET /v1/models/info` | не нужен, ключ игнорируется |
| `GET /api/status` | не нужен |
| `GET /v1/balance`, `GET /v1/usage` | нужен |
| `POST /v1/chat/completions`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/responses`, `/v1/images/generations`, `/v1/web-search` | нужен, плюс подтверждённый email и баланс от $0.01 |

Один и тот же ключ работает со всеми endpoint'ами и всеми моделями — если в настройках ключа не ограничен список моделей.

## Ошибки авторизации

Нет ключа, опечатка, отозванный ключ — ответ `401`:

```json
  "type": "error",
  "error": {
    "type": "authentication_error",
    "message": "Invalid API key. Use Authorization: Bearer fetch_*. Поддержка — https://t.me/zukkopro?direct, напишите, пожалуйста."
  }
}
```

Email не подтверждён — `403` с кодом `email_unverified`. Остальные коды — в разделе [Ошибки API](/docs/api-reference/errors).

## Пример

```bash
  -H "Authorization: Bearer fetch_YOUR_KEY"
```

## Правила безопасности

- используйте только HTTPS: `https://api.zukko.pro`;
- не храните ключ в коде и репозитории — берите его из переменных окружения;
- заведите отдельный ключ под каждое приложение и ограничьте его [лимитами](/docs/api-reference/key-limits-and-cache): утёкший ключ можно отозвать, не трогая остальные.

---

# Ошибки API

Zukko отвечает на ошибки обычным HTTP-статусом и JSON-телом в формате OpenAI. Разбирайте ошибку по `status` и `error.code`, а не по тексту: текст собственных ошибок Zukko на русском и может меняться.

## Формат ошибки

```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` есть всегда;
- `error.code` — у всех ошибок Zukko, кроме `401`;
- `error.type` и `error.param` — там, где применимо.

К каждому сообщению об ошибке в конце дописывается строка поддержки: «Поддержка — https://t.me/zukkopro?direct, напишите, пожалуйста.» У `401` есть ещё поле верхнего уровня `"type": "error"` и `error.type: "authentication_error"`. У `403 email_unverified` поля `message` и `code` продублированы и на верхнем уровне.

## Коды ошибок

| Статус | Код | Когда | Повторять? |
| --- | --- | --- | --- |
| 400 | `model_retired` | модель снята с обслуживания; в сообщении — на какую перейти | нет, смените модель |
| 400 | `invalid_json` | тело не JSON (GLM, веб-поиск, изображения) | нет, исправьте запрос |
| 400 | `invalid_request` | веб-поиск без `query` | нет |
| 400 | `invalid_request_error` | изображения: нет `prompt` или `images` для edit-модели | нет |
| 400 | `unknown_glm_model` | неизвестная модель GLM | нет |
| 401 | — (`authentication_error`) | ключа нет, он с опечаткой или отозван | нет |
| 402 | `insufficient_balance` | на балансе меньше $0.01 (для изображений — меньше цены картинки) | после пополнения |
| 403 | `email_unverified` | email не подтверждён | после подтверждения |
| 403 | `model_not_allowed_for_key` | модели нет в списке разрешённых для этого ключа | нет |
| 404 | `model_not_found` | модели нет в каталоге; запрос не уходит к провайдеру и не оплачивается | нет, исправьте имя по `GET /v1/models` |
| 404 | `not_found` | несуществующий путь | нет |
| 405 | `method_not_allowed` | неверный HTTP-метод | нет |
| 413 | `payload_too_large` | тело запроса больше 32 МБ | нет, уменьшите запрос |
| 422 | `idempotency_conflict` | `Idempotency-Key` уже использован с другим телом | нет, новый ключ |
| 429 | `key_daily_limit_reached` | исчерпан дневной лимит ключа | после 00:00 UTC или поднимите лимит |
| 429 | `key_monthly_limit_reached` | исчерпан месячный лимит ключа | с нового месяца (UTC) или поднимите лимит |
| 429 | код провайдера | лимит на стороне провайдера модели | да, с паузой |
| 502 | `upstream_unreachable` | провайдер модели не ответил | да, с паузой |
| 502 | `web_search_failed` | веб-поиск не выполнился | да |
| 502 | `image_generation_failed` | генерация изображения не удалась | да |
| 503 | `model_disabled` | модель временно выключена | нет, возьмите другую из `GET /v1/models` |
| 503 | `provider_unavailable` | провайдер модели временно недоступен | да, с паузой или другой моделью |
| 503 | `maintenance` | технические работы, есть заголовок `Retry-After: 600` | да, через `Retry-After` секунд |
| 503 | `image_provider_unavailable`, `image_provider_busy` | сервис изображений недоступен или перегружен | да, позже |
| 504 | `image_generation_timeout` | генерация изображения заняла больше 3 минут | да |

Собственного ограничения частоты запросов у Zukko нет. `429` от Zukko — это только лимиты ключа, которые вы задали сами. Любой другой `429` пришёл от провайдера модели.

## Ошибки провайдера

Ошибки, которые вернул провайдер модели, Zukko отдаёт с тем же статусом и текстом — например, превышение контекста или неверный параметр. Исключения:

- если провайдер ответил `402`, клиент получает `503 provider_unavailable` — это не ваш баланс;
- если текст ошибки ничего не объясняет, Zukko заменяет его своим: для 5xx — «Провайдер модели временно недоступен, повторите запрос позже», для 4xx — «Провайдер модели отклонил запрос, проверьте параметры»;
- к ошибкам 5xx Zukko дописывает список моделей, которые сейчас работают.

`500` с телом `{"error": "Internal server error"}` означает сбой на стороне Zukko — повторите запрос и, если не помогло, напишите в поддержку.

## Ошибки внутри потока

При `stream: true` статус `200` приходит раньше, чем ответ модели. Если ошибка случилась уже после этого, она придёт внутри потока событием с полем `error`. Проверяйте события потока, а не только HTTP-статус. Поток, в котором была только ошибка — без текста и без usage, — не оплачивается. Подробнее — [Стриминг](/docs/api-reference/streaming).

## Как повторять запросы

- повторяйте `429` от провайдера, `500`, `502`, `503` (кроме `model_disabled`) и `504` с экспоненциальной паузой;
- при `503 maintenance` ждите столько секунд, сколько указано в `Retry-After`;
- остальные `4xx` не повторяйте — исправьте запрос, ключ или баланс;
- добавляйте заголовок `Idempotency-Key`: повтор уже выполненного запроса вернёт сохранённый ответ и не спишет деньги второй раз. Отправляйте повтор после того, как первый запрос завершился, — см. [идемпотентность](/docs/api-reference/key-limits-and-cache).

---

# Стриминг

Передайте `"stream": true` — и ответ придёт по частям в формате Server-Sent Events (SSE). Формат событий зависит от endpoint'а и совпадает с оригинальным API, поэтому официальные SDK разбирают его сами.

## Chat Completions

`POST /v1/chat/completions` отдаёт строки `data:` с фрагментами ответа и завершается `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]
```

Для моделей Claude и GPT Zukko сам добавляет в запрос `stream_options: {"include_usage": true}`. Поэтому последний фрагмент перед `[DONE]` содержит `usage` и **пустой** массив `choices`. Код, который без проверки читает `chunk.choices[0]`, на нём упадёт:

```python
    if chunk.choices:
        print(chunk.choices[0].delta.content or "", end="", flush=True)
    elif chunk.usage:
        print(f"\n[токены: {chunk.usage.prompt_tokens} вход, {chunk.usage.completion_tokens} выход]")
```

## Messages

`POST /v1/messages` отдаёт события Anthropic: `message_start`, `content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta` и `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":"Привет"}}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}

event: message_stop
data: {"type":"message_stop"}
```

Модели GLM через `/v1/messages` тоже стримят в этом формате: Zukko переводит их ответ в события Anthropic.

## Responses

`POST /v1/responses` отдаёт именованные события: `response.created`, `response.output_item.added`, `response.output_text.delta`, `response.output_item.done`, `response.completed` и другие. Полный список — на странице [POST /v1/responses](/docs/api-reference/responses).

## Ошибки внутри потока

HTTP-статус `200` отправляется до того, как модель начала отвечать. Если ошибка случилась позже, она придёт событием внутри потока — строкой `data:` с полем `error`. Проверяйте события, а не только статус ответа.

Поток, в котором пришла только ошибка — без текста и без usage, — не оплачивается.

## Стоимость и прерывание

- У потокового ответа нет заголовков `X-Zukko-Cost-Micros` и `X-Fetch-Balance-Cents`: стоимость становится известна только в конце. Смотрите её в [GET /v1/usage](/docs/api-reference/usage) или в кабинете.
- Если клиент закрыл соединение раньше времени, Zukko всё равно дочитывает ответ модели до конца и списывает всё сгенерированное. Прерывание потока не экономит деньги — чтобы ограничить расход, задайте `max_tokens`.
- Потоковые ответы тоже попадают в кэш и в `Idempotency-Key`, если их размер не больше 2 МБ; потоковая и обычная версии одного запроса хранятся отдельно.

## Примеры для SDK

Готовый код со стримингом — на страницах [Python](/docs/api-reference/python) и [Node.js](/docs/api-reference/nodejs).

---

# Python

Zukko работает с официальными SDK — менять код не нужно, только `base_url` и ключ.

| 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": "Привет!"}],
)
print(r.choices[0].message.content)
```

В `model` можно передать и модель OpenAI, например `gpt-5.6-sol`, и дешёвую `glm-5.3-flash`.

### Потоковый ответ

```python
    model="claude-sonnet-5",
    messages=[{"role": "user", "content": "Напиши хайку про код"}],
    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[токены: {chunk.usage.prompt_tokens} вход, {chunk.usage.completion_tokens} выход]")
```

Проверка `if chunk.choices` обязательна. Zukko включает `stream_options.include_usage`, и в конце потока приходит отдельный фрагмент с `usage` и пустым `choices` — без проверки `chunk.choices[0]` упадёт с `IndexError`.

### Стоимость запроса

Заголовок `X-Zukko-Cost-Micros` (в микродолларах) доступен через `with_raw_response`:

```python
    model="glm-5.3-flash",
    messages=[{"role": "user", "content": "Привет!"}],
)
print(raw.headers.get("x-zukko-cost-micros"))
r = raw.parse()
```

Заголовок есть только у обычных (не потоковых) ответов.

## 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": "Привет!"}],
)
print(msg.content[0].text)
```

### Потоковый ответ

```python
    model="claude-sonnet-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Напиши хайку про код"}],
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)
```

## Ключ из переменных окружения

Не храните ключ в коде:

```bash
export OPENAI_API_KEY="fetch_..."
```

```python

client = OpenAI()  # возьмёт OPENAI_BASE_URL и OPENAI_API_KEY
```

Любую модель из каталога можно подставить в `model` — список: `GET /v1/models`. Коды ошибок — в разделе [Ошибки API](/docs/api-reference/errors).

---

# Node.js

Официальные SDK `openai` и `@anthropic-ai/sdk` работают с Zukko без изменений кода — меняются только base URL и ключ. Примеры ниже — ES-модули (Node.js 18+, файл `.mjs` или `"type": "module"` в `package.json`).

| SDK | Base URL | Key |
| --- | --- | --- |
| **OpenAI** (`openai`) | `https://api.zukko.pro/v1` | `fetch_...` |
| **Anthropic** (`@anthropic-ai/sdk`) | `https://api.zukko.pro` | `fetch_...` |

Ключ удобно держать в переменной окружения:

```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: "Привет!" }],
});
console.log(r.choices[0].message.content);
```

### Потоковый ответ

```js
  model: "claude-sonnet-5",
  messages: [{ role: "user", content: "Напиши хайку про код" }],
  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("\nтокены:", chunk.usage.prompt_tokens, "вход,", chunk.usage.completion_tokens, "выход");
  }
}
```

Проверка `chunk.choices.length` обязательна: Zukko включает `stream_options.include_usage`, и последний фрагмент потока приходит с `usage` и пустым `choices`.

### Стоимость запроса

```js
  .create({ model: "glm-5.3-flash", messages: [{ role: "user", content: "Привет!" }] })
  .withResponse();

console.log(response.headers.get("x-zukko-cost-micros")); // микродоллары
console.log(data.choices[0].message.content);
```

Заголовок есть только у обычных (не потоковых) ответов.

## 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: "Привет!" }],
});
console.log(msg.content[0].text);
```

Base URL — без `/v1`: SDK сам добавит `/v1/messages`.

### Потоковый ответ

```js
  model: "claude-sonnet-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Напиши хайку про код" }],
});

stream.on("text", (text) => process.stdout.write(text));
const final = await stream.finalMessage();
console.log("\nтокены на выходе:", final.usage.output_tokens);
```

## Если что-то не так

Коды ошибок и что с ними делать — в разделе [Ошибки API](/docs/api-reference/errors). Формат потоков — в разделе [Стриминг](/docs/api-reference/streaming).

---

# POST /v1/chat/completions

OpenAI-совместимый endpoint для чата и генерации текста. Работает со всеми текстовыми моделями: Claude, GPT, Gemini, Qwen, DeepSeek и GLM.

## Пример запроса

```bash
  -H "Authorization: Bearer fetch_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"Hello"}]}'
```

## Параметры

| Параметр | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `model` | string | да | ID модели из `GET /v1/models` |
| `messages` | array | да | массив сообщений |
| `max_tokens` | integer | нет | максимум токенов в ответе |
| `temperature` | float | нет | температура генерации (0–2); при `0` включается [кэш Zukko](/docs/api-reference/key-limits-and-cache). Новые модели Claude могут отклонять нестандартные значения — для них не передавайте |
| `stream` | boolean | нет | потоковый ответ (SSE) |
| `top_p` | float | нет | nucleus sampling |
| `reasoning_effort` | string | нет | для GLM: `high` включает рассуждение; для остальных моделей передаётся провайдеру как есть |

Остальные поля OpenAI Chat Completions передаются провайдеру модели без изменений.

## Особенности

- Для моделей GLM Zukko поднимает `max_tokens` минимум до 256, а при включённом рассуждении — до 4096: при меньшем окне ответ приходит пустым.
- При `stream: true` для Claude и GPT в конце потока приходит фрагмент с `usage` и пустым `choices` — см. [Стриминг](/docs/api-reference/streaming).
- У обычного (не потокового) ответа есть заголовки `X-Zukko-Cost-Micros` и `X-Fetch-Balance-Cents`.
- Кэш: у GPT прочитанные из кэша токены (`prompt_tokens_details.cached_tokens`) оплачиваются по цене чтения кэша. Для Claude в этом формате разбивки на кэш нет — весь вход идёт по цене входа; скидку за кэш у Claude даёт [POST /v1/messages](/docs/api-reference/messages). Цены — в разделе [Модели и цены](/docs/introduction/models-pricing).

---

# POST /v1/images/generations

OpenAI-совместимый endpoint для генерации и редактирования изображений (Nano Banana, GPT Image).

**Модели изображений сейчас временно недоступны:** запросы к ним получают `503 model_disabled`. Формат ниже начнёт работать, когда модели включат снова, — следите за `GET /v1/models`.

## Пример запроса

```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"
  }'
```

## Параметры

- `model` — ID image-модели, обязателен;
- `prompt` — описание изображения или правки, обязателен;
- `images` — массив URL или data URL входных картинок: обязателен для edit-моделей, для reference-моделей нужен `images` или `video_clips`;
- `response_format` — `"b64_json"`, чтобы получить картинку в base64 вместо ссылки;
- `size`, `quality`, `output_format`, `moderation` — для GPT Image;
- `aspect_ratio`, `resolution` — для Nano Banana;
- `n`, `seed` — если поддерживает модель.

## Ответ

```json
  "created": 1735689600,
  "model": "openai-gpt-image-2-text-to-image",
  "data": [{ "url": "https://cdn.example.com/outputs/result.png" }]
}
```

С `response_format: "b64_json"` вместо `url` приходит `b64_json`.

## Стоимость

Оплата за каждое сгенерированное изображение по цене модели. Перед генерацией баланс должен покрывать цену одной картинки, иначе `402 insufficient_balance`.

## Ошибки

- `400 invalid_request_error` — нет `prompt` или `images` для edit-модели;
- `402 insufficient_balance` — не хватает на картинку;
- `404 model_not_found` — неизвестная image-модель;
- `503 model_disabled` — модель временно выключена;
- `503 image_provider_unavailable` / `image_provider_busy` — сервис изображений недоступен или перегружен;
- `502 image_generation_failed`, `504 image_generation_timeout` — генерация не удалась или заняла больше 3 минут.

---

# POST /v1/messages

Anthropic-совместимый endpoint — родной формат для моделей Claude. Его используют Claude Code, Anthropic SDK, Roo Code и другие Anthropic-клиенты.

## Пример запроса

```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"}]}'
```

Ключ можно передать и в заголовке `x-api-key`. Заголовок `anthropic-version` Zukko не требует, но если он есть — передаёт провайдеру.

## Что передавать

- `model` — ID модели: `claude-sonnet-5`, `claude-opus-5-5` или любая другая из [каталога](/docs/introduction/models-pricing);
- `messages` — массив сообщений;
- `max_tokens` — лимит генерации, обязателен;
- `system`, `stream`, `temperature`, `top_p`, `top_k`, `tools`, `tool_choice`, `stop_sequences`, `metadata` — опционально, как в Anthropic Messages API.

## Кэш промптов

Для Claude этот формат — единственный, где разбивка входа на кэш видна прямо в ответе: `usage.cache_read_input_tokens` оплачивается по цене чтения кэша, `usage.cache_creation_input_tokens` — по цене записи, остальной вход — по цене входа. Итоговое списание в любом формате пересчитывается по фактическому подсчёту поставщика примерно через минуту. Цены — в разделе [Модели и цены](/docs/introduction/models-pricing).

## Модели GLM

`glm-5.3` и `glm-5.3-flash` тоже можно вызывать через `/v1/messages`: Zukko переводит запрос в формат OpenAI, а ответ и поток — обратно в формат Anthropic. Поле `thinking` с `"type": "enabled"` включает у GLM рассуждение.

Формат потоковых событий — в разделе [Стриминг](/docs/api-reference/streaming).

---

# POST /v1/messages/count_tokens

Считает входные токены запроса в формате Anthropic Messages, не запуская генерацию. Работает только с моделями Claude.

## Пример запроса

```bash
  -H "Authorization: Bearer fetch_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"claude-sonnet-5","system":"Ты ассистент","messages":[{"role":"user","content":"Привет!"}]}'
```

Тело — как у `POST /v1/messages`, `max_tokens` не нужен.

## Ответ

```json
  "input_tokens": 12
}
```

## Стоимость и требования

- Запрос платный: посчитанные токены списываются по цене входа модели. 12 токенов на `claude-sonnet-5` ($0.2 за 1M) стоят 2.4 микродоллара, после округления до целого — $0.000002.
- Как и для генерации, нужны подтверждённый email и баланс не меньше $0.01; действуют лимиты ключа.
- Кэш Zukko и `Idempotency-Key` к этому endpoint'у не применяются.
- Работает для Claude, GPT, Gemini, Qwen и DeepSeek. Для GLM подсчёт не поддерживается.

---

# GET /v1/models

Список доступных моделей в формате OpenAI. Endpoint публичный: ключ не нужен, а если передан — игнорируется. Запрос бесплатный.

## Пример запроса

```bash
```

## Ответ

```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" }
  ]
}
```

## Что попадает в список

- Только включённые модели. Выключенные не показываются и на запрос отвечают `503 model_disabled`.
- Модели, которые прямо сейчас не проходят проверку работоспособности, временно скрываются из списка. По явному ID их по-прежнему можно вызвать — запрос дойдёт до провайдера.
- Поле `created` условное и одинаковое у всех моделей.
- В примере выше список сокращён: на деле в ответе все доступные текстовые модели.

Цены и лимиты моделей — в [GET /v1/models/info](/docs/api-reference/models-info).

## GET /v1/models/{id}

Одна модель по ID. Тоже публичный.

```bash
```

```json
```

- Понимает вторые имена и другие варианты написания: `GET /v1/models/cursor-sonnet-5` и `GET /v1/models/anthropic%2Fclaude-sonnet-5` вернут `"id": "claude-sonnet-5"`.
- Возвращает `200` для любой модели из каталога, в том числе выключенной. Доступна ли модель сейчас, смотрите по списку `GET /v1/models`.
- Неизвестный ID — `404 model_not_found`; снятая модель — `404 model_retired` с подсказкой, на какую перейти.

---

# GET /v1/models/info

Расширенный каталог: названия, контекст, возможности и цены моделей. Endpoint публичный: ключ не нужен, а если передан — игнорируется. Цены одинаковые для всех аккаунтов.

## Пример запроса

```bash
```

## Ответ

```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
    }
  ]
}
```

## Поля ответа

| Поле | Тип | Описание |
| --- | --- | --- |
| `id` | string | ID модели для поля `model` |
| `display_name` | string | читаемое название |
| `max_input_tokens` | int | контекстное окно |
| `max_output_tokens` | int или null | максимум выходных токенов; `null` — Zukko свой предел не задаёт |
| `supports_thinking` | bool | поддержка рассуждения |
| `supports_streaming` | bool | поддержка SSE-стриминга |
| `supports_vision` | bool | приём изображений на вход |
| `input_price_per_1m` | float | USD за 1M входных токенов |
| `output_price_per_1m` | float | USD за 1M выходных токенов |
| `cache_write_price_per_1m` | float | USD за 1M токенов записи в кэш |
| `cache_read_price_per_1m` | float | USD за 1M токенов чтения из кэша |

Кэш тарифицируется отдельно от обычного входа — как именно, описано в разделе [Модели и цены](/docs/introduction/models-pricing). У моделей, где обе цены кэша равны цене входа, отдельной скидки за кэш нет.

В список попадают те же модели, что и в [GET /v1/models](/docs/api-reference/models): выключенные и временно не прошедшие проверку не показываются.

---

# GET /v1/balance

Текущий баланс аккаунта и суммарный расход токенов. Нужен ключ; запрос бесплатный.

## Пример запроса

```bash
  -H "Authorization: Bearer fetch_YOUR_KEY"
```

## Ответ

```json
  "balance_cents": 1234,
  "total_input_tokens": 5000000,
  "total_output_tokens": 1200000,
  "total_tokens_used": 6200000
}
```

| Поле | Тип | Описание |
| --- | --- | --- |
| `balance_cents` | int | баланс в центах USD, округлён вниз: `1234` = $12.34. Может быть отрицательным |
| `total_input_tokens` | int | входные токены за всё время |
| `total_output_tokens` | int | выходные токены за всё время |
| `total_tokens_used` | int | сумма входных и выходных |

## Как устроен баланс

- Баланс общий для аккаунта: все ключи тратят одни и те же деньги. Счётчики токенов тоже считаются по всему аккаунту и включают расход в веб-чате и Telegram-боте, а не только через API.
- Резервирования нет: деньги списываются после ответа модели. Запрос пропускают, если на балансе не меньше $0.01, иначе `402 insufficient_balance`. Дорогой запрос, начатый с маленьким остатком, может увести баланс в минус — тогда следующие запросы получат `402` до пополнения.
- Сразу после запроса остаток виден и в заголовке `X-Fetch-Balance-Cents` — он в микродолларах (1 000 000 = $1), подробнее в разделе [Оплата и списания](/docs/introduction/billing).

---

# GET /v1/usage

История списаний аккаунта, от новых к старым, с курсорной пагинацией. Нужен ключ; запрос бесплатный.

## Пример запроса

```bash
  -H "Authorization: Bearer fetch_YOUR_KEY" \
  --data-urlencode "limit=50"
```

## Параметры

| Параметр | Тип | Описание |
| --- | --- | --- |
| `limit` | int | записей на странице, от 1 до 100, по умолчанию 20 |
| `cursor` | string | значение `next_cursor` из предыдущего ответа |

## Ответ

```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"
}
```

| Поле | Тип | Описание |
| --- | --- | --- |
| `created_at` | string | время списания, ISO 8601 в UTC |
| `model` | string | отображаемое название модели (например, `Claude Sonnet 5`) или служебное имя вроде `web-search` |
| `input_tokens` | int | входные токены, включая кэш |
| `output_tokens` | int | выходные токены (для изображений — число картинок) |
| `cache_read_tokens` | int | из входа прочитано из кэша |
| `cache_write_tokens` | int | из входа записано в кэш |
| `total_tokens` | int | сумма входных и выходных |
| `cost_cents` | float | стоимость в центах с дробной частью: `0.0546` = $0.000546 |

## Пагинация

Курсор имеет вид `<время>|<числовой id записи>`. Передавайте `next_cursor` в параметр `cursor` как есть, но обязательно URL-кодируйте его: в нём есть `+` и `|`, и незакодированный `+` превратится в пробел — тогда вернётся первая страница. Удобнее всего `curl -G --data-urlencode`:

```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` — записей больше нет.

## Что попадает в историю

- Все списания аккаунта, а не только этого ключа: другие ключи, веб-чат, Telegram-бот, веб-поиск.
- Ответы из кэша Zukko и повторы по `Idempotency-Key` бесплатны и в историю не попадают.

---

# Лимиты ключа, идемпотентность и кэш

Три вещи, которые нужны агентам и пайплайнам больше, чем скидка: контроль расхода, защита от двойной оплаты и бесплатные повторы.

## Лимиты на ключ

У каждого ключа в кабинете можно задать потолок расхода в день и в месяц и список разрешённых моделей. Ключ для эксперимента — не больше $2 в день; ключ для бота поддержки — только `glm-5.3-flash`.

Проверка идёт до похода к модели, поэтому за отклонённый запрос ничего не списывается.

| Ситуация | Ответ |
| --- | --- |
| Модель вне списка ключа | `403`, код `model_not_allowed_for_key` |
| Дневной потолок исчерпан | `429`, код `key_daily_limit_reached` |
| Месячный потолок исчерпан | `429`, код `key_monthly_limit_reached` |

Как считаются лимиты:

- Лимиты мягкие. Перед запросом сравнивается уже списанная сумма с потолком; стоимость самого запроса заранее неизвестна. Поэтому последний пропущенный запрос может превысить потолок, а несколько параллельных запросов, начатых до достижения лимита, пройдут все.
- Дневной расход обнуляется в 00:00 UTC, месячный — в начале месяца по UTC.
- Пустой лимит или `0` — ограничения нет.
- В лимиты ключа входят и запросы к `/v1/web-search`; список разрешённых моделей на веб-поиск не влияет.
- Остальные ключи аккаунта продолжают работать: лимит — на ключ, а не на баланс.

## Idempotency-Key

Пайплайн с ретраями может отправить один и тот же запрос дважды — и дважды заплатить. Передайте заголовок `Idempotency-Key` с любым уникальным значением (до 200 символов): повтор с тем же ключом вернёт сохранённый ответ и **не спишет второй раз**.

```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":"Классифицируй: ..."}]}'
```

- Работает только для `POST /v1/chat/completions`, `/v1/messages` и `/v1/responses`.
- Тот же ключ с другим телом запроса — `422`, код `idempotency_conflict`: один ключ — один запрос.
- Сохраняется только успешный ответ (2xx) размером до 2 МБ, на 24 часа. После ошибки повтор с тем же ключом снова уходит к модели.
- Блокировки на время выполнения нет: если два одинаковых запроса с одним ключом пришли одновременно, оба уйдут к модели и оба будут оплачены. Отправляйте повтор после того, как первый запрос завершился или упал.
- Ключи идемпотентности общие для всего аккаунта, а не для отдельного API-ключа.

## Кэш по содержимому

Одинаковый запрос — тот же endpoint, та же модель, то же тело — при включённом кэше второй раз отдаётся бесплатно. Пайплайн, перезапущенный на половине, не платит за уже сделанное.

- Надёжный способ включить кэш — заголовок `X-Zukko-Cache: true` (также `yes`, `on`). Он работает при любой temperature и без неё.
- Без заголовка кэш включается сам, только если в теле `temperature: 0`.
- Выключить в любом случае: `X-Zukko-Cache: false` (также `no`, `off`, `no-cache`).

Для моделей Claude используйте заголовок: новые модели Claude могут отклонять нестандартные значения `temperature` и `top_p`, поэтому передавать `temperature: 0` ради кэша не стоит.

Подробности:

- Кэш работает для тех же трёх endpoint'ов: `POST /v1/chat/completions`, `/v1/messages`, `/v1/responses`.
- Одинаковость определяется по телу запроса; поля `user`, `metadata` и `stream_options` не учитываются, порядок ключей в JSON тоже. Поле `stream` учитывается: потоковый и обычный ответы хранятся раздельно.
- Хранятся только успешные ответы до 2 МБ, срок — 7 дней. Кэш свой у каждого аккаунта.
- Ответ из кэша тоже проходит проверку баланса и лимитов ключа, но ничего не стоит и не попадает в историю списаний.

Попадание видно по заголовкам ответа `X-Zukko-Cache: hit` (или `idempotent-replay` для повтора по `Idempotency-Key`) и `X-Zukko-Cost-Micros: 0`.

## Стоимость в ответе

Успешный обычный (не потоковый) JSON-ответ несёт два заголовка:

- `X-Zukko-Cost-Micros` — стоимость этого запроса в микродолларах (1 000 000 = $1);
- `X-Fetch-Balance-Cents` — остаток после списания. Несмотря на название, тоже в **микродолларах**: `12340000` = $12.34.

У потоковых ответов этих заголовков нет — стоимость смотрите в [GET /v1/usage](/docs/api-reference/usage). Ответ из кэша или повтор по `Idempotency-Key` приходит с `X-Zukko-Cost-Micros: 0`. Агент может следить за расходом сам и остановиться до нуля, а не после.

## Авто-замена зависшей модели

Для запросов по ключу API. Запрос сам уходит похожей модели, если выбранная:

- перегружена или вернула ошибку 5xx;
- в стриминге не начала отвечать за 40 секунд.

Без стриминга по времени не подменяем: там ответ приходит целиком в конце, и долгую работу не отличить от зависания. До подмены клиенту не уходит ни байта.

- Замена не дороже выбранной модели (вход, выход и чтение кэша) и с теми же возможностями: картинки, инструменты, JSON-схема. Через `/v1/messages` — только Claude.
- Пока модель сбоит, запросы идут к замене сразу, без ожидания. Как только модель снова отвечает, запросы возвращаются к ней.
- Оплачивается только ответившая модель, она же видна в [истории расходов](/app/usage).

По умолчанию выключено. Включить — в [кабинете](/app/overview) для всех ключей или у отдельного [ключа](/app/api-keys). Для одного запроса — заголовок `X-Zukko-Fallback: on` или `off`.

---

# POST /v1/responses

Endpoint в формате OpenAI Responses API. Его используют Codex CLI и OpenAI Agents SDK.

## Пример запроса

```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"}'
```

## Какие модели поддерживаются

Responses API работает с моделями `claude-*` и `gpt-*`. Модели GLM поля `input` и `instructions` не понимают — для них используйте `/v1/chat/completions` или `/v1/messages`.

## Основные параметры

- `model` — ID модели, обязателен;
- `input` — строка или массив сообщений и вызовов инструментов;
- `instructions` — системный промпт;
- `stream` — потоковый ответ (SSE);
- `max_output_tokens`, `temperature`, `top_p`, `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning`.

## Авторизация

`Authorization: Bearer fetch_...` или `x-api-key: fetch_...`. Ключ в query string не принимается.

## Codex

```bash
export OPENAI_API_KEY=fetch_...
codex -m gpt-5.6-sol
```

Для постоянной настройки пропишите провайдера в `~/.codex/config.toml` — см. гайд [Codex](/docs/ide-integrations/codex).

Claude Code работает не через Responses API, а через `/v1/messages` — см. гайд [Claude Code](/docs/ide-integrations/claude-code).

## Ошибки

Формат: `{"error": {"message", "type", "code", "param"}}`. `401` — неверный ключ, `402` — меньше $0.01 на балансе, `403` — не подтверждён email или модель запрещена для ключа, `404 model_not_found` — модели нет в каталоге, `429` от Zukko — только лимиты ключа (`key_daily_limit_reached`, `key_monthly_limit_reached`), остальные `429` приходят от провайдера. Полный список — [Ошибки API](/docs/api-reference/errors).

---

# POST /v1/web-search

Поиск в интернете через API: возвращает список найденных страниц. Удобно, чтобы подложить свежие данные в промпт модели.

## Пример запроса

```bash
  -H "Authorization: Bearer fetch_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"latest AI news","max_results":5}'
```

## Параметры

| Параметр | Тип | Описание |
| --- | --- | --- |
| `query` | string | поисковый запрос, обязателен |
| `max_results` | int | сколько результатов вернуть: от 1 до 10, по умолчанию 5. Значения вне диапазона приводятся к границе |

## Ответ

```json
  "results": [
    {
      "title": "...",
      "url": "https://...",
      "snippet": "..."
    }
  ]
}
```

Отдельные поля результата могут отсутствовать.

## Стоимость и требования

- Поиск оплачивается как небольшой расход токенов: оцениваются запрос и текст результатов, цена — $0.30 за 1M токенов. В истории списаний строка называется `web-search`.
- Нужны подтверждённый email и баланс не меньше $0.01. Дневной и месячный лимиты ключа действуют, список разрешённых моделей — нет.
- Кэш Zukko и `Idempotency-Key` к поиску не применяются.

## Ошибки

- `400 invalid_json` — тело не JSON;
- `400 invalid_request` — нет `query`;
- `401` — неверный ключ;
- `402 insufficient_balance` — на балансе меньше $0.01;
- `403 email_unverified` — email не подтверждён;
- `429 key_daily_limit_reached` / `key_monthly_limit_reached` — исчерпан лимит ключа;
- `503 provider_unavailable` — поиск временно недоступен;
- `502 web_search_failed` или `upstream_error` — поиск не выполнился, повторите позже.

---

# GET /api/status

Публичный статус сервиса и моделей. Ключ не нужен, запрос бесплатный. Отдельного `GET /health` у Zukko нет — используйте этот endpoint.

## Пример запроса

```bash
curl "https://api.zukko.pro/api/status?hours=24"
```

`hours` — окно статистики в часах, от 1 до 24, по умолчанию 6.

## Ответ

```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
    }
  ]
}
```

| Поле | Описание |
| --- | --- |
| `overall` | `ok`, `degraded` (часть моделей со сбоями), `down` (не работает ни одна включённая модель) или `maintenance` |
| `maintenance` | текст о технических работах или `null` |
| `window_hours` | окно, за которое считана статистика |
| `checked_at` | время ответа, ISO 8601 |
| `models[].id`, `models[].name` | ID и название текстовой модели |
| `models[].enabled` | включена ли модель |
| `models[].state` | `ok`, `degraded`, `down`, `unknown` (мало данных) или `disabled` |
| `models[].latency_ms` | среднее время ответа за окно; может быть `null` |

Во время технических работ запросы к моделям получают `503` с кодом `maintenance` и заголовком `Retry-After: 600`, а `overall` равен `maintenance`.

## GET /api/gateway-status

Сводка по шлюзу целиком — то, что показывает страница статуса:

```bash
```

`timeframe` — `1h` (по умолчанию), `24h` или `7d`. В ответе: `healthy`, `avg_success_rate` (доля успешных запросов, %), `avg_latency_ms`, `requests_total`, `timeseries` (число запросов по интервалам), `status_override` и `admin_message_ru` / `admin_message_en` на время технических работ.

## Для мониторинга

- Проверка доступности: `GET /api/status`, ожидайте `200` и `overall` не равный `down` или `maintenance`.
- Проверка конкретной модели: поле `state` в `models`.
- Проверка своего ключа: `GET /v1/balance` с ключом — бесплатно и без вызова модели.

---

# Помощь

## Ошибка: что делать

Найдите в таблице, что видите, — рядом написано, что сделать.

## Проверьте за минуту

### Сервис работает?
Откройте [api.zukko.pro/api/status](https://api.zukko.pro/api/status): `ok` — да, `degraded` — часть моделей со сбоями, `maintenance` — технические работы.
### Ключ работает?
Бесплатная проверка:

```bash
```

Пришёл баланс — ключ в порядке, `401` — нет.
### Модель есть?
Сверьте имя с [api.zukko.pro/v1/models](https://api.zukko.pro/v1/models) буква в букву.

## Ошибки

| Что видите | Что сделать |
| --- | --- |
| `401`, «Invalid API key» | Ключ с опечаткой или отозван. Скопируйте заново или [создайте новый](/app/api-keys) |
| `402`, `insufficient_balance` | [Пополните баланс](/app/payments) — перезапуск не нужен |
| `403`, `email_unverified` | Подтвердите почту по ссылке из письма |
| `403`, `model_not_allowed_for_key` | Разрешите модель в настройках [ключа](/app/api-keys) |
| `429`, `key_daily_limit_reached` / `key_monthly_limit_reached` | Сработал ваш лимит на ключ. Поднимите его или дождитесь сброса: 00:00 UTC (03:00 МСК) или 1-го числа |
| `429` без кода Zukko, «rate limit» | Модель перегружена. Повторите через 10–30 секунд или возьмите другую |
| `404`, `model_not_found` | Опечатка в имени. Сверьте со [списком моделей](/app/models) |
| `400`, `model_retired` | Модель снята — в тексте ошибки написано, на какую перейти |
| `503`, `model_disabled` | Модель выключена — возьмите другую |
| `502`, `503`, другие 5xx | Модель временно не отвечает. Повторите через 10–30 секунд или возьмите одну из моделей, перечисленных в тексте ошибки |
| `503`, `maintenance` | Технические работы, повторите минут через 10 |
| Долго нет ответа, timeout | Сильные модели думают 30–60 секунд. Поставьте таймаут 120–300 секунд и включите стриминг |
| `413`, `payload_too_large` | Запрос больше 32 МБ — уменьшите картинки или файлы |
| Ответ обрывается | Увеличьте `max_tokens` |

Ошибки не оплачиваются. Все коды — [Ошибки API](/docs/api-reference/errors).

## Claude Code, Cursor и другие программы

| Режим | Адрес |
| --- | --- |
| Anthropic (Claude Code, Roo Code) | `https://api.zukko.pro` — **без** `/v1` |
| OpenAI (Cursor, Cline, Codex) | `https://api.zukko.pro/v1` — **с** `/v1` |

Перепутали — будет `404` на каждый запрос.

| Что видите | Что сделать |
| --- | --- |
| Claude Code просит войти в Anthropic | Ключ — в `ANTHROPIC_AUTH_TOKEN`, не `ANTHROPIC_API_KEY`. Проверьте запятые в `settings.json`. Выполните `/logout` |
| Cursor ходит мимо Zukko | Включите **Override OpenAI Base URL** с `https://api.zukko.pro/v1`. Нужен Cursor Pro |
| Cursor не даёт добавить модель | Используйте имена для Cursor: `cursor-sonnet-5`, `cursor-opus-55` |

## Сайт не открывается

Попробуйте другую сеть (мобильный интернет вместо Wi-Fi) или пользуйтесь Zukko в Telegram: [@zukkopro_bot](https://t.me/zukkopro_bot) — там есть пополнение и чат с моделями.

## Оплатили, а баланс не пополнился

1. Подождите 2–5 минут и обновите [страницу оплаты](/app/payments).
2. Сохраните номер заказа со страницы результата («Заказ: …») и чек.
3. Через 10–15 минут денег нет — напишите в поддержку: номер заказа, сумма, способ, время. По [правилам](/terms) обращайтесь в течение 24 часов.

## Поддержка

[Telegram](https://t.me/zukkopro?direct) — быстрее всего, или почта zukkopro@rambler.ru. Напишите:

- время ошибки с часовым поясом;
- где: Claude Code, Cursor, свой код, веб-чат, бот;
- модель и полный текст ошибки (можно скриншот);
- id ответа (`chatcmpl-…` или `msg_…`), если есть;
- последние 4 символа ключа.

**Осторожно: Не отправляйте ключ целиком**
Хватит последних 4 символов. Ключ куда-то утёк — удалите его на [странице ключей](/app/api-keys) и создайте новый.

---

## Частые вопросы

Короткие ответы на вопросы, которые нам задают чаще всего. У каждого вопроса есть своя ссылка — ею удобно поделиться.

## Что такое Zukko простыми словами?

Один ключ ко многим нейросетям: Claude, GPT, Gemini, DeepSeek, Qwen, Kimi и другим. Вы пополняете один баланс и платите только за то, что использовали, — без подписок и абонентской платы. Пользоваться можно через свой код, редакторы вроде Claude Code и Cursor, [чат в браузере](/app/chat) или Telegram-бота [@zukkopro_bot](https://t.me/zukkopro_bot). С чего начать — [Первый запрос за 3 минуты](/docs/introduction/quick-start).

## Это законно и безопасно?

Да, законно. Zukko работает по публичной оферте — [пользовательскому соглашению](/terms). Zukko — посредник: принимает ваш запрос, передаёт его выбранной модели и возвращает ответ. Сам Zukko моделей не создаёт и не обучает и не аффилирован с Anthropic, OpenAI или Google.

С безопасностью так: соединение только по HTTPS, а содержимое ваших запросов Zukko не записывает (см. вопрос ниже). Ваша часть — хранить ключ в секрете и не публиковать его в коде, чатах и на скриншотах.

## Вы храните мои данные и запросы?

Нет. Содержимое ваших запросов к моделям и их ответов Zukko не хранит и не логирует — так записано и в [политике конфиденциальности](/privacy). Сохраняются только служебные данные о запросе: какая модель, сколько токенов, сколько стоило, — по ним строится история расходов. Ещё хранятся данные аккаунта: почта, Telegram-id при входе через Telegram, история платежей.

Текст запроса, конечно, уходит выбранной модели — иначе она не сможет ответить.

Исключение — две функции, которые вы включаете сами: кэш ответов (заголовок `X-Zukko-Cache`, ответ хранится 7 дней) и `Idempotency-Key` (ответ хранится 24 часа). Они сохраняют ответ, чтобы повтор был бесплатным. Подробнее — [Лимиты ключа и кэш](/docs/api-reference/key-limits-and-cache).

## Как оплатить?

Пополните баланс в кабинете на странице [«Баланс и платежи»](/app/payments):

- СБП — в рублях, по рыночному курсу доллара, платёжный сервис добавляет около 8,5%; итог виден до оплаты;
- криптовалюта;
- иностранные карты — платёжный сервис берёт сверху около 13%.

Российские карты временно не принимаются — пользуйтесь СБП.

Минимальное пополнение — $1. Баланс ведётся в долларах, деньги списываются за каждый запрос по факту. Подробнее — [Оплата и списания](/docs/introduction/billing).

## Можно ли вернуть деньги?

По [правилам сервиса](/terms) деньги за уже выполненные запросы не возвращаются. Вернуть можно, если:

- оплата прошла, а баланс не пополнился;
- услуга не была оказана по технической вине Zukko.

Напишите в поддержку в течение 24 часов после оплаты — решение принимается по каждому случаю отдельно.

Отдельно: за неудачные запросы деньги и так не списываются — если модель вернула ошибку или запрос отклонён до модели, вы ничего не платите.

## Почему у Zukko дешевле, чем на официальных сайтах?

Zukko покупает мощности оптом и делит их между пользователями — поэтому цена за токен ниже, чем при покупке напрямую в розницу. Сравнить легко: цены всех моделей — в таблице [Модели и цены](/docs/introduction/models-pricing).

## Какую модель выбрать?

Коротко:

- простые массовые задачи (сортировка, извлечение данных, короткие ответы) — `deepseek-v4.1-flash`, `qwen3.8-flash`, `claude-haiku-5-5`;
- универсальная модель на каждый день — `claude-sonnet-5`;
- самое сложное (большой код, длинные рассуждения) — `claude-opus-5-5`.

Подробно и с примерами — [Как выбрать модель](/docs/introduction/choose-model).

## Работает ли Zukko в России?

Да, и VPN не нужен. Оплата в рублях через СБП, поддержка на русском. Zukko доступен и через Telegram-бота [@zukkopro_bot](https://t.me/zukkopro_bot).

## Можно ли пользоваться без программирования?

Да. Есть [чат с моделями в браузере](/app/chat) и Telegram-бот [@zukkopro_bot](https://t.me/zukkopro_bot) с мини-приложением — там можно просто писать вопросы. Все они тратят тот же баланс.

## Можно ли подключить Claude Code или Cursor?

Да, и это одно из самых частых применений. Главное — правильный адрес:

- Claude Code: `https://api.zukko.pro` (без `/v1`), ключ в `ANTHROPIC_AUTH_TOKEN` — [инструкция](/docs/ide-integrations/claude-code);
- Cursor: `https://api.zukko.pro/v1` в поле Override OpenAI Base URL, нужен платный Cursor Pro — [инструкция](/docs/ide-integrations/cursor-ide).

Другие редакторы и агенты — в разделе [Выбор провайдера](/docs/ide-integrations/choose-provider).

## Есть ли ограничения на число запросов?

Собственного ограничения частоты запросов у Zukko нет. Ограничения бывают двух видов:

- лимиты, которые вы сами поставили на ключ (расход в день или в месяц, список моделей), — [подробнее](/docs/api-reference/key-limits-and-cache);
- временная перегрузка самой модели — тогда приходит ошибка `429`, и запрос стоит повторить через 10–30 секунд.

Ещё для запроса нужны подтверждённая почта и баланс от $0.01, а размер одного запроса — до 32 МБ.

## Как безопасно дать ключ подрядчику или боту?

Создайте для него отдельный ключ на странице [API-ключей](/app/api-keys) и сразу ограничьте его:

- поставьте лимит расхода в день и в месяц — больше этой суммы ключ не потратит (лимит мягкий: последний запрос может немного его превысить);
- оставьте только нужные модели — например, запретите дорогую Opus.

Когда работа закончена или ключ утёк, удалите его: остальные ключи продолжат работать. Баланс при этом общий для всего аккаунта — все ключи тратят одни и те же деньги.

## Как посмотреть, сколько я потратил?

- В кабинете на странице [«Использование»](/app/usage) — каждое списание с моделью и числом токенов.
- Через API — [GET /v1/usage](/docs/api-reference/usage) и [GET /v1/balance](/docs/api-reference/balance).
- В каждом обычном ответе API есть заголовок `X-Zukko-Cost-Micros` — стоимость этого запроса в миллионных долях доллара.

В историю попадает расход по всему аккаунту: все ключи, веб-чат и Telegram-бот. Как тратить меньше — [Как платить меньше](/docs/guides/save-money).

## Сгорают ли деньги на балансе?

Абонентской платы нет: деньги списываются только за запросы. Срок действия баланса в [правилах сервиса](/terms) не указан. При закрытии аккаунта остаток на балансе не возвращается.

## Есть ли бесплатный пробный период?

Пробного периода и бонуса за регистрацию нет. Можно один раз получить $0.50 за подписку на Telegram-канал [@zukkopro](https://t.me/zukkopro) — забрать бонус можно в кабинете. Минимальное пополнение — всего $1, и на дешёвых моделях этого хватает на тысячи коротких запросов.

## Что будет, если деньги закончатся посреди запроса?

Запрос, который уже начался, доработает до конца, даже если баланс уйдёт в небольшой минус. Следующие запросы получат ошибку `402`, пока вы не пополните счёт, — пополнение сначала закроет минус.

## Чего Zukko не умеет?

Zukko работает с текстовыми моделями: чат, ответы по схеме JSON, вызов инструментов, распознавание картинок у моделей, которые их принимают, веб-поиск. Сейчас нет эмбеддингов, распознавания и синтеза речи, загрузки файлов, пакетной обработки (batch API) и дообучения моделей.

## Как связаться с поддержкой?

- Telegram: [t.me/zukkopro](https://t.me/zukkopro?direct) — быстрее всего.
- Почта: zukkopro@rambler.ru.

Укажите время, модель и текст ошибки, а из ключа — только последние 4 символа. Что ещё приложить — в статье [Ошибка: что делать](/docs/help/troubleshooting#как-написать-в-поддержку).

## Что дальше

- [Первый запрос за 3 минуты](/docs/introduction/quick-start) — от ключа до ответа модели
- [Как выбрать модель](/docs/introduction/choose-model) — какая модель для какой задачи
- [Как платить меньше](/docs/guides/save-money) — десять способов снизить расходы
- [Ошибка: что делать](/docs/help/troubleshooting) — если что-то пошло не так

---

_Конец базы знаний Zukko API. Источник: https://zukko.pro/docs_
