First Tenant Setup in Node and Python: A Minimal Repo That Works on the First Try
A minimal, copy-paste starter setup in Node and Python for an LLM API aggregator: load one API key, pick a model alias, make a streaming call, and wrap it in a single retry so the first request succeeds. Conceptual guidance plus code skeletons you adapt to your provider's SDK or HTTP surface.
What this starter covers
The goal is a repo small enough to read in one sitting that still behaves like production for the first request. It does four things in both Node and Python:
- Loads one API key from the environment instead of hard-coding it.
- Selects a model through a single alias so you can switch models without touching call sites.
- Makes a streaming call and prints tokens as they arrive.
- Wraps the call in exactly one retry for transient failures.
Keep the repo to a handful of files. Anything more is deferred until the first call works.
Repo layout
A flat layout is enough:
starter/
node/
.env.example
package.json
index.mjs
python/
.env.example
requirements.txt
main.py
README.md
Commit the .env.example files, never a real .env. Add .env to .gitignore in both folders.
Step 1: Load the key without surprises
Never read the key at import time in a way that breaks when it is missing. Read it once, fail loudly with a clear message, and keep the value out of logs.
Node:
// index.mjs
const API_KEY = process.env.LLM_API_KEY;
if (!API_KEY) {
console.error("Set LLM_API_KEY in your environment or .env file.");
process.exit(1);
}
Python:
# main.py
import os, sys
api_key = os.environ.get("LLM_API_KEY")
if not api_key:
sys.exit("Set LLM_API_KEY in your environment or .env file.")
If you want a .env file, load it explicitly at startup with dotenv (Node) or python-dotenv (Python). Do not scatter os.environ reads across modules; centralize them in one config module as the repo grows.
Step 2: Select the model through one alias
Hard-coding a model name in every call site is the most common reason a starter repo rots. Define one alias that maps to a concrete model and read it from a single place.
Node:
const MODEL_ALIAS = process.env.MODEL_ALIAS || "default-chat";
const MODELS = { "default-chat": "<provider-model-id>" };
const model = MODELS[MODEL_ALIAS];
if (!model) throw new Error(`Unknown model alias: ${MODEL_ALIAS}`);
Python:
MODEL_ALIAS = os.environ.get("MODEL_ALIAS", "default-chat")
MODELS = {"default-chat": "<provider-model-id>"}
model = MODELS.get(MODEL_ALIAS)
if model is None:
raise ValueError(f"Unknown model alias: {MODEL_ALIAS}")
Keep the alias table short and explicit. An aggregator exposes many models; you only need one working alias to prove the path.
Step 3: Make a streaming call
Streaming validates more of the stack than a non-streaming call: auth, request shape, and response parsing. Use your provider's SDK or a plain HTTP client and iterate the stream.
Node (shape only):
async function streamChat(messages) {
const res = await client.chat.completions.create({
model,
messages,
stream: true,
});
for await (const chunk of res) {
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) process.stdout.write(delta);
}
process.stdout.write("\n");
}
Python (shape only):
def stream_chat(client, model, messages):
stream = client.chat.completions.create(
model=model, messages=messages, stream=True
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
print()
The exact client and method names depend on which SDK or HTTP surface you use. Treat the snippets as structure: create a streamed request, iterate chunks, print deltas.
Two practical notes:
- Check the SDK's streaming return type. Some return a sync iterator, some an async one; match your loop.
- Flush stdout in Python so output appears as tokens arrive.
Step 4: Add exactly one retry
One retry covers the common transient failure (a dropped connection or a short-lived upstream error) without hiding real problems. Retry only on errors that are plausibly transient, and never retry a stream after you have already printed tokens unless you are willing to restart the output.
Node:
async function withOneRetry(fn) {
try {
return await fn();
} catch (err) {
const status = err?.status ?? err?.response?.status;
const retryable = !status || status === 429 || status >= 500;
if (!retryable) throw err;
await new Promise((r) => setTimeout(r, 500));
return await fn();
}
}
Python:
import time
def with_one_retry(fn):
try:
return fn()
except Exception as err:
status = getattr(err, "status_code", None)
retryable = status is None or status == 429 or status >= 500
if not retryable:
raise
time.sleep(0.5)
return fn()
For streaming, the cleanest pattern is to retry only the connection setup, before the first chunk is emitted. If the stream fails mid-way, log it and let the caller decide.
Step 5: Run it
Node:
cd node
cp .env.example .env # then paste your key
npm install
node --env-file=.env index.mjs
Python:
cd python
cp .env.example .env
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
set -a && source .env && set +a
python main.py
The .env.example should contain empty placeholders only:
LLM_API_KEY=
MODEL_ALIAS=default-chat
Step 6: Confirm the first call
A first run is successful when you see:
- No auth error, meaning the key loaded correctly.
- Tokens arriving incrementally, meaning streaming is wired up.
- One clean exit, meaning the retry wrapper did not swallow the result.
If the very first request fails, check in this order: the key is present in the environment of the process you launched; the alias maps to a valid model; the streamed response is parsed at the right nesting level; the retry predicate is not re-throwing on a 4xx you should surface.
How billing works on an aggregator
With an aggregator you top up once and call many models through a single key. For this platform:
- Top up with USDC on Base; no KYC.
- You pay the official model price times 1.3.
- Contributors who add value are credited official price times 1.1 (times 1.2 for premium) in USDC.
So the multiplier is a transparent margin on top of official pricing, not a discount. Budget from official prices and multiply accordingly; do not assume usage is cheaper than going direct.
Where to go next
Once the minimal repo runs, extend deliberately:
- Add a config module that owns all environment reads.
- Add a non-streaming path for callers that do not need tokens incrementally.
- Add structured logging with request IDs, but never log keys or full prompts by default.
- Add a second model alias once the first is stable, to confirm alias switching works without code changes.
Keep the starter small. The value of this repo is that a new contributor can clone it, add a key, and see tokens on the first try.