DocsStart9 min read

The proxy: zero code changes#

shad0w proxy sits between your app and any OpenAI-compatible API. Your code keeps using its OpenAI SDK; only the base_url changes. This page covers starting it, marking which calls are decisions, keys, what comes back, and every flag.

TL;DR#

pip install "shad0wllm[compile]"
shad0w proxy --upstream https://api.openai.com/v1 --auto-train 1000
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8010/v1")             # 1. the only change to your client
r = client.chat.completions.create(
    model="gpt-6-luna", messages=[{"role": "user", "content": "my card was stolen"}],
    extra_headers={"X-Shad0w-Question": "intent", "X-Shad0w-Options": "refund,lost_card,balance,other"})  # 2. mark it
print(r.choices[0].message.content)                                   # lost_card

At first every marked call goes to the upstream LLM and its answer is logged. With --auto-train 1000, the proxy trains a table (shad0w's small learned model) after 1,000 answers. From then on it answers the calls it is certified on by itself, in microseconds. ("Certified" = tested on answers it never trained on, and allowed to answer only where it disagrees with your LLM on at most α of them; α = 5% by default.) Everything that is not a marked decision is forwarded untouched.

Your app (OpenAI SDK, base_url changed) sends every request to the shad0w proxy. Marked chat completions (a header, a shad0w/ model name or a forced enum tool) and Decisions API / System One requests are decisions: the table answers the certified ones locally; the rest go to the upstream LLM and their answers are logged. Unmarked requests are forwarded untouched.

1. Start it#

shad0w proxy --upstream https://api.openai.com/v1
shad0w proxy on http://127.0.0.1:8010/v1  ->  https://api.openai.com/v1
  dashboard http://127.0.0.1:8010/   metrics http://127.0.0.1:8010/metrics   data /home/you/app/shad0w
  decisions API: POST /v1/decisions and /v1/systemone need no marking
  chat: mark a decision with header 'X-Shad0w-Question: <name>', model 'shad0w/<name>', or a forced tool
  upstream key: each client sends its own

The upstream can be OpenAI, Groq, Together, OpenRouter, Mistral, a LiteLLM proxy, Ollama (http://localhost:11434/v1), vLLM, or anything else that speaks the chat completions API.

2. Point your client at it#

from openai import OpenAI
client = OpenAI(base_url="http://localhost:8010/v1")
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:8010/v1" });

Keys#

How Flag Use it when
Forward each client's own key (default) none clients already hold their keys. Each client's key is used for its own requests and for the spot checks on them, so two clients never borrow each other's key.
The proxy's key, from an environment variable --api-key-env OPENAI_API_KEY the key lives with the proxy, not the clients
The proxy's key, from a file --api-key-file /run/secrets/openai a Docker or Kubernetes secret. The file is re-read on every request, so a rotated secret needs no restart.

There is no flag that takes the key itself: it would show up in ps and shell history. The two flags cannot be combined. The startup banner says where the upstream key comes from, masked: upstream key: set via OPENAI_API_KEY (sk-…3f9a).

Access token#

On its own machine (--host 127.0.0.1, the default) the proxy needs nothing more. As soon as it listens on a network (a container, --host 0.0.0.0, a LAN address), give it an access token, so only your apps can use it:

export SHAD0W_PROXY_TOKEN="$(openssl rand -hex 24)"    # or --token-env NAME, or --token-file /run/secrets/shad0w-token
shad0w proxy --host 0.0.0.0 --api-key-file /run/secrets/openai

Every route except GET /v1/health then needs the token, sent one of three ways:

Client How it sends the token
An OpenAI SDK, when the proxy holds the upstream key as its API key: OpenAI(base_url="http://shad0w:8010/v1", api_key=token)
Any client, including ones that send their own upstream key the header X-Shad0w-Token: <token> (default_headers={"X-Shad0w-Token": token} in the OpenAI SDK)
A browser, for the dashboard it asks once; type anything as the user name and the token as the password

The header that carried the token is never sent upstream. A --token-file is re-read on every request, so a rotated secret needs no restart. Requests without the right token get a 401 and never reach your LLM.

Refused on purpose: listening beyond this machine with the proxy's own key (--api-key-env / --api-key-file) and no token. Anyone who could reach the port could spend your key and read recent messages on the dashboard, so the proxy will not start. If the network itself is private and you accept that, pass --insecure-open. A non-local proxy without a token that forwards each client's own key starts with a warning.

3. Mark which calls are decisions#

Using the OpenAI Decisions API or a System One model (Jev, Kev, Laya)? Nothing to mark. Requests to /v1/decisions and /v1/systemone already name each question and list its answers. See Decisions API & System One.

For chat completions, pick whichever is easiest:

msgs = [{"role": "system", "content": "Classify the support message."},
        {"role": "user", "content": "my card was stolen"}]

# a header
client.chat.completions.create(model="gpt-6-luna", messages=msgs, extra_headers={"X-Shad0w-Question": "intent"})

# a model name: "shad0w/<question>@<upstream model>" (or "shad0w/<question>" with --model on the proxy)
client.chat.completions.create(model="shad0w/intent@gpt-6-luna", messages=msgs)

# a forced tool whose parameters hold exactly one enum: the tool's name is the question
client.chat.completions.create(model="gpt-6-luna", messages=msgs,
    tools=[{"type": "function", "function": {"name": "intent", "parameters": {"type": "object",
        "properties": {"intent": {"type": "string", "enum": ["refund", "lost_card", "balance", "other"]}}}}}],
    tool_choice={"type": "function", "function": {"name": "intent"}})

Which shapes count as decisions is set by --capture (or capture in Configuration):

Capture Recognises Default
header the X-Shad0w-Question: <name> request header on
model model="shad0w/<name>" or "shad0w/<name>@<upstream model>" on
tools a forced tool (or the only tool) with exactly one enum parameter; the table's answer comes back as a tool_calls message on
decisions POST /v1/decisions and POST /v1/systemone on
json_schema a response_format JSON schema with a name; the name is the question off: turn on with --capture json_schema

json_schema is off by default because many apps use named schemas for things that are not decisions. Turning it on treats every such request as one. --capture takes a comma-separated list and can be repeated; it replaces the default list.

Where the options come from, first match wins: the request's own enum (JSON schema or tool parameter), the X-Shad0w-Options: refund,lost_card,balance,other header, --schema, then a trained table. Without any of these the proxy learns the option set from the short answers your LLM gives.

The text shad0w learns from is the last user message. Put the thing to classify there and keep instructions in the system message.

model="shad0w/<name>" with no @<model> and no --model flag is refused with a 400, because the proxy would not know which upstream model to call.

What happens to a decision#

Situation What the proxy does
No trained table yet forwards to the upstream, returns its reply untouched, logs the answer to shad0w/<question>/log.jsonl
Table certified for this text answers itself in microseconds with a normal chat.completion
Table not sure forwards, returns the reply, logs the answer (training data for next time)
--mode shadow always forwards and returns the LLM's reply; counts what the table would have answered
Upstream error returns the upstream's error as-is

A table answer is shaped like the request asked for:

Its usage is all zeros, and it carries an extra shad0w object: question, source, answer, confidence, certified, flag, latency_us.

Response headers on a marked chat completion:

Header When Value
x-shad0w-source always table (answered locally) or teacher (your LLM answered)
x-shad0w-question always the question name
x-shad0w-latency-us always time spent in the proxy, in microseconds
x-shad0w-confidence table answers the table's confidence, 0 to 1
x-shad0w-certified table answers 1 or 0

On /v1/decisions and /v1/systemone, x-shad0w-source is table, mixed or teacher. Request headers starting with X-Shad0w- are never forwarded upstream.

4. Train#

shad0w stats --log shad0w/intent/log.jsonl        # how many answers, which options, ready yet?
shad0w train --dir shad0w --question intent       # the running proxy picks up the new table within a second

Or let the proxy do it: --auto-train 1000 retrains a question every 1,000 new answers. By default (retrain = "gated") the new table replaces the old one only if it certifies at least four fifths (0.8) of the old one's share. Training needs pip install "shad0wllm[compile]". See Training.

Flags#

Every flag can also come from shad0w.toml or a SHAD0W_* variable, and a [questions.<name>] section sets values for one question; see Configuration. A flag beats both.

Flag Type Default What it does Change it when
--upstream URL https://api.openai.com/v1 the real API you use another provider or a local server
--host, --port str, int 127.0.0.1, 8010 where the proxy listens it runs in a container (--host 0.0.0.0, with an access token) or the port is taken
--token-env NAME, --token-file PATH str, path $SHAD0W_PROXY_TOKEN the access token clients must send it listens on a network; see Access token
--insecure-open flag allow a non-local --host with the proxy's own key and no token only on a network you fully trust
--dir path shad0w logs and tables live in <dir>/<question>/ several apps share a machine
--bundle Q=PATH repeatable use the table at PATH for question Q the table was trained elsewhere
--model str upstream model for model="shad0w/<question>" you use the model-name marking
--schema path a schema.json naming each question's options requests do not list the options
--api-key-env NAME str send the key held in this variable upstream, instead of the client's see Keys
--api-key-file PATH path send the key held in this file upstream, re-read on every request see Keys
--capture list header,model,tools,decisions which request shapes count as decisions see above
--mode serve / shadow / off serve shadow: always return the LLM's answer, count what the table would have done; off: never consult the table rolling out; see Rollout
--canary 0 to 1 1 share of certified answers the table may serve rolling out gradually, e.g. 0.1
--never-serve comma list labels that always go upstream sensitive labels such as fraud
--min-confidence 0 to 1 a confidence floor on top of the certificate (only makes serving stricter) you want extra margin
--audit-rate 0 to 1 0.01 share of decisions spot-checked against the upstream in the background more (or less) live measurement
--auto-train int 0 (off) retrain a question every N new answers you want it hands-off
--min-rows int 1000 logged answers needed before auto-train runs (100 at least) few options, clean data
--alpha 0 to 1 0.05 most disagreement with your LLM allowed among table answers (α) stricter (0.02) or looser (0.1)
--delta 0 to 1 0.1 chance the certificate is wrong because of an unlucky sample (δ; 0.1 = 90% confidence) rarely
--timeout seconds 120 upstream timeout slow models
--trace path JSON Lines file receiving every decision auditing, offline analysis
--cost-per-call float your cost per LLM call, to show money saved on the dashboard you want the dashboard figure
--llm-latency-ms float LLM latency to assume until one is measured, to show time saved you want the dashboard figure
--no-text flag keep request text off the dashboard texts are sensitive
--config path ./shad0w.toml (or $SHAD0W_CONFIG) the config file to read it lives elsewhere

Routes#

Route What it does
POST /v1/chat/completions decisions when marked (above); otherwise forwarded
POST /v1/decisions, POST /v1/systemone decision-model formats, no marking needed
GET / live dashboard, with a Playground
GET /v1/stats JSON: counts, share answered, flags, latency, per-question settings and log sizes
GET /metrics Prometheus text format
GET /v1/health {"ok": true, "upstream": ..., "questions": [...]}; the one route that never needs the access token
POST /v1/playground {"question", "text", "ask_llm": false, "log": false}: what the table thinks of a text, and with ask_llm (needs --model) what your LLM says
anything else forwarded untouched (embeddings, images, GET /v1/models, ...)

The dashboard's Playground uses that last route: type a text, pick a question, and see the table's answer, its confidence against the threshold, and why; with --model set, also ask the upstream LLM and compare the two side by side.

Off this machine, set an access token. Without one, anyone who reaches the port can use the proxy and read the dashboard.