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.
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.
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.
"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.
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.
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.
Three deployment details cause most first-attempt failures.
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;
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.
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.
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.
Current rates for models suited to interactive chat:
| Model ID | Input / 1M tokens | Output / 1M tokens |
|---|---|---|
deepseek-v4-flash | $0.206 | $0.412 |
deepseek-v4-pro | $1.09 | $2.17 |
glm-4.7-flash | free | free |
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.
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.
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.
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.
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.
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.
NEXT_PUBLIC_.maxDuration so long generations are not cut off by the platform
default.maxTokens and maxSteps — both are cost ceilings.usage in onFinish so cost is attributable per
conversation.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.
Yes, through createOpenAI with a custom baseURL. No DeepSeek-specific
provider package is required, and streamText, useChat,
generateObject and tool calling all work unchanged.
Yes. useChat talks to your own route handler, so it is unaware of which upstream model
the server chose.
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.
No. Sign up with email or GitHub and pay in USD. See accessing Chinese AI models without a Chinese phone number.
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.