ADR 0042 — Живой каталог, жизненный цикл модели и проверяемая цена
Контекст
ADR 0041 поставил OpenAI-совместимую базу, контракт providers и capabilities на модель. Не хватало второй половины Фазы 9: самостоятельно обнаруживать модели, знать, что делать, когда одна из них исчезает, и менять цену, не портя историю.
Исследование перед написанием кода нашло три вещи, переопределившие работу.
Историческая стоимость уже была заморожена — дыра была в другом
Охват требовал теста, доказывающего, что «изменение цены не меняет вчерашнюю
стоимость». Такой тест прошёл бы сегодня, вхолостую: calculateCostMicros выполняется в момент
вызова, и результат становится token_usage.cost_micros; ничто не пересчитывает потом.
Настоящая дыра в том, что token_usage не хранил цену, давшую стоимость.
Старое значение было неизменяемым, но не воспроизводимым: tokens × preço переставало
сходиться, как только кто-то исправлял строку models, и не было способа
отличить «стоимость верна» от «стоимость неверна с самого начала».
is_active был декоративным
Столбец существовал с Фазы 1 и читался в одном месте: listActive().
findById и findCandidates его игнорировали. Деактивация модели не мешала
новому binding, не прерывала существующий binding, и вызов продолжал
выполняться и оплачиваться. Экран говорил «деактивирована», а система была не согласна.
Не было fallback по доступности
binding-resolver.ts получал {scope, modelId} и ничего больше — он не знал
модели. Каскад шёл по ОБЛАСТЯМ, никогда по состоянию указанной модели.
Binding к модели, которую provider удалил, разрешался бы нормально и
падал бы только при вызове, с 404 от вендора.
Решение
Две независимые оси, а не одно состояние
| столбец | кто пишет | на какой вопрос отвечает |
|---|---|---|
models.is_active | owner, через экран курирования | «хочу ли я использовать эту модель?» |
models.availability | sync, сам | «существует ли ещё эта модель там?» |
Одного состояния не хватило бы: если бы sync писал в is_active, модель, которая
исчезла на час, вернулась бы выключенной, потеряв курирование; если бы owner
писал в availability, он мог бы «реактивировать» модель, которой больше не
существует на той стороне. Именно пересечение двух порождает предупреждение на экране.
set в upsert репозитория не включает is_active — это строка, которая
гарантирует правило: sync, вновь обнаруживший модель, не может снова включить то, что кто-то
намеренно выключил.
Обнаруженная модель входит НЕАКТИВНОЙ, исчезнувшая помечается и сохраняется
Формализовано в RN-043. Удаление никогда не вариант:
model_bindings и token_usage указывают на строку.
Третье правило — то, что заметно меньше всего и важно больше всего: сбойный provider
ничего не делает недоступным. 401 означает «не знаю, что там есть». Считать это
пустым каталогом пометило бы все модели этого provider как исчезнувшие и
обрушило бы все binding разом — из-за отозванного ключа. Provider
ПРОПУСКАЕТСЯ, с источником сбоя (infra | modelo) в отчёте, в
словаре ADR 0020.
По той же причине listModels базы бросает исключение, когда capability не
объявлена, вместо того чтобы вернуть []: пустой список неотличим от «у provider
нет ни одной модели», и sync прочитал бы это как «исчезли все».
Каскад перепроверяет capability на каждом уровне
resolveBinding начал получать {availability, supportsToolCalling} вместе с
id и возвращает skipped[] — что было отброшено и почему.
Неочевидный момент: когда ход несёт инструменты, фильтр
supports_tool_calling действует для каждого кандидата, а не только для первого.
Без этого binding агента к недоступной модели упал бы на уровень
ниже и приземлился бы на модели только для чата, молча нарушив
RN-040 — сбой проявился бы только позже,
в ToolLoop, как «агент остановился сам». Это ровно тот режим отказа, на диагностику которого
ADR 0020 потребовалось девять запусков.
Триггер — то, что у хода ЕСТЬ инструменты, а не то, что актор — агент: ход резюме
context-manager без tools хорошо работает на модели только для чата, а блокировать его
значило бы ограничивать больше, чем требует правило.
Цена: snapshot в token_usage, аудит в собственной таблице
token_usage получил input_price_per_million_micros и
output_price_per_million_micros — цену, которая дала эту стоимость. Именно это
делает старую стоимость воспроизводимой, а не просто неизменяемой
(RN-044).
Рассматривавшейся альтернативой была таблица действия (цена с интервалом действия, стоимость пересчитывается через join). Она была отброшена: каждому чтению стоимости пришлось бы разрешать правильный интервал, а ошибка в этом join переоценила бы всё прошлое — ровно то, что фаза запрещает. Snapshot кладёт ответ в строку.
model_price_changes — append-only, с парой до/после. Она живёт в собственной таблице
и не в outbox: Engine.Outbox.Drain.run_once/0 фильтрует
aggregate_type == "session", поэтому строка цены там навсегда осталась бы с
processed_at null и загрязнила бы метрику задержки outbox. Это неизменяемый
доменный журнал, как session_events, — то же правило CLAUDE.md.
Engine планирует, api выполняет
Sync работает как самоперепланирующийся worker Oban (ModelSyncSchedulerWorker),
в идиоме, которую репозиторий использует со времён OutboxDrainWorker, —
Oban.Plugins.Cron не установлен, а unique: стоит только в kickoff/0,
никогда в use, иначе выполняющаяся задача столкнулась бы сама с собой и убила бы
цепочку после одного раунда.
Worker вызывает POST /internal/models/sync, потому что именно у api есть
учётные данные и registry providers; дублировать registry в Elixir
означало бы поддерживать два каталога. Перепланирование происходит до
работы, намеренно: плохой раунд не может убить периодическую цепочку.
Кнопка «Обновить каталог» в UI вызывает тот же сценарий использования через
POST /workspaces/:id/models/sync — нет двух согласований, которые могли бы
разойтись.
Курирование зависит от :workspaceId, а каталог глобален
RolesGuard определяет эффективную роль из :projectId или
:workspaceId в маршруте. Без одного из них ему неоткуда взять роль,
и @RequireRole('owner') на маршруте без области отклонял бы всегда. Поэтому
маршруты курирования — /workspaces/:workspaceId/models/*.
Сам каталог остаётся глобальным — таблица models никогда не была на workspace.
Workspace в пути — это якорь RBAC, а не срез данных, а следствие
зафиксировано ниже как backlog.
Последствия
- Новый provider, предоставляющий
GET /models, получает sync, не написав sync: объявляетlistModels: trueи, если формат расходится,parseCatalogo. - В UI появился экран курирования с пакетной активацией и отчётом sync, показывающим каждый provider, включая пропущенный.
ModelPickerперегруппирован по происхождению (Локальные · Прямые API · Хабы) и получил фильтр «пригодные для агентов» — на который сообщение об ошибке RN-040 ссылалось с Фазы 9a, хотя его не существовало.- Цена стала показываться с раздельными входом и выходом. Среднее скрывало асимметрию: модель за 3 USD на входе и 15 на выходе выглядела как «9», что не является ценой ничего.
Что остаётся на потом
- Шесть providers Фазы 9b (NVIDIA NIM, Deep Infra, Together AI, Bitdeer
AI, Vultr и OpenRouter). Политика egress окружения этой сессии запрещает
весь исходящий HTTPS, а охват фазы требует проверить
baseUrl, auth, форматusageи особенности streaming в официальной документации до написания кода. База, контракт, sync и metering поupstream_providerготовы их принять: каждый — это config + seed + вид учётных данных. - Приёмка с реальными учётными данными OpenRouter (настоящий каталог и
заполненный
upstream_providerв задаче), которая зависит от пункта выше. listModelsдля Ollama и Anthropic. У обоих есть endpoint каталога, не проверенный в документации в этой фазе. Они объявляютfalseи явно пропускаются, что честно; объявитьtrueс угаданным разбором пометило бы весь каталог как исчезнувший.- Автоматическая синхронизация цены, включённая по умолчанию. Sync записывает цену только там, где
строка не помечена как
manual_pricing; применение цены из sync к ручной строке требует явного решения owner, а UI для этого решения ещё не существует. - Каталог на workspace. Сегодня курирование глобально, а
:workspaceIdмаршрута — лишь якорь RBAC: owner workspace A, активирующий модель, активирует её и для B. - Bedrock и Azure OpenAI, вне охвата Фазы 9 с самой постановки.
brabo_llm_call_errors_total, который ADR 0041 зафиксировал как практически инертный. По-прежнему отмечен, не исправлен мимоходом.