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
| provider | listModels | почему | проверка соединения | quirks | источник моделей |
|---|---|---|---|---|---|
| OpenRouter (11a) | true | каталог с ценой в самой строке (десятичная строка USD/токен, конвертируется) | GET /key, выделенный endpoint | собственные заголовки (HTTP-Referer/X-Title); ошибка ПОСРЕДИ stream (единственный хаб); id модели с префиксом upstream | sync |
| NVIDIA NIM | false | GET /v1/models существует, цены нет ни в одной проверенной документации | GET /v1/models, только статус | tool calling — по МОДЕЛИ, а не по API; размещённый endpoint ≠ самостоятельно размещённый контейнер | seed |
| Together AI | true | каталог с плоским pricing (число), единица выведена сравнением с рынком — явно не документирована | GET /v1/models, только статус | id с namespace (404 без префикса); 429 с error_type | sync + seed |
| DeepInfra | true | каталог публичный, без аутентификации, подтверждён ВЖИВУЮ с реальной ценой | нет — публичный каталог не отличает хороший ключ от плохого | каталог смешивает чат/изображение/аудио/видео (фильтр по tags) | sync + seed |
| Bitdeer | false | ни одной формы каталога/цены публично не найдено | GET /v1/models, подтверждён 401 вживую без ключа | самая поверхностная публичная документация из шести; 3 РЕАЛЬНЫХ id подтверждены в примере конфигурации из собственного блога | seed |
| Vultr | false | в маршруте, который вызывает база (/models), цены в документации нет; документированный маршрут с ценой вживую вернул 404 | GET /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 связывает цену с endpointGET /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 лишь ради питания документации.
Заметка-инсайт: зацепка для маршрутизации стоимости в Psicólogo
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, будучи хабом.