AIWave API

Node.js Guide: DeepSeek, GLM and Kimi in TypeScript

Calling DeepSeek, GLM, Kimi or Qwen from Node.js uses the official openai package with one changed option: baseURL. Types, streaming, tool calling and error classes all come from the same SDK you would use with OpenAI. This guide covers setup in TypeScript, streaming in an Express or Next.js handler, structured output with Zod, concurrency control, retries and the cost tracking that keeps a Node service from surprising you.

Get an API key →Compare live pricing →

Installation and setup

npm install openai
npm install -D typescript @types/node
// src/llm.ts
import OpenAI from "openai";

export const client = new OpenAI({
  apiKey: process.env.AIWAVE_API_KEY,
  baseURL: "https://aiwave.live/v1",
  timeout: 60_000,      // never inherit an unbounded default
  maxRetries: 0,        // we own the retry policy below
});

export const MODELS = {
  fast: "deepseek-v4-flash",
  strong: "deepseek-v4-pro",
} as const;

Create a key in the console — email or GitHub signup, no Chinese phone number, USD billing. The SDK also honours OPENAI_BASE_URL and OPENAI_API_KEY from the environment, so an existing app can migrate through configuration alone. Endpoint reference: API documentation.

Step 1: A first request, fully typed

import { client, MODELS } from "./llm";

const res = await client.chat.completions.create({
  model: MODELS.strong,
  messages: [{ role: "user", content: "Explain MoE routing in two sentences." }],
});

console.log(res.choices[0].message.content);
console.log(res.usage);   // { prompt_tokens, completion_tokens, total_tokens }

The SDK ships its own types, so res.choices[0].message.content is typed as string | null — handle the null rather than asserting it away. A model can return an empty message when it emits only a tool call.

Step 2: Pick a model, and verify it exists

Current rates from the live pricing endpoint:

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
qwen3-coder-480b-a35b-instruct$0.12$0.36

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

// fail at boot, not on a user request
const served = new Set((await client.models.list()).data.map((m) => m.id));
for (const id of Object.values(MODELS)) {
  if (!served.has(id)) throw new Error(`model ${id} is no longer served`);
}

Model IDs are versioned. A hard-coded string that quietly 404s in six months is a production incident waiting for a deploy; checking at startup turns it into a failed boot you notice immediately.

Step 3: Stream to a browser

In Express, forward chunks as server-sent events:

import express from "express";
import { client, MODELS } from "./llm";

const app = express();
app.use(express.json());

app.post("/chat", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");
  res.flushHeaders();

  const stream = await client.chat.completions.create({
    model: MODELS.fast,
    messages: req.body.messages,
    stream: true,
    max_tokens: 1024,           // always cap; unbounded output is unbounded cost
  });

  req.on("close", () => stream.controller.abort());   // stop billing on disconnect

  for await (const chunk of stream) {
    const piece = chunk.choices[0]?.delta?.content;
    if (piece) res.write(`data: ${JSON.stringify(piece)}\n\n`);
  }
  res.write("data: [DONE]\n\n");
  res.end();
});

Two details matter here. no-transform in the cache header stops intermediaries rewriting the stream, which is a common cause of “it buffers in production but streams locally”. And aborting on client disconnect is the difference between paying for generations nobody reads and not. The wire format is described in streaming responses.

Step 4: Structured output with Zod

import { z } from "zod";

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

const res = await client.chat.completions.create({
  model: MODELS.strong,
  temperature: 0,
  response_format: { type: "json_object" },
  messages: [
    { role: "system", content:
      'Return JSON only: {"severity":"low|medium|high|critical","component":string,"summary":string}' },
    { role: "user", content: "Checkout 500s since the 14:20 deploy." },
  ],
});

const parsed = Ticket.safeParse(JSON.parse(res.choices[0].message.content ?? "{}"));
if (!parsed.success) {
  // do not let malformed output reach business logic
  console.error("unusable model output", parsed.error.issues);
}

Use safeParse rather than parse so a bad response is a branch rather than an exception in a request handler. State the schema in the prompt as well as via response_format, because JSON mode is an upstream model capability that varies — check the model directory.

Step 5: Tool calling

const tools = [{
  type: "function" as const,
  function: {
    name: "get_order_status",
    description: "Look up an order by ID",
    parameters: {
      type: "object",
      properties: { orderId: { type: "string" } },
      required: ["orderId"],
    },
  },
}];

const messages: any[] = [{ role: "user", content: "Where is order A-4471?" }];
const first = await client.chat.completions.create({
  model: MODELS.strong, messages, tools,
});

