Alle Artikel

Vercel AI SDK: Streaming-Chat in Next.js auf Carpe Diem

·Von Carpe Diem Team — Lumen Labs·6 Min. Lesezeit
tutorielguidetechnicalai
Schwarze Schreibmaschine auf einem Holzschreibtisch, ein weißes Blatt mit einer Goldlinie.

Du hast ein Next.js-Dashboard und willst, dass ein Nutzer eine Frage stellt und die Antwort Wort für Wort auf der Seite erscheinen sieht. Das Vercel AI SDK ist der TypeScript-Werkzeugkasten, der das erledigt, ohne dass du HTTP oder den SSE-Parser schreibst. Dieses Tutorial bindet es an Carpe Diem an, vom ersten Aufruf im Terminal bis zur Chat-Seite.

Schwarze Schreibmaschine auf einem Holzschreibtisch, ein weißes Blatt mit einer Goldlinie.

Illustration erzeugt mit seedream-v5-lite auf Carpe Diem.

Was dir das SDK abnimmt

Auf dem Server liefert generateText einen Text in einem Stück und streamText einen Strom Token für Token. Im Browser hält der Hook useChat die Nachrichtenliste, den Status der Anfrage und aktualisiert die Blase bei jedem Fragment.

In unserer App ruft die Seite useChat auf, das an eine Route /api/chat postet. Die Route ruft streamText auf, und createOpenAICompatible schickt die Anfrage an https://carpe-diem.xyz/api/operator/v1. Du wechselst das Modell, indem du eine Zeichenkette änderst.

Wir haben es mit deepseek-v4-1-flash versucht. Ein pong hat 0,000033 $ gekostet (1 592 Eingabe-Token aus dem Cache); ein gestreamtes Haiku etwa 0,0004 $.

Was du brauchst

  • Ein Carpe Diem Konto mit Credits oder einem Prepaid-Konto und einem Schlüssel cdm_ (Reiter API Keys im Käufer-Dashboard).
  • Node.js 22 und npm.

Die genauen Versionen stehen in den npm install-Befehlen weiter unten. Ohne Next.js reichen die beiden Skripte hello.mjs und stream.mjs für einen Versuch im Terminal. Für ein Python-Skript macht das Tutorial OpenAI Python SDK denselben Anschluss mit base_url.

Schritt 1: einen cdm_-Schlüssel anlegen

Öffne Credits & keys, Reiter API Keys. Wähle, womit der Schlüssel zahlt (Credits oder Prepaid), gib ihm optional einen Namen und klicke auf Create a key. Credits ist ein nicht abhebbares Nutzungsguthaben; Prepaid ist ein Konto, das du hältst und von dem du abheben kannst (Zahlungsdetails).

Der Schlüssel beginnt mit cdm_ und wird nur einmal angezeigt: kopiere ihn sofort.

Reiter API Keys: Namensfeld, Wahl Credits oder Prepaid, Schaltfläche Create a key.

In der Shell des Tutorials:

export OPENAI_API_KEY=cdm_your_key_here

Schritt 2: AI SDK und den OpenAI-kompatiblen Provider installieren

npm install ai@7.0.157 @ai-sdk/openai-compatible@3.0.81

createOpenAICompatible sendet POST /chat/completions unter baseURL. Für Carpe Diem muss baseURL bereits /v1 enthalten: https://carpe-diem.xyz/api/operator/v1. Ohne ihn haben wir ein 404 Not Found bekommen, wie der SDK-Leitfaden oben auf der Seite festhält.

SDK-Seite der Carpe Diem Dokumentation: Base URL https://carpe-diem.xyz/api/operator/v1 und Inhaltsverzeichnis mit Streaming.

Schritt 3: ein erster Aufruf mit generateText und seine Kosten

Außerhalb des Streamings schreibt Carpe Diem die Kosten in die Header X-Carpe-*, nicht in das usage des SDK. Der eigene fetch in hello.mjs liest sie im Vorbeigehen.

