AIWave API

Vercel AI SDK + DeepSeek: Streaming Chat in Next.js

The Vercel AI SDK works with DeepSeek, GLM, Kimi and Qwen through its OpenAI-compatible provider — you call createOpenAI with a custom baseURL and everything downstream (streamText, useChat, tool calling, structured output) behaves exactly as it does with OpenAI. This guide builds a streaming chat route in Next.js, wires the client hook, adds tools and structured output, and covers the edge-runtime detail that trips up most first deployments.

Get an API key →Compare live pricing →

The one line that makes it work

The AI SDK’s @ai-sdk/openai package exports a factory rather than a fixed client. Point it at any OpenAI-compatible endpoint and every model behind that endpoint becomes available:

npm install ai @ai-sdk/openai zod
// lib/ai.ts
import { createOpenAI } from "@ai-sdk/openai";

export const aiwave = createOpenAI({
  baseURL: "https://aiwave.live/v1",
  apiKey: process.env.AIWAVE_API_KEY,
});

That is the integration. Get a key from the console — email or GitHub signup, no Chinese phone number — and put it in .env.local as AIWAVE_API_KEY. The endpoint contract is in the API documentation.

Step 1: A streaming chat route

In the Next.js App Router, a route handler returns the SDK’s data stream response directly:

// app/api/chat/route.ts
import { streamText } from "ai";
import { aiwave } from "@/lib/ai";

export const maxDuration = 60;   // long generations need headroom

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: aiwave("deepseek-v4-flash"),
    system: "You are a concise technical assistant. Prefer examples over prose.",
    messages,
  });

  return result.toDataStreamResponse();
}

Model choice matters more here than in a batch job, because the user is watching. Smaller models emit their first token sooner, which dominates perceived speed. Start on deepseek-v4-flash and only move up if answer quality actually gates the product.

Step 2: The client hook

"use client";
import { useChat } from "ai/react";

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat();

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong> {m.content}
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="Ask something" />
        <button type="submit" disabled={isLoading}>Send</button>
        {isLoading && <button type="button" onClick={stop}>Stop</button>}
      </form>
    </div>
  );
}

Wire the stop button from the start. Users abandon long generations constantly, and an aborted request you never cancel keeps consuming tokens you still pay for.

Step 3: Structured output with Zod

When the model’s answer feeds code rather than a human, use generateObject. The SDK handles schema instruction and validation, so you get a typed object instead of a string you have to parse defensively.

import { generateObject } from "ai";
import { z } from "zod";
import { aiwave } from "@/lib/ai";

const Incident = z.object({
  severity: z.enum(["low", "medium", "high", "critical"]),
  component: z.string(),
  summary: z.string(),
  actionRequired: z.boolean(),
});

const { object } = await generateObject({
  model: aiwave("deepseek-v4-pro"),
  schema: Incident,
  prompt: "Checkout returning 500s since the 14:20 deploy. Payments are down.",
});

console.log(object.severity, object.component);

Set temperature to 0 for extraction work; sampling variety has no upside when the output is parsed. If a model returns prose around the JSON, the SDK’s repair path usually recovers it, but the more reliable fix is a stricter schema with clear field descriptions.

Step 4: Tool calling

Tools let the model reach real systems rather than guess. The SDK defines them with Zod and executes them server-side:

import { streamText, tool } from "ai";
import { z } from "zod";

const result = streamText({
  model: aiwave("deepseek-v4-pro"),
  messages,
  maxSteps: 5,
  tools: {
    lookupOrder: tool({
      description: "Fetch order status by ID",
      parameters: z.object({ orderId: z.string() }),
      execute: async ({ orderId }) => {
        const row = await db.order.findUnique({ where: { id: orderId } });
        return row ?? { error: "not found" };
      },
    }),
  },
});

Two cautions. Tool-calling support is a property of the upstream model, not of the SDK — check the model directory before depending on it. And maxSteps is a cost control: every step is another round trip with the full conversation resent. Our write-up on AI agent cost analysis works through why multi-step loops are where cheap models pay for themselves fastest, and function calling with Chinese AI models covers the protocol level.

Step 5: Deploy without surprises

Three deployment details cause most first-attempt failures.

Runtime choice

The edge runtime has a shorter default execution window than Node. For long generations, either set maxDuration explicitly or run the route on Node:

export const runtime = "nodejs";
export const maxDuration = 60;

Never expose the key to the browser

