nodejs-python-client-setup · EN · 2026-10-21

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.