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

ADR 0043 — Шесть providers LLM поверх базы, и Фаза 9b наконец закрыта

Контекст​

ADR 0041 поставил OpenAI-совместимую базу, контракт providers и capabilities в два слоя. ADR 0042 поставил живой каталог и аудируемую цену, но явно оставил в собственном «Что остаётся на потом»: шесть providers Фазы 9b (NVIDIA NIM, Deep Infra, Together AI, Bitdeer AI, Vultr и OpenRouter) не вошли — политика egress сессии той фазы запрещала весь исходящий HTTPS, а проверка baseUrl, auth и особенностей streaming по официальной документации до написания кода была условием самих рамок, а не деталью.

Фаза 11 поставила все шесть, в две партии. Сначала один OpenRouter (11a) — единственный хаб из шести, выбранный первым именно для того, чтобы доказать базу против реального продакшена, прежде чем повторять шаблон пять раз. Затем пять прямых (11b) — NVIDIA NIM, Together AI, DeepInfra, Bitdeer, Vultr — последовательно, каждый исследован с нуля по официальной документации своего API. Правило, направлявшее обе партии, унаследованное от духа самого ADR 0041 («каждый флаг существует, потому что реальный provider расходится»): предположение о quirk НИКОГДА не наследуется между providers. Provider, похожий на другой, может расходиться ровно там, где никто не проверял.

Решение​

Таблица приёмки, provider за provider​

providerlistModelsпочемупроверка соединенияquirksисточник моделей
OpenRouter (11a)trueкаталог с ценой в самой строке (десятичная строка USD/токен, конвертируется)GET /key, выделенный endpointсобственные заголовки (HTTP-Referer/X-Title); ошибка ПОСРЕДИ stream (единственный хаб); id модели с префиксом upstreamsync
NVIDIA NIMfalseGET /v1/models существует, цены нет ни в одной проверенной документацииGET /v1/models, только статусtool calling — по МОДЕЛИ, а не по API; размещённый endpoint ≠ самостоятельно размещённый контейнерseed
Together AItrueкаталог с плоским pricing (число), единица выведена сравнением с рынком — явно не документированаGET /v1/models, только статусid с namespace (404 без префикса); 429 с error_typesync + seed
DeepInfratrueкаталог публичный, без аутентификации, подтверждён ВЖИВУЮ с реальной ценойнет — публичный каталог не отличает хороший ключ от плохогокаталог смешивает чат/изображение/аудио/видео (фильтр по tags)sync + seed
Bitdeerfalseни одной формы каталога/цены публично не найденоGET /v1/models, подтверждён 401 вживую без ключасамая поверхностная публичная документация из шести; 3 РЕАЛЬНЫХ id подтверждены в примере конфигурации из собственного блогаseed
Vultrfalseв маршруте, который вызывает база (/models), цены в документации нет; документированный маршрут с ценой вживую вернул 404GET /v1/models, подтверждён 401 вживую без ключаtool calling подтверждён реальным примером (kimi-k2-instruct, finish_reason: "tool_calls")seed

«Честное false» победило «хрупкое true» дважды, вживую​

Правило ADR 0041/0042 («capability объявляется только тогда, когда доказана») — не только принцип: оно было проверено во время реализации и дважды изменило результат:

  • DeepInfra вошёл в планирование как кандидат на listModels: false (документация намекала на обязательную аутентификацию и неподтверждённую цену в endpoint, который вызывает база). Проверка ВЖИВУЮ против GET https://api.deepinfra.com/v1/openai/models (тот же endpoint, который всегда вызывает OpenAICompatibleProvider.listModels() — {baseUrl}/models, без исключений) показала, что каталог публичный, вообще без аутентификации, и возвращает реальную цену под metadata.pricing.{input_tokens,output_tokens}. Стало true — без нужды расширять базу, потому что URL, который она уже вызывает по умолчанию, оказался достаточным.
  • Vultr прошёл обратный путь: планирование указывало true (документация Vultr связывает цену с endpoint GET /provider с cost/contextWindow). Проверка ВЖИВУЮ по этому пути вернула 404 — документация была устаревшей, или документированный путь никогда не существовал в такой форме. Поскольку база вызывает только {baseUrl}/models, а у этого маршрута (подтверждено существование, 401 без ключа) цены нет согласно официальной справке самой Vultr, решение стало false.

В обоих случаях окончательное решение пришло не из плана — оно пришло из обращения к настоящему API во время реализации. План, зафиксировавший решение до этой проверки, угадал бы NIM/Bitdeer и ошибся бы с DeepInfra/Vultr.

Никакого нового «вида учётных данных»​

Все учётные данные LLM у всех девяти providers имеют ОДНУ форму — ключ API, зашифрованный envelope encryption (user_credentials.encrypted_api_key). Не существовало и по-прежнему не существует структурного различия «типа» учётных данных (OAuth против ключа, например) на стороне LLM. По provider меняется только то, есть ли у него объявленная проверка соединения (выделенный GET /key у OpenRouter; GET {baseUrl}/models только по статусу у пяти прямых, которые её поддерживают; никакой у DeepInfra, чей публичный каталог не годится для проверки какого-либо ключа).

