AI Gateway
View phase1 on GitHubYour first LLM response — streaming, typed, containerized
What You Will Build
A production-quality HTTP API that takes a user message and streams the AI response back one token at a time, using Server-Sent Events. By the end of this phase, you will have a Dockerized service you can show to any engineer.
Data Flow Visualization
Watch exactly how your message travels from browser to LLM and back. Use the interactive diagram above to explore each step.
Click any node to see the real data structure at that point in the pipeline. Press Animate Flow to step through the request lifecycle.
Why This Phase Exists
Many senior engineers make the same mistake when they move into AI. They try to learn LangChain, RAG, agents, and Kubernetes all at once. Then they give up before shipping anything.
Phase 1 does one thing: send a message to an AI and get a streamed response back. But it does that one thing well. The API is typed, validated, rate-limited, and containerized. You cannot skip any part of this phase. Every later phase builds on the patterns you set here: the env validation, the service abstraction, the Docker setup. The one to two weeks you spend getting this right is the most valuable time in the whole program.
How LLM APIs Actually Work
Most web developers think of an API call like this: send a request, get JSON back, done. LLM APIs work differently. If you don't update your mental model now, the topics that come later will not make sense: cost, latency, and context limits.
Here is what happens on every request:
- ▸Your text is tokenized first. "Hello world" becomes about 2 tokens. One token is roughly 4 characters, or 0.75 words.
- ▸Tokens go into the context window. This is the model's entire working memory. It remembers nothing outside this window.
- ▸The model does not write a whole sentence at once. It predicts one token at a time. Each prediction depends on every token before it.
- ▸Streaming means each token is sent to you the moment it is generated, instead of waiting for the full response.
- ▸You pay for input tokens (your prompt) and output tokens (the response) as two separate items. Generating tokens costs much more compute than reading them.
Gotcha: if you go over the context window (input plus output combined), you get either a hard error or silent truncation, depending on the provider. Counting tokens is not optional. It decides whether your request works at all.
Why Groq for This Phase
Groq runs open models on custom LPU (Language Processing Unit) hardware instead of GPUs. In practice, that means 500–800 tokens per second, versus about 60 tokens per second on GPT-4o.
We start with Groq for one reason: speed. It is not the only production choice.
- ▸No waiting during development. You see the full streaming behavior in real time instead of watching a spinner.
- ▸A real free tier: 14,400 requests per day and 30,000 tokens per minute on Llama 3.3 70B, with no credit card.
- ▸Groq's SDK is OpenAI-compatible. Switching to Claude or GPT-4 later (Phase 5) is a two-line change, not a rewrite.
- ▸Llama 3.3 70B performs close to GPT-4-class models on most everyday tasks. What you build is not a toy. It has a production shape from day one.
Key API Parameters
| Parameter | Range | What it does — and why it matters |
|---|---|---|
temperature | 0.0–2.0 | Controls randomness. 0 = deterministic, same answer every time. 0.7 = balanced. 1.5+ = creative but prone to rambling. Use low values for extraction/classification, higher for brainstorming. |
max_tokens | 1–32768 | Hard cap on output length. Always set this explicitly — an unbounded response is an unbounded bill. |
stream | boolean | true streams tokens as SSE events. false waits for the full response before returning anything. Chat UIs should always use true. |
system | string | The hidden instruction sent before the conversation starts. This is the single most powerful lever you have over model behavior — persona, output format, constraints all live here. |
top_p | 0.0–1.0 | Nucleus sampling — an alternative to temperature. Use one or the other, not both at once, or you get unpredictable interactions between the two. |
stop | string[] | Generation halts the instant one of these strings appears. Useful when you need structured output and don't want to trust the model to stop cleanly on its own. |
Security Middleware You Cannot Skip
Problem: an HTTP endpoint with no protection will be found by someone else's script within hours of going live. That is even more true when the endpoint calls a metered, billable API.
Four middlewares close the obvious holes. Each one earns its place:
- ▸
helmet()sets secure HTTP headers. It removesX-Powered-Byfingerprinting and adds saneContent-Security-Policydefaults. It is one line, so there is no reason to skip it. - ▸
cors()controls who can call your API. Without an allow-list, any website can call your API from a user's browser using their session. Restrictoriginto the domains you actually serve. - ▸Body size limit (
express.json({ limit: '50kb' })) stops oversized payloads. Without it, one malicious request with a 500MB body can crash your process before your route handler runs. - ▸Rate limiting (
express-rate-limit) protects your wallet, not only your server. Every request that reaches Groq costs money. An endpoint with no limit lets a bug or an attacker run up a large bill.
Skipping this section does not leave you with a theoretical risk. It leaves an unmonitored, metered LLM endpoint that anyone can call as many times as they want. That is the fastest way to turn a side project into a four-figure invoice overnight.
Env Validation — Fail-Fast Pattern
Problem: a missing or malformed environment variable should never show up three requests into production as a confusing undefined is not a function. It should crash the process at startup, loudly, before the server accepts any traffic.
Zod's safeParse does this for you:
const envSchema = z.object({ GROQ_API_KEY: z.string().min(1, 'GROQ_API_KEY is required'), GROQ_MODEL: z.string().default('llama-3.3-70b-versatile'), PORT: z.string().transform(Number).default('3000'), }); const _env = envSchema.safeParse(process.env); if (!_env.success) { console.error('❌ Invalid environment variables:', _env.error.format()); process.exit(1); } export const env = _env.data;
You will reuse this pattern in every phase from here on. Define the schema once. Parse it once when the module loads. Call process.exit(1) on failure. You will not scatter env.SOME_KEY ?? 'fallback' through your business logic later. By the time any other file imports env, it is already valid and correctly typed.
Core Streaming Loop — Code Deep Dive
This is the main abstraction the whole phase is built around. It is an AsyncGenerator that wraps Groq's stream and yields clean chunks to whatever calls it:
export async function* streamChat( messages: ChatMessage[], options: { temperature?: number; maxTokens?: number } = {} ): AsyncGenerator<StreamChunk> { const stream = await groq.chat.completions.create({ model: env.GROQ_MODEL, messages, temperature: options.temperature ?? 0.7, max_tokens: options.maxTokens ?? 2048, stream: true, }); for await (const chunk of stream) { const text = chunk.choices[0]?.delta?.content ?? ''; const finishReason = chunk.choices[0]?.finish_reason; if (finishReason === 'stop') { yield { text: '', done: true, usage: /* computed from chunk.x_groq.usage */ undefined }; } else if (text) { yield { text, done: false }; } } }
Let's walk through it. async function* marks this as a generator. A generator can pause and resume, handing back a value each time with yield instead of returning once with return. The for await...of loop reads the Groq SDK's own stream one network chunk at a time. Each chunk either carries a piece of text (delta.content) or signals completion (finish_reason === 'stop'). On the calling side, for await (const chunk of streamChat(messages)) receives each yielded value the moment it is produced. This is what makes token-by-token streaming work without you managing sockets or buffers by hand.
SSE vs WebSockets vs Polling
This section answers a common interview question, almost word for word: "Why did you use Server-Sent Events instead of WebSockets for streaming?"
- ▸Polling — the client keeps asking "is it done yet?" It is simple, but wasteful and slow. It is the wrong tool for token-by-token output.
- ▸WebSockets — a full two-way, persistent connection. It is powerful, but it is too complex for a one-way text stream. It also needs special handling behind some corporate proxies and load balancers.
- ▸Server-Sent Events (SSE) — plain HTTP, one-way, server to client. It uses the same connection behavior as a normal request. It works through firewalls and proxies that block WebSocket upgrades. The browser's
EventSourceAPI also reconnects automatically if the connection drops, so you get that resilience for free.
SSE is the right choice here because the data only flows one way (server to client). Phase 6 introduces WebSockets, and the reason will be clear by then: two-way, low-latency features such as typing indicators and live collaboration, where the client also needs to send data mid-stream. SSE cannot do that.
Docker — Why Now, Not Later
Problem: "works on my machine" is not something you can deploy. Adding Docker later, to a codebase that was not built for it, is much more painful than building it in from the first commit.
The Dockerfile uses a multi-stage build. One stage compiles TypeScript with the full devDependencies tree. A second, clean stage copies over only the compiled dist/ output and the production node_modules:
FROM node:20-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml* ./ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm build && pnpm prune --prod FROM node:20-alpine AS production WORKDIR /app RUN addgroup -g 1001 -S nodejs && adduser -S agenttelar -u 1001 COPY --from=builder --chown=agenttelar:nodejs /app/dist ./dist COPY --from=builder --chown=agenttelar:nodejs /app/node_modules ./node_modules USER agenttelar HEALTHCHECK --interval=30s --timeout=3s CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1))" EXPOSE 3000 CMD ["node", "dist/index.js"]
Gotcha: most people miss this on their first multi-stage build. The final image on alpine comes out around 200MB instead of 1GB or more. This is because the TypeScript compiler, type definitions, and dev tooling never reach the production stage. Add USER agenttelar (never run a container as root) and a HEALTHCHECK that Docker uses to auto-restart a stuck container. This is the same Dockerfile shape you will reuse through Phase 9's Kubernetes work.
Test It — curl Commands + Expected Output
This is the verification gate. Set up your env and start the service first:
cp apps/api/.env.example apps/api/.env # Open apps/api/.env and paste your free Groq key from https://console.groq.com cd docker && docker compose up --build -d
Compose project name is agenttelar-phase1 (volumes stay isolated from later phases). It loads apps/api/.env into the container. Do not move to the quiz until every command below behaves as shown.
1. Health check:
curl http://localhost:3000/health # {"status":"ok","version":"1.0.0","phase":1,"timestamp":"..."}
2. Streaming chat (watch tokens arrive live):
curl -X POST http://localhost:3000/api/chat \ -H 'Content-Type: application/json' \ -d '{"message": "Explain RAG in 3 bullet points.", "temperature": 0.3}' \ --no-buffer
Expected output: a live sequence of SSE events, not one blob:
event: token
data: {"text":"RAG"}
event: token
data: {"text":" (Retrieval"}
...
event: done
data: {"fullResponse":"...","usage":{"promptTokens":45,"completionTokens":120,"estimatedCostUSD":0.000122}}
3. Rate limiting (quick check — no Groq cost):
The live Compose limit is 100, which is fine for daily use but painful to trip in a tutorial. For this check only, temporarily set RATE_LIMIT_MAX_REQUESTS: 5 in docker/docker-compose.yml, recreate the API, then restore 100.
Use GET /api/chat/health (same /api rate limiter as chat, zero LLM calls). Do not use GET /health — that route is outside /api and is not limited.
# After editing RATE_LIMIT_MAX_REQUESTS: 5 in docker-compose.yml: docker compose up -d --force-recreate api for i in $(seq 1 6); do curl -s -o /dev/null -w "$i %{http_code}\n" \ http://localhost:3000/api/chat/health done # 1–5 → 200; 6 → 429 {"error":"Too many requests, please slow down"} # Restore RATE_LIMIT_MAX_REQUESTS: 100, then: docker compose up -d --force-recreate api
If every line is 200, the limiter is not wired to /api.
Try It Live
Run the actual phase_1 code in your browser. No local install needed. Once the editor loads, copy .env.example to .env in the file tree. Paste your own free Groq API key to make it respond for real.
phase_1 — AI Gateway (live editor)
Runs entirely in your browser via StackBlitz WebContainers
Click to launch editor
Runs entirely in your browser — no install needed
Cost Awareness — Numbers Table
Every CTO asks about cost eventually. Start tracking it on your first API call, not after a bill surprises you.
| Model | Input $/1M tokens | Output $/1M tokens | Cost per 1K calls (avg) |
|---|---|---|---|
| Llama 3.3 70B (Groq) | $0.59 | $0.79 | ~$0.69 |
| Llama 3.1 8B (Groq) | $0.05 | $0.08 | ~$0.065 |
| GPT-4o | $2.50 | $10.00 | ~$6.25 |
| Claude Sonnet 4.6 | $3.00 | $15.00 | ~$9.00 |
At 1M users making 10 calls per day, that is 10M calls per day. On Llama 3.3 70B, that is about $6,900 per day (about $207K per month). The same load on GPT-4o is about $90K per day (about $2.7M per month). Model choice alone is a 13x cost difference. The usage object you already return in the done SSE event is the start of your FinOps strategy in Phase 10.
Completion Checklist — Technical
Before you consider Phase 1 done, all of these should be true:
- ▸
POST /api/chatreturns streaming SSE events — verified with curl - ▸
GET /healthreturns JSON withstatus: "ok" - ▸Temporarily setting
RATE_LIMIT_MAX_REQUESTSto5and sending 6 requests toGET /api/chat/healthtriggers a429on the 6th - ▸An invalid request body returns
400with Zod validation details - ▸A missing
GROQ_API_KEYcausesprocess.exit(1)at startup, not a runtime crash later - ▸The Docker image builds and runs successfully
- ▸
docker compose up --build -dstarts the service and passes its health check - ▸Token usage and estimated cost appear in every
doneSSE event
Conceptual Checklist — Can You Explain?
If you can't answer these out loud without checking your code, re-read the relevant section above before moving on:
- ▸What is a token, and why does tokenization directly determine cost?
- ▸What is a context window, and what happens when a request exceeds it?
- ▸Why SSE instead of WebSockets for this specific use case?
- ▸When do you set
temperaturelow versus high, and why? - ▸Why is the system prompt the most powerful lever in the whole request?
- ▸Why a multi-stage Docker build instead of a single
FROM nodestage? - ▸What happens, exactly, when
GROQ_API_KEYis wrong — and where does that error surface?
What You Will Learn
- ▸Understand tokens, context window, and temperature in depth
- ▸Build a streaming SSE API from scratch, with real security middleware
- ▸Containerize with a multi-stage Docker build from day one
- ▸Know the cost of every API call in dollars before you ship it
Next Steps
Clone the phase1 folder on GitHub, copy apps/api/.env.example to apps/api/.env, add your Groq key, then run cd docker && docker compose up --build -d. Work through the curl tests above until each one matches. When you move to Phase 2, stop this stack first (docker compose down in phase1/docker) so :3000 is free. Complete the quiz below before Phase 2.
Phase Check
0/7 answered1.A user sends a message: "Explain quantum computing." The system prompt is 200 tokens and the response is 350 tokens. How many tokens are you billed for?
2.Why do we use Server-Sent Events (SSE) instead of WebSockets for LLM token streaming?
3.What does setting temperature: 0.0 do to LLM output?
4.You have a 128K context window model. Your system prompt is 2,000 tokens and you are loading the last 50 conversation turns averaging 500 tokens each. What happens?
5.Why do we validate environment variables with Zod at startup instead of checking them when a request arrives?
6.Your /api/chat endpoint has no rate limiter. What is the most immediate risk?
7.Why does the Phase 1 Dockerfile use a multi-stage build instead of a single FROM node stage?