grounded

Документация

API

Спросить по своим документам, найти фрагмент без модели, загрузить файл — из своего кода. Один и тот же движок отвечает в собственном формате и в формате OpenAI, так что готовый SDK работает по одному base_url.

https://ваш-хост/api/v1OpenAPI и живые схемы

Быстрый старт

Токен выпускается на сервере одной командой, дальше — обычный HTTP.

cd backend && uv run rag-token --email вы@example.com --days 365
curl -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 за страницу прозы. Каждый вердикт приходит с фрагментами, на которых основан, — чтобы с ним можно было не согласиться.

Документы

Форматы: .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 и решить, где будет лежать токен