n4nAI

Build an OpenAI-compatible chat endpoint in NestJS

Hands-on tutorial to build a nestjs openai compatible chat endpoint with NestJS: request DTOs, provider proxying, streaming, and curl tests.

n4n Team2 min read479 words

Audio narration

Coming soon — every post will get a voice note here.

Building a nestjs openai compatible chat endpoint lets you expose an OpenAI-style /v1/chat/completions API while controlling auth, logging, and model routing on your own infrastructure. This tutorial walks through a production-shaped implementation using NestJS, from DTOs to streaming proxies, so any OpenAI client can point at your server unchanged.

Prerequisites

  • Node.js 18+ and npm
  • NestJS CLI: npm i -g @nestjs/cli
  • An OpenAI API key, or any OpenAI-compatible base URL and key
  • Familiarity with NestJS decorators, modules, and dependency injection

If you plan to avoid direct provider contracts, you can target a gateway that speaks the same protocol. The code below only changes its baseURL.

Scaffold the project

Create a strict-mode NestJS app and install the OpenAI SDK plus validation helpers:

nest new chat-gateway --strict
cd chat-gateway
npm i openai class-validator class-transformer

Remove the generated app.service.ts and app.controller.ts if you want a clean tree; we will use a dedicated chat resource.

nest g resource chat --no-spec

Define the OpenAI-compatible contract

OpenAI’s chat completion request is a JSON body with model, messages, and optional sampling parameters. We model a minimal subset with class-validator so NestJS can reject bad input before it hits the network.

Create src/chat/dto/chat-completion.dto.ts:

import { IsArray, IsString, IsNumber, IsBoolean, IsOptional, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class ChatMessageDto {
  @IsString()
  role: 'system' | 'user' | 'assistant';

  @IsString()
  content: string;
}

export class ChatCompletionDto {
  @IsString()
  model: string;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => ChatMessageDto)
  messages: ChatMessageDto[];

  @IsOptional()
  @IsNumber()
  temperature?: number = 1;

  @IsOptional()
  @IsBoolean()
  stream?: boolean = false;
}

This is enough to accept the requests the official SDK sends.

Wire the module

In src/chat/chat.module.ts, declare the controller and service:

import { Module } from '@nestjs/common';
import { ChatController } from './chat.controller';
import { ChatService } from './chat.service';

@Module({
  controllers: [ChatController],
  providers: [ChatService],
})
export class ChatModule {}

Import ChatModule into AppModule. The ChatService will own the HTTP client.

Implement the controller and service

Map POST /v1/chat/completions and hand the response object to the service so we can switch between JSON and SSE.

src/chat/chat.controller.ts:

import { Controller, Post, Body, Res } from '@nestjs/common';
import { Response } from 'express';
import { ChatCompletionDto } from './dto/chat-completion.dto';
import { ChatService } from './chat.service';

@Controller('v1')
export class ChatController {
  constructor(private readonly chat: ChatService) {}

  @Post('chat/completions')
  async completions(@Body() body: ChatCompletionDto, @Res() res: Response) {
    return this.chat.handle(body, res);
  }
}

src/chat/chat.service.ts:

import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import OpenAI from 'openai';
import { ChatCompletionDto } from './dto/chat-completion.dto';

@Injectable()
export class ChatService {
  private client: OpenAI;

  constructor() {
    this.client = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,
      baseURL: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',
      timeout: 30_000,
    });
  }

  async handle(dto: ChatCompletionDto, res: Response) {
    if (dto.stream) {
      return this.stream(dto, res);
    }
    try {
      const completion = await this.client.chat.completions.create({
        model: dto.model,
        messages: dto.messages,
        temperature: dto.temperature,
      });
      return res.json(completion);
    } catch (err) {
      return this.error(res, err);
    }
  }

  private async stream(dto: ChatCompletionDto, res: Response) {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    try {
      const stream = await this.client.chat.completions.create({
        model: dto.model,
        messages: dto.messages,
        temperature: dto.temperature,
        stream: true,
      });

      for await (const chunk of stream) {
        res.write(`data: ${JSON.stringify(chunk)}\n\n`);
      }
      res.write('data: [DONE]\n\n');
      res.end();
    } catch (err) {
      res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`);
      res.end();
    }
  }

  private error(res: Response, err: any) {
    res.status(err?.status ?? 500).json({
      error: { message: err?.message ?? 'upstream error', type: 'invalid_request_error' },
    });
  }
}

The whole point of a nestjs openai compatible chat endpoint is that the upstream client library does not know it is not talking to OpenAI. The baseURL is the only knob.

If you point OPENAI_BASE_URL at a gateway such as n4n.ai, the same code works against 240+ models with automatic fallback when a provider is degraded. The gateway honors client routing directives and forwards provider cache-control hints, so a model string like claude-3-5-sonnet needs no branching in your service.

Enable global validation

In src/main.ts, register the pipe so DTOs are checked:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ transform: true }));
  await app.listen(3000);
}
bootstrap();

Now a request missing messages returns 400 before any upstream call.

Test the endpoint

Start the dev server:

npm run start:dev

Export credentials for the backend you chose:

export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://api.openai.com/v1
# or for a multi-provider gateway:
# export OPENAI_BASE_URL=https://api.n4n.ai/v1

Non-streaming request:

curl -X POST http://localhost:3000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [{"role": "user", "content": "Say hi in JSON"}],
    "temperature": 0.2
  }'

Expected output (abridged):

{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "{\"greeting\": \"hi\"}" },
      "finish_reason": "stop"
    }
  ],
  "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
}

Streaming request:

curl -N -X POST http://localhost:3000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Count to 3"}],"stream":true}'

Expected raw SSE frames:

data: {"id":"chatcmpl-abc","choices":[{"delta":{"role":"assistant"}}]}

data: {"id":"chatcmpl-abc","choices":[{"delta":{"content":"1"}}]}

data: [DONE]

Point the OpenAI SDK at your server

Because the route mirrors OpenAI’s, you can repoint the official client in any language. In Node:

import OpenAI from 'openai';
const client = new OpenAI({
  apiKey: 'unused',
  baseURL: 'http://localhost:3000/v1',
});
const res = await client.chat.completions.create({
  model: 'gpt-4o-mini',
  messages: [{ role: 'user', content: 'Ping' }],
});
console.log(res.choices[0].message.content);

This proves the nestjs openai compatible chat endpoint is wire-compatible with existing tooling.

Production hardening

A few changes separate this from a toy:

  • Add @nestjs/throttler to rate-limit per API key.
  • Log usage.total_tokens from each response for cost tracking.
  • Restrict model to an allowlist in the DTO via @Matches() or a custom validator.
  • For streaming, handle client disconnects with res.on('close', ...) to abort the upstream stream.

The contract stays fixed; the proxy logic is where your infrastructure value accumulates.

You now have a minimal but real nestjs openai compatible chat endpoint with validation, streaming, and a pluggable backend. From here, layer routing, metering, and guardrails without touching the external API shape.

Tagsnestjsopenai-apinodejsbackend

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All nestjs llm api integration posts →