Die Deckelung maxOutputTokens: 64 hat für pong gereicht. Bei 16 hat einer unserer Durchläufe nur Reasoning geliefert: leerer Text, finishReason: length. Plane Spielraum ein, weil dieses Modell vor der Antwort nachdenkt.

import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { generateText } from "ai";

const carpe = createOpenAICompatible({
  name: "carpe-diem",
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: "https://carpe-diem.xyz/api/operator/v1",
  fetch: async (url, init) => {
    const res = await fetch(url, init);
    console.log("x-carpe-cost-usdc-micro:", res.headers.get("x-carpe-cost-usdc-micro"));
    console.log("x-carpe-input-tokens:", res.headers.get("x-carpe-input-tokens"));
    console.log("x-carpe-output-tokens:", res.headers.get("x-carpe-output-tokens"));
    return res;
  },
});

const { text, usage, finishReason } = await generateText({
  model: carpe("deepseek-v4-1-flash"),
  prompt: "Reply with the exact word: pong",
  maxOutputTokens: 64,
});

console.log("text:", text);
console.log("finishReason:", finishReason);
console.log("usage:", JSON.stringify(usage));
node hello.mjs

Erhaltene Ausgabe:

x-carpe-cost-usdc-micro: 33
x-carpe-input-tokens: 1722
x-carpe-output-tokens: 4
text: pong
finishReason: stop
usage: {"inputTokens":1722,...,"outputTokens":4,...,"totalTokens":1726}

Der Header gibt die Kosten in Millionstel Dollar: 33, also 0,000033 $. Das ist niedrig, weil 1 592 Eingabe-Token aus dem Cache gelesen wurden. Ohne Cache-Lesen kostet derselbe Aufruf mehr. Diese 1 700 Eingabe-Token kommen vom System-Prompt, den Venice jeder Anfrage hinzufügt. Setze venice_parameters.include_venice_system_prompt: false in den Request-Body, wenn du ihn bei kurzen, häufigen Aufrufen nicht zahlen willst.

Um das Modell zu wechseln, kopiere eine ID von /models und ersetze die Zeichenkette an carpe("…"). Weder baseURL noch der fetch ändern sich.

Schritt 4: streamText, Token für Token

Mit includeUsage: true trägt der Stream die Token-Zähler, und dadurch erscheint carpe_cost_usdc_micro in usage.raw. Datei stream.mjs:

import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText } from "ai";

const carpe = createOpenAICompatible({
  name: "carpe-diem",
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: "https://carpe-diem.xyz/api/operator/v1",
  includeUsage: true,
});

const result = streamText({
  model: carpe("deepseek-v4-1-flash"),
  prompt: "Write a haiku about the sea.",
  maxOutputTokens: 128,
});

process.stdout.write("text: ");
for await (const part of result.textStream) {
  process.stdout.write(part);
}
process.stdout.write("\n");

const usage = await result.usage;
const finishReason = await result.finishReason;
console.log("finishReason:", finishReason);
console.log("usage:", JSON.stringify(usage));
node stream.mjs

Erhaltene Ausgabe:

text: Salt wind on my skin,
the tide pulls its slow breathing —
moonlight drowns in foam.
finishReason: stop
usage: {"inputTokens":1723,"outputTokens":119,"raw":{"prompt_tokens":1723,"completion_tokens":119,"carpe_cost_usdc_micro":393}}

Dieses Haiku hat etwa 0,0004 $ gekostet. Es hat 119 Ausgabe-Token für etwa zehn Wörter verbraucht, ein Zeichen dafür, dass das Reasoning einen guten Teil des Limits verbraucht. Beachte auch, dass carpe_cost_usdc_micro in usage.raw liegt, nicht auf der obersten Ebene von usage.

Schritt 5: die Route /api/chat und die Chat-Seite

Der Schlüssel bleibt auf dem Server: route.js landet nie im Browser-Bundle. Für next dev legst du ihn in .env.local:

