Перейти к основному содержимому

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_activeowner, через экран курирования«хочу ли я использовать эту модель?»
models.availabilitysync, сам«существует ли ещё эта модель там?»

Одного состояния не хватило бы: если бы 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 зафиксировал как практически инертный. По-прежнему отмечен, не исправлен мимоходом.