ADR 0041 — Одна OpenAI-совместимая база, один контракт LLM providers и capabilities, отклоняющие binding
Контекст
Фаза 9 добавит шесть LLM-провайдеров (NVIDIA NIM, Deep Infra, Together AI, Bitdeer AI, Vultr и OpenRouter). Перед тем как написать первый, исследование существующего показало, что фундамент не выдерживал даже трёх нынешних:
OpenAIProviderмолча отбрасывалoptions.tools. Пятьдесят четыре строки, SDKopenaiи комментарий в самом файле, признающий, что tool calling «пока не поддерживается в этом provider». Тот, кто привязал бы модель OpenAI к агенту, увидел бы, как ToolLoop завершается без результата, — тот же симптом, на диагностику которого ADR 0020 потребовалось девять запусков.- Не было ни одного теста ни для
OpenAIProvider, ни дляAnthropicProvider. Единственным протестированным provider был Ollama, и то лишь на уровне транспорта. - Не было таксономии ошибок LLM. Все три provider делали
yield { type: 'error', message: (error as Error).message }. Истёкший ключ, rate limit и несуществующая модель доходили до пользователя одной и той же непрозрачной строкой вендора. Сторона git решила это в ADR 0002, а урок так и не перешёл через границу. - У
modelsне было capabilities, а уLLMProviderне былоcapabilities— в отличие отGitProviderContract, который несёт свои с Фазы 2. Любую модель можно было привязать к любому агенту. - Не было набора контрактных тестов LLM, хотя набор для git
(
test/contract/git-provider.contract.ts) значится в CLAUDE.md как соглашение проекта со времён ADR 0001.
Шесть новых provider поверх такой базы умножили бы пять проблем на шесть.
Решение
База говорит на node:http, а не на SDK
OpenAICompatibleProvider реализует диалект /chat/completions один раз и
реализует его поверх голого node:http — а не поверх SDK openai, который удаляется из
package.json.
Причина — пункт, который требует охват фазы и который SDK не даёт: таймаут
бездействия. SDK говорит на fetch, чей таймаут относится ко всему запросу; нам
нужно обрывать сокет, когда он ЗАТИХАЕТ, что одинаково относится и к
«ещё не прислал заголовки», и к «перестал присылать chunks посреди
stream». Это именно то различие, за которым охотился ADR 0020, и postStream, который
он написал для Ollama, был вынесен в infrastructure/llm/http-stream.ts
и стал обслуживать обоих.
Побочный выигрыш в том, что особенности каждого provider становятся флагами
конфигурации (baseUrl, заголовок auth, streamOptionsIncludeUsage,
maxTokensField), а не упираются в то, что SDK позволяет настроить. Каждый флаг
существует потому, что реальный provider расходится, — правило: не добавлять флаг без
provider, которому он нужен, а Фаза 9b подтверждает каждый в официальной документации во время
реализации.
Нормализованная ошибка идёт в chunk, с обязательным code
ChatErrorChunk получил code: LLMErrorCode, а таксономия стала классами в
domain/llm/llm-provider-errors.ts. Поле обязательное, а не опциональное:
при опциональном поле новый provider забывает классифицировать, и его ошибка снова
становится непрозрачной строкой, а никто этого не замечает.
| статус | code |
|---|---|
| 401, 403 | auth |
| 404 | model_not_found |
| 429 | rate_limit |
| 413, или 400 с маркером контекста | context_length |
| немой сокет | timeout |
| не подключился | connection |
| остальное | upstream |
В отличие от git-errors.ts, который является набором отдельных классов, здесь есть
базовый класс: назначение ошибки — не HTTP-фильтр со статусом по типу, а
преобразование в ChatErrorChunk. Тому, кто преобразует, нужна единая точка, которая
всегда предоставляет code и message.
Capabilities в два слоя и binding, который отклоняется
LLMProvider получил capabilities (ПОТОЛОК backend), а models получил три
столбца supports_* (что умеет конкретная модель). Модель может быть
беднее provider, но никогда не богаче.
Дискретные столбцы, а не jsonb: фильтр «пригодные для агентов» Фазы 9c
должен быть WHERE, а capability без столбца — это capability, которую
никто не может запросить.
Поверх этого идёт новое правило (RN-040):
assertModelFitsBindingScope отказывается привязывать к агенту модель без
нативного tool calling, с сообщением, указывающим фильтр, которым пользователю нужно
воспользоваться. Проверяет только область agent — workspace и project являются fallback
человеческого чата, и их блокировка запретила бы модель только для чата во всём продукте.
ToolCallRecovery в engine по-прежнему существует и по-прежнему является спасением,
а не лицензией: он зависит от того, что модель случайно угадает формат, и молча
проваливается, когда не угадывает. Намеренно выбирать эту случайность — вот что правило
отвергает.
Исправление предпосылки охвата: context-manager — это не область
binding, а slug агента под scope='agent' (ADR 0007). Правило покрывает его
по построению, без нового enum.
Контракт владеет утверждениями; harness — диалектом
test/contract/llm-provider.contract.ts запускает одну и ту же батарею против любого
LLMProvider. Harness каждого provider переводит девять сценариев в свой
формат провода и наследует тесты: stream с фреймом, разорванным между двумя
res.write, присутствующий и отсутствующий usage, tool calling, четыре ошибки и
немой сервер.
Фальшивый сервер — настоящий node:http на эфемерном порту — шаблон
теста Ollama из Фазы 4, — а не mock fetch. Под тестом находится
именно поведение сокета; mock ответил бы красиво и ничего бы не
доказал.
Provider Фазы 9b рождается с тридцатью утверждениями, не написав ни одного.
Anthropic получил tool calling и остался на SDK
Anthropic не говорит на /chat/completions, поэтому не наследуется от базы. Он получил нативный tool
calling (блоки tool_use, а сообщения role: 'tool' становятся
tool_result, сгруппированными в ход user, — формат требует, чтобы результаты
параллельных вызовов приходили в одном ходе), ошибки, нормализованные по статусу, и
потолок бездействия через withIdleTimeout, который оборачивает генератор SDK и
перезапускает часы на каждом событии.
Последствия
Расхождения, которые задокументированы, а не спрятаны
У контракта есть ось, параметризуемая harness, — usageFallback, — потому что
три диалекта отвечают по-разному на один и тот же вопрос:
- совместимая база считает локальным tokenizer и помечает
estimated: true; - Ollama не испускает никакого
usage(без строкиdoneсообщать нечего); - Anthropic не умеет опускать подсчёт —
usageобязателен вmessage_start, и сценарий «без usage» там был бы недопустимым протоколом.
Спрятать это в единый тест, который лишь проверяет «есть usage или нет», стоило бы
различия между «provider сказал ноль» и «provider ничего не сказал» — а это
именно то, ради чего существует метка estimated.
Принятые издержки
OpenAIProviderсменил транспорт. Это не «просто перенос кода»: он ушёл с SDK и стал делать собственный разбор SSE. Поскольку тестов для него раньше не было, миграция была проверена новым набором контрактных тестов, а не предсуществующими тестами. Зато теперь разбор проверяется тридцатью утверждениями и проверенной мутацией: отключение отправкиtoolsзаставляет контракт упасть.- Изменилось одно существующее утверждение теста.
ollama-provider.spec.tsсравнивал chunk ошибки точнымtoEqual; лишний ключcodeэто ломает. Одна строка. - Четыре тестовых fake получили
capabilities.capabilitiesв port абстрактны, поэтомуFakeProvider/ThrowingProviderсценариев использования должны были их объявить. Без этого у них был бы недопустимый тип и они бы проходили —tsconfig.buildисключаетtest/, а vitest использует SWC без typecheck, то есть ошибка была бы немой. - Backfill миграции — это литеральный список из семи моделей. Слепой
UPDATEбыл бы проще и лгал бы о любой модели, которую оператор вставил через SQL, — значение по умолчаниюfalseдолжно продолжать действовать для тех, кто не был проверен.
Что остаётся открытым
brabo_llm_call_errors_totalпрактически не срабатывает.TracedLLMProviderувеличивает его только при брошенном исключении, а provider yield-ят chunk ошибки вместо того, чтобы бросать. Теперь, когда у ошибки естьcode, счётчик мог бы получить метку причины и начать считать по-настоящему — но это означало бы трогать observability завершённой фазы без запроса, и это зафиксировано здесь, а не сделано мимоходом.supports_visionесть в таблице и никем не используется. Он вошёл, потому что охват фазы перечисляет его среди capabilities; потребителя он получит в ModelPicker Фазы 9c.- Шесть provider,
list_models, синхронизация цен и перегруппированный ModelPicker — это 9b и 9c. Эта фаза — фундамент: ни одного нового значения вllm_providerилиcredential_provider.
Ссылается на ADR 0020, откуда пришли
postStream и правило всегда регистрировать источник сбоя, и
ADR 0002, нормализацию ошибок которого этот ADR
наконец воспроизводит на стороне LLM.