Три вечера на один SSE-фрейм: как я подключал OpenCode к AgentRouter
- Зачем вообще шлюз
- Ошибка первая: я поверил curl
- Ошибка вторая: обход, который не обходит
- Что на самом деле в потоке
- Обход
- Мелочи, на которых я тоже спотыкался
- Стоило ли
Короткая версия для тех, кто пришёл из поиска с ошибкой AI_TypeValidationError ... path ["choices"] expected array, received undefined: виноват не ваш конфиг. Шлюз досылает в поток лишний кадр, которого не ждёт OpenAI SDK. Внизу есть рабочий обход на 40 строк.
Длинная версия интереснее, потому что я потратил на неё три вечера и почти сдался на неверном выводе.
Зачем вообще шлюз
Я держу три инструмента, которые хотят LLM: редактор с агентом, свой CLI и пара скриптов для рутины. У каждого свой конфиг и свой ключ. Когда хочется сравнить, кто лучше разберёт незнакомый репозиторий, Claude или GPT, начинается administrative ад: подписка там, подписка тут, лимиты кончаются в разное время.
Шлюз решает это одним ключом на всё. Я взял AgentRouter, потому что он говорит на OpenAI-совместимом протоколе, а значит любой клиент, умеющий base_url, подключается без правок кода. Регистрация через GitHub, карту не спрашивают, на старте дают кредиты, которых мне хватило на месяц активной работы: agentrouter.org/register. Ссылка реферальная, мне за неё капает бонус, вам за регистрацию по ней тоже. Дальше по тексту ничего не продаю, там сплошная отладка.
Какие модели реально отдаёт шлюз на сегодня, можно посмотреть без регистрации, эндпоинт с прайсом открытый:
curl -s https://agentrouter.org/api/pricing | jq '.data[].model_name'
У меня он вернул claude-opus-4-8, claude-opus-5 и gpt-5.6-sol. Запомните этот вызов, он ещё пригодится, когда документация начнёт отставать от реальности.
Ошибка первая: я поверил curl
Начал я как нормальный человек, с проверки ключа руками:
curl https://agentrouter.org/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}'
Ответ:
{"error":{"message":"unauthorized client detected, contact support..."},
"message":"UNAUTHENTICATED","success":false,"type":"unauthorized_client_error"}
Я живу в России, поэтому вывод сделал мгновенно и неправильно: геоблок. Записал в заметки «нужен VPN, ключ бесполезен», закрыл ноутбук.
Через несколько дней я запустил тот же ключ из настоящего клиента, без VPN, из той же квартиры. Он ответил. Полез в базу OpenCode (~/.local/share/opencode/opencode.db, таблица message) и увидел свои ответы с error: null и живым текстом.
То есть блокировка была не по адресу, а по клиенту. Шлюз смотрит на то, чем вы к нему пришли, и голый curl ему не нравится. Ключ был в порядке всё это время.
Вывод, который я потом выписал себе отдельно: curl это не доказательство. Если вы проверяете сторонний шлюз и получили отказ, проверьте тем же клиентом, которым будете работать, и только потом строьте теории про границы и провайдеров. Я на этом потерял вечер и чуть не выбросил рабочий ключ.
Ошибка вторая: обход, который не обходит
Дальше стало веселее. Claude-путь через @ai-sdk/anthropic заработал, GPT-путь через @ai-sdk/openai-compatible падал в тот самый AI_TypeValidationError. Я посмотрел на это и решил, что жизнь коротка: буду ходить Anthropic-протоколом, там же тоже Claude, какая разница.
Разница есть. На моём аккаунте Anthropic-путь принимает запрос, возвращает error: null и ноль токенов. Пустой ответ, никакой ошибки. Это худший вид поломки, потому что выглядит как «модель промолчала», а не как «протокол не тот». AgentRouter по-настоящему говорит только на OpenAI-совместимом /v1, а Anthropic-эндпоинт у него декоративный.
Значит, обходить нельзя, надо лечить OpenAI-путь.
Что на самом деле в потоке
Я снял сырой дамп ответа и первый раз получил бинарный мусор. Причина скучная: undici просит Accept-Encoding: gzip, br, и я смотрел на сжатые байты. Форсируем identity, смотрим снова, и вот оно, последним кадром:
data: {"billing":{...,"cost_cny":{...}},"object":"billing.summary"}
По спецификации SSE от OpenAI каждый кадр это объект с choices либо с error. Здесь ни того, ни другого, просто справка о списанных деньгах. Валидатор в AI SDK видит объект без choices и падает.
А через неделю на claude-opus-5 я поймал второй мусорный кадр, уже другой:
data: null
Фильтр, который я написал под billing, пропустил его дальше, и SDK умер по-новому:
Invalid input: expected object, received null (path: [], code: invalid_type)
Отсюда правило для фильтра, которое пришлось вывести кровью: выкидывать надо не «кадры про биллинг», а любой data:, который не является JSON-объектом. Логика такая:
[DONE]пропускаем, он часть протокола;- не-JSON пропускаем не глядя, это не наше дело;
- JSON-объект пропускаем, включая
{"error":...}, потому что SDK сам умеет его показать; null, массивы, числа иobject == "billing.summary"выкидываем.
Обход
Локальный реверс-прокси, который переписывает поток на лету. Только стандартная библиотека Python, никаких зависимостей.
Он делает три вещи:
- Пересылает запрос как есть, сохраняя заголовки клиента, чтобы фингерпринт шлюза остался доволен.
- Заставляет апстрим отдавать
Accept-Encoding: identityи снимаетContent-Encodingс ответа. Без этого пункта фильтровать нечего, вы будете резать сжатые байты. - Читает тело построчно и выбрасывает мусорные кадры вместе с их пустой строкой-разделителем.
В клиенте меняется одна строка, baseURL смотрит на http://127.0.0.1:8787/v1 вместо шлюза. Для OpenCode так:
"agentrouter-openai": {
"name": "AgentRouter OpenAI",
"npm": "@ai-sdk/openai-compatible",
"options": { "baseURL": "http://127.0.0.1:8787/v1" },
"models": { "claude-opus-5": { "name": "Claude Opus 5" } }
}
Проверка, что всё срослось:
opencode run 'Reply with exactly: SMOKE_OK' --model agentrouter-openai/claude-opus-5
Возвращает строку без единого исключения. В логе прокси видно, сколько кадров он съел.
Мелочи, на которых я тоже спотыкался
ID провайдера должен совпадать с ключом в credentials. В OpenCode ключ provider.<id> в opencode.jsonc обязан буква в букву совпадать с ключом в auth.json. Переименовали провайдера, забыли продублировать кред, получили Model not found при том, что opencode models модель показывает. Полчаса моей жизни.
ConnectionResetError [WinError 10054] в логе прокси это не ошибка. Это undici простукивает keep-alive соединения. Шум, глушится в handle_error.
Шлюз иногда отдаёт 500. Видел 未提供令牌 и sensitive_words_detected, второе почему-то любит прилетать на служебном запросе генерации заголовка чата. Клиент делает ретрай и проходит. К прокси отношения не имеет.
Документация отстаёт от прайса. claude-opus-5 в доках на момент моей настройки не значился, а работал, апстрим при этом рапортовал себя как MaaS_Cl_Opus_5_20260724_cache. Поэтому дёргайте /api/pricing и пробуйте, а не верьте таблице в документации.
Стоило ли
Для меня да, но я честно скажу, ради чего терпел. Один ключ на все инструменты, кредиты на старте без карты и возможность за секунду переключить модель в конфиге, не заводя новую подписку. Ценой одного локального костыля на 40 строк, который после написания я больше не трогал.
Если вам нужен продакшн с гарантиями и SLA, берите вендора напрямую, здесь их нет. Если нужно поперебирать модели и не разориться на подписках, шлюз своё отрабатывает: agentrouter.org/register.
И на всякий случай: если увидите под каким-нибудь гайдом по AgentRouter комментарий в духе «у автора код не работает, вот рабочий», это не забота о вас, это подмена реферальной метки. Заметил такое под одним из англоязычных гайдов, пока копал тему. Метка на работоспособность ключа не влияет вообще никак.
Write a comment