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/throttlerto rate-limit per API key. - Log
usage.total_tokensfrom each response for cost tracking. - Restrict
modelto 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.