Документация
API
Спросить по своим документам, найти фрагмент без модели, загрузить файл — из своего кода. Один и тот же движок отвечает в собственном формате и в формате OpenAI, так что готовый SDK работает по одному base_url.
Быстрый старт
Токен выпускается на сервере одной командой, дальше — обычный HTTP.
cd backend && uv run rag-token --email вы@example.com --days 365curl -X POST https://ваш-хост/api/v1/answer \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"question": "сколько дней отпуска?"}'Аутентификация
Тот же JWT, которым входят в интерфейс, в заголовке Authorization.
curl -H "Authorization: Bearer $TOKEN" https://ваш-хост/api/v1/me
# {"user_id":"db8122ef-4ce8-4dce-a3d4-d6b0ab53cf08"}Токен — это и есть пользователь: всё, что видно по нему, принадлежит одному человеку, а фильтр по user_id уходит в сам индекс. Отозвать токен досрочно нельзя — проверка подписи не ходит в базу, — поэтому для интеграции стоит выпустить отдельный, с нужным сроком.
Все методы
- POST
/api/v1/answerОтвет по документам: одним JSON или потоком - POST
/api/v1/verifyПроверить готовый текст по документам, утверждение за утверждением - GET
/api/v1/searchФрагменты без модели — десятая доля секунды, ноль токенов - GET
/api/v1/documentsВсё, что доступно для поиска - POST
/api/v1/documentsЗагрузить файл (multipart/form-data) - GET
/api/v1/documents/{id}/linkГде открыть оригинал, с ?page= и ?quote= — на нужном месте - POST
/api/v1/documents/urlПроиндексировать страницу по ссылке - GET
/api/v1/changesЧто добавилось и изменилось за последние дни, по источникам - GET
/api/v1/relatedДругие фрагменты о том же — «где ещё об этом» - DELETE
/api/v1/documents/{id}Удалить загруженный файл - GET
/api/v1/sourcesПодключённые источники и их состояние - POST
/api/v1/chatsСоздать сохранённый диалог - GET
/api/v1/chatsСписок диалогов - GET
/api/v1/chats/{id}/messagesКонец чата страницами, в порядке чтения - DELETE
/api/v1/chats/{id}Удалить диалог с сообщениями - POST
/api/v1/chat/completionsТо же самое в формате OpenAI - GET
/api/v1/modelsgrounded и grounded-web - GET
/api/v1/meЧей это токен — самая дешёвая проверка
Спросить
POST /api/v1/answer — вопрос, ответ со сносками, шаги агента и стоимость запроса.
- questionstring
- Вопрос, 1–8000 символов
- streambool · false
- true — server-sent events вместо одного JSON
- webbool · false
- Разрешить поиск в интернете, когда в документах ответа нет. Выключено по умолчанию: один поиск стоит около $0.00125
- chat_iduuid · null
- Продолжить сохранённый диалог: история подгрузится, вопрос и ответ запишутся
- historyarray · []
- Или ведите историю сами. Вместе с chat_id — 422: это два диалога, называющих себя одним
- answerstring
- Текст со сносками [1], [2]
- citationsarray
- Только те источники, на которые ответ действительно ссылается. Пустой список — ответа в базе нет, и это факт о выдаче, а не мнение модели
- stepsarray
- Что агент делал, по порядку. Два поиска подряд означают, что первый ничего не дал
- usageobject
- Токены и деньги — ниже
- took_msint
- Сколько ждал вызывающий
- chat_id, message_iduuid · null
- Заполнены, только если вопрос задан в сохранённый чат
{
"answer": "Основной отпуск — 28 календарных дней [1].",
"citations": [
{
"n": 1,
"document_id": "0f0f9d3c-9c4e-4f4b-9d61-0b1d1c1c1c1c",
"filename": "Политика.pdf",
"page": 3,
"url": null,
"snippet": "28 календарных дней…"
}
],
"steps": [{ "tool": "search_knowledge", "query": "отпуск дней политика" }],
"usage": {
"prompt_tokens": 5000,
"completion_tokens": 60,
"total_tokens": 5060,
"cost_usd": 0.00133215,
"cost_complete": true,
"calls": 3,
"stages": [
{ "stage": "rerank", "model": "google/gemini-2.5-flash-lite", "calls": 2,
"prompt_tokens": 1900, "completion_tokens": 22, "cost_usd": 0.0002269 },
{ "stage": "answer", "model": "openai/gpt-5-mini", "calls": 1,
"prompt_tokens": 3100, "completion_tokens": 38, "cost_usd": 0.00110525 }
]
},
"took_ms": 6227,
"chat_id": null,
"message_id": null
}Сноска бывает и на страницу из интернета — тогда document_id пустой, а url заполнен. У фрагмента из базы наоборот: ссылку на оригинал даёт /documents/{id}/link, который умеет её подписать.
Поток
stream: true — тот же ответ как text/event-stream. Данные каждого события — JSON.
- step
- { tool, query } — приходит дважды: сначала имя инструмента, затем запрос, как только он разобрался
- token
- Строка с куском ответа
- citations
- Итоговый список источников
- usage
- Объект usage целиком
- done
- Весь ответ одним объектом — тот же JSON, что вернул бы вызов без потока
- error
- Ход не удался; done после него не будет
Последнее событие — это и есть весь ответ, поэтому собирать токены вручную не нужно: показывайте их для скорости, а читайте done.
const response = await fetch("https://ваш-хост/api/v1/answer", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ question: "что у меня по проекту?", stream: true }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const [head, body] = frame.split("\n");
const event = head.replace("event: ", "");
const data = JSON.parse(body.replace("data: ", ""));
if (event === "token") process.stdout.write(data);
if (event === "done") console.log(data.citations, data.usage.cost_usd);
}
}Проверка текста
POST /api/v1/verify — обратная сторона обещания «только по документам»: даёте абзац, получаете разбор по утверждениям.
curl -X POST https://ваш-хост/api/v1/verify \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text": "Основной отпуск — 28 дней, заявление за три месяца."}'- supported
- Фрагменты подтверждают утверждение
- contradicted
- Во фрагментах сказано иное — самый дорогой случай: кто-то собирается сказать то, что записано иначе
- absent
- В документах об этом ничего нет — повод написать документ
- unknown
- Судья не ответил. Не то же самое, что absent: про документы мы ничего не узнали, и принять это за «в базе нет» значит отправить человека писать то, что уже написано
До 20 000 символов и не больше 20 утверждений: каждое стоит поиска и вердикта, около $0.002 за страницу прозы. Каждый вердикт приходит с фрагментами, на которых основан, — чтобы с ним можно было не согласиться.
Поиск без модели
GET /api/v1/search — говорит, где написано; никогда не объясняет и не пересказывает.
- qstring
- Запрос, 1–500 символов
- limitint · 20
- 1–50
- sourceuuid
- Искать только в одном источнике — сужение уходит в сам индекс
- daysint
- 1–3650: только документы, изменённые за последние N дней
curl -G https://ваш-хост/api/v1/search \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "q=вишлист" --data-urlencode "limit=5"{
"hits": [
{
"document_id": "0f0f…",
"filename": "Заметки.md",
"page": null,
"snippet": [
{ "text": "добавил в ", "hit": false },
{ "text": "вишлисте", "hit": true },
{ "text": " наушники", "hit": false }
]
}
],
"found": 20,
"took_ms": 96
}snippet приходит уже разрезанным на куски с флагом hit — правило, что считать совпадением, живёт на сервере, а клиенту не нужно класть в DOM ничего, кроме текста. found — сколько индекс предложил до отбора: «3 из 20» говорит, что поиск работал. Ответ 503 означает, что индекс перестраивается, а не что запрос неверный.
Документы
Форматы: .pdf, .docx, .pptx, .xlsx, .txt, .md — до 64 МБ.
curl -X POST https://ваш-хост/api/v1/documents \
-H "Authorization: Bearer $TOKEN" \
-F "file=@Политика.pdf"- status
- processing или ready. Читается прямо из индекса: ready значит, что документ действительно находится поиском, а не что его приняли
- text_layer
- false — это PDF без текстового слоя, то есть скан: он загрузится и будет считаться готовым, но не найдётся никогда
- removable
- false — документ из подключённого источника: удалять его нужно там, где он лежит, а DELETE вернёт 404
Формат, который мы не читаем, — это 415 сразу, а не документ, навсегда застрявший в статусе «индексируется».
curl -X POST https://ваш-хост/api/v1/documents/url \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/статья"}'Страница по ссылке скачивается, превращается в текст и индексируется как обычная загрузка. Границы: 15 секунд, 5 МБ (считается при чтении, а не по Content-Length, которому нельзя верить), не больше трёх переадресаций — и каждая переадресация проверяется тем же правилом, что и присланный адрес, потому что следующий адрес выбирает уже не вызывающий. Отказ — 400 с причиной: недоступен, страница за входом, не страница, адрес внутрь сети.
Чаты
Нужны, только если вы хотите, чтобы историю хранили мы. Без chat_id не сохраняется ничего.
CHAT=$(curl -sX POST https://ваш-хост/api/v1/chats \
-H "Authorization: Bearer $TOKEN" | jq -r .id)
curl -X POST https://ваш-хост/api/v1/answer \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"question\": \"а что там по срокам?\", \"chat_id\": \"$CHAT\"}"История отдаётся страницами по 50 сообщений: next_cursor из ответа передаётся обратно в before. Заголовок чата ставится из первого вопроса.
Стоимость
Не оценка по таблице цен, которая устареет, а сумма того, что провайдер выставил за каждый вызов.
- cost_usd
- Доллары, сумма по вызовам
- cost_complete
- false, если какой-то вызов цену не сообщил — тогда cost_usd это нижняя граница, а не счёт
- calls
- Сколько платных вызовов сделал ход
- stages
- Те же числа по этапам: answer (сам агент), rerank (отбор релевантного за каждым поиском), web_search
Разбивка обычно и есть самое интересное. Замерено на живых вызовах: отбор релевантного — $0.000021 из четырёх кандидатов и около $0.00017 из двадцати; один поиск в сети — $0.0012607, то есть поход в интернет дороже, чем всё остальное в коротком ответе. Реальный ответ про ключевую ставку с включённой сетью стоил $0.0024171: ответ $0.001068 плюс поиск $0.0013491.
Один запрос ограничен сверху: не больше 3 поисков по базе, 2 в сети, 6 вызовов инструментов и 8 вызовов модели за ход, плюс общий таймаут. Сбежать со счётом один вызов не может.
Формат OpenAI
Чтобы работал уже написанный клиент: официальные SDK, n8n, LangChain, LibreChat, плагины редакторов.
from openai import OpenAI
client = OpenAI(base_url="https://ваш-хост/api/v1", api_key=TOKEN)
done = client.chat.completions.create(
model="grounded", # grounded-web — с поиском в интернете
messages=[{"role": "user", "content": "сколько дней отпуска?"}],
)
print(done.choices[0].message.content)
print(done.usage.model_extra["cost"], done.model_extra["citations"])Имя модели — это переключатель. Фиксированный протокол не даёт передать наши флаги, но имя модели даёт всегда: grounded отвечает по документам, grounded-web может ещё и поискать в интернете. Любое другое имя — 404, а не молчаливая подмена.
- системное сообщение
- Игнорируется: наш промпт — это то, что держит ответ на документах и нумерует сноски
- temperature, top_p, max_tokens
- Принимаются и игнорируются. Один ход — это несколько вызовов модели плюс поиск; выполнить такой параметр наполовину хуже, чем не выполнять вовсе
- вопрос
- Это последнее сообщение от пользователя, а не последнее сообщение: клиент, дославший реплику ассистента, продолжает диалог, чей вопрос по-прежнему выше
- сноски
- Приходят в нестандартном поле citations рядом с choices — так же делают Perplexity и OpenRouter. Клиент, который его не читает, всё равно покажет связный ответ: [1] стоит в самом тексте
- стоимость
- В usage.cost — расширение OpenRouter к тому же объекту. Разбивка по этапам есть только в нативном /answer
- обрыв потока
- finish_reason: length и [DONE]: своего кадра для ошибки протокол не предусматривает, и «ответ не закончен» — честное его прочтение
Ошибки
Везде {"detail": "…"}, кроме двух путей OpenAI, где {"error": {…}} — так читает ошибку клиент OpenAI.
- 400
- Пустой вопрос, пустой файл, имя файла без имени
- 401
- Нет токена, не наша подпись, истёк. В ответе заголовок WWW-Authenticate: Bearer
- 404
- Нет такого чата, документа или модели — или он не ваш: какие id существуют, знает только владелец
- 413
- Файл больше 64 МБ или тело запроса больше 4 МБ
- 415
- Формат, который мы не читаем
- 422
- Тело не проходит валидацию — detail это список полей
- 502
- Ход не удался. Причина в логах сервера, не в ответе: в тексте таких ошибок бывают внутренние адреса и ключи
- 503
- Индекс перестраивается — только у /search
- 504
- Модель не ответила за отведённое время
Ограничения
- дневной лимит выключен
- DAILY_COST_LIMIT_USD задаёт потолок в долларах на сутки UTC; по умолчанию 0 — без потолка, потому что у личной установки один вызывающий. Ставить стоит, как только токен отдан чему-то ещё: досрочно он не истекает
- тело запроса — 4 МБ
- У двух эндпоинтов загрузки — 64 МБ плюс запас. Ограничение стоит перед всем остальным, потому что тело разбирается до проверки токена
- /answer без фильтров
- Агент сам решает, когда сузить поиск по дате, по формулировке вопроса. Явные фильтры есть у /search
- источники — через интерфейс
- Подключение и отключение только на странице «Источники»: там пошаговые инструкции под каждым видом
- CORS закрыт
- Кроме CORS_ORIGINS из настроек. API рассчитан на вызов с сервера: браузерная интеграция потребует добавить свой origin и решить, где будет лежать токен