const msg = first.choices[0].message;
if (msg.tool_calls?.length) {
  messages.push(msg);
  for (const call of msg.tool_calls) {
    const args = JSON.parse(call.function.arguments);
    const result = await lookupOrder(args.orderId);
    messages.push({
      role: "tool",
      tool_call_id: call.id,
      content: JSON.stringify(result),
    });
  }
  const second = await client.chat.completions.create({
    model: MODELS.strong, messages, tools,
  });
  console.log(second.choices[0].message.content);
}

More depth in function calling with Chinese AI models.

Step 6: Retries, fallback and bounded concurrency

import { APIError } from "openai";

const RETRYABLE = new Set([408, 429, 500, 502, 503]);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function chat(
  messages: any[],
  { model = MODELS.strong, fallback = MODELS.fast, attempts = 5, ...rest } = {},
) {
  let current = model;
  for (let i = 0; i < attempts; i++) {
    try {
      return await client.chat.completions.create({ model: current, messages, ...rest });
    } catch (err) {
      const status = err instanceof APIError ? err.status : undefined;
      if (!status || !RETRYABLE.has(status)) throw err;   // permanent: fix the request
      if (i === attempts - 2 && fallback) current = fallback;
      await sleep(Math.min(32_000, 2 ** i * 1000) * (0.5 + Math.random()));
    }
  }
  throw new Error("all retries exhausted");
}

For batches, bound concurrency rather than firing everything at once. Most rate-limit problems are concurrency problems wearing a disguise:

async function mapLimit<T, R>(items: T[], limit: number,
                              fn: (t: T) => Promise<R>): Promise<R[]> {
  const out: R[] = new Array(items.length);
  let next = 0;
  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, async () => {
      while (true) {
        const i = next++;
        if (i >= items.length) return;
        out[i] = await fn(items[i]);
      }
    }),
  );
  return out;
}

const summaries = await mapLimit(docs, 8, (d) =>
  chat([{ role: "user", content: `Summarise:\n${d}` }], { model: MODELS.fast }));

Which statuses are worth retrying is in error codes; backoff strategy is in rate limits.

Step 7: Track cost per request

const PRICES: Record<string, [number, number]> = {
  "deepseek-v4-flash": [0.206, 0.412],     // USD per 1M, refresh from /pricing
  "deepseek-v4-pro": [1.0875, 2.175],
};

export function costOf(model: string, usage?: { prompt_tokens: number; completion_tokens: number }) {
  if (!usage) return 0;
  const [pin, pout] = PRICES[model] ?? [0, 0];
  return (usage.prompt_tokens / 1e6) * pin + (usage.completion_tokens / 1e6) * pout;
}

Keep the price map in configuration and refresh it from the pricing page. Logging cost per request from day one turns an invoice surprise into a metric you can alert on.

Streaming in Next.js route handlers

The App Router expects a Response with a ReadableStream body rather than an Express-style writable. Bridging the SDK’s async iterator to a web stream is a few lines:

// app/api/chat/route.ts
import { client, MODELS } from "@/lib/llm";

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

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

  const completion = await client.chat.completions.create({
    model: MODELS.fast,
    messages,
    stream: true,
    max_tokens: 1024,
  });

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of completion) {
          const piece = chunk.choices[0]?.delta?.content;
          if (piece) controller.enqueue(encoder.encode(piece));
        }
      } catch (err) {
        controller.error(err);
      } finally {
        controller.close();
      }
    },
    cancel() {
      completion.controller.abort();   // client went away: stop generating
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
    },
  });
}

The cancel handler is easy to omit and expensive to omit. Without it, a user who closes the tab leaves a generation running to completion that you still pay for. If you would rather not hand-roll this, the Vercel AI SDK wraps the same mechanics with a client hook.

Keeping the key on the server

The most expensive mistake in Node AI projects is shipping the key to the browser. Two rules prevent it entirely.

Never prefix the variable with a build-time public prefix — in Next.js that is NEXT_PUBLIC_, in Vite VITE_. Anything so prefixed is inlined into the client bundle and is effectively published.

Never construct the client in code that can run in a browser. Keep it in a server-only module and let the type system help:

// lib/llm.ts
import "server-only";        // build error if imported from a client component
import OpenAI from "openai";

export const client = new OpenAI({
  apiKey: process.env.AIWAVE_API_KEY,   // no NEXT_PUBLIC_ prefix
  baseURL: "https://aiwave.live/v1",
});

If a key does leak, revoke it in the console immediately rather than rotating later. Guidance on key handling is in authentication.

Typing your own wrapper

