ADR 0166 — Предпочтение маршрутизации живёт в binding модели, путешествует вместе с ним и замораживается в metering
Статус
Accepted. Решено сопровождающим 2026-09-18 (AT-090): «предпочтение
маршрутизации НАСТРАИВАЕТСЯ ДЛЯ КАЖДОГО BINDING». Расширяет
ADR 0041
(capability в два слоя, объявляемая только после доказательства) и
ADR 0042
(то, что дало число, идёт замороженным в строке token_usage). Не редактирует
ни один из них. Правило, рождающееся отсюда, —
RN-583.
Контекст
Измерено в token_usage установки v6.1.0 (2026-09-14): 24 вызова
~deepseek/deepseek-v4-flash-latest через openrouter. upstream_provider
(RN-042) уже говорил, кто обслужил каждый:
Relace в 23, со средней задержкой 69,5 с (от 32 до 47 ток/с), и
Baidu в 1, с 37,8 с (~190 ток/с). OpenRouter выбирает upstream по
балансировке, отдающей предпочтение ЦЕНЕ, когда запрос ничего не говорит, — а
openrouter-provider.ts ничего не говорил: тело /chat/completions уходило
без provider/sort.
Хаб принимает provider: { sort: "price" | "throughput" | "latency" } в теле.
Остающийся вопрос — ПРОДУКТОВЫЙ, а не о проводе: кто выбирает критерий и
где живёт этот выбор.
Решение
1. Один столбец в binding, в агрегате llm
routing_preference (enum routing_preference: price | throughput | latency), допускающий null, в model_bindings. null — сегодняшнее поведение:
ничего не отправляется, и хаб решает сам. Столбец входит в
apps/api/src/db/schema/llm.ts, где уже живёт binding, — агрегат принадлежит
модели, а enum живёт с двумя таблицами, которые его вызывают (правило enum из
ADR 0121).
2. Предпочтение НЕ каскадируется само: оно путешествует с победившим binding
Каскад (session > agent > area > project > workspace, плюс пост-каскадный
шаг Criativo) остаётся каскадом binding. Победивший binding
несёт модель И своё предпочтение; ни один уровень не «наследует только предпочтение»
от другого. Три причины:
- Предпочтение имеет смысл только вместе с моделью, с которой оно было выбрано. Оно
существует для ОДНОГО provider (хаба); каскадирование отдельно давало бы комбинации,
которые никто не выбирал, — модель агента в provider без capability и
throughputпроекта, илиlatencyworkspace, применённую к модели, которую владелец workspace никогда не видел. - Независимый каскад потребовал бы второй цепочки на экране. RN-470 сделала цепочку МОДЕЛИ видимой узел за узлом; две цепочки, приземляющиеся на разных уровнях в одной строке, были бы ровно тем состоянием, которое экран не умеет объяснить.
- Уровень, который каскад ПРОПУСКАЕТ, уносит предпочтение с собой. Binding, пропущенный из-за недоступности или отсутствия tool calling, перестаёт действовать целиком — модель и критерий. Если бы уцелел только критерий, это был бы тот же дефект, что в первой причине, созданный падением.
Записанное следствие: чтобы поставить throughput целой области, записывают в
binding ОБЛАСТИ; агент, расходящийся с собственным binding, имеет СВОЁ
предпочтение (null, пока кто-нибудь не выберет).
3. Запись: отсутствие сохраняет, null очищает, provider без capability отказывает
PUT .../model-binding (все пять областей) получает
routingPreference?: 'price' | 'throughput' | 'latency' | null:
- отсутствует — сохраняет записанное предпочтение, если provider новой модели
принимает его; иначе оно становится
null. Пропуск не может молча очищать (так привычная смена модели и «применить ко всем» продолжают вызывать маршрут), и также не может оставлять записанным критерий, который новый provider не понимает; null— очищает, явно;- значение, при provider без capability — 422
(
RoutingPreferenceNotSupportedError), в том же фильтре, что и другие отказы binding. Запрос корректно сформирован; несостоятельна комбинация.
Инвариант, который поддерживают три правила: ни один binding не хранит предпочтение для provider, который его не объявляет.
4. Capability PROVIDER, объявленная false, пока не доказана
LLMProviderCapabilities.routingPreference, обязательная, как embeddings
(опциональное поле — это новый provider, который забывает объявить). Все девять
объявляют. Отображение на провод (provider: { sort }) — особенность
OpenRouter и входит как hook его config (campoDeRoteamento), по дизайну
extrairUpstreamProvider: база вызывает его только когда capability — true И
есть предпочтение, — никогда if (name === 'openrouter') в разборе.
Слой МОДЕЛИ не получает столбца: sort — критерий ХАБА по upstreams,
обслуживающим модель, каталог OpenRouter не публикует его построчно, а модель с
одним upstream получает его как no-op. Выдумать столбец значило бы
capability без источника.
Значение для OpenRouter — то, что скажет доказательство. Capability становится true только
когда smoke против реального API (openrouter-provider.roteamento.smoke.spec.ts)
возвращает выбранный upstream. Без учётных данных в окружении реализующего
она рождается false, и функция рождается СПЯЩЕЙ: маршрут отказывает с 422, а экран
говорит текстом, что ни у одного provider этой установки опция не доказана.
5. То, что было ЗАПРОШЕНО, идёт замороженным в token_usage
token_usage.routing_preference, допускающий null, записываемый на вызов:
предпочтение, которое ушло в провод, — null, когда у binding его не было, и null
также, когда было, но provider его не объявляет (оно не было отправлено, значит, не является
происхождением чего-либо). Это та же линейка, что и у замороженной цены: без неё сравнение
upstream_provider и latency_ms до и после включения throughput
зависело бы от восстановления состояния binding в тот момент, которое нигде
не фиксируется.
6. Экран узнаёт capability через маршрут PROVIDER
GET llm/provider-capabilities возвращает capabilities девяти providers,
прочитанные из экземпляров реестра, — тот же источник, что использует adapter. Новый маршрут,
а не поле в существующем ответе, потому что ни один существующий ответ не касается
PROVIDER: модель — строка каталога (факт вендора, MesmasChaves с
сущностью), а binding — решение области. Без роли (только аутентификация), как
users/me/credentials: это факт кода этой установки, одинаковый для всех.
Элемент управления живёт там, где редактируется binding, — в таблице агентов и разделе
областей в Настройках, — с минимальной ролью ENDPOINT
(RN-102): developer для агента,
maintainer для области. Редактируем только в строке, у которой ЕСТЬ собственный binding; в строке,
которая наследует, он говорит, с какого уровня пришёл действующий критерий. Модель, чей provider не
имеет capability, не получает опции — она получает фразу с объяснением.
Последствия
- При capability
trueустановкаthroughputв binding OpenRouter меняет тело каждого вызова, который на него разрешается, а строкаtoken_usageговорит, какой критерий был запрошен и кто обслужил, — сравнение до/после становится запросом, а не реконструкцией. - Пока capability —
false, в проводе ни у кого ничего не меняется: код на месте, маршрут отказывает, а экран объясняет. Переключение флага — PR в одну строку, сопровождаемый выводом smoke. - Объявленная цена:
sort: throughputможет маршрутизировать на более ДОРОГОЙ upstream. Metering по-прежнему берёт цену каталога модели (ADR 0042), то есть справочную цену OpenRouter, а не upstream, — эта разница уже существовала и здесь не измеряется.
Вне охвата, объявлено:
- у сессии (
SessionPage) и уровней проекта и workspace нет элемента управления на экране: API принимает поле во всех пяти областях, но у проекта и workspace нет селектора модели ни на одном экране (измерено: ни одинsetProjectModelBinding/setWorkspaceModelBindingне вызывается из web), а селектор сессии — другая поверхность; - карточка агента (Обзор/Исполнители) показывает модель и НЕ редактирует binding — элемент управления туда не пошёл, потому что там binding не редактируется;
- остальные поля объекта
providerOpenRouter (order,only,ignore,allow_fallbacks,max_price) и суффикс:nitro: ни одно не было запрошено, и каждое — отдельное продуктовое решение.