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

ADR 0041 — Одна OpenAI-совместимая база, один контракт LLM providers и capabilities, отклоняющие binding

Контекст​

Фаза 9 добавит шесть LLM-провайдеров (NVIDIA NIM, Deep Infra, Together AI, Bitdeer AI, Vultr и OpenRouter). Перед тем как написать первый, исследование существующего показало, что фундамент не выдерживал даже трёх нынешних:

  1. OpenAIProvider молча отбрасывал options.tools. Пятьдесят четыре строки, SDK openai и комментарий в самом файле, признающий, что tool calling «пока не поддерживается в этом provider». Тот, кто привязал бы модель OpenAI к агенту, увидел бы, как ToolLoop завершается без результата, — тот же симптом, на диагностику которого ADR 0020 потребовалось девять запусков.
  2. Не было ни одного теста ни для OpenAIProvider, ни для AnthropicProvider. Единственным протестированным provider был Ollama, и то лишь на уровне транспорта.
  3. Не было таксономии ошибок LLM. Все три provider делали yield { type: 'error', message: (error as Error).message }. Истёкший ключ, rate limit и несуществующая модель доходили до пользователя одной и той же непрозрачной строкой вендора. Сторона git решила это в ADR 0002, а урок так и не перешёл через границу.
  4. У models не было capabilities, а у LLMProvider не было capabilities — в отличие от GitProviderContract, который несёт свои с Фазы 2. Любую модель можно было привязать к любому агенту.
  5. Не было набора контрактных тестов 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, 403auth
404model_not_found
429rate_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.