OPENAI_API_KEY=cdm_your_key_here
npm install next@16.4.3 react@19.3.1 react-dom@19.3.1 @ai-sdk/react@4.0.161

Datei app/layout.js (App Router):

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Route app/api/chat/route.js nimmt die Nachrichten der Seite entgegen, wandelt sie um und gibt den Strom zurück. maxDuration = 30 begrenzt die Laufzeit der Funktion auf Vercel, und 512 Ausgabe-Token lassen Platz fürs Reasoning.

import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
} from "ai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";

const carpe = createOpenAICompatible({
  name: "carpe-diem",
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: "https://carpe-diem.xyz/api/operator/v1",
  includeUsage: true,
});

export const maxDuration = 30;

export async function POST(req) {
  const { messages } = await req.json();
  const result = streamText({
    model: carpe("deepseek-v4-1-flash"),
    messages: await convertToModelMessages(messages),
    maxOutputTokens: 512,
  });
  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

Seite app/page.js: useChat postet auf /api/chat und zeigt message.parts. Die Ausschnitte sind JavaScript; die TypeScript-Fassung steht im offiziellen AI-SDK-Leitfaden.

"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";

export default function Chat() {
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });
  const [input, setInput] = useState("");

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.role === "user" ? "User: " : "AI: "}
          {message.parts.map((part, i) =>
            part.type === "text" ? <span key={i}>{part.text}</span> : null,
          )}
        </div>
      ))}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (input.trim()) {
            sendMessage({ text: input });
            setInput("");
          }
        }}
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          disabled={status !== "ready"}
          placeholder="Say something..."
        />
        <button type="submit" disabled={status !== "ready"}>
          Send
        </button>
      </form>
    </div>
  );
}

Das Feld status von useChat ist ready, submitted, streaming oder error. Die Schaltfläche Send bleibt deaktiviert, solange es nicht ready ist, und wenn die Route einen Fehler wirft (401, 400, 404 oder ein Abbruch mitten im Strom), wird status zu error.

Starte npx next dev und befrage die Route dann direkt:

curl -sS -N http://localhost:3000/api/chat \
  -H "content-type: application/json" \
  -d '{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"Reply with the exact word: pong"}]}]}'

Ausgabe des UI-Stroms:

data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"txt-0"}
data: {"type":"text-delta","id":"txt-0","delta":"p"}
data: {"type":"text-delta","id":"txt-0","delta":"ong"}
data: {"type":"text-end","id":"txt-0"}
data: {"type":"finish-step"}
data: {"type":"finish","finishReason":"stop"}
data: [DONE]

Das Wort kommt in zwei Deltas, p dann ong, und useChat hängt sie in message.parts zusammen.

Wenn es nicht läuft

401 Unauthorized. Mit OPENAI_API_KEY=cdm_invalid wirft das AI SDK AI_APICallError: Unauthorized (Code AUTH_REQUIRED). Der Schlüssel ist unvollständig, falsch kopiert, oder die Variable ist in dieser Shell nicht exportiert.

400 Bad Request. carpe("deepseek-v4-1-flash-typo") hat die Meldung This model is no longer available upstream zurückgegeben (Code MODEL_ERROR). Prüfe die ID auf /models.

404 Not Found. baseURL endet nicht auf /v1. Setze es wieder.

Leere Antwort, finishReason: length. Das Reasoning hat die Deckelung aufgebraucht: erhöhe maxOutputTokens.

Methode: Versuch vom 29 Oktober 2026 mit ai@7.0.157 und Next.js 16.4.3, eine Ausführung pro Ausschnitt; jedes Skript ist so gelaufen. Vision, Tools und Deploy auf Vercel nicht getestet.

Weiterführendes

Um ein Modell ohne Code zu testen, ist der Create Hub die Sandbox. Für etwas anderes als deepseek-v4-1-flash reicht es, die Zeichenkette in carpe("…") zu ändern. Lege einen Schlüssel an, starte npx next dev und schick die erste Nachricht von der Seite.