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.
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:
- plain text: the option name;
- JSON object mode:
{"intent": "lost_card"}; - your JSON schema:
{"<your field>": "lost_card"}; - a forced tool: a
tool_callsmessage withfinish_reason: "tool_calls"; stream: true: server-sent events, for text and tool calls;/v1/decisionsand/v1/systemone: the same JSON shape the upstream would return (details).
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.