Any environment variable prefixed NEXT_PUBLIC_ ships to the client. The API key belongs only in a server route. This is the most common and most expensive mistake in AI SDK projects — see authentication for key handling.

Buffering proxies defeat streaming

If chunks arrive all at once in production but stream fine locally, something in front of your app is buffering. Disable response buffering at that layer. The stream format itself is standard server-sent events, documented in streaming responses.

Step 6: Handle errors and fall back

import { streamText } from "ai";
import { aiwave } from "@/lib/ai";

export async function POST(req: Request) {
  const { messages } = await req.json();
  try {
    const result = streamText({ model: aiwave("deepseek-v4-pro"), messages });
    return result.toDataStreamResponse();
  } catch (err: any) {
    const status = err?.statusCode ?? err?.status;
    if ([408, 429, 500, 502, 503].includes(status)) {
      const result = streamText({ model: aiwave("deepseek-v4-flash"), messages });
      return result.toDataStreamResponse();   // same body, different model
    }
    return new Response("Upstream error", { status: 502 });
  }
}

Because every model here accepts an identical request body, the fallback is a one-word change. Which codes deserve a retry is covered in error codes.

What a chat product costs

Current rates for models suited to interactive chat:

Model IDInput / 1M tokensOutput / 1M tokens
deepseek-v4-flash$0.206$0.412
deepseek-v4-pro$1.09$2.17
glm-4.7-flashfreefree
glm-5$1.55$4.96
kimi-k2.5$0.66$3.30

Rates read from the AIWave pricing endpoint on 2026-07-26. Check live pricing before budgeting — providers revise rates.

Work it out for your own traffic rather than trusting a generic figure. A chat turn that sends 1,500 input tokens and generates 400 output tokens costs (1,500 ÷ 1,000,000) × input price + (400 ÷ 1,000,000) × output price. Multiply by turns per month. That arithmetic, not a benchmark, is what decides your model.

The dominant cost in a chat UI is usually conversation history, since every turn resends the whole transcript. Trimming to a token budget and summarising older turns typically saves more than switching models does.

Persisting conversations without paying twice

A chat product needs history, and history is the single largest cost driver in an AI SDK application — every turn resends the full transcript as input tokens. Persist messages server-side and control how much of that transcript actually reaches the model.

// app/api/chat/route.ts
import { streamText } from "ai";
import { aiwave } from "@/lib/ai";

const MAX_HISTORY_TOKENS = 3000;

function trim(messages: any[], budget = MAX_HISTORY_TOKENS) {
  // rough heuristic: ~4 characters per token
  let used = 0;
  const kept: any[] = [];
  for (let i = messages.length - 1; i >= 0; i--) {
    const cost = Math.ceil((messages[i].content?.length ?? 0) / 4);
    if (used + cost > budget && kept.length > 0) break;
    used += cost;
    kept.unshift(messages[i]);
  }
  return kept;
}

export async function POST(req: Request) {
  const { messages, chatId } = await req.json();

  const result = streamText({
    model: aiwave("deepseek-v4-flash"),
    messages: trim(messages),
    onFinish: async ({ text, usage }) => {
      await db.message.createMany({
        data: [
          { chatId, role: "user", content: messages.at(-1).content },
          { chatId, role: "assistant", content: text },
        ],
      });
      await db.usage.create({ data: { chatId, ...usage } });   // cost tracking
    },
  });

  return result.toDataStreamResponse();
}

The onFinish callback is the right place for both persistence and usage logging. It fires after the stream completes, so it does not delay the first token, and the usage object gives you exact token counts to attribute cost per conversation.

Trimming by token budget rather than message count matters because message lengths vary enormously. Ten short exchanges may cost less than one pasted stack trace. For long-running assistants, summarising older turns instead of dropping them preserves context while keeping the budget flat.

Switching models at runtime

Because the model is a string, you can let the request decide. This is how a product offers a “fast” and “deep” mode without maintaining two code paths:

const MODELS = {
  fast: "deepseek-v4-flash",
  balanced: "glm-5",
  deep: "deepseek-v4-pro",
} as const;

export async function POST(req: Request) {
  const { messages, mode = "fast" } = await req.json();
  const model = MODELS[mode as keyof typeof MODELS] ?? MODELS.fast;

  const result = streamText({ model: aiwave(model), messages });
  return result.toDataStreamResponse();
}

