Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP/SSE API

Справочник по API — Swagger UI на /apidocs (схема: /v1/openapi.json). Схема OpenAPI генерируется из аннотаций прямо в коде (src/api.rs, utoipa) — руками не пишется и не расходится с реальностью.

Локально: подними ядро и открой http://127.0.0.1:8090/apidocs.

Пара вещей, которых не расскажет схема:

  • Ответы чатов — SSE (text/event-stream), по JSON-объекту на событие: text_delta, thinking_delta, tool_call, tool_result, tool_progress, client_op, extra_tool_call, request_resolved, usage, compacting, turn_squeezed, sleeping, image, plan, done, error — плюс зеркало этих же шагов единой лентой элементов: item_started, item_delta, item_completed (см. ниже). На переходный период едут оба набора; ненужный глушится полем mute в send_message (списком типов событий) или query-параметром mute у /live (через запятую). usage приходит после каждого похода к провайдеру — по нему клиент двигает счётчик токенов и расхода, не дожидаясь конца ответа; его reasoning_tokens — часть completion_tokens, ушедшая в рассуждения, а не добавка к ним; долю занятого окна клиент считает от prompt_tokens и потолка модели из context_windows (GET /v1/models), вычитая базу — prefix_tokens из GET /v1/chats/{id}/spend. Последний снимок лимитов провайдера отдаёт /v1/stats полем rate_limits; compacting предупреждает, что ядро сворачивает историю в резюме: пауза перед done бывает долгой и без отметки выглядит зависанием; turn_squeezed ({"freed_chars":N}) — ядро поджало трафик ИДУЩЕГО тура: длинный ход перерос долю порога компакции, и его старый выхлоп схлопнут прямо посреди работы. Ответ продолжается — событие показывают тихо; sleeping открывает поток тура, пришедшегося на сон: тур ждёт пробуждения в очереди — клиент показывает «спит…» вместо «печатает…» и кнопку «Разбудить» (POST /v1/sleep/wake), дальше поток идёт как обычно.
  • image — модель тура сгенерировала картинку, ядро сохранило её: {"type":"image","id":"<uuid>","media_type":"image/png","url":"…/images/<id>"}. Клиент рисует картинку по url; в истории на её месте остаётся текстовый маркер [image <id>] <url>. Байты отдаёт GET /images/{id} — ручка вне /v1 и потому вне токена: ключ доступа — неугадываемый uuid, ответ неизменен (Cache-Control: immutable).
  • planплан работы изменился: tasks — состояние целиком, а не дифф, клиент рисует дерево не собирая его из правок. Пустой список — план закрыт (всё сделано), блок пора убрать. В историю не пишется: живое состояние достаётся GET /v1/chats/{id}/plan, стирается DELETEом по тому же пути.
  • Свёртка истории оставляет след и в самой истории: после компакции ядро дописывает служебное сообщение-маркер — role: user, единственный текстовый блок, байт-в-байт "[compacted] Older history was folded into the chat summary here.". Клиент опознаёт маркер точным совпадением текста и рисует границу компакции даже после перезагрузки, а не только по живому compacting; рисовать его репликой Господина нельзя (признак master у него, как у любого сообщения без sender, истинный). В промпт модели маркер не попадает. В единой ленте элементов маркер отдаётся элементом compaction, не machine_note: клиенту ленты текст не нужен, граница — сама по себе вид шага.
  • Единая лента элементов — одна модель для живого тура, реплея /live и чтения истории, чтобы клиент не сшивал /messages и SSE в свою третью. Элемент несёт id, kind, status (running/completed/failed/ aborted), turn_id (группировка шагов по турам) и тело по виду: user_message, agent_message, thinking, tool_call (результат вложен, отдельного элемента нет), client_op, image, compaction (виден и в истории, не только в живом туре), machine_note (служебные вставки ядра, note — id вида из реестра фрагментов), plan. Новый вид шага доезжает во все клиенты одной правкой ядра плюс case на клиента.
    • Жизненный цикл на проводе: item_started (полный элемент, уже рисуемый) → сколько-то типизированных item_delta (текст дописывается, прогресс замещается, план приходит целиком) → item_completedавторитетное финальное состояние. started оптимистичен, completed авторитетен: клиент рисует сразу и правит потом.
    • GET /v1/chats/{id}/items — история той же лентой, пагинация как у /messages (limit, before — id элемента). limit считает сообщения-источники, а не элементы: одно сообщение разбирается на несколько элементов или ни одного, так что страница привозит их произвольное число, и «элементов меньше limit» началом истории НЕ является — листать до пустой страницы. Id исторических элементов детерминированы: {message_id}:{n}; у живых — {turn_id}:{n}. После done клиент перечитывает хвост /items и заменяет элементы тура; вызовы сшиваются по call_id, картинки — по image_id.
    • Реплика пользователя в живой ленте не едет (свою клиент знает сам, переподключившийся берёт её из /items); client_op-элементы живут только в живом стриме и закрываются своим request_resolved (resolution в теле) — иногда уже после done.
  • GET /v1/chats/{id}/live — переподключение к идущему туру: реплей накопленного + живой хвост. События из реплея несут replayed: true (живой хвост идёт без признака) — контракт: реплей рисует, но не исполняет. Клиент обязан не исполнять client_op с этим признаком, чем бы ни кончилась операция; своих эвристик «моложе подключения» не нужно. Операция из реплея без парного request_resolved ещё ждёт ответа — она истечёт своим таймаутом и уедет модели ошибкой, это штатный исход переподключения.
  • Уровень рассуждения (off|low|mid|high) выбирается по убыванию силы: поле effort в send_message (на один ход) → слово ultrathink в реплике Господина → настройка чата (PUT /v1/chats/{id}/effort) → поле effort записи модели (GET /v1/models отдаёт их в efforts) → глобальный llm.reasoning (он же effort_default в каталоге). Внутри тура уровень может подняться ещё раз — инструментом think_harder; понизить его до конца хода нельзя.
  • DELETE /v1/chats/{id}/messages/{message_id} отматывает чат к состоянию до сообщения: удаляет его и всё после. «Переписать» у клиента — это тот же вызов плюс обычная отправка нового текста. Разрушающе и невозвратно — действием по умолчанию клиенту стоит делать ветку (ниже), а стирание оставить вторым пунктом с подтверждением.
  • POST /v1/chats/{id}/fork ({before_message, title?}) — неразрушающая правка прошлого: новый чат с копией истории строго до before_message, мать не меняется ни на строку. «Переписать в ветке» — форк плюс отправка исправленного текста в ветку. Ветка наследует полку, проект, модель, уровень рассуждения, набор инструментов, профиль компакции, рабочий каталог, резюме компакции (если его якорь попал в копию), блокнот и план; срез посреди тура (tool_use без результата в копии) закрывается той же синтетикой, что у отмены тура, — ветка едет с первого запроса. Связь — в полях чата forked_from/forked_at_message; первым сообщением ветки лежит служебный маркер [forked] … (в ленте элементов — machine_note с note fork_marker, в промпт модели не едет). Ветка — обычный чат: форк от форка разрешён, удаление матери ветку не трогает; расход считается своим чатом. Автоматически ядро веток не плодит — форк только жестом Господина.
  • Действия client_op: read, write, shell — текстовые, и байтовые read_bytes (path, offset, size{"total", "data"}, данные в base64), write_bytes (path, offset, data, truncate{"total"}) и find_bytes (path, data — образец, offset — откуда, limit{"total", "offsets"}). Клиент, который байтовых не умеет, отвечает ошибкой «unknown op action» — договариваться о версиях не нужно.
  • Служебное действие list_up (path — каталог, names — имена): клиент идёт от path вверх до корня ФС и на каждом уровне говорит, какие из имён существуют — ответ {"dirs": [{"dir": "...", "found": ["..."]}]}, уровни от path вверх. Им ядро собирает файлы проекта (AGENTS.md, .eva/MEMORY.md) в блок [project] на входе в тур, без участия модели, и разрешения оно не заслуживает — только чтение имён. Прилетает лишь тем, кто объявил project_docs: true в send_message; объявивший перестаёт клеить AGENTS.md в context сам — иначе файлы задвоятся.
  • Рабочий каталог чата умеет ставить не только модель тулом workdir, но и сам клиент: поля workdir/workdir_on (client|server, дефолт client) в POST /v1/chats и PUT /v1/chats/{id}/workdir (null снимает); читается он полем workdir в GET чата. Валидация общая с тулом. Так кодовая сессия получает [project] с первого же тура, не тратя итерацию инструментов на «выставь workdir».
  • Живой вывод долгой операции (shell у клиента): пока client_op ждёт результата, клиент может читать вывод потоком и постить снимки хвоста в POST /v1/chats/{id}/ops/progress ({id, output}). Снимок ЗАМЕЩАЕТ прошлый (семантика tool_progress, в который он превращается), режется по границе UTF-8 и ужимается ядром до последних 8 КиБ; секреты затираются на входе, в историю ничего не пишется. Постить разумно — не чаще нескольких снимков в секунду; финальный полный вывод по-прежнему едет в ops/result. Для операций подагентов прогресс молча глушится: в стриме тимлида у них нет своего элемента вызова.
  • У client_op бывает поле agent: операцию просит подагент, а исполняет её клиент тимлида. Показывать имя обязательно — иначе непонятно, чью правку разрешает Господин. Результат постится как обычно: операция ищется по op_id, чат в пути не важен.
  • Тулы на один тур: поле extra_tools в send_message ({name, description, input_schema?, timeout_secs?, ends_turn?}) объявляет тулы поверх реестра ядра — модель видит их как обычные, а исполняет сам объявивший клиент. Вызов приходит в стрим событием extra_tool_call {id, name, input} (id совпадает с id tool_call-события этого вызова), ядро стоит и ждёт; ответ клиент постит в ops/result под этим id — лучше полем blocks ([{title, content}]): ядро отрендерит секции канонично (== title), тем же форматом, что у батчевых read_file и shell. ends_turn: true делает тул вердиктом — успешный вызов закрывает тур (так живёт телеграмный telegram_skip). Имя, тенящее тул реестра, — 400. Контракт реплея тот же, что у client_op: реплей рисуют, но не исполняют. SDK: в Rust — Turn::extra_tool::<T>() + событие ExtraToolCall {call, response} с одноразовым респондером (брошенный без ответа сам шлёт ядру ошибку), в Python — Turn.extra_tools([ExtraTool(...)]) + Kernel.tool_result.
  • Запрос ядра к клиенту (client_op, extra_tool_call, вопрос ask_questions) — сущность со снятием: любой исход уезжает в живой стрим событием request_resolved {id, kind: "op"|"question", reason, question?}. reason: answered — ответ получен (приходит всегда, включая ответ самого клиента), expired — срок вышел, turn_ended — тур кончился, не дождавшись, cancelled — тур отменили. Клиент по нему закрывает диалог и вычищает свою очередь одобрений — не гадая таймером «чуть короче ядрового». У операции id — её op_id; у вопроса id — id tool_call-вызова ask_questions, а question — номер вопроса в его массиве.
  • Набор кнопок задаёт ядро, а не клиент: client_op несёт decisions (сегодня allow, allow_session, allow_always, deny; у служебного list_up поля нет — диалог ему не рисуют), в input tool_call-события ask_questions вклеивается словарь мета-решений decisions (discuss, answer_in_text) поверх модельных questions — и в живое событие, и в одноимённый исторический элемент ленты: карточка оборванного вопроса после перезагрузки рисует те же кнопки. Клиент рисует известные ему решения, неизвестные молча пропускает — новое решение вводится правкой ядра, без выпуска клиентов. Выбранное решение операции клиент возвращает полем decision в ops/result; deny без error ядро превращает в канонический отказ для модели.
  • Инварианты диалога одобрения, обязательные для всех клиентов: выбор всегда порождает явное решение — молчаливое закрытие диалога запрещено (закрыли крестиком — это deny, и он уезжает ядру); Esc в диалоге всегда значит «отказ», даже при перенастроенных клавишах — пользовательская настройка не должна превращать безопасное умолчание в разрешающее.
  • Зарезервировано контрактом: kind: "permission" — запрос профиля разрешений (например {fileSystem: {write: [...]}}), на который клиент отвечает выданным подмножеством; ядро таких запросов пока не задаёт.
  • /v1/youtube/* — библиотека Господина: подписки на каналы и история просмотра. Ядро тут общая полка клиентов (телефон, браузер, десктоп видят один список), в сам YouTube оно не ходит. Правила, которые клиентам не нужно повторять каждому у себя:
    • PUT /v1/youtube/history/{video_id} — идемпотентная отметка просмотра: видео поднимается наверх истории со свежими метаданными. Место остановки хранится только между 2% и 97% длины ролика — ниже это случайный клик, выше досмотрено, а при неизвестной длине (duration_s ≤ 0) мерить не от чего; во всех этих случаях position_s становится null, но запись остаётся: это история просмотров, а не список недосмотренного. Ответ отдаёт строку такой, какой она легла, — по нему клиент видит, пережило ли место остановки.
    • История помнит 200 последних видео, хвост уходит на каждой отметке.
    • before в GET /v1/youtube/history — курсор по video_id последней показанной строки, а не время: пачка, приехавшая одной секундой (импорт истории с телефона), при сравнении по времени теряла бы строки на границе страницы. Неизвестный id — 400, а не пустая страница.
  • Кривое тело запроса (не-JSON, тело не сходится со схемой) axum отвергает сам — статусом 422 Unprocessable Entity, не 400; свои 400 ядро отдаёт уже поверх разобранного тела (кривой курсор, невалидные значения полей). Клиентской ветке «я прислал не то» стоит ждать обоих статусов.
  • Аутентификации у ядра нет — её вешает reverse-proxy перед ним (у нас Caddy требует X-Eva-Token); /apidocs и /v1/openapi.json открыты.