SDK Guide
Decorators for strategies, rules, signals, middleware and telemetry; settings, error handling, threading and plugins.
Stability & versioning
- SemVer. Everything exported from
opensmartroute(top-level) is public API.opensmartroute.enterprise,.security,.learning,.realtime,.math,.eval,.aio,.adapters,.configare public too but may add fields between minor versions. - Modules prefixed
_and anything not in an__all__are private. - The complete list of public modules and names, with one-line summaries, is generated into Python API reference from the source.
- Deprecations are announced one minor version ahead with
DeprecationWarning.
Installation matrix
curl -LsSf https://opensmartroute.ai/install.sh | sh (Linux, macOS) and
irm https://opensmartroute.ai/install.ps1 | iex (Windows) install the osr CLI with the yaml and
server extras in an isolated tool environment (uv, pipx or a private venv). For the library, pick
the extras you need:
| Extra | Adds | Use when |
|---|---|---|
| (none) | pure stdlib | embedding in any Python 3.10+ service |
yaml | PyYAML | loading targets.yaml / rules.yaml |
server | FastAPI, uvicorn, pydantic | running osr serve |
fast | numpy | faster SLM training and scoring (pure Python otherwise) |
embeddings | sentence-transformers | semantic SimilarityStrategy |
tiktoken | tiktoken | exact OpenAI tokenisation in estimate |
tensorflow | TensorFlow / Keras | learning.neural token-count and routing-head models (large; not part of all) |
otel | opentelemetry-api/sdk | adapters.OpenTelemetryTelemetry |
crypto | cryptography | AES-GCM encrypted state stores |
dev | pytest, ruff, mypy, bandit | contributing |
all | every extra above except tensorflow | the installer's OSR_EXTRAS=all |
osr doctor prints which extras are present, the bundled routing model, the config directory and the sign-in,
and with --targets / --rules whether a catalogue loads; it exits 1 when something required is missing.
Two ways in: a router in your process, or the platform over HTTP
In your process. Router.starter() is a complete router with nothing configured - the same three targets
and two rules osr route uses without --targets (a small fast model, a frontier model, an on-premises one;
personal data stays on-prem, hard requests go to the frontier). Decisions take a fraction of a millisecond and
nothing leaves the machine:
from opensmartroute import Router
router = Router.starter()
d = router.route("Prove that sqrt(2) is irrational, step by step.")
print(d.target.id, round(d.confidence, 2)) # llm-frontier 0.9x
print(d.trace.explain())Router.from_files("targets.yaml", "rules.yaml") loads your own catalogue; osr stack init --out stack.json
writes the starter one to disk to edit.
Over HTTP. OpenSmartRoute is the client of the hosted platform and of a self-hosted osr serve: the
same surface as the TypeScript SDK, standard library only. With no arguments it reads OSR_API_KEY /
OSR_API_URL, then the profile osr login saved; without any key it can still quote (estimate()) and read
the deployment (info()).
from opensmartroute import OpenSmartRoute, RateLimitError
osr = OpenSmartRoute() # or OpenSmartRoute(api_key="osr_live_...", base_url="https://osr.example.com")
d = osr.route(
"Refund order #123, the customer is upset",
constraints={"data_boundary": "private"},
objective={"cost": 0.4, "quality": 0.6},
)
print(d.target_id, d.confidence, d.explanation) # d.raw holds everything the server sent (d["ranked"] ...)
answer = osr.route("Summarise this ticket: ...", execute=True).text # run the chosen target too
osr.feedback(d, success=True, quality=0.9) # close the loop: the learners improve on your traffic
print(osr.ask("What is the capital of France?")) # OpenAI-compatible chat with model="auto"
for chunk in osr.chat("Write a haiku about routers", stream=True):
print(chunk["choices"][0]["delta"].get("content", ""), end="")
quote = osr.estimate("Translate this contract", monthly_requests=10_000) # no key needed
print(quote["recommended"]["target_id"], quote["monthly"]["savings_usd"])What the client does on its own: a timeout on every call (OSR_CLIENT_TIMEOUT_S, 60 s), bounded retries with
backoff after a connection failure, a 408, a 429 or a 502-504 - Retry-After is honoured, a 4xx that is final is
never retried (OSR_CLIENT_MAX_RETRIES, 2) - and typed errors: AuthenticationError (401/403, says how to sign
in), RateLimitError (429, with .retry_after), APIError (.status, .detail, .request_id for a support
ticket). No error message and no repr() ever contains the key. An API key is refused over plain http:// to
anything but a private host (loopback, RFC 1918, a bare service name, .internal, .svc ...) unless you pass
allow_insecure_http=True or set OSR_CLIENT_ALLOW_INSECURE_HTTP=1. An osr_local_... token puts the REST
routes at the root for osr serve (api_prefix="" does the same by hand); tenant= and app= are sent on
every call; osr.rest-style access is osr.get(path) / osr.post(path, body) for anything not wrapped.
Every tunable is a ClientSettings field (OSR_CLIENT_*).
Connecting real providers
adapters.OpenAICompatClient is a stdlib HTTP client for any OpenAI-compatible API (OpenAI, Azure
OpenAI, vLLM, Ollama, LiteLLM, OpenRouter, Groq, Mistral). It has explicit timeouts, exponential
backoff on 408/429/5xx, and maps failures to SDK errors (ConfigurationError for 401/403,
TargetUnavailableError for connectivity/5xx, ExecutionError for the rest). The API key is read
from OPENAI_API_KEY (or OPENAI_API_KEY_FILE) and never appears in logs or exceptions.
from opensmartroute.adapters import OpenAICompatClient, chat_handler, embedder, judge_fn
openai = OpenAICompatClient() # https://api.openai.com/v1
local = OpenAICompatClient("http://localhost:11434/v1", api_key="ollama") # Ollama
registry = TargetRegistry(
[
RouteTarget(
"gpt-4o-mini",
TargetKind.LLM,
cost={"usd_per_1k_tokens": 0.00015},
handler=chat_handler(openai, model="gpt-4o-mini"),
),
RouteTarget(
"llama-local",
TargetKind.LLM,
cost={"usd_per_1k_tokens": 0.0},
constraints=TargetConstraints(data_boundary="on_prem"),
handler=chat_handler(local, model="llama3.1"),
),
]
)
router = Router(
registry,
strategies=[CapabilityStrategy(), SimilarityStrategy(embedder=embedder(openai, model="text-embedding-3-small"))],
llm_judge=LLMJudgeStrategy(judge_fn(openai, model="gpt-4o-mini")),
escalate_llm_judge_below=0.5,
)
decision = router.route(request)
result = router.execute(decision, request) # ChatResult(text, tokens, latency_ms); outcome auto-recordedchat_handler builds the message list from request.history (user, assistant - including tool_calls -
and tool turns are kept verbatim), and uses request.context["system"] as the system prompt — which is
how a persona slot in a RoutePlan changes model behaviour without a separate deployment. It forwards
context["openai"] (tools, tool_choice, response_format, stop, seed, ...) and context["on_delta"]
(a streaming callback) to the upstream, and the ChatResult carries tool_calls, finish_reason and
refusal beside the text and token counts. embed_handler(client, model) is the counterpart for
embeddings-tagged targets (what answers /v1/embeddings), and attach_chat_handlers(targets, client) gives
every LLM target the right one from metadata.model - the call behind OSR_LLM_BASE_URL in osr serve
(an api.anthropic.com base URL there picks the Anthropic client automatically).
adapters.AnthropicClient speaks Anthropic's Messages API natively behind the same chat /
stream_chat surface, so chat_handler, judge_fn and every other adapter route to Claude without an
OpenAI-compatible gateway in between: chat-shaped messages, OpenAI function specs, tool_choice, tool
results, images and streaming are translated to POST /v1/messages (x-api-key + anthropic-version
headers) and back; reasoning_effort (or an explicit thinking dict) turns on extended thinking and the
thinking text lands on ChatResult.reasoning. Fields the Messages API has no equivalent for are dropped
rather than sent to fail, and embed() raises a clear error (Anthropic serves no embeddings endpoint).
from opensmartroute.adapters import AnthropicClient, chat_handler
claude = AnthropicClient() # https://api.anthropic.com/v1, key from ANTHROPIC_API_KEY
target = RouteTarget("claude", TargetKind.LLM, handler=chat_handler(claude, model="claude-sonnet-4-5"))Optional adapters (lazy-imported):
from opensmartroute.adapters import sentence_transformers_embedder, OpenTelemetryTelemetry
SimilarityStrategy(embedder=sentence_transformers_embedder("all-MiniLM-L6-v2"))
RouterBuilder(reg).with_telemetry(OpenTelemetryTelemetry())TensorFlow / Keras models (pip install 'opensmartroute[tensorflow]', opensmartroute.learning.neural) sharpen
two learnable parts of every request without touching the zero-dependency core:
from opensmartroute.estimate import estimate
from opensmartroute.learning import NeuralRoutingHead, RouterSLM, TokenCountModel
# tokenisation: learn the residual between the heuristic and the provider's billed prompt_tokens
tokens = TokenCountModel(family="o200k")
report = tokens.fit(pairs) # [(text, billed_tokens), ...] from your usage rows
report["mape_heuristic"], report["mape_model"]
tokens.save("models/tokens-o200k") # .keras + .json sidecar
TokenCountModel.load(
"models/tokens-o200k"
).install() # estimate_tokens(), the admission bound and quotes count through it
# the decision: a dense head over the SLM's hashed features, and its representation as the SLM's dense embedder
head = NeuralRoutingHead(hidden=64)
head.fit(rows, targets=router.registry.all()) # the same EvalRows `osr slm train` reads
head.predict_proba("Refactor this function", candidates=["coder", "writer"])
head.predict_proba("why?", history=[{"role": "user", "content": "prove that sqrt 2 is irrational"}]) # context
head.observe("why?", "frontier", reward=1.0, history=...) # experience replay: every outcome is remembered ...
head.replay_fit() # ... and replayed between full fits (saved with the model)
slm = RouterSLM(router.registry.all(), embedder=head.embedder()) # or OSR_SLM_EMBEDDER=keras:models/headThe head reads a follow-up with the user turns it continues, exactly as the SLM does (learning.routing_text;
EvalRow.history on training rows), and its learning.ReplayMemory (bounded, oldest dropped first) travels in the
.json sidecar, so a restart keeps what live traffic taught.
estimate.set_token_counter(fn) takes any str -> int counter (a tokenizer library works too); a counter that
raises falls back to the heuristic. OSR_ESTIMATE_TOKENIZER names one without code: tiktoken:o200k_base (or any
tiktoken encoding; pip install 'opensmartroute[tiktoken]') counts exactly the way OpenAI bills, keras:<path> loads a saved
TokenCountModel; a counter that cannot be built is logged and the heuristic stays. Feature vectors
(learning.text_stats, the hashed featurizer) are pure Python, so a model trained on one machine reads the same
features on the router that loads it.
Knowledge graph and context engineering
opensmartroute.knowledge is the SDK's knowledge management layer: a KnowledgeGraph of typed, sourced, weighted,
expiring facts (JSONL on disk; JSON-LD, N-Triples, GraphML, Cypher and NetworkX out, JSON-LD in), rule-based
ingestion of what a person states, of the catalogue, of decisions, outcomes and live web hits, and the
ContextEngineer that chooses, ranks and trims what a model should know for one request into a token budget with
full accounting (User guide). RouterBuilder.with_knowledge()
wires the ContextMiddleware and the KnowledgeStrategy; the graph is router.knowledge.
from opensmartroute.knowledge import ContextEngineer, KnowledgeGraph, ingest_targets, ingest_turn
from opensmartroute.learning import KnowledgeEmbedding # TensorFlow to train, numpy to use
g = KnowledgeGraph("state/knowledge.jsonl")
ingest_targets(g, registry.all())
ingest_turn(g, "person:u1", "I work at Acme and we use Python and dbt")
ctx = ContextEngineer(g, budget_tokens=400).build(RouteRequest(text="test a dbt model"), subject="person:u1")
ctx.text, ctx.tokens, ctx.sections(), [ln.reason for ln in ctx.dropped]
emb = KnowledgeEmbedding(dim=32)
report = emb.fit(g) # TransE over the shared facts: {mrr, hits_at_10, ...}
g.embedding = emb # KnowledgeGraph.suggest() asks it first
emb.predict_tail("target:llm-small", "has_domain") # [("domain:finance", 0.81), ...]
emb.save("models/knowledge")
KnowledgeEmbedding.load("models/knowledge") # .npz + .json, numpy onlyLoading configuration
from opensmartroute.config import load_targets, load_rules
registry = load_targets("targets.yaml") # or .json
rules = load_rules("rules.yaml")Both raise ConfigurationError with the file, the offending entry and the reason. Only
yaml.safe_load is used.
Executing end to end
Router.route() returns a decision; Router.run() (also on AsyncRouter and EnterpriseRouter)
routes and executes the resulting plan, returning an ExecutionResult. execute(decision, request)
is still available and returns just the primary handler's raw response.
res = router.run("Write SQL for monthly active users by region", task_id="job-17")
res.text # best-effort text of the primary response
res.response # the raw handler response (ChatResult, HarnessResult, dict, str, ...)
res.target_id # primary target
res.steps # [ExecutionStep(role, target_id, latency_ms, ok, note), ...]
res.system_prompt # what was composed into context["system"] (None if nothing was added)
res.effective_request # the RouteRequest the primary handler actually received
res.outcomes # one Outcome per participant, already passed to router.learn()What run() does, in order:
route(request, plan=True)builds the plan: primary + optionalpersonaandskillslots. A slot is only filled when its best candidate's quality estimate is at leastRouter(slot_quality_floor=0.5)and (forrun) its confidence is at leastmin_slot_confidence.- Skill pre-processing. If the
skillslot's target has ahandler, it is called with the request and may return- a
RouteRequest— replaces the request the primary sees (rewrite / enrich); - a
str— attached ascontext["skill_output"]and appended to the system prompt; None— the skill contributes instructions only. Exceptions are recorded as a failed step (and a failedOutcome) but do not abort the run.
- a
- Prompt composition.
context["system"]is set to: any existingsystem+# Persona: <name>RouteTarget.instructionsof the persona +# Skill: <name>+ skill instructions + the primary's owninstructions(forllm/agenttargets) +# Skill output.chat_handlerand the harness adapters read this key.
- Primary call.
target.handler(request, **kw). Missing handler →ExecutionError. In the sync path a coroutine result raisesExecutionError("use aexecute()");AsyncRouter.run()awaits both skill and primary handlers. - Outcomes. Success is read from
response.success/response["success"](defaultTrue); cost fromresponse.cost_usdortotal_tokens × unit_cost; quality fromresponse.quality. OneOutcomeis created for the primary (role=None) and one per slot (role="persona"|"skill"), all carryingtask_id(argument orrequest.context["task_id"]) so delayed feedback can be joined later. Each is passed tolearn; on a handler exception, failure outcomes are recorded and the exception re-raised.
Targets carry instructions
- id: persona-legal-counsel
kind: persona
primary: false
instructions: |
You are cautious in-house legal counsel. Cite the governing clause...
- id: skill-sql
kind: skill
instructions: "Always qualify table names with the schema; never SELECT *."instructions is the persona's system prompt, a skill's body (SKILL.md style) or an agent's
standing instructions. It is disclosed to the model only when the target is part of the plan.
Agent harnesses
An agent harness is any runtime that takes a task and drives its own loop (coding agent, research
agent, OpenHands, SWE-agent, Claude Code, LangGraph graph, ...). Adapters live in
opensmartroute.adapters and all produce a HarnessResult(text, success, steps, prompt_tokens, completion_tokens, cost_usd, latency_ms, quality, artifacts, raw):
from opensmartroute.adapters import CallableHarness, HTTPHarness, SubprocessHarness, harness_handler
# in-process: fn(task, context, history) -> str | dict | HarnessResult
registry.get("research-agent").handler = harness_handler(CallableHarness(my_graph.invoke))
# HTTP: POST {"task", "context", "history"} -> JSON {"text"|"output", "success", "usage", "cost_usd"}
registry.get("support-agent").handler = harness_handler(
HTTPHarness("https://agents.internal/support/run", api_key_env="AGENT_TOKEN", timeout_s=300)
)
# CLI: task on stdin, answer on stdout (or JSON with json_output=True); system prompt via a flag
registry.get("coding-agent").handler = harness_handler(
SubprocessHarness(["my-agent", "--repo", "."], system_flag="--system", timeout_s=900)
)Non-zero exit codes and "success": false become failed outcomes; HTTP 408/429/5xx, timeouts and
unreachable hosts raise TargetUnavailableError (so HealthRegistry breakers trip); other HTTP
errors raise ExecutionError; a missing binary or non-http URL raises ConfigurationError.
Implement the AgentHarness protocol (run(task, *, context, history) -> HarnessResult) for anything
else.
Skills from SKILL.md
Folders following the Agent Skills layout load directly:
from opensmartroute.adapters import load_skill, load_skills, skill_from_markdown
for skill in load_skills("skills/"): # skills/<name>/SKILL.md, name must match the folder
registry.add(skill)Frontmatter name/description become id/examples; the Markdown body becomes instructions
(progressive disclosure: only the description is used for routing, the body is shown to the model
once selected). Routing metadata can be added under metadata: — osr-domains, osr-actions,
osr-languages, osr-tags, osr-quality-prior, osr-primary ("false" for slot-only skills),
osr-latency-ms. license, compatibility, allowed-tools and other metadata are preserved in
RouteTarget.metadata. Give the returned target a handler to make it an executable pre-processor
or primary skill.
Learning from outcomes
Every learner (bandits, IRT, preference, LinUCB, Markov, task table, judge calibration) consumes the
same Outcome:
from opensmartroute import Outcome
d = app.route(req)
resp = call_provider(d.target, req)
app.learn(
Outcome(
request_id=req.request_id,
target_id=d.target.id,
success=True,
quality=grade(resp),
cost_usd=resp.cost,
latency_ms=resp.ms,
)
)- Plans:
Router.run()records oneOutcomeper participant withrole="skill" | "persona" | "primary"; learners keep a target's record as a skill separate from its record as the primary answerer (signals.extra["role"]). - Cascades:
Cascade.run(request, d.trace.ranked)returns aCascadeResult; feed every step to the learners withapp.learn_cascade(request, result)(rejected steps are failures with the gate score as quality, the accepted step a success).result.total_cost_usdis the spend across steps. - Delayed / task-level rewards: set
Outcome.task_id(andstep) so a reward observed at the end of a multi-step task credits every call in the trajectory (Router(task_credit=...)). - Offline warm start:
learning.warm_start_from_matrix(rows, strategies, weight=0.5)replays a full-information reward matrix(prompt, {target_id: reward})from an offline evaluation into every learner before serving traffic;learning.warm_start()seeds a brand-new target from its neighbours. - Replicas:
learning.merge_learners(local, remote)folds another replica's learners into yours (sufficient statistics only, no prompts);AutoLearner.refresh()re-loads a writer's snapshot from a sharedStateStore. Corrupt state is quarantined on load (AutoLearner.quarantined) instead of raising. - Objective:
Objective(quality, cost, latency, quality_floor, energy, carbon);energyandcarbonweighRouteTarget.unit_energy(cost["wh_per_1k_tokens"]) andunit_carbon(cost["gco2_per_1k_tokens"]or energy xcost["gco2_per_wh"]). - Cold-start exploration: a target declared with only
id,kindandcostis never ranked first and so never earns an outcome.Router(explore_rate=0.1, explore_min_samples=200)(orRoutingSettings.explore_rate/explore_min_samples) serves the least-seen viable candidate with probabilityexplore_rateuntil it has that many outcomes;decision.propensitiesincludes the exploration mass (so off-policy estimates stay unbiased) andsignals.extra["explored"]marks the request. Off by default. - Measured exit criteria:
opensmartroute.eval.criteriaturns each offline-measurable routing guarantee into a simulation of the real router (cold_start_ratio(),effort_token_savings(),multi_round_vs_best_single(),match_at_1_at_scale(),conformal_coverage(),knapsack_never_exceeds_cap(),ope_within_live_ci()) andopensmartroute.eval.agenticadds the tau-bench-style one (AgentTask,load_agentic_tasks(),synthetic_agentic_tasks(),task_routing_frontier(): per-step routing throughProgressRouteragainst every always-use-agent-X policy on the accuracy-latency frontier). On your own data,osr eval DATASET --multi-roundruns the multi-round comparison on scored rows,osr eval DATASET --effortthe effort-routing token comparison on rows that carry per-targetscoresandtokens(EvalRow.tokens), andosr eval TASKS.jsonl --agenticthe task-routing frontier on tasks with per-agent per-stepsuccessandlatency_mstables. - Contrastive and policy-gradient learners:
learning.ContrastiveRouter().fit(rows, targets)trains on scoredEvalRows against each row's acceptable set (objective="distilled"uses reward-softened labels); wrap it inlearning.ContrastiveStrategy(model)to keep learning online.learning.PolicyGradientStrategy()learns a routing policy directly fromdecision_reward()(quality minus weighted cost and latency) with no quality predictor in between;learning.decision_regret(rows, targets, choose, predict)compares decision regret with prediction error on the same rows. - Headroom before you learn:
eval.headroom.routing_headroom(rows, targets)tells you how much of the oracle gap is measurable on your data;target_diversity,min_catalogue,scaling_curveandlearnability_by_difficultyanswer whether, and where, a learned router can beat the best single target. - Other target kinds:
strategies.SemanticCache.target()turns a semantic cache into aDESTINATIONtarget scored bySemanticCacheStrategy;strategies.AnnotatorPool+HumanRoutingStrategyroute amongHUMANannotators with Dawid-Skene skill estimates andselect_quorum();strategies.MemoryRouterroutes what to remember across memory tiers under a token budget;strategies.ModalityStrategy/ModalityEscalationhandle text-first escalation to multimodal targets;strategies.SpeculativeCascaderuns draft and strong models in parallel when the learned acceptance rate makes that cheaper.
Error handling
All exceptions derive from OpenSmartRouteError and carry .code and .details:
from opensmartroute import OpenSmartRouteError, NoRouteError, SecurityError
try:
d = app.route(req)
except NoRouteError as e:
# e.details["rejections"] -> {target_id: reason}
fallback()
except SecurityError as e:
reject_request(e.to_dict())
except OpenSmartRouteError:
log.exception("routing failed")The SDK never raises bare Exception/RuntimeError; strategy failures (e.g. a broken LLM judge)
degrade to neutral scores rather than failing the route.
Threading & async
RouterandEnterpriseRouterare safe to share across threads.Router.lock(a re-entrant lock) guards strategy scoring, every learner update (learn,learn_correction,credit_task), task pins, the request memory, retrieval narrowing and the exploration RNG, so a strategy'sscorenever observes a half-appliedupdate. The optional LLM judge is scored outside the lock (network I/O) - put it behindescalate_llm_judge_belowso it runs rarely, and setTimeoutMiddleware.AutoLearnertakes the same lock (EnterpriseRouter/RouterBuilderpassrouter.lock; pass it yourself when wiring by hand) solearn,reset_target,save,loadandrefreshserialise against routing.save()deep-copies a sequence-numbered snapshot under the lock and writes it outside; a separate I/O lock orders writers and readers so a slow save never overwrites a newer snapshot and two saves never race on the state files.- Per-module state elsewhere has its own lock:
SemanticCache,MemoryRouter,AnnotatorPool,ModalityEscalation(posterior) andSpeculativeCascade(acceptance posteriors, history, failure counters). Embedding / hashing runs outside those locks. SpeculativeCascaderunsconcurrencyspeculative requests at once (pool of2 * concurrencyworkers);draft_timeout_sabandons a slow draft and serves the strong answer; a draft that raises escalates instead of failing (details["draft_error"]); a strong future that is no longer needed is cancelled or its late failure swallowed.stats()reports runs, modes, failure counts and per-bucket acceptance.AsyncRouterwraps any router forawait-based apps and awaits coroutine handlers;route/learnrun in the default executor under the same lock.- Routing has no I/O of its own.
Multi-round execution
ProgressRouter.run_step routes through router.route (middleware and telemetry apply) and
executes with execution.execute; a handler that raises is recorded as a failed step for its
target before the exception propagates, so the next step already denies it after
failures_before_switch. Step cost falls back to target.unit_cost x estimated tokens when the
handler reports none, so budget_usd is tracked even for plain-string handlers. arun_step is
the coroutine twin.
MultiRoundExecutor (route -> execute -> judge -> refine) learns once per round with the
judged quality (no provisional learn + correction double count); a round whose handler raised
becomes a Round with error set, learns a failure for that target and the loop continues on a
different target. stop_reason is one of accepted, budget, deadline (deadline_ms) or
max_rounds; return_best=True (default) returns the highest-quality round when nothing reached
the threshold; credit_task=True delivers the final quality as the task-level reward through
router.credit_task, which re-credits every step (learners then see one judged update per round
plus one credited update). arun awaits coroutine handlers and an async judge.
Type checking
The package ships py.typed; mypy --strict-friendly signatures on the public surface.
Configuration
# targets.yaml
targets:
- id: llm-frontier
kind: llm
capabilities: { domains: [math, coding], min_complexity: 0.5, supports_tools: true }
constraints: { data_boundary: public, pii_allowed: false, regions: [us, eu] }
cost: { usd_per_1k_tokens: 0.015 }
latency_ms: 2500
quality_prior: 0.93
examples: ["Prove that sqrt(2) is irrational."]# rules.yaml (Arch-Router-style preferences)
rules:
- name: escalation-intent
when: { actions: [escalate] }
prefer: [human-escalation]
pin: true # restrict candidates, not just boostSecrets are never in YAML. Handlers read them at call time:
from opensmartroute.security import load_secret
api_key = load_secret("OPENAI_API_KEY") # env var or OPENAI_API_KEY_FILEExtending
Decorator SDK
Every routing component - targets, strategies, signal extractors, policy rules, middleware and
telemetry sinks - can be declared with a decorator and assembled by a ComponentRegistry. The
decorators are transparent (they return the decorated object unchanged) and the registry stores
blueprints (factories), so one declaration can back many independent routers.
import opensmartroute as osr
@osr.tool("weather", domains=["travel"], actions=["lookup"], cost=0.0, examples=["weather in paris"])
def weather(request, **kw):
"""Current weather for a city.""" # first paragraph -> target.description
return lookup(request.text)
@osr.agent("planner", domains=["travel"], min_complexity=0.3, quality_prior=0.9)
def planner(request, **kw):
"""Plan a multi-day trip."""
return plan(request.text)
@osr.strategy(weight=0.8) # fn(request, signals, candidates) -> {id: score}
def geo_affinity(request, signals, candidates):
region = request.constraints.region
return {t.id: 1.0 if region in t.constraints.regions else 0.4 for t in candidates}
@osr.strategy # or a Strategy subclass; name inferred -> "recency"
class RecencyStrategy(osr.Strategy):
def score(self, request, signals, candidates): ...
@osr.signal(order=-1) # runs before the built-in extractors
def urgency(request, signals):
return {"urgent": "asap" in request.text.lower()} # unknown keys land in signals.extra
@osr.policy_rule # fn(target, request, signals) -> reason | None
def business_hours_only(target, request, signals):
if target.kind is osr.TargetKind.HUMAN and request.context.get("after_hours"):
return "human queues are closed"
return None
@osr.middleware # fn(request, next_) -> RouteDecision
def tag_tenant(request, next_):
request.context.setdefault("tenant", "public")
return next_(request)
router = osr.components.router() # Router: targets + strategies + signals + rules
app = osr.components.builder().with_auto_learning().build() # EnterpriseRouter variantMost applications need none of the plumbing above. The one-liners below cover the common cases - a rule
without a rules.yaml, a hook without a Telemetry subclass, a guard without a middleware chain, code behind a
catalogue's targets - and osr.route() / osr.run() route through the registry without building a router by hand:
import opensmartroute as osr
osr.components.load("targets.yaml", "rules.yaml") # optional: a catalogue and its rules as components
@osr.handler("gpt-4o", "gpt-4o-mini") # code for targets the catalogue declares
def chat(request, **kw):
return provider.complete(request.text, model=kw.get("model"))
@osr.persona("legal-counsel") # first paragraph routes it, the rest is its system prompt
def legal(request, **kw):
"""Contract and compliance questions.
You are cautious in-house legal counsel. Cite the governing clause before answering."""
osr.rule("pii-stays-home", prefer=["onprem"], pin=True, contains_pii=True) # what rules.yaml holds, in code
@osr.rule(prefer=["human-desk"], weight=0.8, domains=["support"]) # a predicate when `when` cannot say it
def angry_customer(request, signals):
return "unacceptable" in request.text.lower()
@osr.guard # fn(request) -> None (pass) | str / False (refuse: SecurityError) | RouteRequest (rewrite)
def no_empty(request):
return "empty request" if not request.text.strip() else None
@osr.on_decision # one hook each; on_outcome(outcome), on_error(request, error) likewise
def log_decision(request, decision):
log.info("%s -> %s (%.2f)", request.request_id, decision.target.id, decision.confidence)
@osr.signal(name="urgent") # a scalar lands under the extractor's name in signals.extra
def urgent(request, signals):
return "asap" in request.text.lower()
d = osr.route("Refund order #123, the customer is upset") # the registry builds and caches its router
print(d.target.id, d.trace.explain())
osr.feedback(d, success=True, quality=0.9) # Outcome built from the decision and learned
res = osr.run("Summarise this ticket: ...") # route + execute the handler + learn
osr.feedback(res, quality=grade(res.text)) # a later judgement of a run is recorded as a correction
d = await osr.aroute("...") # the same loop for async applications; `async def` handlers are awaited
res = await osr.arun("...")osr.components.live() is the router behind route / run / learn / feedback (alive() wraps it in an
AsyncRouter for aroute / arun / alearn): a plain Router, or an
EnterpriseRouter as soon as a guard, middleware or hook is registered so they apply. It is built on first use
and rebuilt when something is registered afterwards (learned state restarts) - declare first, route later.
A rule condition that is not one of RULE_CONDITIONS (domains, actions, all_actions, language,
min_complexity, max_complexity, contains_pii, needs_tools, event, workflow, context) raises
ConfigurationError instead of matching silently; a @handler for an id no catalogue declares raises when
the targets are materialised, naming the ids it knows.
The same declarations ride along with the CLI and the served router. osr --components my_components route "..."
(repeatable; my_components:setup also calls setup(registry)) imports the module so its decorators register,
then adds what they declared to the router built from --targets / --rules: targets and @handler bindings,
strategies, rules, signals and policy rules - @guard, middleware and the hooks make osr serve wrap it in an
EnterpriseRouter. OSR_SERVER_COMPONENTS=pkg.module,pkg.other:setup does the same for the container and the
Helm chart. In code the step is osr.components.apply(router) for a Router that already exists (one from
Router.from_files), plus enterprise.harden(router, middleware=osr.components.middlewares(), telemetry=osr.components.telemetry_sinks()) when middleware or hooks are declared.
| Decorator | Accepts | Materialised as |
|---|---|---|
@target(id, kind, ...), @tool, @skill, @agent, @llm, @persona, @human | handler fn(request, **kw); docstring = description + instructions | RouteTarget(handler=fn) (@persona is primary=False) |
@handler(*target_ids) | handler fn(request, **kw) | bound to the catalogue's targets when materialised |
rule(name, prefer=, avoid=, pin=, weight=, **when) / @rule(...) | nothing, or a predicate fn(request, signals) -> bool | Rule / FunctionRule in one rules strategy in front of the ensemble |
@strategy(name=, weight=, order=) | Strategy subclass or scorer function | Strategy / FunctionStrategy |
@signal(name=, order=) | SignalExtractor subclass or fn(request, signals) -> mapping | value | SignalExtractor / FunctionSignal |
@policy_rule(name=, order=) | fn(target, request, signals) -> str | None or Policy subclass | rule appended to Policy.with_rules |
@guard(name=, order=-100) | fn(request) -> None | str | False | RouteRequest | FunctionGuard middleware, first in the chain |
@middleware(name=, order=) | Middleware subclass or fn(request, next_) | Middleware / FunctionMiddleware |
@on_decision, @on_outcome, @on_error | fn(request, decision), fn(outcome), fn(request, error) | FunctionTelemetry |
@telemetry(name=, order=) | Telemetry subclass | Telemetry |
osr.componentsis the process-wide registry the top-level decorators bind to. Create your ownComponentRegistry()for isolation (tests, multi-tenant apps) - its methods are the same decorators, androute/run/learn/feedback/live(and theira-prefixed async forms) work on it the same way.- Duplicate names raise
ConfigurationError(register(..., replace=True)to override);unregister,clear,merge,add(*instances)(targets, strategies, rules - aRulesStrategyfolds into the declared rules - extractors, middleware, telemetry, policy rules) andload(targets, rules)manage the catalogue. RouterBuilder.with_components(registry, targets=, strategies=, rules=, signals=, policy=, middleware=, telemetry=)wires a registry into an existing builder selectively; declared rules merge into the builder'swith_rules(...)strategy when there is one.- Plugins:
registry.include("pkg.module")imports a module so its decorators run ("pkg.module:setup"callssetup(registry)afterwards);registry.discover()loads every installed distribution advertising anopensmartroute.pluginsentry point.
Subclassing
from opensmartroute import Strategy, StrategyScore
class GeoAffinity(Strategy):
name = "geo"
def score(self, request, signals, candidates):
region = request.context.get("region")
return {
t.id: StrategyScore(1.0 if region in t.constraints.regions else 0.5, f"region={region}") for t in candidates
}
app = RouterBuilder(reg).with_defaults().with_strategy(GeoAffinity(), weight=0.8).build()Custom Policy, SignalExtractor, Middleware, Telemetry, StateStore, AuditSink follow
the same pattern — subclass, implement, pass to the builder (or register with a decorator).
Settings
Every tunable lives in one immutable Settings object, grouped by consumer
(routing, policy, rules, capability, bandit, weights, slm, implicit, learning, server,
observability, marketplace, compression, estimate, inference, knowledge, client). Components resolve the process-wide settings unless
given an explicit instance.
import opensmartroute as osr
from opensmartroute.settings import RoutingSettings, WeightSettings
osr.configure(routing=RoutingSettings(narrow_above=200), weights=WeightSettings(rules=3.0))
router = osr.Router(reg) # uses the configured settings
isolated = osr.Router(reg, settings=osr.Settings()) # library defaults, unaffected
print(osr.get_settings().env_keys()) # every honoured OSR_* variableEnvironment overlay: OSR_<GROUP>_<FIELD> (e.g. OSR_ROUTING_SOFTMAX_TEMPERATURE=0.2,
OSR_WEIGHTS_CAPABILITY=1.5, OSR_POLICY_DATA_BOUNDARIES=public,internal,confidential).
Values are parsed against the default's type; a bad value raises ConfigurationError with
details={"env": key}. osr.configure() with no arguments re-reads the environment.
osr settings [--json] prints the effective values, their OSR_* keys and which ones the
environment overrides.
Naming conventions
opensmartroute.branding is the single source of truth for every brand-derived name:
| Convention | Value | Helper |
|---|---|---|
| Package / import | opensmartroute | branding.PACKAGE |
| CLI | osr | branding.CLI |
| Environment variables | OSR_<GROUP>_<FIELD> | branding.env_key("routing", "narrow_to") |
| Error codes | OSR_<KIND> (OSR_NO_ROUTE, OSR_CONFIG, ...) | branding.error_code("no_route") |
| Metadata / frontmatter keys | osr-<field> (osr-quality-prior) | branding.metadata_key("quality_prior") |
| State directory | .osr-state | branding.STATE_DIR |
| Loggers | opensmartroute[.component] | branding.logger("enterprise") |
HTTP User-Agent | opensmartroute/<version> | branding.user_agent() |
| Plugin entry-point group | opensmartroute.plugins | branding.ENTRY_POINT_GROUP |
| Config directory | ~/.config/opensmartroute (%APPDATA%\opensmartroute), OSR_CONFIG_DIR | credentials.config_dir() |
| Platform URL / key | OSR_API_URL, OSR_API_KEY | branding.platform_url(), branding.API_KEY_ENV |
| Access tokens | osr_live_ (platform key), osr_local_ (self-hosted server) | credentials.token_kind(), generate_token() |
Strategy names are snake_case nouns (capability, llm_judge); decorator-registered classes
drop the Strategy/Signal/Middleware/Telemetry suffix automatically (GeoAffinityStrategy
-> geo_affinity).
HTTP API (osr serve)
| Method | Path | Body / response |
|---|---|---|
| POST | /route | {text, context, history, objective, constraints, kinds, exclude, top_k, plan} → RouteDecision.to_dict() |
| POST | /feedback | Outcome fields → {status} |
| GET | /targets | catalogue |
| GET | /stats | feedback aggregates (+ health snapshot when the router has one, autopilot / marketplace status when enabled) |
| POST | /autopilot/cycle | run one self-improvement cycle now (serve --autopilot) |
| POST | /marketplace/sync | sync the followed marketplace listings into the catalogue now (serve --marketplace-refresh / OSR_MARKETPLACE_REFRESH_S) |
| GET | /healthz, /readyz | liveness; readiness (503 until the catalogue is loaded and a probe request routes) |
| GET | /metrics, /events, /trace/{request_id} | Prometheus exposition; in-memory event buffer; span tree of one request |
| GET | /whoami | {service, edition: "self-hosted", version, auth_required, authenticated, scope, targets} - what osr whoami shows |
| GET | /v1/models | OpenAI-compatible model list: osr/auto, its :cheap/:fast/:quality/:private/:agentic/:llm variants and every executable primary target |
| POST | /v1/chat/completions | OpenAI-compatible completion; model: "osr/auto" routes, a target id pins, models: [...] names candidates with fallback; stream: true (SSE), tools / tool_choice / response_format passthrough, multi-part content |
| POST | /v1/responses | OpenAI Responses API over the same routed path (opensmartroute.responses): input string or items, instructions, flat tools, function_call_output turns, streaming events (response.created ... response.completed); previous_response_id continues a response this process remembered (OSR_SERVER_RESPONSES_STORE responses for OSR_SERVER_RESPONSES_TTL_S seconds, store: false opts out; unknown ids are refused with 400); built-in tools (web_search, file_search, ...) are refused with 400, never dropped |
| POST | /v1/messages, /v1/messages/count_tokens | Anthropic Messages API over the same routed path (opensmartroute.anthropic): system, text / image / tool_use / tool_result blocks, tools with input_schema, tool_choice (auto / any / tool / none), stop_sequences, streaming events (message_start ... message_stop); errors wear the {"type": "error", "error": {...}} envelope; count_tokens answers {input_tokens} from the router's estimate without a provider call |
| POST | /v1/moderations | OpenAI-shaped moderation over the router's guards (opensmartroute.security.moderate): prompt_injection, router_steering, confounder_gadget, pii (reported; flag_pii: true makes it flag), oversize, control_characters; no provider call |
| POST | /v1/embeddings | OpenAI-compatible embeddings over targets tagged embeddings (embed_handler) |
| POST | /v1/audio/speech, /v1/audio/transcriptions, /v1/images/generations | OpenAI-compatible text to speech (targets tagged speech), speech to text (multipart, targets tagged transcription) and image generation (tool targets with actions: [image]) - opensmartroute.media; attach_chat_handlers gives tagged targets the handlers when OSR_LLM_BASE_URL is set |
| POST | /mcp | Model Context Protocol (Streamable HTTP) - the same tools as osr mcp (GET /mcp describes them); a client that accepts only text/event-stream gets the reply as one SSE event |
The proxy is a drop-in base_url for the official OpenAI SDK and the MCP endpoint for the official MCP
clients; the test suite runs both against a real osr serve process. Section "The
OpenAI-compatible proxy" in User guide documents the streaming, tool-calling, embeddings and
fallback semantics; the building blocks are public (execution.execute_with_fallback,
execution.ChatStream, server.proxy_request / proxy_response / embeddings_response).
create_app(router) takes a plain Router or an EnterpriseRouter (middleware, health, telemetry
and audit then apply to every request). Errors map to HTTP statuses: SecurityError /
ValidationError → 400, NoRouteError → 422, TargetUnavailableError → 503, ExecutionError → 502,
unknown pinned model → 404. Handlers may be async def; the endpoints run routing and execution in
worker threads and drive coroutine handlers to completion.
Hardening the served router. osr serve builds a plain Router from the catalogue; the production
controls are one flag (or one OSR_SERVER_* variable, for the image and the Helm chart) each and wrap it in an
EnterpriseRouter through enterprise.harden():
| Flag | Variable | Control |
|---|---|---|
--guard | OSR_SERVER_GUARD=1 | GuardMiddleware: gadget / steering detection, size limits (--learned-guard / OSR_SERVER_LEARNED_GUARD=1 uses the learned detector) |
--redact-pii | OSR_SERVER_REDACT_PII=1 | PII redaction before routing (implies the guard); the executor gets the original text |
--output-guard MODE | OSR_SERVER_OUTPUT_GUARD | OutputGuard after every model / agent handler: flag records leaked secrets, PII and instruction injection in the trace and the output_guard entry of the answer's metadata block; redact rewrites them to <SECRET_n> / <EMAIL_n>; block refuses the answer (400); a streamed answer under redact / block is buffered and screened before its first token leaves (or, with OutputGuard(buffer_streams=False), screened delta by delta behind a hold of OSR_SERVER_OUTPUT_GUARD_HOLD_CHARS characters) |
--health | OSR_SERVER_HEALTH=1 | circuit breakers per target + HealthStrategy |
--web-grounding | OSR_SERVER_WEB_GROUNDING=1 | WebGroundingMiddleware: requests about current facts (signals.freshness_need) are searched on the web and the hits, screened for injection, are handed to the model as a Live web context system block (Guide) |
--audit PATH | OSR_SERVER_AUDIT_PATH | hash-chained JSONL audit trail of every decision (FileAuditSink) |
--origin-policy | OSR_SERVER_ORIGIN_POLICY=1 | OriginPolicy on state-changing tools (sensitive arguments must come from the user or trusted context) |
--limits | OSR_SERVER_RESOURCE_LIMITS=1 | ResourceLimits per task (steps, tool calls, depth, tokens, cost, wall-clock) |
--state-store URL | OSR_SERVER_STATE_STORE | learner state in redis://..., postgresql://... (psycopg, reconnects on a dropped connection), mysql://..., sqlite:///path.db or file:///dir (enterprise.stores.open_state_store), IRT / Bradley-Terry / LinUCB / Markov learners added; --state-key-env VAR / OSR_SERVER_STATE_KEY_ENV encrypts it (AES-256-GCM). A store that becomes unreachable never blanks or blocks the learners: they keep learning in memory, the store is left alone for OSR_LEARNING_STORE_COOLDOWN_S (30 s) so a dead backend costs one timeout per cool-down rather than one per outcome, AutoLearner.store_errors / store_degraded say so, and the next successful save catches up |
The same wiring in code: enterprise.harden(router, guard=True, redact_pii=True, health=True, audit="audit.jsonl", origin_policy=True, limits=True, web_grounding=True, state_store="redis://..."), or on the builder
RouterBuilder(...).with_guard(redact=True).with_origin_policy().with_limits(max_steps=20).with_web_grounding().
create_app(router, auth_tokens=[...]) (CLI: serve --token, --generate-token, or
OSR_SERVER_AUTH_TOKENS=a,b) turns on access control: every path except /healthz, /readyz,
/metrics, /whoami and the OpenAPI documents needs Authorization: Bearer <token> or
X-API-Key: <token>, compared in constant time; anything else is 401 with WWW-Authenticate: Bearer. A token entry may carry scopes: <token>:ro is read-only (every GET, /route without execution
and /mcp list / read calls, but 403 on /feedback, /autopilot/cycle, /marketplace/sync and on everything
that runs a target - /v1/* POSTs and /mcp tools/call; OSR_SERVER_READONLY_EXECUTES=1 restores execution for
read-only tokens) and :exp=<unix seconds> expires it
(server.AccessToken). ServerSettings.require_auth (OSR_SERVER_REQUIRE_AUTH=1, serve --require-auth)
refuses to start without a token. Tokens are opaque strings; osr token generate mints osr_local_... ones
and osr login --url http://host:8000 --token ... stores one for the CLI.
CLI
osr login [--url URL] [--token T | --with-token] [--no-browser] [--profile P] [--json]
osr whoami [--json] | osr logout [--all] | osr token generate|create|list|revoke
osr trace REQUEST_ID [--json] | osr events [--name guard.*] [--level warning] [--request-id ID] [--limit N] [--json]
osr -t targets.yaml -r rules.yaml route "text" [--plan] [--json] [--cost-weight 0.3]
osr -t targets.yaml -r rules.yaml eval dataset.jsonl [--frontier] [--multi-round --threshold 0.8 --max-rounds 3]
osr -t targets.yaml eval rows.jsonl --effort # rows with per-target scores + tokens vs always-think
osr -t targets.yaml eval tasks.jsonl --agentic [--retries 1] # per-step task routing vs best single agent
osr -t targets.yaml targets | stats | serve [--host --port] [--token T ...] [--generate-token] [--require-auth]
osr -t targets.yaml serve --guard --redact-pii --health --audit audit.jsonl --origin-policy --limits --state-store redis://...
osr -t targets.yaml serve --marketplace-refresh 86400 [--marketplace-url URL] # follow the marketplace in-process
osr -t targets.yaml mcp [--list-tools] [--marketplace-url URL | --no-marketplace] # MCP server over stdio for an IDE
osr mcp --url https://host/mcp --api-key K # stdio bridge to a remote /mcp endpoint
osr -t targets.yaml improve --slm slm.json --cache-dir data [--feed URL ...] # self-improvement loop
osr -t targets.yaml --slm bundled route "text" # the routing SLM shipped inside the package
osr slm info bundled | osr slm predict bundled "text" --explain
osr -t targets.yaml --skills <folder> route "text" --plan # add SKILL.md packages as slot targets
osr skills [ROOT] [--json] # validate + list SKILL.md packages
osr settings [--json]python -m opensmartroute ... is the same command for environments where the osr script is not on
PATH (IDE MCP server configurations, containers, uv run).
osr login signs in to the hosted platform with the OAuth 2.0 device authorization grant (the
browser opens /platform/cli/authorize, you approve the code, the platform mints a workspace API key) or
stores a pasted token; opensmartroute.credentials (CredentialStore, device_login, whoami,
PlatformClient) is the library behind it and works with any injected transport.
Works with your stack
Every LLM provider and agent framework connects through one of four surfaces, all of which ship today. There is no per-framework plugin to wait for: if it can speak OpenAI, Anthropic, MCP or call a function, it can use the router.
Providers (the router calls them). Any OpenAI-compatible endpoint works through
adapters.openai_compat.OpenAICompatClient; Anthropic is spoken natively over the Messages API by
adapters.AnthropicClient (tools, images, streaming and extended thinking are translated both ways, and the
platform picks the dialect from the provider's kind or an anthropic.com base URL). The hosted platform's
provider presets cover OpenAI, Azure OpenAI / AI Foundry, Amazon Bedrock, Google Gemini and Vertex AI,
Anthropic, Mistral, Groq, Together, Fireworks, DeepSeek, xAI, Cohere, OpenRouter, Ollama, vLLM, a LiteLLM
gateway and any custom OpenAI-compatible URL - each preset is the provider's real endpoint and auth header,
probed by Check before it serves traffic.
Clients and frameworks (they call the router). The served router (osr serve and the hosted /v1)
answers four dialects on one metered chat path, so a framework only needs a base URL:
| Framework / client | How it connects |
|---|---|
OpenAI SDKs, LangChain (ChatOpenAI), LlamaIndex, CrewAI, pydantic-ai, Semantic Kernel, Vercel AI SDK, Continue, aider | OpenAI-compatible client with base_url=<router>/v1, model="auto" (or a target id) |
| OpenAI Agents SDK and Responses-API clients | POST /v1/responses - translated onto the same chat path, state included |
| Anthropic SDKs, Claude Code | POST /v1/messages (+ count_tokens) - the Anthropic dialect, translated both ways |
| MCP hosts (Claude Desktop, VS Code, any MCP client) | the router's own MCP server (/mcp on osr serve and the hosted platform) exposes route / estimate / recommend / feedback as tools |
| Any function-calling model | adapters.frameworks.openai_tool_spec() + openai_tool_handler(router) make routing one tool call |
Deep adapters (the router inside their graph). adapters.frameworks.langgraph_node /
langgraph_condition make the router a LangGraph node and conditional edge; maf_router_executor is a
Microsoft Agent Framework (AutoGen's successor) executor; adapters.a2a imports A2A agent cards as targets;
adapters.mcp imports other MCP servers' tools (with signed manifests) as targets; adapters.harness wraps
any callable, HTTP endpoint or subprocess agent as a target. All of the above are exercised against real
in-process servers in the test suite.
Something wrong or out of date on this page? Tell us - the page is named for you, so a one-line note is enough.