Validate the incoming mode against a whitelist as above. Passing a user-supplied string straight into the model field lets a caller select your most expensive model at will — a small but real abuse vector. The routing idea is developed further in building a multi-model router.

Rate limiting your own endpoint

Your route is a public endpoint that spends money on every call. Without a limit, one script can drain a balance overnight. A simple per-IP window is enough to stop the obvious cases:

const hits = new Map<string, { n: number; reset: number }>();

function allow(ip: string, limit = 20, windowMs = 60_000) {
  const now = Date.now();
  const rec = hits.get(ip);
  if (!rec || now > rec.reset) {
    hits.set(ip, { n: 1, reset: now + windowMs });
    return true;
  }
  if (rec.n >= limit) return false;
  rec.n++;
  return true;
}

export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0] ?? "unknown";
  if (!allow(ip)) return new Response("Too many requests", { status: 429 });
  // ...
}

An in-memory map resets on every deploy and does not span instances, so treat it as a floor rather than a solution — use a shared store for anything serious. Also cap maxTokens on the generation itself; an unbounded response is an unbounded bill. The upstream side of rate limiting is covered in rate limits.

Generative UI: streaming React components

The AI SDK can stream interface, not just text. Instead of asking the model to describe an order status in prose, have it call a tool and render a component:

import { streamUI } from "ai/rsc";
import { z } from "zod";
import { aiwave } from "@/lib/ai";

const result = await streamUI({
  model: aiwave("deepseek-v4-pro"),
  prompt: userMessage,
  text: ({ content }) => <p>{content}</p>,
  tools: {
    showOrder: {
      description: "Display an order card",
      parameters: z.object({ orderId: z.string() }),
      generate: async function* ({ orderId }) {
        yield <OrderSkeleton />;
        const order = await db.order.findUnique({ where: { id: orderId } });
        return <OrderCard order={order} />;
      },
    },
  },
});

This pattern depends on reliable tool calling, so verify the model supports it on the model page before designing an interface around it. When it works, it removes an entire class of formatting prompts — the model decides what to show, your components decide how it looks.

Testing a route that streams

Streaming routes are awkward to test because the response is a stream. Assert on the accumulated text rather than trying to mock the SDK internals:

import { describe, it, expect } from "vitest";

async function collect(res: Response) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let out = "";
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    out += decoder.decode(value);
  }
  return out;
}

describe("chat route", () => {
  it("streams a response", async () => {
    const res = await POST(new Request("http://localhost/api/chat", {
      method: "POST",
      body: JSON.stringify({ messages: [{ role: "user", content: "Say OK" }] }),
    }));
    const body = await collect(res);
    expect(body.length).toBeGreaterThan(0);
  });
});

Set temperature: 0 in tests so output is as stable as the model allows, and keep the prompts trivial — you are testing your plumbing, not the model’s intelligence. Run these against a cheap model; there is no reason for CI to spend premium rates.

Production checklist

Migrating an existing AI SDK project

If your app already uses @ai-sdk/openai, the change is the provider factory. Routes, hooks, tools, schemas and components stay as they are.

// before
import { openai } from "@ai-sdk/openai";
const model = openai("gpt-4o");

// after
import { createOpenAI } from "@ai-sdk/openai";
const aiwave = createOpenAI({
  baseURL: "https://aiwave.live/v1",
  apiKey: process.env.AIWAVE_API_KEY,
});
const model = aiwave("deepseek-v4-pro");

Keep the previous provider behind an environment flag during rollout so a regression is one deploy to undo. The broader migration is covered in migrating an OpenAI app to Chinese models.

Frequently asked questions

Does the Vercel AI SDK support DeepSeek?

Yes, through createOpenAI with a custom baseURL. No DeepSeek-specific provider package is required, and streamText, useChat, generateObject and tool calling all work unchanged.

Can I use useChat with Chinese AI models?

Yes. useChat talks to your own route handler, so it is unaware of which upstream model the server chose.

Why does my stream arrive all at once?

Something between the client and your route is buffering the response. Check your reverse proxy or CDN configuration; the endpoint itself emits standard server-sent events.

Do I need a Chinese phone number?

No. Sign up with email or GitHub and pay in USD. See accessing Chinese AI models without a Chinese phone number.

Get an API key →Compare live pricing →

Sources

Every claim above traces back to one of these. Pricing figures come from the AIWave pricing endpoint on 2026-07-26; capability claims come from the vendors’ own documentation.