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

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 проекта, или latency workspace, применённую к модели, которую владелец 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 не редактируется;
  • остальные поля объекта provider OpenRouter (order, only, ignore, allow_fallbacks, max_price) и суффикс :nitro: ни одно не было запрошено, и каждое — отдельное продуктовое решение.