Vercel AI SDK: подключение в Next.js-приложении к Claude и GPT
В AI SDK OpenAI- и Anthropic-провайдер по-разному достраивают путь к baseURL — ошибиться с /v1 в одном route handler легче, чем кажется. Разбираем настройку.
5 мин · · Обновлено · разработка
Vercel AI SDK предоставляет фабрики createOpenAI и createAnthropic, которые принимают baseURL и apiKey — это единственное, что нужно поменять, чтобы streamText и generateText пошли на гейтвей вместо родного API OpenAI или Anthropic.
В Next.js специфика в том, что провайдер обычно живёт в route handler на сервере, а UI-часть с useChat ничего не знает про baseURL — она просто стучится в ваш собственный /api/chat. Из-за этого ошибки подключения к гейтвею на клиенте выглядят одинаково («что-то пошло не так»), и разбираться приходится на сервере: в логах route handler'а, а не в консоли браузера.
OpenAI-совместимый провайдер в route handler
// app/api/chat/route.ts
import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';
const zukko = createOpenAI({
baseURL: 'https://api.zukko.pro/v1',
apiKey: process.env.ZUKKO_API_KEY,
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: zukko('gpt-5.6-sol'),
messages,
});
return result.toDataStreamResponse();
}Ключ читается из process.env.ZUKKO_API_KEY — без префикса NEXT_PUBLIC_. Это не стилистическая рекомендация: любая переменная с таким префиксом попадает в клиентский бандл Next.js и будет видна в исходниках браузера. Route handler выполняется на сервере, поэтому обычной серверной переменной достаточно и безопаснее.
Anthropic-совместимый провайдер для Claude
import { createAnthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
const claude = createAnthropic({
baseURL: 'https://api.zukko.pro/v1',
apiKey: process.env.ZUKKO_API_KEY,
});
const result = streamText({
model: claude('claude-sonnet-5'),
messages,
});Здесь baseURL — с /v1: Anthropic-провайдер AI SDK добавляет к нему только путь /messages (и сам ставит заголовок anthropic-version: 2023-06-01). Это отличается от Claude Code и официального Anthropic SDK, которым нужен адрес без /v1. Если в одном проекте используются оба провайдера — скажем, GPT для быстрых ответов и Claude Sonnet 5 для задач с расширенным мышлением — самая частая ошибка ровно здесь: по привычке от Claude Code указать для Anthropic-провайдера https://api.zukko.pro без суффикса. Оба провайдера при этом инициализируются без ошибок, а вот первый же вызов claude(...) вернёт 404, потому что запрос уйдёт на /messages вместо /v1/messages.
Выбор модели на основе задачи
Держать оба провайдера настроенными сразу, а не подключать второй по мере надобности, стоит с самого начала: как только в проекте появляется задача, требующая расширенного мышления или анализа изображения размером в несколько мегабайт, переключение — это смена одной строчки в model, а не рефакторинг всего route handler'а под новый SDK.
const model = useVision
? claude('claude-sonnet-5')
: zukko('glm-5.3-flash');
const result = streamText({ model, messages });Модели Claude и GPT в каталоге принимают изображения и держат контекст 1,000,000 токенов; у GLM — 128K и без vision, поэтому в примере запросы с картинками уходят в Claude, а текстовые — в дешёвую glm-5.3-flash. Формат сообщений при переключении между моделями в рамках одного route handler менять не нужно. Актуальный список моделей — GET https://api.zukko.pro/v1/models.
Генерация изображений в том же приложении
Генерация изображений через Zukko сейчас временно недоступна, поэтому отдельный route handler под картинки пока заводить не нужно. Когда она вернётся, это будет отдельный эндпоинт с тарификацией за изображение, а не за токены, — и вызывать его удобнее обычным fetch из своего route handler, а не через streamText. Актуальный список моделей — GET https://api.zukko.pro/v1/models или страница «Модели» в кабинете.
Веб-поиск для моделей без него
Ни одна модель каталога не ходит в интернет сама — для этого есть POST /v1/web-search, который можно завернуть в tool для streamText:
import { tool } from 'ai';
import { z } from 'zod';
const webSearch = tool({
description: 'Ищет актуальную информацию в интернете',
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => {
const res = await fetch('https://api.zukko.pro/v1/web-search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZUKKO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
return res.json();
},
});
const result = streamText({ model: zukko('gpt-5.6-sol'), messages, tools: { webSearch } });Это отдельный оплачиваемый вызов со своей строкой в GET /v1/usage, не часть тарификации основного чат-запроса.
403 в мультитенантном приложении
Если приложение выдаёт разные fetch_... ключи разным клиентам (например, у каждого тенанта свой ключ с собственным лимитом), стоит заранее продумать обработку 403 model_not_allowed_for_key — она возникает, когда на ключе в кабинете настроен список разрешённых моделей, а route handler пытается вызвать модель не из этого списка:
try {
const result = streamText({ model: zukko(requestedModel), messages });
return result.toDataStreamResponse();
} catch (err) {
if (err?.code === 'model_not_allowed_for_key') {
return Response.json({ error: 'Эта модель недоступна на вашем тарифе' }, { status: 403 });
}
throw err;
}Без такой обработки пользователь тарифного плана с ограниченным списком моделей увидит невнятную ошибку 500 вместо понятного сообщения о том, что модель ему не разрешена. То же самое стоит предусмотреть для 402 insufficient_balance — но учтите, что баланс принадлежит аккаунту, а не ключу: если все тенанты сидят на ключах одного аккаунта, баланс кончится у всех сразу. Раздельный бюджет на клиента задаётся лимитами ключей — тогда упёршийся в свой лимит тенант получит 429 key_daily_limit_reached или key_monthly_limit_reached, и это разумно показывать прямо в UI, а не сваливать в общий лог ошибок.
Идемпотентность при обрыве стрима
useChat на клиенте автоматически повторяет запрос, если соединение оборвалось на середине стрима. Важно понимать: сам обрыв стрима списание не останавливает — шлюз дочитывает ответ провайдера и списывает всю генерацию. Чтобы повтор не тарифицировался ещё раз как новый вызов, передайте Idempotency-Key через headers у конкретного вызова streamText. requestId должен быть уникальным для каждого сообщения и одинаковым при его повторе — например, id сообщения, пришедший с клиента, а не значение, которое route handler генерирует заново на каждый запрос:
const result = streamText({
model: zukko('gpt-5.6-sol'),
messages,
headers: { 'Idempotency-Key': requestId },
});Повтор с тем же значением заголовка в течение 24 часов вернёт тот же ответ без повторного списания — важно на нестабильном мобильном соединении, где обрыв стрима на середине почти всегда означает не лимит по токенам, а таймаут сети.
Быстрая проверка вне Next.js
Прежде чем разбираться, почему route handler отдаёт пустой стрим, проверьте связку ключ+модель напрямую:
curl https://api.zukko.pro/v1/chat/completions \
-H "Authorization: Bearer fetch_ваш_ключ" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"ping"}]}'Если curl отвечает, а Next.js — нет, дело почти всегда в переменной окружения, которая не долетела до серверного рантайма (например, забыли перезапустить next dev после правки .env.local). Баланс и списания по каждому запросу видно в кабинете — GET /v1/balance и GET /v1/usage, ключ выпускается на странице https://zukko.pro/app/api-keys. При деплое на Vercel стоит отдельно проверить, что переменная окружения добавлена именно в нужное окружение (Production/Preview/Development) — значение, заданное только для Development, тихо не появится в проде, и симптом будет ровно такой же, как отсутствующий ключ.
zukko
Платите в 25×
меньше
за Claude Code & ChatGPT
Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.
Похожие статьи
- Промпт-инъекция: что это и как защитить бота и агентаОбъясняем, как работает промпт-инъекция через пользовательский ввод и чужие документы, и какие меры снижают ущерб: изоляция данных, права инструментов, лимиты ключа.
- Свой прокси к нейросети на Node.js: не показывать API-ключ во фронтендеНебольшой сервер на Express между вашим фронтендом и API нейросети: ключ остаётся на сервере, а модели, длина запроса и частота ограничены.
- DeepSeek API на Python: запросы, стриминг, JSON и обработка ошибокГотовый код для DeepSeek V4.1 Flash и V4 Pro на OpenAI SDK: запрос, стриминг, разбор JSON и аккуратная обработка ошибок.
- Промпт-инжиниринг: примеры и 12 готовых шаблонов для работыДвенадцать шаблонов промптов для рабочих задач: что в них написано, почему именно так и сколько стоит один вызов по API.