Calling OpenAI on Node
Two endpoints backed by the OpenAI API — one that answers all at once, one that streams
Overview
A resource calls OpenAI the way it does anything else: an ordinary await inside POST.
What does need thought is the shape of the answer. A model generates its reply a piece at a time, and a request that waits for the whole thing can sit for a minute before anything reaches the browser. So this example builds both:
| Endpoint | Answers with |
|---|---|
POST /chat | One JSON body, once the model has finished |
POST /chat/stream | A server-sent event per piece, as the model produces it |
Your resource returns whatever your runtime wants back, and Drash passes that value through untouched. That is what makes a streamed body a non-event for the framework — and why the resource below writes to context.response instead of returning something.
This example uses Chat Completions, not the Responses API. OpenAI offers both. Chat Completions is the one a dozen other providers implement, so the code here is also the code that talks to anything OpenAI-compatible — the Perplexity example is this file with a baseURL added. If you are only ever calling OpenAI and want its newer surface, client.responses.create() takes input instead of messages and returns output_text; everything this page says about resources, streaming, and errors applies unchanged.
Objectives
To gain familiarity with:
- reading and validating a JSON request body inside a resource;
- returning a
Responsewhose body is aReadableStream; - turning an API client’s errors into ones the chain can render; and
- reading a secret once, at startup, and sharing one client.
Steps
Folder Structure End State
- app.ts
Set Up the Project
You need the runtime itself — the quickstart sets one up if you do not have one — and an OpenAI API key .
npm install @drashland/drash openai
export OPENAI_API_KEY="sk-..."Create the Following File
import OpenAI from "openai";
import {
Application,
HTTPError,
Resource,
} from "@drashland/drash/modules/http.polyfill.js";
import { Status } from "@drashland/drash/core/http/response/Status.js";
import type { RequestMethod } from "@drashland/drash/core/Types.js";
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
// Node has no `URLPattern` and no `Request`, so the chain gets a context object
// you build. It requires `url` and `method`; everything else on it is yours.
type Context = {
url: string;
method: RequestMethod;
request: IncomingMessage;
response: ServerResponse;
};
const MODEL = "gpt-5"; // Any chat model. See
// platform.openai.com/docs/models.
const client = new OpenAI({ // Reads the key once, at
apiKey: process.env.OPENAI_API_KEY, // startup.
});
// `IncomingMessage` is a stream, so the body has to be collected before it can
// be parsed. Both resources start here.
async function readPrompt(context: Context): Promise<string> {
let raw = "";
for await (const chunk of context.request) {
raw += chunk;
}
let body: { prompt?: unknown };
try {
body = JSON.parse(raw);
} catch {
throw new HTTPError(Status.BadRequest, "Body must be JSON");
}
if (typeof body.prompt !== "string" || body.prompt === "") {
throw new HTTPError(Status.BadRequest, "Body must have a `prompt` string");
}
return body.prompt;
}
// OpenAI's failures are not your caller's failures. Restate each one as the
// status your caller should actually see. Order matters — the specific classes
// come before `APIError`, which is their shared parent.
function openAIErrorToHTTPError(error: unknown): unknown {
if (error instanceof OpenAI.AuthenticationError) {
return new HTTPError(Status.InternalServerError); // Your key is wrong.
}
if (error instanceof OpenAI.RateLimitError) {
return new HTTPError(Status.TooManyRequests); // Pass the limit on.
}
if (error instanceof OpenAI.BadRequestError) {
return new HTTPError(Status.BadRequest); // Usually the prompt.
}
if (error instanceof OpenAI.APIError) {
return new HTTPError(Status.BadGateway); // Upstream is down.
}
return error; // Not ours. Leave it.
}
class Chat extends Resource { // Answers with the whole reply.
public paths = ["/chat"];
public async POST(context: Context) {
const prompt = await readPrompt(context);
let completion;
try {
completion = await client.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
});
} catch (error) {
throw openAIErrorToHTTPError(error);
}
const text = completion.choices[0]?.message.content ?? "";
context.response.setHeader("content-type", "application/json");
context.response.end(JSON.stringify({ text }));
return context.response; // Always return something.
}
}
class ChatStream extends Resource { // Answers a piece at a time.
public paths = ["/chat/stream"];
public async POST(context: Context) {
const prompt = await readPrompt(context); // Throws before anything is
// written — that matters.
let stream;
try {
stream = await client.chat.completions.create({ // Awaited, so a rejected key
model: MODEL, // still throws while there is
messages: [{ role: "user", content: prompt }], // a status code left to send.
stream: true,
});
} catch (error) {
throw openAIErrorToHTTPError(error);
}
context.response.writeHead(200, { // Status and headers go out
"content-type": "text/event-stream", // now. Nothing after this line
"cache-control": "no-store", // can change them.
"connection": "keep-alive",
});
try {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta.content; // Empty on the first and last
// chunks. Skip those.
if (text) {
context.response.write(`data: ${JSON.stringify({ text })}\n\n`);
}
}
context.response.write("event: done\ndata: {}\n\n");
} catch (error) { // Too late for a status code.
context.response.write( // Say so in the stream instead.
`event: error\ndata: ${JSON.stringify({ message: "upstream failed" })}\n\n`,
);
console.error(error);
}
context.response.end();
return context.response;
}
}
const app = Application
.builder()
.resources(Chat, ChatStream)
.build();
const hostname = "localhost";
const port = 1447;
const server = createServer((request, response) => {
const context = {
url: `http://${hostname}:${port}${request.url}`,
method: request.method,
request,
response,
};
return app
.handle(context)
.catch((error) => {
// `name` is checked before `instanceof` because `instanceof` fails when
// two copies of Drash end up in one bundle.
if (error.name === "HTTPError") {
response.statusCode = error.status_code;
response.statusMessage = error.status_code_description;
return response.end(error.message);
}
console.error(error);
response.statusCode = 500;
response.end("The server could not generate a response");
});
});
server.listen(port, hostname, () => {
console.log(`\nDrash running at http://${hostname}:${port}`);
});Run the Drash App
npx tsx app.tsVerification
With the application running on localhost:1447:
curl -X POST localhost:1447/chat -d '{"prompt":"Say hi"}' -> {"text":"..."}
curl -N -X POST localhost:1447/chat/stream -d '{"prompt":"Hi"}' -> data: {"text":"..."} ... event: done
curl -X POST localhost:1447/chat -d 'not json' -> 400 Body must be JSON
curl -X POST localhost:1447/chat -d '{}' -> 400 Body must have a `prompt` string
curl -X GET localhost:1447/chat -> 501 Not Implemented
curl -X POST localhost:1447/nope -> 404 Not Found-N on the second one turns off curl’s buffering. Without it you still get every frame, but they arrive in a lump at the end, which makes a working stream look like a broken one.
The last two come from the chain rather than from your code, on URLs your server was happy to hand over.
Proving the Streaming Caveat
Point the application at a key you know is wrong and send the same prompt to both endpoints:
curl -X POST localhost:1447/chat -d '{"prompt":"hi"}'
-> 500 Internal Server Error
curl -N -X POST localhost:1447/chat/stream -d '{"prompt":"hi"}'
-> 500 Internal Server ErrorBoth fail the same way, and that is the point of awaiting create() before writing the head: the rejection arrives while the status line is still unspent. Move the await below writeHead and the second command starts answering 200 OK with an error event in the body instead — a client that ignores that event then sees a successful, empty answer. The ordering is the whole protection.
The Non-Streaming Endpoint
Chat is the shorter of the two, and everything in it is an ordinary resource method.
class Chat extends Resource {
public paths = ["/chat"];
public async POST(request: Request) {
const prompt = await readPrompt(request);
const completion = await client.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
});
// ...
}
}Two things are worth naming.
The reply is buried two levels down, and both are optional. A completion holds a list of choices, each with a message:
const text = completion.choices[0]?.message.content ?? "";The optional chaining is not defensive noise. choices is empty when the request was filtered, and content is null whenever the model answered with something other than text — a tool call, most often. Reading .content straight off choices[0].message compiles and then throws in production on the first refusal.
There is no required token ceiling. The model stops when it is finished. If you want a cap, the parameter is max_completion_tokens; the older max_tokens is rejected by newer models rather than ignored, which is the one migration detail worth knowing if you are porting existing code.
The Streaming Endpoint
ChatStream returns before the model has finished. The body is a ReadableStream that fills in as chunks arrive:
const stream = await client.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
stream: true,
});
const body = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta.content;
if (text) {
controller.enqueue(encoder.encode(
`data: ${JSON.stringify({ text })}\n\n`,
));
}
}
controller.close();
},
});
return new Response(body, {
headers: { "content-type": "text/event-stream" },
});stream: true changes the return type from a completion to an async iterable of chunks. Each chunk carries a delta rather than a message, and the if (text) matters: the first chunk announces the role with no content, the last carries a finish_reason with no content, and a run of nulls in the middle is normal. Without the guard you emit frames containing null.
Note the await. client.chat.completions.create() returns a promise even in streaming mode, and it does not resolve until the response headers are back — which means a bad key, a rate limit, or an unknown model throws before your Response exists. That is a genuine difference from clients that hand you a stream object synchronously, and it is why the streaming endpoint here can still answer with a real status code for the most common failures.
The Frame Format
Each data: line is one server-sent event, and the blank line is what ends it. A browser reads these with EventSource, or with fetch if you need to POST:
data: {"text":"Hello"}
data: {"text":" there"}
event: done
data: {}The named done event exists because a closed connection and a finished reply look identical to the client otherwise.
Expect a pause before the first token on a reasoning model. The GPT-5 family thinks before it answers, and the thinking is not streamed — the connection opens, nothing arrives for a while, then text comes quickly. To shorten the gap, pass a lower effort:
client.chat.completions.create({
model: MODEL,
reasoning_effort: "low",
messages: [{ role: "user", content: prompt }],
stream: true,
});The trade is the obvious one: less thinking, faster first token, worse answers on anything hard.
What Your Runtime Expects Back
Node’s HTTP server does not deal in Request and Response. Your resource gets the context object you built and writes to context.response, an http.ServerResponse:
context.response.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-store",
"connection": "keep-alive",
});
for await (const chunk of stream) {
// ...
context.response.write(`data: ${JSON.stringify({ text })}\n\n`);
}
context.response.end();
return context.response;Same frames, written instead of enqueued.
Return context.response at the end. A resource method that writes its response and returns nothing works on its own. It breaks the moment you wrap it in middleware: this.next() throws HTTPError(500, "The server was unable to generate a response") when the call it wraps returns a falsy value. Returning the response object costs nothing and keeps the resource wrappable.
Handling Errors From the API
Error handling covers how a throw reaches your .catch(). What is specific here is the translation step in front of it: a failure talking to OpenAI is a failure of your server, so the status you return is rarely the status you received:
| The SDK throws | You throw | Why |
|---|---|---|
OpenAI.AuthenticationError | Status.InternalServerError | Your key is wrong or missing. The caller did nothing to cause it and can do nothing about it. |
OpenAI.RateLimitError | Status.TooManyRequests | The one case where passing the status through is right — your caller should back off too. It also covers an exhausted quota, which is worth alerting on separately. |
OpenAI.BadRequestError | Status.BadRequest | Almost always the prompt, which came from the caller. |
OpenAI.APIError | Status.BadGateway | The parent class, so it catches outages and timeouts. It must be checked last. |
openAIErrorToHTTPError in the sample does exactly that and nothing else. Returning the unrecognized error unchanged matters: a TypeError in your own code is a bug, and turning it into a tidy 502 hides it.
None of this applies once the stream is open. The status line and headers go out with the first byte. After that a failure cannot become a 500, because a 200 has already been sent — the client has a successful response containing half an answer.
Awaiting create() before you write the head buys you most of the protection: connection failures, bad keys, and rejected parameters all happen while a status code is still available. What it cannot cover is a failure mid-generation — the upstream connection dropping after ten tokens. For that, report in-band:
send(`event: error\ndata: ${JSON.stringify({ message: "upstream failed" })}\n\n`);Clients have to handle that event for it to mean anything, so document it alongside the endpoint.
Getting the API Key to the Resource
The key is read once, at startup, from process.env.OPENAI_API_KEY, and one client is built that both resources close over. Node does not load a .env file on its own; export the variable, or pass --env-file=.env.
Where to Next
- Calling Claude — the same two endpoints against the Anthropic API, where the streaming client behaves differently enough to be worth comparing
- Calling Perplexity — this application with a
baseURL, answering with sources attached - Creating Middleware — put an API key check or a request log in front of both endpoints
- Rate Limiter — the bundled middleware for capping how often a caller can reach an endpoint this expensive
- Grouping Resources — mount both under
/api/v1and share middleware between them
This example sends one prompt and gets one answer. Holding a conversation means sending the whole history on every request, since the API keeps no state between calls — the messages array grows, and where you store it is your decision, not Drash’s.
The finished app is in the repository at examples/runtimes/node/calling-openai.