Application code should not scatter raw SDK calls. A thin typed wrapper keeps model choice, retry policy and logging in one place:

// lib/llm/chat.ts
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { client, MODELS, costOf } from "./index";

type Tier = keyof typeof MODELS;

interface ChatOptions {
  tier?: Tier;
  temperature?: number;
  maxTokens?: number;
}

export async function ask(
  messages: ChatCompletionMessageParam[],
  { tier = "fast", temperature = 0.2, maxTokens = 1024 }: ChatOptions = {},
): Promise<{ text: string; cost: number }> {
  const model = MODELS[tier];
  const res = await client.chat.completions.create({
    model, messages, temperature, max_tokens: maxTokens,
  });
  return {
    text: res.choices[0].message.content ?? "",
    cost: costOf(model, res.usage),
  };
}

Importing ChatCompletionMessageParam from the SDK rather than redefining a message type means your wrapper stays correct when the SDK adds a role or a content shape. Redefining these types by hand is a maintenance trap.

Caching repeated calls

A meaningful share of production traffic is exact repeats. Caching removes those calls entirely:

import { createHash } from "node:crypto";

const cache = new Map<string, string>();   // swap for Redis in production

function keyOf(model: string, messages: unknown, temperature: number) {
  return createHash("sha256")
    .update(JSON.stringify({ model, messages, temperature }))
    .digest("hex");
}

export async function cachedAsk(messages: any[], model = MODELS.fast) {
  const key = keyOf(model, messages, 0);
  const hit = cache.get(key);
  if (hit) return hit;

  const res = await client.chat.completions.create({
    model, messages, temperature: 0,
  });
  const text = res.choices[0].message.content ?? "";
  cache.set(key, text);
  return text;
}

Include the model and temperature in the key. Omitting the model means switching models silently serves answers from the previous one, which is a genuinely confusing bug. Only cache deterministic calls — caching at temperature 0.7 stores one arbitrary sample forever.

Testing without spending money

import { describe, it, expect, vi } from "vitest";
import * as llm from "../lib/llm";

describe("summarise", () => {
  it("returns the model text", async () => {
    vi.spyOn(llm.client.chat.completions, "create").mockResolvedValue({
      choices: [{ message: { content: "a summary", role: "assistant" } }],
      usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
    } as any);

    const out = await summarise("long text");
    expect(out).toBe("a summary");
  });

  it("falls back when the primary model is unavailable", async () => {
    const spy = vi.spyOn(llm.client.chat.completions, "create")
      .mockRejectedValueOnce(Object.assign(new Error("overloaded"), { status: 503 }))
      .mockResolvedValueOnce({
        choices: [{ message: { content: "fallback", role: "assistant" } }],
        usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
      } as any);

    expect(await summarise("x")).toBe("fallback");
    expect(spy).toHaveBeenCalledTimes(2);
  });
});

Unit tests should cover your parsing, retry and fallback logic without touching the network. Keep a handful of integration tests that do hit the API, run them on the cheapest model with temperature: 0, and skip them unless a key is present so a contributor without credentials can still run the suite.

Observability worth having on day one

import { performance } from "node:perf_hooks";

export async function instrumentedAsk(messages: any[], model = MODELS.fast) {
  const t0 = performance.now();
  try {
    const res = await client.chat.completions.create({ model, messages });
    const ms = performance.now() - t0;
    logger.info({
      model,
      ms: Math.round(ms),
      in: res.usage?.prompt_tokens,
      out: res.usage?.completion_tokens,
      cost: costOf(model, res.usage),
    }, "llm.ok");
    return res;
  } catch (err: any) {
    logger.error({ model, status: err?.status, ms: Math.round(performance.now() - t0) },
                 "llm.fail");
    throw err;
  }
}

Four fields carry almost all the diagnostic value: model, latency, token counts and status. With those in structured logs you can answer “which route got more expensive this week” and “is the provider degrading or is it our prompt” without adding instrumentation after the fact.

Production checklist

Frequently asked questions

Is there a DeepSeek Node.js SDK?

You do not need one. The official openai package works against any OpenAI-compatible endpoint — set baseURL and use the model ID.

Does the OpenAI SDK work in edge runtimes?

Yes, it targets the Fetch API. Watch the runtime’s execution time limit for long generations, and prefer streaming so output starts arriving early.

How do I cancel an in-flight request?

Call stream.controller.abort() for streams, or pass an AbortSignal in the request options. Cancelling on client disconnect stops you paying for output nobody receives.

Do I need a Chinese phone number?

No. Email or GitHub signup and USD billing. 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.