This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
llm-proxy is a small Node.js + TypeScript + Express service that:
- Issues JWT bearer tokens for an admin API (
POST /auth/token). - Manages Nginx configuration and TLS certificates (Let’s Encrypt via
certbot, Cloudflare DNS challenge). - Proxies OpenAI-style API requests under
/v1/*to one of multiple upstream LLM endpoints. - Aggregates upstream model lists into a single
GET /v1/modelsendpoint.
Primary entrypoint: src/index.ts.
THIS PROJECT REQUIRES TECHNICAL RIGOR. READ THIS SECTION COMPLETELY BEFORE MAKING ANY CODE CHANGES.
THE MOST IMPORTANT RULE: VERIFY EVERYTHING BY READING THE ACTUAL CODE
Before using ANY function, type, method, class, or import:
- READ THE SOURCE FILE where it is defined
- VERIFY THE EXACT SIGNATURE (parameters, return types, visibility)
- CONFIRM IT EXISTS (do not assume based on naming conventions)
- UNDERSTAND ITS BEHAVIOR (read the implementation if unclear)
NEVER GUESS. The only way to know for certain how something works is to read the code where it's defined.
BEFORE using any import, function, type, or method:
STEP 1: LOCATE THE DEFINITION
- Read the file where it's defined
- If unsure of location, search the repo
- For third-party libraries, read their .d.ts or official docs
STEP 2: VERIFY THE SIGNATURE
- Parameter names, types, and order
- Return type
- Optional vs required
- Exported vs internal
STEP 3: UNDERSTAND THE BEHAVIOR
- Read implementation
- Identify edge cases + error conditions
STEP 4: VERIFY COMPATIBILITY
- Ensure intended usage matches the verified signature
IF ANY STEP FAILS OR IS UNCERTAIN:
- DO NOT PROCEED with assumptions
- Read more code and/or consult official docs
.d.tsfiles are the source of truth for third-party library types.- Direct property access on union types
A | Bis forbidden unless that property exists on all union members. - Prefer type narrowing (
typeof,instanceof,in, custom type guards) over type assertions. - Use
async/awaitand handle promise rejections viatry/catch.
src/index.ts– Express bootstrap and route wiring.src/controllers/auth.ts– Token issuing endpoint.src/controllers/nginx.ts– Nginx admin API routes.src/controllers/llm.ts–/v1/modelsaggregation and request proxying.src/utils/auth.ts–tokenMiddleware(JWT bearer verification).src/utils/nginx.ts–NginxManager(nginx start/reload, config read/write, certbot obtain/renew).src/static/nginx-server-template.conf– Nginx server block template used bywrite-default.
# Dev server
npm run dev
# Build
npm run build
# Start built output
npm startNotes:
buildoutputs todist/and copiessrc/static/→dist/static/.testis a placeholder script that exits 1.
Defined in src/index.ts:
- Loads env from
.envviadotenv.config(). - Uses
body-parserJSON + urlencoded parsing withPAYLOAD_LIMIT(default:1mb). - Listens on
PORT(default:8080).
POST /auth/tokenis not protected bytokenMiddleware.- All
/nginx/*and/v1/*routes configured insrc/index.tsare protected bytokenMiddleware.
Token issuing behavior (src/controllers/auth.ts):
- Validates JSON body
{ username, password }againstAUTH_USERNAME/AUTH_PASSWORD. - Signs an HS256 JWT using
JWT_SECRET.
Token verification behavior (src/utils/auth.ts):
- Requires
Authorization: Bearer <token>. - Uses
jwt.verify(..., { algorithms: ['HS256'], ignoreExpiration: true }).- Expiration is not enforced by this middleware; treat
JWT_SECRETrotation as the primary invalidation mechanism.
- Expiration is not enforced by this middleware; treat
Defined in src/controllers/llm.ts:
GET /v1/modelsreturns an aggregated list from upstreams.- Upstream model lists are refreshed in a loop (every 60 seconds) via
cacheModels(). - Proxy forwarding is implemented in
forwardPostRequest()and only triggers when:req.method === 'POST'- path starts with
v1or/v1 req.body.modelexistsTARGET_URLSis non-empty
Upstream selection:
TARGET_URLSis comma-separated.- Each entry may be
http(s)://host:port[/v1]|api-key. - Upstream models are cached by
md5(model.id). - Incoming request chooses upstream by hashing
req.body.modelwithmd5and looking up that hash inmodelCache. - If no cached match exists, falls back to the first
TARGET_URLSentry.
Request forwarding:
- Uses axios with
responseType: 'stream'and pipes upstream response to the client. - If upstream entry includes
|api-key, outgoing request setsAuthorization: Bearer <api-key>(overriding any incoming Authorization header).
Routes are defined in src/controllers/nginx.ts and backed by src/utils/nginx.ts.
Protected routes:
GET /nginx/reloadPOST /nginx/config/update(expects{ config: string })GET /nginx/config/getGET /nginx/config/get-defaultPOST /nginx/config/write-default(expects{ domain: string, cidrGroups: string[] })POST /nginx/certificates/obtain(expects{ domains: string[] })GET /nginx/certificates/renew
Template behavior (src/static/nginx-server-template.conf + NginxManager.writeDefaultTemplate()):
- Replaces
{{domainName}}with the requested domain. - Replaces
{{allowedIPs}}with a list ofallow <cidr>;entries. - Adds
deny all;after the allow list for the/(auth|nginx)/*location.
Cert issuance behavior (NginxManager.obtainCertificates()):
- Uses
certbot certonly ... --preferred-challenges dns-01. - When called with
cloudflare === true(current controller always passestrue):- Adds
--dns-cloudflare --dns-cloudflare-credentials /opt/cloudflare/credentials.
- Adds
- The command currently hardcodes the email as
email@email.com.
From src/index.ts, src/controllers/auth.ts, src/utils/auth.ts, and example.env:
PORT(default8080)PAYLOAD_LIMIT(default1mb)TARGET_URLS(comma-separated upstreams; each may include|api-key)JWT_SECRETAUTH_USERNAMEAUTH_PASSWORD
Dockerfileinstalls:nginx,certbot,python3-certbot-dns-cloudflare.- Image exposes ports
8080(Node) and443(Nginx). docker-compose.ymlmounts:.env→/app/.env./cloudflare_credentials→/opt/cloudflare/credentials./nginx→/etc/nginx/conf.d./certs→/etc/letsencrypt
- The Node app only forwards POST requests and only when
req.body.modelis present; other requests fall through. tokenMiddlewareusesignoreExpiration: true.TARGET_URLSparsing (getPath()) falls back to basehttp://localhostand path/v1when URL parsing fails.- Nginx is started by Node via
exec('nginx')(NginxController.start()→NginxManager.start()).