Frameworks#
Copy-paste recipes for putting shad0w in front of the LLM call your framework already makes. shad0w needs one thing from a framework: a way to ask the LLM for an answer. Every recipe below is that, plus a line or two.
Pick a recipe#
| You use | Easiest route | Code change |
|---|---|---|
| any Python function that asks an LLM | @shad0w.decide |
a decorator |
| LangChain / LangGraph | llm_teacher(complete=...) or shad0w_runnable |
a few lines |
| LiteLLM (SDK or proxy) | shad0w.integrations.litellm |
one import, or a callback |
| Vercel AI SDK | shad0wMiddleware / decisionModel |
wrap the model |
| Pydantic AI and other decision-model clients | the proxy | base_url only |
| FastAPI, Django, Flask | create once, call per request | a few lines |
| any OpenAI SDK, any language | the proxy | base_url only |
Every recipe has the same behaviour: the table (shad0w's small learned model) answers what it is certified on (tested on answers it never trained on, with a bound on how often it disagrees with your LLM); everything else goes to your LLM and the answer is logged for the next train().
Any function: the decorator#
If you already have a function that asks your LLM, annotate its return type with the options:
from typing import Literal
import shad0w
@shad0w.decide()
def route(text: str) -> Literal["billing", "tech", "sales", "other"]:
return my_llm_call(text) # Instructor, Pydantic AI, LangChain, raw SDK: anything
route("my invoice is wrong") # from the table once trained and certified, else from my_llm_call (logged)
route.shadow.train() # after ~1,000 logged answers
-> bool makes a yes/no question, and an Enum return type returns enum members. To let shad0w call the LLM itself, pass it to the decorator: @shad0w.decide(llm="openai/gpt-6-luna"), and leave the body as .... See Python.
LangChain / LangGraph#
Use the chat model you already have as the teacher. shad0w builds the prompt and maps the reply to one option:
import shad0w
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnableLambda
llm = ChatOpenAI(model="gpt-6-luna", temperature=0)
teacher = shad0w.llm_teacher(["billing", "tech", "sales", "other"], question="team",
complete=lambda messages: llm.invoke(messages).content)
team = shad0w.decision("team", llm=teacher)
route = RunnableLambda(lambda x: team(x["text"]).answer) # use it in a chain...
def pick_next(state): # ...or as a LangGraph conditional edge
return team(state["messages"][-1].content).answer
graph.add_conditional_edges("triage", pick_next, {"billing": "billing_agent", "tech": "tech_agent", "sales": "sales_agent", "other": "human"})
Or wrap a chain you already have (your own prompt and model) as a Runnable. The table answers what it is certified on; otherwise the chain runs, its reply is mapped to one option and logged:
import shad0w
from shad0w.integrations.langchain import shad0w_runnable
team = shad0w.decision("team", options=["billing", "tech", "sales", "other"]) # no llm: the chain supplies it
router = shad0w_runnable(team, prompt | llm) # .invoke(text | {"text": ...} | messages), .ainvoke(...) too
router.invoke({"text": "my invoice is wrong"}) # -> "billing"
With shad0w_runnable, shad0w never calls the model itself, so there are no background spot checks; the first recipe has them.
LiteLLM#
In-process, any of LiteLLM's providers as the teacher:
import litellm, shad0w
complete = lambda messages: litellm.completion(model="anthropic/claude-haiku-4-5", messages=messages,
temperature=0).choices[0].message.content
intent = shad0w.decision("intent", llm=shad0w.llm_teacher(["refund", "lost_card", "other"], question="intent", complete=complete))
Keep calling LiteLLM and let shad0w short-circuit certified answers. They come back as normal ModelResponse objects, through LiteLLM's mock_response:
import shad0w
from shad0w.integrations.litellm import completion
intent = shad0w.decision("intent", options=["refund", "lost_card", "other"])
resp = completion(intent, model="gpt-6-luna", messages=[{"role": "user", "content": "my card was stolen"}])
LiteLLM proxy. Register a callback in a file next to your config.yaml:
# shad0w_hooks.py
import shad0w
from shad0w.integrations.litellm import Shad0wLogger
handler = Shad0wLogger({"intent": shad0w.decision("intent", options=["refund", "lost_card", "other"])})
# config.yaml
litellm_settings:
callbacks: shad0w_hooks.handler
A request is a decision when its metadata names the question, extra_body={"metadata": {"shad0w_question": "intent"}}, or when the handler has only one question. Shad0wLogger(shadows, question_of=fn) lets you pick the question from the request yourself. Answers from the model are logged for the next train(). These adapters are tested against stand-ins for LiteLLM's interfaces, not against a running LiteLLM proxy.
Or put the shad0w proxy in front of a LiteLLM proxy. It speaks OpenAI, so nothing else changes:
shad0w proxy --upstream http://localhost:4000 --port 8010
Vercel AI SDK#
Wrap the model: certified answers skip it, everything else is logged for training.
import { wrapLanguageModel, generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { decision, shad0wMiddleware } from "shad0wllm";
const intent = await decision("intent", { options: ["refund", "lost_card", "balance", "other"],
bundle: "shad0w/intent/bundle", log: "shad0w/intent/log.jsonl" });
const model = wrapLanguageModel({ model: openai("gpt-6-luna"), middleware: shad0wMiddleware(intent, { specificationVersion: "v3" }) });
export async function POST(req: Request) {
const { message } = await req.json();
const { text } = await generateText({ model, system: "Answer with one of: refund, lost_card, balance, other.", prompt: message });
return Response.json({ intent: text });
}
specificationVersion must match your ai major version (ai 5 → "v2", ai 6 → "v3", ai 7 → "v4"). With experimental_decide, use decisionModel(intent, { fallback: openai.decisionModel("gpt-6-luna") }) as the model: the table answers the questions it certifies and the fallback answers the rest. See JavaScript.
Pydantic AI and other decision-model clients#
Point the client's base URL at shad0w proxy. Requests to /v1/decisions (OpenAI Decisions API) and /v1/systemone (Jev, Kev, Laya) are recognised with no marking. See Decisions API & System One.
FastAPI#
import shad0w
from fastapi import FastAPI
app = FastAPI()
intent = shad0w.decision("intent", options=["refund", "lost_card", "balance", "other"], llm="openai/gpt-6-luna",
auto_train=2000) # retrain in the background every 2,000 new answers
@app.post("/route")
async def route(body: dict):
d = await intent.adecide(body["text"]) # never blocks the event loop on a table answer
return {"intent": d.answer, "source": d.source, "why": d.why, "us": d.latency_us}
@app.get("/shad0w")
def stats():
return intent.stats()
For Prometheus, share one shad0w.Metrics() across your deciders (metrics= keyword) and expose metrics.prometheus() on /metrics. See Monitoring.
Django / Flask#
Create the decider once, at import time (module level). It is thread-safe. Call intent(text) in your views.
Cloudflare Workers, Deno, Bun#
See JavaScript. The same decision() works everywhere fetch does.
Anything with an OpenAI client#
Run the proxy and set base_url. That covers Ruby, Go, Java, .NET, PHP and every other language with an OpenAI SDK.