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

bash
// 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

bash
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.

bash
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:

bash
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 пытается вызвать модель не из этого списка:

bash
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 генерирует заново на каждый запрос:

bash
const result = streamText({
  model: zukko('gpt-5.6-sol'),
  messages,
  headers: { 'Idempotency-Key': requestId },
});

Повтор с тем же значением заголовка в течение 24 часов вернёт тот же ответ без повторного списания — важно на нестабильном мобильном соединении, где обрыв стрима на середине почти всегда означает не лимит по токенам, а таймаут сети.

Быстрая проверка вне Next.js

Прежде чем разбираться, почему route handler отдаёт пустой стрим, проверьте связку ключ+модель напрямую:

bash
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.