Последствия​

  • LLM_PROVIDER_NAMES вырос с 3 до 9 записей (llmProviderEnum и credentialProviderEnum следуют, через миграцию 0030). Двунаправленная проверка исчерпываемости, которую ADR 0041 уже гарантировал (тип × массив), держала сборку красной, пока реестр и модуль не получили все 9 случаев, — сработало ровно так, как задумано.
  • DTO регистрации учётных данных (upsert-credential.dto.ts) и тестировщик соединения (llm-credential-connection-tester.ts) перестали иметь захардкоженный список providers (утроенный в DTO — Swagger, @IsIn, тип TS) и стали выводить его из LLM_PROVIDER_NAMES_COM_CREDENCIAL/карты переопределений. Реальная находка этой фазы: ручной список был ровно тем, что сломало тест посреди работы, когда конструктор тестировщика учётных данных получил лишний параметр, а один spec не поспел, — тот же тип молчаливого отказа, от которого проверка исчерпываемости ADR 0041 уже существовала со стороны типа, только здесь — со стороны значения.
  • Экран учётных данных и ModelPicker вобрали 9 без нового компонента: ROTULO_DO_PROVIDER (исчерпывающий Record) получил 6 меток, HUBS по-прежнему с одним членом (openrouter) — пять прямых попадают в «APIs diretas», потому что их нет в этом списке, а не по новому правилу.
  • Сгенерированная справка (docs/reference/llm-providers.md, блок providers-capabilities) получила три колонки — учётные данные, источник моделей (sync | seed | sync + seed) и кратко quirks, — все ВЫВЕДЕННЫЕ механически (из кода и уже написанной вручную прозы), без прикосновения к какому-либо файлу provider лишь ради питания документации.

upstream_provider (ADR 0042, подготовка Фазы 9b) теперь имеет за собой реальные данные — не только пустую схему. Пример запроса в docs/reference/llm-providers.md («Хабы и реальная стоимость») показывает, что сравнение стоимости ОДНОЙ И ТОЙ ЖЕ модели, хаб × напрямую, уже сегодня доступно для запроса. Это зафиксировано как семя, а не реализовано: нет автоматического потребителя этого сравнения, и естественное прочтение — Psicólogo однажды предложит «эта модель дешевле напрямую, чем через хаб» — лишь отмечено здесь, без тикета и без рамок фазы.

Проверка того, что база осталась нетронутой​

git diff origin/main -- apps/api/src/application/use-cases/llm/sync-model-catalog.use-case.ts пуст — SyncModelCatalogUseCase уже обобщённо перебирал LLM_PROVIDER_NAMES начиная с ADR 0042; вобрать ещё 6 providers не потребовало ни строки.

git diff origin/main -- apps/api/src/infrastructure/llm/openai-compatible-provider.ts не пуст: +31/-0, единственный хук —

export type ParseErrorFrame = (
frame: Record<string, unknown>,
) => LLMProviderError | undefined;

плюс необязательное поле parseErrorFrame?: ParseErrorFrame в OpenAICompatibleConfig и его вызов ПОСРЕДИ цикла SSE, до чтения delta/usage, — потому что кадр ошибки хаба — это не кадр содержимого с пустыми полями, это нечто иное, и трактовать его как второе значило бы скрыть отказ, а не сообщить о нём. 31 строка существует исключительно потому, что OpenRouter — единственный хаб из шести: только хаб принимает соединение, начинает слать текст и имеет за собой реального provider, падающего ПОСРЕДИ stream, — ни у одного прямого provider нет этого класса отказа, потому что ни один не маршрутизирует на стороннюю инфраструктуру. Доказательство того, что расширение было минимальным и необходимым, а не спекулятивным: пять прямых providers 11b прошли всю контрактную suite, не объявляя никакого parseErrorFrame, и ни одному он не понадобился.

Ни у одного другого файла базы (http-stream.ts, llm-provider-errors.ts, model-capabilities.ts, model.repository.ts) нет никакого diff против origin/main.

Что остаётся на потом​

  • Приёмка шести smoke с реальными учётными данными (openrouter-provider.smoke.spec.ts и пять из 11b), каждая за собственным <PROVIDER>_TEST_KEY. Написаны, протестированы против mock, ни разу не запускались против настоящего ключа — тот же хвост, который ADR 0042 уже фиксировал для OpenRouter, теперь распространённый на пять новых. Запуск каждого (когда будет ключ) — то, что наконец докажет цену Together/DeepInfra и оценочные ID NIM/Bitdeer/Vultr против реальности, а не против документации.
  • Оценочная цена NVIDIA NIM, Bitdeer и Vultr — ни один из трёх не публикует цену по модели в доступной на этой фазе документации; засеянные значения — рыночное приближение (manual_pricing: true), исправимое, как только появится официальный источник.
  • Прочтение маршрутизации стоимости в Psicólogo (см. заметку-инсайт выше) — семя, пока не рамки какой-либо фазы.
  • Находки P1 dogfooding (Фаза 10) остаются нетронутыми, конкурируя за Фазу 12 с остальным backlog, — у Фазы 11 никогда не было разрешения их трогать, и она не тронула.

Ссылается на ADR 0041 и ADR 0042, чей фундамент удержал шесть providers как конфигурацию — ни одному из них не понадобился рефакторинг базы, кроме единственного хука, необходимость которого доказал OpenRouter, будучи хабом.