User guide
The complete walkthrough: targets, rules, plans, real providers, execution, learning, the CLI and SKILL.md packages.
Everything the README leaves out: the full quick-start sequence, running plans end to end, wiring real providers, the enterprise builder, the research-track modules, routing latency and the repository layout. API reference material lives in SDK guide (decorator SDK, components, settings) and Python API reference (generated; every module and exported name); design in Architecture.
Contents
- Targets
- Routing
- Executing a plan
- Real providers
- Learning from outcomes
- Declaring components with decorators
- Enterprise builder
- Research-track modules
- Command line
- Routing latency
- Package layout
1. Targets
Every routable thing is a RouteTarget with the same contract: an id, a kind, declared
capabilities, cost, latency_ms, a quality_prior, optional examples, instructions and a
handler. Adding a target never requires retraining; it is routable from its declaration and learns
from its outcomes.
| Kind | What it is | Typical capabilities | Wins the primary route? | Executed by |
|---|---|---|---|---|
llm | A model endpoint (optionally per reasoning effort) | domains, min/max_complexity, languages, context_window | yes | chat_handler / any callable |
skill | A deterministic or narrow capability (SQL, translate, summarise, a SKILL.md package) | actions, domains, token footprint as cost | yes, or fills the skill plan slot | your function; instructions are disclosed to the model |
persona | System-prompt / behaviour layer (primary: false) | domains, actions | no; fills the persona slot on top of a model or agent | instructions become the system prompt in run() |
agent | An agent harness: coding, research or support runtime with tools, memory and its own loop | supports_tools, tags: [autonomous], actions | yes | CallableHarness / HTTPHarness / SubprocessHarness |
tool | A single MCP / function tool | actions, input_schema in metadata | yes | tool call |
workflow | A fixed multi-step pipeline | actions, domains | yes | workflow engine |
human | Queue, team or expert (Erlang-C capacity maths) | actions: [escalate], tags: [safety] | yes; also the abstention target | ticketing / hand-off |
# examples/targets.yaml (excerpt)
- id: coding-agent # agent harness: repo access, tests, review loop
kind: agent
capabilities: { domains: [coding], actions: [code_generation, code_review, action],
min_complexity: 0.3, supports_tools: true, tags: [autonomous] }
cost: { usd_per_1k_tokens: 0.008 }
latency_ms: 6000
examples: ["Refactor this class to remove the circular import and run the test suite."]
- id: skill-sql # deterministic skill; `instructions` is its SKILL.md-style body
kind: skill
capabilities: { domains: [data_analysis, coding], actions: [code_generation, extract] }
cost: { usd_per_1k_tokens: 0.001 }
instructions: "Always qualify table names with the schema; never SELECT *."
- id: persona-legal-counsel # never primary; layered on whichever model/agent wins
kind: persona
primary: false
capabilities: { domains: [legal], actions: [reasoning, summarize, qa] }
instructions: "You are cautious in-house counsel. Cite the clause, flag jurisdiction risk."Catalogues load from JSON or YAML (config.load_targets), from MCP tools/list payloads
(adapters.tools_from_mcp), A2A agent cards (adapters.agent_from_card), SKILL.md directories
(adapters.load_skills), persona folders (adapters.load_personas) and vLLM semantic-router
configurations (adapters.load_semantic_router_config).
latency_ms is the time to an answer whatever its length; a target that answers token by token at a known
pace - a self-hosted model on CPU at five tokens a second, say - also declares latency_per_token_ms (200 there),
and the router then expects latency_ms + latency_per_token_ms x expected output tokens for each request
(RouteTarget.estimated_latency_ms): the slow box keeps the one-line classification and loses the long draft,
the hard max_latency_ms constraint, the health strategy's SLO fit and the pre-call estimate all read the same
figure. The hosted platform fills both from what it measures (see docs/PLATFORM.md, Self-hosted models).
Agent skills for coding assistants
OpenSmartRoute ships Agent-Skills packages (SKILL.md, agentskills.io
format) that teach a coding agent how to use and extend OpenSmartRoute. The product-facing ones -
osr-routing-catalogue,
osr-decorator-sdk, osr-enterprise-builder, osr-evaluation, osr-security-hardening,
osr-integrations and osr-deploy-serve - are
published on the documentation site under /docs/skills/<name> and on the marketplace; a coding agent loads a folder of such
packages, and they are also valid routing
targets: the same loader the SDK uses (load_skills) validates them and the router fills the
plan's skill slot with the right one.
osr skills <folder> # validate + list the SKILL.md packages in a folder
osr -t examples/targets.yaml --skills <folder> route "Create a JSONL eval dataset and gate CI on accuracy" --plan
# plan: llm:llm-mid -> skill:osr-evaluation2. Routing
from opensmartroute import Router, RouteRequest, RequestConstraints, Objective
router = Router(registry)
# plain text
d = router.route("Prove that sqrt(2) is irrational, step by step.")
print(d.target.id, f"{d.confidence:.2f}")
print(d.trace.explain()) # per-strategy scores and rationales
# hard constraints and a per-request objective
req = RouteRequest(
text="Summarize this patient intake note.",
constraints=RequestConstraints(region="eu", data_boundary="private", max_cost_per_1k=0.005),
objective=Objective(quality=1.0, cost=0.5, latency=0.1, quality_floor=0.6),
)
d = router.route(req)
print(d.trace.policy_rejections) # {'llm-frontier': 'cost 0.015 > budget 0.005', ...}
# a composed plan: persona -> skill -> model (+ the tools the model may call)
d = router.route("Review this NDA clause for GDPR liability.", plan=True)
for slot in d.plan.slots:
print(slot.role, slot.target.id, f"{slot.confidence:.2f}")Policy runs before scoring and is never traded off: enable flags, allow / deny lists, kind, region,
data boundary, PII, tenant, cost / latency / token SLOs, modality, language and jailbreak risk.
Strategies then score the survivors and a confidence-weighted ensemble produces a quality estimate;
utility is w_q * quality - w_c * norm(cost) - w_l * norm(latency) with a hard quality_floor.
With a ConformalCalibrator, RouteDecision.candidate_set is the smallest set with the requested
coverage and abstain is set when no target is safe enough.
What the signals read out of a request is an ontology of twenty-five task types in seven families (signals.TASK_TYPES;
osr route prints the detected domains and actions on its signals: line): the transformations (summarisation, translation, rewriting - which
includes the editing help the extension and the desktop bar send), analysis (extraction, reading a scanned or
photographed document - document_intelligence, classification, factual questions, web_search for a lookup on
the live web, embeddings), reasoning (explanation, maths, planning, observability - the root cause of an incident
from logs, traces, metrics and alerts), coding, generation (creative writing, images, speech), interaction (chit-chat,
support, escalation) and action (tool_action, browsing - open the page, log in, fill the form, click through,
download - and desktop_action - rename, move, paste, type into the window in front). Each type maps to the actions
targets declare (search, ocr, browse, action ... - signals.ACTION_LEXICON) and requests carry up to three
domains (signals.DOMAIN_LEXICON: coding, finance, legal, medical, observability, science, education ...). A human
target is scored as a last resort: unless the request asks to escalate its capability fit is halved
(capability.human_fallback_penalty, OSR_CAPABILITY_HUMAN_FALLBACK_PENALTY), so a queue with a high quality prior
never wins a plain question on that prior alone.
A generative judge can be consulted only when the ensemble is unsure:
from opensmartroute import LLMJudgeStrategy
router = Router(registry, llm_judge=LLMJudgeStrategy(llm=lambda prompt: my_llm(prompt)), escalate_llm_judge_below=0.6)3. Executing a plan
route() decides; run() decides and executes the whole plan: skill pre-processors run first,
persona and skill instructions are composed into the system prompt, the primary target is called,
and an Outcome is recorded for every participant so each one learns from the result.
from opensmartroute.adapters import (
CallableHarness,
HTTPHarness,
SubprocessHarness,
chat_handler,
harness_handler,
load_skills,
)
registry.get("llm-frontier").handler = chat_handler(openai, model="gpt-4o")
registry.get("coding-agent").handler = harness_handler(
SubprocessHarness(["my-coding-agent", "--repo", "."], system_flag="--system")
)
registry.get("research-agent").handler = harness_handler(
HTTPHarness("https://agents.internal/research/run", api_key_env="AGENT_TOKEN")
)
registry.get("skill-sql").handler = lambda req: lookup_schema(req.text) # runs BEFORE the model
for skill in load_skills("examples/skills"): # SKILL.md folders
registry.add(skill)
res = router.run("Is this indemnification clause enforceable in California?", task_id="ticket-812")
res.text # the primary target's answer
res.steps # [persona:persona-legal-counsel, llm:llm-frontier] with per-step latency
res.system_prompt # composed persona + skill instructions
res.outcomes # one Outcome per participant, role="persona"/"skill"/None, shared task_idA skill handler may return a RouteRequest (rewrite the request), a str (attached as
context["skill_output"] and shown to the model) or None (instructions only). Async handlers work
through AsyncRouter.run(); EnterpriseRouter.run() adds health, budgets and telemetry. See
examples/end_to_end.py and SDK guide.
Tools the model may call
A model or agent plan also carries tool slots: the tool targets (MCP tools, @tool handlers) whose
ensemble quality for this request clears RoutingSettings.tool_quality_floor (default 0.5; measured on a live
catalogue, a tool whose examples match the request scores 0.53-0.75 and an unrelated one stays below 0.47 - the
model still decides whether to call it, so an offered-but-unused tool only costs its spec tokens), at most
tool_slots of them (default 2; Router(reg, tool_slots=0) or OSR_ROUTING_TOOL_SLOTS=0 turns the slot
off). run() offers them to the model as OpenAI function-calling specs (execution.tool_spec: the tool's MCP
input_schema, or a single input string for handler-only tools) through context["openai"]["tools"].
When the model calls one, the tool target's handler runs (context["tool_arguments"] = the parsed arguments,
or the input argument as the request text), the result goes back as a tool message and the model is asked
again - up to RoutingSettings.tool_rounds rounds - so the caller receives the finished answer. A request that
brings its own tools keeps its own tool protocol (nothing is added), and a call to a tool the router did not
offer is handed back untouched. res.steps shows tool … offered / called entries and every tool that was
called is credited with a role="tool" outcome, so the learners find out which tools actually help.
A skill or tool slot is only filled when the request names the companion, whatever its quality score: an
action it declares (a translation for the translate skill), one of its domains, a need for fresh facts for a
tool tagged web, or a text close to its own examples (RoutingSettings.companion_similarity_floor, default
0.7 on the similarity strategy's score). A greeting or a bare word names nothing and gets a model and, at most,
a persona - a companion is a request of its own, and its record on other requests is no reason to run it here.
The trace shows a slot left empty for this reason as plan.slot … "nothing in the request names one".
res = router.run("How many orders shipped last week?")
[(s.role, s.target_id, s.note) for s in res.steps]
# [('tool', 'warehouse:sql_query', 'offered'), ('tool', 'warehouse:sql_query', 'called'), ('llm', 'llm-frontier', 'primary')]4. Real providers
The bundled client is stdlib only and speaks the OpenAI chat and embeddings API, which covers OpenAI, Azure OpenAI, vLLM, Ollama, LiteLLM, OpenRouter, Groq and Mistral.
from opensmartroute import Router, LLMJudgeStrategy
from opensmartroute.adapters import OpenAICompatClient, chat_handler, embedder, judge_fn
from opensmartroute.strategies import CapabilityStrategy, SimilarityStrategy
openai = OpenAICompatClient() # OPENAI_API_KEY from env
ollama = OpenAICompatClient("http://localhost:11434/v1", api_key="ollama")
registry.get("llm-frontier").handler = chat_handler(openai, model="gpt-4o")
registry.get("llm-small").handler = chat_handler(ollama, 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,
)
d = router.route(req)
result = router.execute(d, req) # ChatResult(text, prompt_tokens, completion_tokens, latency_ms)
res = router.run(req) # route + execute the full planadapters.sentence_transformers_embedder() and adapters.OpenTelemetryTelemetry are lazy imports
behind the embeddings and otel extras. osr serve exposes a FastAPI app with an OpenAI-compatible
/v1/chat/completions proxy that routes, executes and learns.
adapters.AnthropicClient speaks the Messages API natively behind the same chat / stream_chat surface and
places prompt-cache breakpoints on its own when the caller sent none: on the tool list, on a system prompt
long enough to be cached and on the conversation prefix (the user turn before the current one), so an agent loop
that resends the same tools and history pays Anthropic's cache-read rate for them instead of the input rate;
prompt_cache=False turns it off, markers the caller placed are respected, and the cached and written token
counts come back in usage (prompt_tokens_details.cached_tokens, cache_creation_input_tokens).
adapters.MockClient is the provider for tests and demos: deterministic answers without a network, streaming,
embeddings and failure on cue (OSR_LLM_BASE_URL=mock:// gives every LLM target of osr serve one).
The OpenAI-compatible proxy
osr serve (opensmartroute.server.create_app) is a drop-in base URL for the official OpenAI SDKs: point a
client at http://router:8000/v1 and keep the rest of the code.
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="osr_local_...") # the token from `osr token generate`
client.chat.completions.create(model="osr/auto", messages=[{"role": "user", "content": "..."}])- Model names.
osr/auto(orauto) lets the router choose,osr/auto:cheap|fast|quality|private|agentic|llmpresets the objective, constraints or kinds, and any target id pins that target.models: ["llm-a", "llm-b"]names the candidates: the router picks among them and, when the chosen handler fails, falls back down the list (osr.fallbacks: falseturns that off;OSR_SERVER_MAX_FALLBACKScaps the re-routes). Every failed attempt is a failed outcome for the learners and health breakers; the response'sopensmartroute.fallback_fromlists them. - Streaming.
stream: trueanswers withchat.completion.chunkserver-sent events: a role chunk, one content delta per provider token (relayed while the upstream is still generating), tool-call deltas, then a final chunk withfinish_reason,usageand theopensmartroutemetadata, thendata: [DONE]. Errors before the first token keep their HTTP status (404 unknown model, 422 no admissible target, 503 provider down); the response headersX-OSR-TargetandX-OSR-Request-Idname the decision. A handler that does not stream is chunked after it returns (OSR_SERVER_STREAM_CHUNK_CHARScharacters per delta). The response is held for the first token for at mostOSR_SERVER_STREAM_FIRST_TOKEN_WAIT_S(15 s); a model still silent after that - a cold load, a long prompt on a CPU, a queue - gets its stream opened anyway and an SSE comment (: keepalive) everyOSR_SERVER_STREAM_KEEPALIVE_S(10 s) until it speaks, so an ingress idle timeout never cuts a slow answer and the client sees the request is alive; a failure after that point arrives in-band as anerrorevent with the status it would have had. - Tools and passthrough fields.
tools,tool_choice,response_format,stop,n,seed,logprobs,top_logprobs,parallel_tool_callsanduserreach the target's handler throughrequest.context["openai"];chat_handlerforwards them to the upstream untouched. Assistanttool_callsandtoolmessages are kept in the conversation, and the response carriesmessage.tool_calls,message.refusaland the realfinish_reason(tool_callswhen the model called tools). A request that sendstoolsonly routes to LLM targets that declarecapabilities.supports_tools: true. - Prompt compaction. Before any model or agent handler runs,
executioncompacts the prompt (opensmartroute.compress,OSR_COMPRESSION_*, on by default): trailing whitespace, zero-width characters and runs of blank lines go, consecutive duplicate lines of 24+ characters (pasted logs, repeated boilerplate) collapse to one, and the same is applied to the history and the composed system prompt. Fenced code blocks are kept byte-for-byte and no line is rewritten, so the model reads the same words with fewer tokens.OSR_COMPRESSION_HISTORY_BUDGET_TOKENSadds a conversation budget: the oldest turns are dropped until the history fits, never splitting an assistanttool_callsfrom itstoolresults and never touching the newestOSR_COMPRESSION_KEEP_LAST_TURNSturns. The estimated saving is reported astokens_savedin the response metadata,ExecutionResult.tokens_savedand acompressstep (-N tokens);OSR_COMPRESSION_ENABLED=0turns it off. Tool, workflow and human targets receive the request verbatim. Token estimates come fromestimate.estimate_tokens, calibrated against billed gpt-4.1 prompt tokens on prose, chat, logs, code, JSON, Markdown, URLs, numbers, German and Japanese (mean ratio 1.02, every type within about 20 %).examples/precall_check.pyre-measures the whole story against a live endpoint - routing, the tool slot, the compaction - and prints real tokens, cost and correctness next to an always-frontier baseline. - Multi-part content. Text parts of the current turn join into the routed text;
image_url,input_audioandfileparts land incontext["attachments"](andimages/audio/filesfor the modality signal, so an image only routes to a target whosecapabilities.modalitiesincludeimage).chat_handlersends them on as multi-part content; earlier turns keep their text. - Embeddings.
POST /v1/embeddings(input: str | list[str]) routesosr/autoamong LLM targets taggedembeddings(capabilities.tags) or withmetadata.embeddings: true; a target id pins one. The target's handler receivescontext["embeddings_input"]and returns{"embeddings": [[...]], "model": ..., "prompt_tokens": n}-adapters.openai_compat.embed_handler(client, model)does exactly that for any OpenAI-compatible endpoint, andOSR_LLM_BASE_URLattaches it automatically to embeddings-tagged targets (the example catalogue ships one,embed-small). Embeddings-only targets are never chosen for a chat completion. - Other dialects.
POST /v1/responses(the OpenAI Responses API,opensmartroute.responses) andPOST /v1/messages(the Anthropic Messages API,opensmartroute.anthropic) are translated onto the same chat path, so the OpenAI Agents SDK, the Anthropic SDKs and Claude Code use the router as their base URL unchanged: content blocks, tools, tool results, stop sequences and the streaming events of each dialect are mapped both ways, theopensmartrouteblock rides along, andPOST /v1/messages/count_tokensanswers from the router's token estimate without calling a provider. GET /v1/modelslistsosr/auto, its variants and every executable primary target: those with a handler, plus instructions-only skills and personas when a model with a handler can run them.- Async handlers. Handlers may be
async def; the server runs routing and execution in worker threads and drives coroutine handlers to completion, so sync and async targets mix freely. Handler keywords such ason_deltaare only passed to handlers whose signature accepts them (on_deltais also inrequest.context). - Token scopes. An entry in
--token/OSR_SERVER_AUTH_TOKENSmay be<token>(full access),<token>:ro(read-only: everyGET,/route,/v1/*and/mcp, but 403 on/feedbackand/autopilot/cycle) and carry an expiry:exp=<unix seconds>(401 once passed).GET /whoamireports the scope of the presented token.
The same building blocks are public in the SDK: execution.execute_with_fallback (route -> execute -> re-route on
failure), execution.ChatStream / completion_chunks (OpenAI chunk events from any handler) and
server.proxy_request / proxy_response / embeddings_response (the body conversions).
This is tested against the real clients, not a look-alike: the test suite starts an osr serve
process on the example catalogue and drives it with the official openai SDK (models, chat, streaming, a tool
call, embeddings, a rejected token) and the official mcp SDK (POST /mcp over Streamable HTTP, and osr mcp
over stdio).
5. Learning from outcomes
from opensmartroute import Outcome
router.learn(
Outcome(
request_id=d.request_id,
target_id=d.target.id,
success=True,
quality=0.9,
cost_usd=0.002,
latency_ms=1800,
domains=d.trace.signals.domains,
)
)One Outcome fans out to every learner: Thompson bandits per domain, IRT ability and difficulty,
Bradley-Terry preferences, LinUCB on the signal vector, the Markov / MDP lookahead, the task table and
the personalisation posteriors. Outcome.task_id and step join delayed, task-level rewards to the
routing decisions that produced them (learning.TaskCredit, learning.TaskPins). Page-Hinkley
drift detection flags targets whose success rate degrades; every learner forgets with a configurable
decay and can be merged across replicas (learning.merge_learners). Each learn() also records a
learn.outcome event on the request's trace and, with with_audit(sink, outcomes=True), an outcome
record in the audit chain, so a decision and its result stay joined by request_id
(Tracing and observability; on the hosted platform POST /api/v1/feedback does the same).
Offline warm starts: learning.warm_start_from_matrix() replays a (prompt, {target: reward})
matrix, learning.warm_start() copies posteriors from the nearest declared neighbours and
osr train --from rows.jsonl --out models.json fits the hashed signal models.
Implicit feedback: learning from the request and the response
Almost no traffic calls /feedback, so the router also reads the evidence it already has
(learning.implicit, settings group implicit):
- The answer itself. Whenever
execute()(and with itosr serve's/v1/chat/completions,/route/execute, the MCP tool) runs an LLM, agent, skill or persona target whose handler reports noqualityof its own,learning.assess_responsejudges the response against the request: a refusal (message.refusal,finish_reason: content_filter, "I cannot ..."), an empty answer, a truncated one (finish_reason: length), hedging and self-correction phrases, a coding request answered without any code, and a call slower thanOSR_IMPLICIT_SLOW_FACTORtimes the target's typical latency all lower the outcome'squality(OSR_IMPLICIT_REFUSAL_QUALITY,OSR_IMPLICIT_TRUNCATED_QUALITY, ...). An answer with nothing wrong getsOSR_IMPLICIT_NEUTRAL_QUALITY(0.7) - good enough to learn from, not the 1.0 that a baresuccess=Trueused to mean, which had every learner reinforcing whatever the router picked simply because the call returned.Outcome.sourcerecords the verdict (response:refusal,response:ok, ...).
- The next turn. With
context["session_id"](the proxy'ssession_id/metadata.session_id),learning.ReactionLearnerreads each new turn of a session as feedback on the previous one: a complaint ("that's wrong", "try again", "no, I meant ..."), a near-identical re-ask (token Jaccard >=OSR_IMPLICIT_RETRY_SIMILARITY, a retry) or thanks become an outcome for the previous request's target (reaction:negative0.2,reaction:retry0.3,reaction:positive0.95) withinOSR_IMPLICIT_REACTION_WINDOW_S. The router feeds it throughlearn_correction(), so it reaches every learner - including the SLM's online step, which now accepts a later correction of a request it already saw. - Into the self-improvement loop.
eval.rows_from_feedbackreconciles several outcomes for one request by trust (reaction > explicit feedback > response assessment) and dropsresponse:okoutcomes - "it did not fail" says nothing about which target was best, and training on it would only teach the SLM its own policy. A row that scores a single target badly (learning.negative_only) trains as "not this one" - a push towards every other target - in both objectives; it used to become a full positive for the target it complained about, because a softmax over one score is 1.0.
OSR_IMPLICIT_ENABLED=false returns to plain success/failure outcomes; OSR_IMPLICIT_REACTIONS=false turns
off the next-turn reading. A handler that returns its own quality (a judge, an exact-match check, an agent
harness) is always believed over the assessment.
The routing SLM and the self-improvement loop
learning.RouterSLM is a small routing model in one JSON file: a hashed dual encoder
(SLMSettings.dim x feature_dim), a two-stage calibrator and a snapshot of every target's price,
latency and quality prior. It has no dependencies, predicts in microseconds and can be trained from
any EvalRow source - labelled datasets, your own outcomes, synthetic ontology prompts or the full
router's decisions (distill_router, which compresses rules + capability fit + learners + judge into
one model). Calibration fits the softmax temperature on the validation rows and, from 40 rows up, also a
monotone (isotonic) map from the tempered top probability to the observed hit rate, shrunk toward the tempered
value while the calibration set is small (n / (n + 100)): a single temperature cannot fix a model that is right
at 0.6 as often as at 0.9, and the ensemble weighs the SLM by the probability it reports. Measured out of
sample on the example catalogue (calibrate on half the holdout, score the other half, ~120 rows): ECE 0.07-0.14
-> 0.03-0.09 and Brier down a third for both encoders, at unchanged accuracy; the ranking never changes (the
winner stays the winner).
from opensmartroute import RouterSLM, SLMStrategy, Router, RouteRequest
from opensmartroute.eval import DatasetCollector, synthetic_rows
from opensmartroute.learning import distill_router
slm = RouterSLM(reg.all(), seed=0)
slm.fit(synthetic_rows(reg.all())) # no data yet: seed prompts scored by capability fit
slm.fit(DatasetCollector("data").corpus()) # collected battle datasets (RouteLLM, LMArena)
student, report = distill_router(Router(reg), texts) # or copy the whole ensemble
print(report.summary()) # accuracy, loss, ECE, Brier, cost/1k
slm.save("slm.json")
router = Router(reg, strategies=[SLMStrategy(RouterSLM.from_file("slm.json"))])
d = router.route(RouteRequest(text="prove that sqrt(2) is irrational"))
print(slm.predict(d.request.text, objective=Objective(cost=2.0))[:3]) # calibrated p, quality, $/1k, utilitySLMStrategy sits in the ensemble under weight OSR_WEIGHTS_SLM and, like the other learners, updates
the model online from every router.learn(Outcome). Its scores and confidence are read from the SLM's
calibrated probabilities (the fitted temperature), the same numbers osr slm predict prints.
Slot models: which persona, skill or tool suits the request
The primary encoder answers one question - which target answers. A plan asks more: which persona, which
skill, which tools. Those decisions get their own small encoders, the slot models (RouterSLM.slots, one per
role in OSR_SLM_SLOT_ROLES - persona, skill, tool), trained on the outcomes credited to that slot
(Outcome.role; eval.rows_from_feedback(..., role="skill")) and saved in the same file, so a promotion or a
rollback moves them with the primary. SLMStrategy reads the slot the router is filling
(signals.extra["role"]) and votes with that role's model - a marketplace skill with a record is learned exactly
like a catalogue one - and abstains for a role it has no model for; slot outcomes never enter the primary corpus
(a persona is not an answer), and the online update of a planned request keeps the primary's and each slot's
decision apart. The improve cycle trains a slot once it has OSR_SLM_SLOT_MIN_ROWS (20) rows and reports each
role under slots (rows, accuracy against the constant predictor, skill); RouterSLM.info()["slots"] and
osr slm info show what is served.
rows = rows_from_feedback(feedback, texts, role="persona") # the persona slot's outcomes, prompt by prompt
report = slm.fit_slot("persona", rows) # its own encoder and calibration
slm.slots["persona"].predict_proba("review this contract clause", slm.slot_candidates("persona"))Conversation context
A follow-up says nothing about the task on its own: "why?", "shorter please", "and what about the edge cases?"
after a proof, a translation or a code review each belong with the target that served the turn before. The
SLM reads such a message together with the last OSR_SLM_CONTEXT_TURNS (2) user turns of RouteRequest.history
(learning.routing_text: "<message> || <previous user turn> ...", at most OSR_SLM_CONTEXT_CHARS (400) of
context, newest first) - SLMStrategy does it for every request, RouterSLM.predict*(text, history=...) for a
direct call, EvalRow.history for training and evaluation rows. Only a follow-up gets context
(learning.is_followup): a message of at most OSR_SLM_CONTEXT_MAX_WORDS (3) words, or one that opens with a
continuation (learning.slm.FOLLOWUP_RE: "and", "now", "instead", "try again", "can you elaborate" ...) and stays
under OSR_SLM_CONTEXT_MAX_TEXT_CHARS (240). "Translate this paragraph into French" after a coding chat names
its own task and routes on its own words. Context words hash under their own ctx: namespace
(signals.models.CONTEXT_MARK, weight CONTEXT_WEIGHT 0.7; words and bigrams only, in the attention block
too), so "python" said earlier and "python" said now are different features and a model trained without
conversations reads unchanged. eval.synthetic_followup_rows builds follow-up conversations (and, with
switches=, topic switches) from any labelled rows for training; explain() reports the text the encoder
actually read and names context features ctx:w:....
The online update is reinforcement learning
An outcome is a reward for the routed target, not a label: the served model learns from it with a REINFORCE
step on its own softmax (OSR_SLM_ONLINE_POLICY=reinforce, the default) - the reward's advantage over the
target's running mean reward (OSR_SLM_REWARD_BASELINE_RATE, the baseline starts at 0.5) raises or lowers the
target's log-probability, so the first good answer of a new target is a surprise worth learning from and the
hundredth is not, and a bad outcome moves probability to the other candidates in proportion to what the model
already gives them. Because the router picked the target, not the SLM, the step is importance-weighted by
p_SLM(target) / propensity (capped at OSR_SLM_IPS_CLIP, 5): Router.learn fills Outcome.propensity from
its request memory when the caller did not, the same logged propensity eval.ope uses, so traffic routed by
the whole ensemble trains the SLM without bias. OSR_SLM_ONLINE_POLICY=supervised is the plain label update (a
good outcome is a positive, a bad one spreads the label over the others). SLMStrategy.status() reports
online_policy and last_advantage; the same REINFORCE with a learned baseline also drives the stand-alone
learning.PolicyGradientStrategy over hashed features.
The bundled model
The package ships a trained model, so an on-prem or air-gapped install routes with a learned SLM before it
has seen a request: RouterSLM.bundled() (or the path "bundled" anywhere a model file is accepted -
--slm bundled, osr slm info bundled, RouterBuilder.with_slm(), OSR_SLM=bundled in the container,
OSR_PLATFORM_SLM=bundled on the platform). It is an attention-encoder model trained
on the reference catalogue (examples/targets.yaml, 27 targets) and labelled by the reference ensemble -
rules, capability fit, cost and quality priors together - rather than by the capability fit alone, whose
near-ties between generalists had sent a quarter of everyday prompts to the on-prem model. Its corpus is the
ontology's seed templates over three seeds plus the everyday phrasings of every task type
(signals.corpus.NATURAL_PROMPTS: terse chat, office English, pasted context, typos, a second language), the
multicloud benchmark prompts routed by the ensemble, one follow-up conversation per row, the 70 labelled example
rows with their follow-ups - weighted ten times so ground truth outvotes near-tied synthetic labels - and 64 bare
follow-ups with a flat label; a 128-token attention window, learning rate 0.08. The labelled rows cover what the
browser extension's writing help, the desktop bar and the newsroom's writer actually send (grammar fixes, shorter
drafts, e-mail replies, subject lines, voice memos, read-aloud, embeddings, release notes) and what the new agents
and tools are for (search the web for..., OCR this receipt, root-cause this alert, open the portal and download
the statement, rename the files in this folder).
It is measured two ways. The probe holdout - a fourth seed's templates and a fifth of the everyday phrasings
the probe never trained on - reads 0.77 (follow-ups 0.76, topic switches 0.77; a template-only holdout had read
0.97 of a model that routed unseen wording at 0.49). The held-out set (examples/eval_heldout.jsonl, 117
everyday asks naming every target, in none of the training data) reads 0.73 for the model alone, and the
ensemble with it 0.82 against 0.84 without - which is why OSR_WEIGHTS_SLM defaults to 0.5 and the strategy
reports its calibrated top probability as its confidence, scaled by that held-out skill (meta.heldout). On
the labelled set the model alone routes all 70 rows acceptably and the ensemble with it 69 of 70; ~1.8 MB as an
int8 file. The learning rate matters more than the seed: at the
attention encoder's default 0.2 the holdout swung between 0.71 and 0.88 from one epoch to the next and the file
depended on where early stopping landed, at 0.08 every epoch improves on the last, so the recipe is reproducible -
the repository's training script rebuilds the shipped file bit for bit. The file also carries the plan's slot models: a
persona slot and a skill slot (RouterSLM.slots, trained by fit_slot on the same seed prompts scored for
every persona and skill of the catalogue plus the labelled rows that name one), so plan=True ranks which
persona speaks and which skill instructs with a learned model from the first request - 0.99 holdout accuracy on
both slots. Its targets are the reference ids. Its calibration - temperature plus the isotonic stage -
is the one fitted on rows the model never trained on (the script's probe fit, everyday phrasings included), not
on its own training rows, so the probability it reports is honest; the final fit runs for the number of epochs
the probe's early stopping chose. Your own targets are added when it is loaded; the ensemble hears the model only
on targets it has compared, so with a different catalogue it starts silent and osr improve / the autopilot
- which now start warm from it whenever no model file exists yet - train a challenger that knows your
targets on the first cycle (the file records
origin: bundledthrough every promotion). Retrain the shipped file with the script; never edit it by hand.
from opensmartroute import RouterSLM
from opensmartroute.enterprise import RouterBuilder
app = RouterBuilder(reg).with_defaults().with_slm().build() # the packaged model, your targets added
print(RouterSLM.bundled().info()["holdout"]) # what it was measured atThe package ships the hand-labelled set the bundled model was measured on next to it
(opensmartroute/data/labelled.jsonl, a copy of examples/eval_dataset.jsonl), and RouterSLM.labelled_check()
scores any model on it - on its own, on the rows whose targets the model can choose between. That is the
labelled floor of the self-improvement loop (SLMSettings.labelled_floor, on): a holdout is self-agreement
with the router's own decisions on your traffic, and a deployment whose traffic is one kind of job can promote
itself into a model that is right on that job and wrong on everything else while every holdout reads 0.95. A
challenger that routes fewer of the applicable labelled rows acceptably than the champion - beyond
labelled_tolerance (5 %) of them - is refused whatever its holdout says (ImprovementReport.labelled carries
both scores), and SelfImprover.floor_check(RouterSLM.bundled()) holds a served champion to the same floor
against the packaged model, adopting it when the champion fell below. Fewer than labelled_min_rows (20)
applicable rows - a catalogue of your own - switch the floor off. The floor is also rehearsed: the applicable
labelled rows train every challenger next to your traffic, replicated to about labelled_rehearsal (25 %) of its
training rows, so a challenger that learns one kind of traffic keeps the reference behaviour instead of being
refused every cycle (the holdout stays your own rows; ImprovementReport.sources["labelled"] counts them).
Data comes from three collectors that all degrade gracefully when offline:
adapters.ModelCataloguemerges OpenRouter prices and Hugging Face model cards (downloads, likes,model-indexbenchmarks) intoModelCards, turns them intoRouteTargets (targets(min_quality=, max_usd_per_1k=, measured=True)), prints a cost/quality benchmark and the Pareto frontier.refresh(leaderboard=True)adds Open LLM Leaderboard accuracies (IFEval, BBH, MATH, GPQA, MuSR, MMLU-Pro) as measured quality;apply_quality()takes the Bradley-Terry win rates thateval.model_qualityderives from collected battles. Third-party descriptions are risk-scored and replaced when they look like injections.eval.DatasetCollectorpulls routing datasets from the Hugging Face datasets-server into a JSONL cache. Fifteen public sources are on by default: the pairwise battlesroutellm-battles(GPT-4-judged),arena-55k,arena-100k,arena-140k(LMArena human votes, with the code / language / math / hard-prompt tags),ppe-human,webdev-arena,mt-bench-humanandreward-bench, the 2025 releasesarena-expert-5k(domain experts judging 2025 models; the conversation column is a Python repr, parsed as such),search-arena-7k(search-augmented models on live questions - the names carry their own tier rules),repochat-arena(answers over a repository) andllama3-70b-battles, the per-model judge scores ofultrafeedback, and RouteLLM'sroutellm-gpt4("was Mixtral good enough for this prompt") androutellm-mmlubattles. RouterBench / RouterEval / the raw arena conversations are known but gated: download them with your own Hub token and load the file withosr collect --file routerbench=routerbench.jsonl --preset routerbench(.jsonl/.json/.csvin the preset's shape,--tierfolds model columns onto your targets), and any dataset works throughDatasetSource. Battles name a moving population of models, so--tier frontier=llm-frontier --tier mid=llm-mid --tier small=llm-smallfolds every side onto your tiers as it is parsed (tier_of(): theARENA_TIERStable, name rules for the 2024-26 generations, then the parameter count in the name) - the RouteLLM question "was the cheaper tier good enough?". Collection is polite and resumable: pages are paced, 429s are retried with backoff, and a run the server cuts short keeps its rows and continues from the recorded offset on the nextosr collect(<name>.meta.json).rows_from_feedbackturns yourFeedbackStoreinto labelled rows.adapters.WebKnowledgesearches Hugging Face (text-generation repos, per-keyword fallback), Wikipedia and DuckDuckGo (a self-hosted SearXNG withSEARXNG_URL, Brave withBRAVE_API_KEY) for new models, datasets and live context; fetches are https-only, byte-capped and page text is risk-scored.read_feeds(urls)reads RSS / Atom article feeds - vendor blogs, release notes, arXiv listings (adapters.parse_feed, stdlib XML, newestOSR_SLM_FEED_MAX_ENTRIESentries withinOSR_SLM_FEED_MAX_AGE_DAYS) - so the loop hears about a model launch the day it is announced, not when a benchmark gets around to it.
learning.SelfImprover closes the loop as a champion/challenger cycle: refresh the catalogue, discover,
gather evidence, split, train a challenger, calibrate it on the holdout and promote it only when it really
wins there. Articles feed the discover step (feeds= / --feed URL / OSR_SLM_FEEDS): each cycle counts
which catalogue models the articles mention (ImprovementReport.mentions), looks up Hugging Face cards for
model names the catalogue has never seen (gemini-2.5-pro announced in a post becomes a card lookup and a
discovered_models entry) and lets priced cards the articles talk about join the candidate targets
(trending) even before a leaderboard has measured them - the evidence rows still decide whether the
challenger learns to name them, and the promotion gates below still apply, so an article can make a model
eligible, never preferred. Three gates stand between a challenger and production: the holdout must hold at least
OSR_SLM_PROMOTE_MIN_HOLDOUT rows (a four-row holdout cannot judge anything); holdout accuracy must improve
by at least OSR_SLM_MIN_GAIN (or loss must drop at equal accuracy); and the rows the challenger newly gets
right must outnumber the rows it newly gets wrong with a one-sided exact binomial p-value of at most
OSR_SLM_PROMOTE_ALPHA (learning.paired_test, McNemar's discordant pairs - two flipped rows out of a
hundred are noise, not a win; 1.0 turns the test off). The replaced champion is kept next to the model as
<model>.prev.json (OSR_SLM_KEEP_PREVIOUS), so osr slm rollback slm.json - or SelfImprover.rollback()
- undoes a promotion that looked good on the holdout and bad in traffic; the files swap, so a second call
redoes it. Every cycle is appended to a JSONL history with the champion and challenger reports, the
discordant counts and the p-value, and the model file carries a
versionthat counts the training runs behind it.
osr catalogue --cache data/catalogue.json --hf "llama instruct" --leaderboard --markdown # live cost / quality
osr collect --cache-dir data --tier frontier=llm-frontier --tier mid=llm-mid --tier small=llm-small
osr -t targets.yaml slm train --out slm.json --cache-dir data --report # every *-tiers cache, holdout metrics
osr -t targets.yaml slm train --out slm.json --from rows.jsonl --encoder attention --warm bundled # fine-tune the packaged model
osr slm predict slm.json "write a python function to parse csv" --cost-weight 1.0 --explain
osr -t targets.yaml improve --slm slm.json --cache-dir data --catalogue data/catalogue.json \
--tier frontier=llm-frontier --tier mid=llm-mid --tier small=llm-small \
--search "qwen2.5 instruct" --feed https://openai.com/news/rss.xml \
--feed https://huggingface.co/blog/feed.xml --interval 3600 # daemon; --offline for air-gapped
osr slm rollback slm.json # undo the last promotion--explain (and RouterSLM.explain) prints the hashed features that decided the pick - each word, bigram
or character gram contributes v_j (W_j . e_t) / tau to a target's logit, so the list is the exact
decomposition of the logit margin between the pick and its runner-up, plus the learned per-target bias.
The hosted SLM service returns the same block from POST /predict with "explain": true.
Serve the result: osr -t targets.yaml --slm slm.json serve, or in the container OSR_SLM=/config/slm.json
(Helm config.slm). With OSR_LLM_BASE_URL (and OSR_LLM_API_KEY / OSR_LLM_MODEL) pointing at any
OpenAI-compatible endpoint - OpenAI, Azure OpenAI, vLLM, Ollama, OpenRouter - every LLM target gets a
chat handler from metadata.model, so /v1/chat/completions with model: "auto" routes and answers.
Speed, and what the numbers mean
Training is plain SGD over hashed features; with numpy installed (extra fast, included in the serve
image) the same steps run on dense arrays - 18 000 rows train in under a minute, ten times the pure-Python
speed - and prediction projects through a dense mirror of the weights. OSR_SLM_BACKEND=auto|numpy|python
chooses; both paths seed identically and write the same model file, so a zero-dependency deployment reads a
numpy-trained model unchanged. The model carries a learned per-target bias (the base rate a unit-norm dot
product cannot express) and trains with inverse-time learning-rate decay (OSR_SLM_LR_DECAY) and l2 1e-3.
The model file: quantised weights
The file is JSON, but its weights are not text: every row of the projection W, of the target embeddings E
and of the attention encoder's token table is stored as symmetric per-row integers - q = round(x / s) with
one scale s = max|x| / 127 per row - packed into a base64 block (opensmartroute.learning.quant). At the
default OSR_SLM_QUANTIZE_BITS=8 that is a fifth of the bytes of the four-decimal floats it replaces with a
relative RMS weight error near 0.5 %; 4 packs two codes per byte (a tenth of the bytes, ~5 % error - measure
before shipping); 0 writes floats. Inference dequantises on load and stays in floating point, so a quantised
model ranks like the float one: the packaged model went from 2.15 MB to 0.58 MB with identical top-1 picks on the
example dataset. Files written by earlier versions (format 1, float rows) load unchanged; a file written at 8
bits carries format 2 and is refused with a clear error by a package that predates it.
osr slm quantize slm.json --bits 8 --check rows.jsonl # rewrite in place; size, error and top-1 agreement
osr slm quantize slm.json --bits 4 --out slm-4bit.json # smaller still, for edge deployments
osr slm quantize slm-4bit.json --bits 0 --out slm-float.json
osr -t targets.yaml slm train --out slm.json --quantize 0 # train and keep floats in the fileosr slm info reports quantization.loaded_bits (what the file carried) and quantization.bits (what the next
save writes).
How long to train is decided by the data, not a constant: from OSR_SLM_VALIDATION_MIN_ROWS rows up, fit
splits a deterministic OSR_SLM_HOLDOUT share off as validation, scores it after every epoch, stops once the
validation loss has not improved for OSR_SLM_PATIENCE epochs (at most OSR_SLM_EPOCHS) and keeps the best
epoch's weights; the calibrator and the report are fitted on those validation rows, so --report is honest
even without a separate holdout. Every row also trains through OSR_SLM_AUGMENT_VIEWS (2) word-dropout
views - copies with each word dropped at OSR_SLM_AUGMENT_DROPOUT (0.15), drawn once per fit so both
arithmetic backends see the same corpus - which teaches the hashed features that a prompt survives losing a
word, exactly how the validation prompts differ from the training ones. OSR_SLM_LABEL_SMOOTHING (0, off)
spreads that share of every label over the row's candidates; it changed nothing measurable here and is left
for corpora with noisier labels. On the example catalogue (24 targets, 391 synthetic training rows, 97
holdout, three seeds) the old fixed four epochs reached 0.66-0.70 holdout
accuracy at loss 2.22-2.30; early stopping alone 0.68-0.72 at loss 2.17-2.24; early stopping with the two
views 0.80-0.87 at loss 1.81-1.88 (ECE 0.05-0.11), training in 1.5 s (numpy) or 11 s (pure Python) and
predicting in 0.3 ms. Views are not rows: trained_rows, the report and the confidence ramp count the
originals, and rare-feature pruning counts a feature's rows without them. Every history entry records
epochs, best_epoch and validation. The file also records the feature-space signature it was trained
under (hashed/v1:dim=...); a model built on a different tokenisation or hash width is refused at load
instead of routing on scrambled weights.
The encoder reads at most OSR_SLM_MAX_TOKENS (512) whitespace tokens of a prompt, for scoring, familiarity and
explain alike (0 = every token); prompts below the cap are untouched, and the cap is a runtime setting, so an
older model file honours it too. A longer prompt is read through a reading window (learning.window,
ReadingWindow) in two views:
- the window:
OSR_SLM_WINDOW_HEAD(0.4) andOSR_SLM_WINDOW_TAIL(0.4) of the budget keep the opening and the closing tokens - where instructions usually live - and the rest goes to the middle: it is cut into sentences, each is read where it holds the most instruction cues (the words a request opens with, taken from the first word of every task template of the ontology - write, translate, summarise, extract, fix, what, why ... - plus a question mark) and scored for salience (cues per square root of the length), and the most salient sentences are kept and placed first, before the head, because the encoders read an opening more closely than anything after it. Budget left over goes toOSR_SLM_WINDOW_RUN-token (8) runs spaced evenly through the middle, so bigrams and character grams stay meaningful and the body's own vocabulary (legal, medical, code) still reaches the model; - the ask: the sentences that ask for something - among the opening and closing spans and the salient
middle - read on their own, at most
OSR_SLM_WINDOW_ASK_TOKENStokens (0 = two sentence spans), and added to the query as a unit vector weightedOSR_SLM_WINDOW_ASK(2.0) against the window's: twelve words of request among five hundred of document are two per cent of a bag of features, and the body decided; now the request is read the way it would be on its own (0 = window only). A fit and an online step read a long row through the same two views, on both arithmetic backends, so training and serving agree.OSR_SLM_WINDOW_NOVELTY(1.0, in 0..1) is the share of a sentence's salience that depends on its words being rare in the rest of the prompt, so a question the document itself repeats on every page (an FAQ, a form) is never mistaken for the request.
Measured with the bundled model on the 70 labelled rows, each buried at the head, the middle or the tail of a
thousand-word document (a finance report without punctuation, legal clauses, FAQ questions, a code dump):
head-and-tail reading routed 7-44 of 70 acceptably, the window alone 29-44 (9 on code, 7 on the FAQ), window
and ask view 63-69 of 70 on every body; the same rows on their own route 70/70 before and after. explain
reports the text the window view read and the ask next to it. The attention block has its own, tighter
window (below) and cuts through the same policy.
A served encoder also remembers the features and query vector of the last OSR_SLM_ENCODE_CACHE (512)
prompts it scored (0 = none), dropped on every weight change (an online step, a fit, a reload): the
familiarity gate reads the features the score just computed instead of hashing the prompt a second time,
and a repeated prompt - a retry, an evaluation set, the labelled floor check - is encoded once. Measured on the
bundled model with numpy: SLMStrategy.score 735 µs -> 600 µs on a prompt never seen, 130 µs on a repeat;
predict_proba 400 µs -> 35 µs on a repeat.
Read --report against the constant baseline. Human preference between tiers is noisy: on the ten-source
corpus "always the mid tier" is acceptable for 64 % of holdout prompts and the SLM reaches 64-66 % with a
holdout cross-entropy of 0.98-1.00 (the constant predictor: 1.03), well calibrated (ECE 0.05-0.08). It is much
stronger where the label really depends on the prompt - 94 % on routellm-gpt4 (baseline 86 %), +12 points
on arena-100k - and no better than the prior on sources where the prompt carries no signal. That is what a
learned routing prior is; the ensemble adds rules, capability, similarity and the bandit on top. The
corollary: an SLM trained only on synthetic seed rows is a compressed capability scorer, and in a rule-driven
ensemble it can cost accuracy on labelled traffic (on the 33 example rows: 0.88 without it, 0.82 with it at
weight 1.0). Every report therefore also states the baseline - the accuracy of always predicting the most
common acceptable target of those rows - and the skill = (accuracy - baseline) / (1 - baseline), the share
of the constant predictor's errors the model actually fixes; SLMStrategy speaks as loudly as that skill
(OSR_SLM_TRUST_BY_SKILL: confidence x (0.5 + 0.5 x skill) of the last validated fit, so a model no better
than the constant predictor is heard at half volume, one that fixes every error at full). Warm-start with
synthetic rows, but let osr eval on your own labelled rows set OSR_WEIGHTS_SLM until real outcomes have
trained it. OSR_SLM_ABSTAIN_MARGIN makes SLMStrategy return
no scores at all when its top two probabilities are closer than the margin (top-1 accuracy on such near-ties
was 22 % against 72 % elsewhere); it is off by default because a near-tie still carries the model's opinion
about every other target, and the ensemble breaks the tie anyway - measure before turning it on. The
familiarity gate is on: the share of a prompt's features the model has a trained row for scales its confidence
from nothing at OSR_SLM_FAMILIARITY_NONE (0.7) to full at OSR_SLM_FAMILIARITY_FULL (0.95; 0 = no gate) -
a model is calibrated on the prompts it saw, and a prompt made of features it never saw is where it is most
confidently wrong. Measured with the production model trained on synthetic rows against the 33 labelled
example rows: 0.879 for the ensemble alone, 0.758 with the SLM ungated, 0.879 gated; on prompts from its own
training distribution nine in ten score above 0.94 familiarity and pass at full volume. The trace shows the
gate (familiarity 0.51 -> x0.00), and the same number feeds the input-drift monitor below.
Online learning is bounded so one prompt or one noisy stretch cannot drag the served model: a request's
outcomes apply at most OSR_SLM_ONLINE_MAX_PER_REQUEST (3) SGD steps, and a served model takes at most
OSR_SLM_ONLINE_BUDGET (5000) steps in total before updates pause until the next fit or promotion re-anchors
it on the full corpus (SLMStrategy.online_steps / online_skipped). fit also drops the projection rows
of features seen in fewer than OSR_SLM_MIN_FEATURE_COUNT (2) training rows once the corpus has 50 rows or
more - a feature seen once carries its random seed row and nothing else, so an unseen feature contributes
nothing instead of noise: measured -18 % file size at identical accuracy.
One SLMStrategy can serve one model per tenant: SLMStrategy(slm, resolver=lambda req: models.get(req.context.get("account_id")))
scores a request with the tenant's own RouterSLM when the resolver returns one (the shared model
otherwise), trains that same model from the request's outcomes and reports confidence from that model's
skill. The hosted platform uses it for per-workspace models (Platform guide, "Learning").
A corpus of model battles only knows the models it compared. Scored rows therefore train a softmax over the
scored population (every target any row scores, plus OSR_SLM_NULL_TARGETS learned "none of these"
logits that keep it from saturating), so a tool, skill or agent the data never mentions keeps its catalogue
embedding instead of being pushed to zero, and SLMStrategy abstains on such targets - its confidence is
scaled by the share of the request's candidates it has evidence about. Rows that name only an expected
target (your own feedback, examples/eval_dataset.jsonl) contrast against the whole catalogue, which is how
the model learns the non-LLM targets once outcomes arrive.
Where a transformer helps
The hashed encoder is a bag of words, bigrams and character grams: it cannot tell "explain this SQL"
from "SQL that explains itself". Two opt-in encoders add that context, both persisted in the same JSON
file and restored by RouterSLM.from_file:
OSR_SLM_ENCODER=attentionaddslearning.AttentionEncoder, a transformer block (hashed token embeddings, sinusoidal positions,OSR_SLM_ATTENTION_HEADSheads ofOSR_SLM_ATTENTION_HEAD_DIM, residual, attention pooling) to the query encoder: . No dependencies, deterministic, hand-derived gradients. It reads a prompt up toOSR_SLM_ATTENTION_MAX_TOKENS(128; word and punctuation tokens) through the same reading window as the hashed features - head, the salient middle first, tail - and is quadratic in that window - at 128 tokens prediction on a 150-word prompt still takes about a millisecond. With numpy (extrafast, in every image) the block runs as dense matrix products over a mirror of its weights: a fit that took four and a half minutes in pure Python takes 12-30 s, prediction 0.7 ms instead of 6 ms, and both paths draw the same random numbers and end within float rounding of each other (the model file is identical). The block learns slower than the linear projection, so it trains on its own budget:OSR_SLM_ATTENTION_EPOCHS(40),OSR_SLM_ATTENTION_LR(0.2) andOSR_SLM_ATTENTION_PATIENCE(4) replaceepochs,lrandpatiencewhen the encoder is on (0 = the shared value). With four heads of sixteen dimensions and that budget it reaches 0.95-0.97 holdout accuracy on the example catalogue's synthetic rows where the hashed encoder with views stops at 0.80-0.87 (the views add nothing measurable to the attention encoder, which sees word order already, and double its fit time); with the old budget (two heads of eight, twelve epochs at 0.05) it lost three points, and on the public arena corpora it lost five at full size - measure on your holdout (osr slm train --report) before switching. The bundled model and the hosted platform's SLM service use this encoder (OSR_SLM_ENCODERon the trainer). The default isauto: a loaded model keeps the encoder it was trained with - the improver clones a bundled or promoted attention champion and fine-tunes it on the attention budget - and a fresh model is hashed. Naming an encoder explicitly after a champion was trained does not clone it -SelfImproverbuilds a fresh model with the configured architecture (ImprovementReport.encodervschampion_encoder) and the promotion gate decides, so the switch takes effect only where it wins.OSR_SLM_EMBEDDER=openai:<model>[@<base_url>]appends a frozen pretrained embedding as dense features (learning.EmbeddingFeaturizer) from any OpenAI-compatible/embeddingsendpoint over the stdlib client - no extra, no download: the embedding model a deployment already serves (openai:qwen3-embedding:4b@http://osr-ollama:11434/v1), Azure OpenAI (openai:text-embedding-3-small@https://<resource>.openai.azure.com/openai/v1) or OpenAI itself (openai:text-embedding-3-small). The key comes from the variableOSR_SLM_EMBEDDER_API_KEY_ENVnames (OSR_LLM_API_KEY; an in-cluster model server needs none), each call waitsOSR_SLM_EMBEDDER_TIMEOUT_S(20) and texts are cut at their first 6,000 characters. Every vector is cut to its firstOSR_SLM_EMBEDDER_DIMS(256) components and renormalised - what the providers'dimensionsparameter does for a Matryoshka-trained model (text-embedding-3, Qwen3-Embedding, nomic, jina v3 keep almost all of their quality there) and what keeps a training corpus of tens of thousands of rows in memory;0keeps the full width. The dense block enters atOSR_SLM_EMBEDDER_SCALE(4) times the weight of one hashed feature - at 1 the hundreds of sparse cues drowned it.Wthen learns a linear head on top of a real transformer while the hashed features keep the surface cues (code fences, length, exact vocabulary); a fit embeds its corpus in batches of 64 and a text is embedded once per process, so a training run costs one pass over its rows. Measured on the reference catalogue's 117 held-out phrasings with Azuretext-embedding-3-small: the model alone routes 0.79 of them acceptably against 0.74 for the hashed encoder, and the ensemble with it 0.83. The file records the embedder's name and the cut, so a reload rebuilds the same client from settings alone; when the endpoint fails at request timeSLMStrategysits that request out (SLMStrategy.status()["embedder_errors"]) and the other strategies decide, so an embedding outage never fails a route.OSR_SLM_EMBEDDER=sentence-transformers/all-MiniLM-L6-v2(extraembeddings) runs the transformer in process instead, andRouterSLM(embedder=callable)takes any callable (passed again tofrom_file(embedder=...)). The packaged model ships without an embedder - it must load with zero dependencies and no endpoint - so a deployment that wants the dense features trains its own: the hosted platform's SLM service andosr slm trainreadOSR_SLM_EMBEDDER, andSelfImproverbuilds a fresh challenger with the configured embedder when the champion was trained without one, promoted only when it wins the gate.OSR_SLM_EMBEDDER=keras:/path/head(extratensorflow) names a savedlearning.NeuralRoutingHead- a Keras classifier over the same hashed features, trained on the same rows - whose representation layer becomes the dense embedder, so the SLM's linear head sees a non-linear view of the prompt next to the raw hashed cues. The head reads conversation context the same way the SLM does (context=True:EvalRow.historyat training time,predict_proba(text, history=...)at prediction time) and carries an experience-replay memory:observe(text, target, reward, history=)keeps every outcome in a boundedlearning.ReplayMemory,replay_fit()fine-tunes on it (good outcomes as positives, bad ones as reward-weighted mass on the other targets), and the memory is saved next to the model, so a restart keeps what live traffic taught. The same extra offerslearning.TokenCountModel, which learns billed token counts from(text, tokens)pairs and, once installed (estimate.set_token_counter), replaces the heuristic behindestimate_tokens(SDK guide).
Live web context
A router picks the model; it cannot make a model know what happened this morning. signals.freshness_need
scores how much a request depends on facts that change - news, prices, weather, scores, "latest", "today", an
explicit "look it up", this year's date - into Signals.freshness_need; the capability strategy reads it as a
nudge toward targets tagged web / search / browsing (OSR_CAPABILITY_WEB_BONUS), and
enterprise.WebGroundingMiddleware acts on it: at or above its threshold (0.5) it runs the request's first
sentence through adapters.WebKnowledge - keyless Wikipedia (adapters.wikipedia_search: encyclopaedic, hours to
days behind the news, but real text for "who / what is X"), DuckDuckGo instant answers and Hugging Face by default;
a self-hosted SearXNG instance for the whole live web when SEARXNG_URL is set (adapters.searxng_search), Brave
with BRAVE_API_KEY, or any provider you register - screens every hit with the injection lexicon (security.injection_risk; hits at or above
OSR_SLM_PAGE_RISK_THRESHOLD are dropped) and attaches the rest to the request - context["web_context"] for the
trace and context["system"] as a Live web context block the OpenAI-compatible chat handler puts in front of
the model, so the answer is grounded in what the web says today and can cite it. context["web"] = True forces
it for one request, False disables it; identical queries within cache_ttl_s come from memory; a failing or
slow provider never blocks routing (web.grounded / web.empty / web.failed events). Turn it on with
RouterBuilder.with_web_grounding(), harden(router, web_grounding=True) or osr serve --web-grounding
(OSR_SERVER_WEB_GROUNDING=true).
Knowledge graph and context engineering
Outcomes say which target did well on which task; conversations say what was said; the knowledge graph
(opensmartroute.knowledge) keeps facts - typed, sourced, weighted, expiring triples - about the people who
ask, the targets that answer, the topics that come up and the pages that were consulted, and turns them into the
context a model should see for one request:
KnowledgeGraphstoresTriple(subject, predicate, object)facts with a confidence, an evidence weight (a fact stated twice grows instead of duplicating), a source (catalogue,conversation:<id>,web:<provider>,outcome,user), timestamps and an optional expiry; it answers pattern queries (query), neighbourhood walks (neighbors), word search (search), forgets on request (forget), decays what nobody confirmed (decay,OSR_KNOWLEDGE_DECAY_PER_DAY), bounds itself (OSR_KNOWLEDGE_MAX_TRIPLES) and persists to one JSONL file. Interoperability with the open-source graph ecosystem is by format, not by dependency:to_jsonld(JSON-LD 1.1),to_ntriples(RDF for rdflib, Jena, GraphDB, Blazegraph),to_graphml(Gephi, yEd, igraph),to_cypher(Neo4j / MemgraphMERGEstatements) andto_networkx(when networkx is installed);from_jsonldreads back. The vocabulary isknowledge.PREDICATES; the person-subject ones (PERSONAL_PREDICATES) are never exported to a shared graph unless asked.- Ingestion is rules, not a model:
extract_factsreads what a person states about themselves (my name is, I work at, I'm a data engineer based in Toronto, we use Python and dbt, I prefer, answer in French, I'm working on, remember that),mentionsthe entities a message names;ingest_turn,ingest_targets(the catalogue as facts),ingest_decision(task type and domainsrouted_tothe chosen target, the personasked_aboutthem),ingest_outcome(target performed_on taskwith the running mean quality and the count) andingest_web_context(a live search's hits as expiringcitesfacts) write into the graph and return what they added. ContextEngineerbuilds the block: About the user (the subject's facts, identity first, then relevance), Relevant knowledge (shared facts whose words overlap the request) and Live web context (the hits aWebGroundingMiddlewareattached, numbered so the model can cite them); every candidate line has a score and lines are admitted in score order untilOSR_KNOWLEDGE_BUDGET_TOKENS(600) is spent, at mostOSR_KNOWLEDGE_MAX_FACTSper section andOSR_KNOWLEDGE_MAX_WEB_HITShits.EngineeredContextrecords what was placed and what was dropped and why (budget, limit, duplicate);applyputs the block in front ofcontext["system"]and the accounting undercontext["knowledge"], so a trace shows the model's context the way it shows the routing decision.ContextMiddlewareruns the engineer in front of every routed request (context["user_id"]/["account_id"]/["session_id"]orprofile["id"]names the person) and teaches the graph from the traffic: the message's facts before routing, the decision and any web hits after; it emitscontext.engineered.KnowledgeStrategy(knowledgein the ensemble,OSR_WEIGHTS_KNOWLEDGE) scores candidates by what the graph knows worked on the request's task type and domains - it abstains for a target with fewer thanOSR_KNOWLEDGE_STRATEGY_MIN_EVIDENCEoutcomes on the topic and trusts itself fully atOSR_KNOWLEDGE_STRATEGY_FULL_EVIDENCE; outcomes reaching it become newperformed_onevidence.- A learned link predictor closes the loop (optional extra
tensorflow):learning.KnowledgeEmbeddingtrains a TransE embedding of every entity and predicate in Keras (fit(graph); personal facts left out) and then answers with numpy alone -predict_tail("target:llm-small", "has_domain")(which domain is it probably good at that nobody declared),predict_head("performed_on", "task:coding"),similar,score; saved as.npz+.jsonorto_dict()for a database row. Attached asgraph.embeddingit backsKnowledgeGraph.suggest, which otherwise counts what peers of the same type carry.
from opensmartroute.enterprise import RouterBuilder
router = (
RouterBuilder(registry)
.with_defaults()
.with_web_grounding()
.with_knowledge("state/knowledge.jsonl", budget_tokens=600) # ingests the catalogue; learns from traffic
.build()
)
d = router.route(RouteRequest(text="I work at Acme; how do I test a dbt model?", context={"user_id": "u1"}))
print(d.trace.signals.task_type, router.knowledge.query(subject="person:u1"))
print(router.knowledge.to_cypher())harden(router, knowledge=True) (or a KnowledgeGraph / a path) does the same for a router that already
exists. The hosted platform runs one graph per workspace as its knowledge service
(Platform guide).
Self-operation
osr serve --autopilot runs the SelfImprover inside the server (learning.Autopilot): a cycle every
OSR_SLM_AUTOPILOT_INTERVAL_S seconds, an early one when learning.DriftMonitor (Page-Hinkley over
the success and quality of every outcome the router learns from, via Router.observers) sees the served
quality drop or when learning.InputDriftMonitor (Page-Hinkley over the SLM's top probability and over the
share of each prompt's features it has a trained row for - both fall when traffic moves to prompts it was not
trained on, long before outcomes show it; OSR_SLM_INPUT_DRIFT_THRESHOLD, OSR_SLM_INPUT_DRIFT_MIN_N) sees
unfamiliar traffic, never closer than OSR_SLM_AUTOPILOT_MIN_GAP_S. Feedback is joined back to prompts through
the router's request memory (OSR_SLM_AUTOPILOT_REMEMBER texts; RequestMemory(path=...) keeps it in a JSONL
file across restarts), which also records the decision the router took on every prompt: prompts without an
outcome become distillation rows (the ensemble's propensities as soft labels, newest OSR_SLM_DECISION_ROWS_MAX
per cycle), so the SLM learns the router's behaviour on real traffic first and the outcomes correct it. While
real rows are scarce (fewer than the cycle's synthetic_below, 200) synthetic rows over the catalogue fill in,
OSR_SLM_SYNTHETIC_PER_TEMPLATE per prompt template (2; the hosted stacks use 8). An
accepted challenger is hot-swapped
into the SLMStrategy and written back to disk, a failing cycle is logged and the server keeps serving.
GET /stats reports slm whenever the ensemble serves one (SLMStrategy.status(): rows, version, encoder,
origin, calibration, trust, online steps, abstentions, input drift) and autopilot (cycles, promotions, drift, input drift, last report, next run) and
POST /autopilot/cycle schedules one now. In the container: OSR_AUTOPILOT=1 with OSR_STATE
mounted (Helm autopilot.enabled); --offline keeps it off the network. --slm is a warm start, not a
prerequisite: without it the loop begins with the bundled model over the catalogue (or the one a previous run
promoted into <cache-dir>/slm.json) and trains a challenger on the first cycle. Each cycle is traced as an
autopilot.cycle span with a learn.improve event (and learn.promote when the challenger wins), so
GET /events?name=learn.* shows the loop at work. The hosted platform runs the same loop from
OSR_PLATFORM_SLM / OSR_PLATFORM_AUTOPILOT and shows it at /platform/dashboard/learning
(Platform guide, section 10).
osr -t targets.yaml --slm slm.json --state state serve --autopilot --autopilot-interval 1800 \
--catalogue data/catalogue.json --source arena-55k --tier frontier=llm-frontier --tier small=llm-small \
--feed https://openai.com/news/rss.xml --marketplace-refresh 86400
curl -s localhost:8000/stats | jq '.autopilot, .marketplace'The same server follows the marketplace (--marketplace-refresh / OSR_MARKETPLACE_REFRESH_S,
Marketplace): new and re-versioned skills and tools land in the served catalogue on the
next tick, and the autopilot's next cycle trains with them as candidates.
6. Declaring components with decorators
Targets, strategies, signal extractors, policy rules, middleware and telemetry can be declared where
they live and assembled by a ComponentRegistry (details in SDK guide):
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.""" # docstring -> target.description
return lookup(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.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"
router = osr.components.router(registry)
app = osr.components.builder(registry).with_auto_learning().build()The short forms take the rest of the boilerplate away - a rule without rules.yaml, code for a catalogue's
targets, a guard and hooks without subclasses, and routing without building a router:
osr.components.load("targets.yaml", "rules.yaml") # the catalogue and its rules as components
@osr.handler("llm-small", "llm-frontier") # one function behind several catalogue targets
def chat(request, **kw):
return provider.complete(request.text)
osr.rule("pii-stays-home", prefer=["llm-onprem"], pin=True, contains_pii=True) # a typo in a condition raises
@osr.guard # None passes, a str refuses (SecurityError), a RouteRequest rewrites
def no_empty(request):
return "empty request" if not request.text.strip() else None
@osr.on_outcome # also @on_decision(request, decision), @on_error(request, error)
def count(outcome):
metrics.inc(outcome.target_id, outcome.success)
d = osr.route("Refund order #123, the customer is upset") # router built from the registry on first use
osr.feedback(d, success=True, quality=0.9)
print(osr.run("Summarise this ticket: ...").text) # route + execute + learnexamples/decorator_sdk.py runs the whole loop offline.
Every tunable (ensemble weights, thresholds, scales) lives in one typed Settings object and can be
overridden per process (osr.configure(...)) or through OSR_<GROUP>_<FIELD> environment variables;
osr settings prints the effective values.
7. Enterprise builder
from opensmartroute.enterprise import (
RouterBuilder,
CacheMiddleware,
TenantMiddleware,
LoggingTelemetry,
MetricsTelemetry,
FileAuditSink,
)
from opensmartroute.security import GuardMiddleware
metrics = MetricsTelemetry()
app = (
RouterBuilder(registry)
.with_rules(rules) # declarative preferences
.with_defaults() # capability + similarity + Thompson bandit
.with_auto_learning(state_dir=".osr-state") # IRT + Bradley-Terry + LinUCB + Markov lookahead
.with_health(latency_slo_ms=3000) # circuit breakers, rate limits, budgets
.with_middleware(
GuardMiddleware(redact=True), # gadget defence + PII redaction
TenantMiddleware({"acme": {"max_cost_per_1k": 0.005, "data_boundary": "private"}}),
CacheMiddleware(ttl_s=30),
)
.with_telemetry(LoggingTelemetry(), metrics)
.with_audit(FileAuditSink("audit.jsonl"))
.build()
)
d = app.route(RouteRequest("Summarize this contract", constraints=RequestConstraints(tenant="acme")))
app.learn(
Outcome(
d.request_id,
d.target.id,
success=True,
quality=0.9,
cost_usd=0.002,
latency_ms=1200,
domains=d.trace.signals.domains,
complexity=d.trace.signals.complexity,
)
)
print(metrics.snapshot()["route_latency_ms"], app.health_snapshot())with_retrieval(narrow_above=, narrow_to=) switches to retrieve-then-rank for large catalogues,
with_queue_awareness() replaces static latency with a queue-aware estimate, with_shadow(candidate, mode="shadow" | "ab") compares policies with SPRT, with_calibration() adds temperature scaling and
conformal candidate sets, with_fair_share() enforces tenant weights and with_state_store() accepts
Redis, SQL, encrypted, versioned or batched stores. Ports (state, telemetry, audit) are ABCs; see
Enterprise builder.
8. Research-track modules
Each module below implements one roadmap item with tests; papers are cited in Research and formulas in Mathematics.
Signals from a draft or a small model
from opensmartroute.signals import DEFAULT_EXTRACTORS, VerbalisedDifficultySignal, DraftResponseSignal, extract_signals
extractors = [
*DEFAULT_EXTRACTORS,
VerbalisedDifficultySignal(source=lambda req: small_model_difficulty(req.text)),
DraftResponseSignal(drafter=lambda req: cheap_model(req.text)),
]
router = Router(registry, extractors=extractors)VerbalisedDifficultySignal accepts numbers, 7/10 or words (trivial ... expert) from
context["difficulty"] or a callable and blends them into complexity. DraftResponseSignal
measures hedging, self-corrections, query overlap and length ratio of a cheap draft.
Response-side uncertainty
from opensmartroute.signals import UncertaintyGate, EventTrigger, TriggerRule, response_uncertainty
from opensmartroute.strategies import Cascade
gate = UncertaintyGate(sampler=lambda req, n: [cheap_model(req.text) for _ in range(n)], n_samples=4)
cascade = Cascade(quality_gate=gate, threshold=0.6) # escalate when samples disagree
result = cascade.run(req, router.route(req).alternatives)
feats = response_uncertainty(samples, p_true=judge_p_true)
trigger = EventTrigger(
[TriggerRule("semantic_entropy", 0.5, "reroute"), TriggerRule("agreement", 0.5, "ask_human", below=True)]
)
trigger.check(feats) # ["reroute", ...]Hidden-state Dirichlet probe
from opensmartroute.strategies import HiddenStateStrategy
probe = HiddenStateStrategy(
state_fn=lambda req: host_model.hidden_state(req.text), targets=["small", "large"], dim=4096
)
probe.fit(logged_states, logged_winners, epochs=20) # warm start
router = Router(registry, strategies=[*default_strategies(), probe])The probe's confidence is 1 - K/S (epistemic uncertainty), so unfamiliar states defer to the other
strategies. math.DirichletProbe is usable on its own with any feature vector.
Elastic models and token budgets
from opensmartroute.strategies import BudgetVariant, TokenBudgetStrategy, expand_elastic
for sib in expand_elastic(
registry.get("reasoner"), [BudgetVariant(1024, quality=0.7, cost_scale=0.4), BudgetVariant(8192, quality=0.9)]
):
registry.add(sib) # reasoner@1024, reasoner@8192
router = Router(registry, strategies=[*default_strategies(), TokenBudgetStrategy()])Edge and cloud tiers
Tag targets with metadata: {tier: edge} or {tier: cloud} and add EdgeCloudStrategy(). Edge
success is a Thompson posterior per complexity bucket, penalised when decode time eats into the
latency SLO; the cloud tier is penalised by upload time and excluded when the request's
data_boundary is private or on_prem and the target is public.
Auctions
AuctionStrategy(bid_fn=None, value=0.01) lets each target bid a claimed success probability and a
price (default_bid derives both from the catalogue). Claims are corrected by each bidder's observed
bias; the highest corrected surplus wins and pays the second price. Supply a bid_fn for
multi-vendor catalogues where providers self-report.
Protocol selection, aggregation, self-escalation, hand-off
from opensmartroute.strategies import ProtocolPolicy, MixtureOfAgents, SelfEscalation, wrap_stream
from opensmartroute.learning import HandoffPolicy, MixtureCureModel
policy = ProtocolPolicy() # risk / budget -> protocol
choice = policy.choose(req, decision, decision.trace.signals, uncertainty=gate(req, draft))
if choice.protocol == "aggregate":
res = MixtureOfAgents(k=3, budget_usd=0.05).run(decision, req) # top-k answers, majority vote
for o in res.outcomes(req):
router.learn(o)
monitor = SelfEscalation.for_target(decision.target) # streaming competence posterior
streamed = wrap_stream(provider.stream(req.text), monitor)
if streamed.escalated:
... # restart on a stronger target
handoff = HandoffPolicy(MixtureCureModel(), fallback_target="senior-agent", pins=task_pins)
if handoff.step(task_id, teacher_risk): # permanent hand-off
target = handoff.target_for(task_id, decision.target.id)
handoff.finish(task_id, failed=False) # censored observationPersonalisation
learning.UserAdaptiveStrategy reads context["user_id"] (or profile["user_id"]) and keeps a
Beta posterior per user and target, shrunk toward similar users and the global posterior;
observe_user() warm-starts from logged interactions. learning.SkillAffinity.relevance(profile, skills) feeds retrieval.select_skill_set(relevance=) so skill sets depend on who is asking.
Multi-turn conversations
from opensmartroute.learning import HistoryTargetStrategy
router = Router(registry, strategies=[*default_strategies(), HistoryTargetStrategy(turns=6)])
d = router.route(
RouteRequest(
"can you explain more?", history=chat_history, context={"last_target": "llm-small", "last_failed": True}
)
)The same follow-up message routes differently depending on what the conversation has been about:
the strategy learns a logistic model over the joint embedding of the recent history and each target's
catalogue embedding, so a target that keeps failing in legal threads but succeeds in coding threads
is scored accordingly, and an unseen target is still scored through its description and examples.
last_target gives the incumbent a small continuity bonus that turns into a penalty when the previous
turn failed.
Discovery beyond text similarity
from opensmartroute.discovery import SchemaAwareStrategy, CachePreservingSelector, SkillGraph
router = Router(registry, strategies=[*default_strategies(), SchemaAwareStrategy()])
selector = CachePreservingSelector(prefix_size=8, evict_after=5)
tools = selector.select(session_id, candidates, needed=[d.target.id]) # prefix-stable tool order
graph = SkillGraph(load_skills("skills")) # osr-requires / osr-conflicts
ordered, dropped = graph.compose(["report-builder"])MCP server recommendation and semantic-router import
from opensmartroute.adapters import recommend_servers, load_semantic_router_config
for rec in recommend_servers(
"open a pull request", server_cards, k=2, constraints=req.constraints, allowed_auth=["none", "token"]
):
print(rec.server.name, rec.score, rec.rationale)
imported = load_semantic_router_config("config.yaml") # vLLM semantic-router
router = Router(imported.registry, strategies=[RulesStrategy(imported.rules), *default_strategies()])Energy and carbon
math.EnergyModel fits Wh per target from measured samples; math.hardware_profile("h100-sxm")
gives priors when no meter exists. Both convert to gCO2 with a grid factor and feed
wh_per_1k_tokens / gco2_per_1k_tokens on the target's cost, which Objective(energy=, carbon=)
trades against quality.
9. Command line
Install osr with the platform installer (curl -LsSf https://opensmartroute.ai/install.sh | sh on
Linux and macOS, irm https://opensmartroute.ai/install.ps1 | iex in PowerShell) or with
uv tool install 'opensmartroute[yaml,server]' (add fast for numpy-accelerated SLM training); osr --version
confirms the install.
osr takes the catalogue with -t targets.yaml, optional rules with -r rules.yaml and an optional
routing SLM with --slm slm.json; --help on any subcommand lists its options. python -m opensmartroute ...
is the same command for environments where the script is not on PATH (IDE MCP configurations, containers,
uv run). Grouped by task:
# Sign in and manage access tokens
osr login # browser sign-in to the hosted platform
osr login --url https://osr.example.com --profile work # another deployment, kept as a profile
osr login --url http://router:8000 --token osr_local_... # self-hosted osr serve token
echo "$TOKEN" | osr login --with-token # non-interactive (CI)
osr whoami # workspace, plan, edition, token source
osr trace <request-id> # span tree of one request (server or platform)
osr events --name guard.* --limit 20 # recent tracer events there
osr token generate # mint an osr_local_ token for osr serve
osr token create --name ci # new platform API key (shown once)
osr token list # keys of the signed-in workspace
osr token revoke <key-id>
osr logout # forget the profile (--all for every one)
# Route, quote and inspect
osr -t examples/targets.yaml -r examples/rules.yaml route "I want a refund for order #123" --plan
osr -t examples/targets.yaml route "Summarise this HR complaint" \
--constraint data_boundary=on_prem --constraint region=eu # hard constraints; --kinds agent,tool
osr -t examples/targets.yaml -r examples/rules.yaml route "" --event order.shipped --plan # textless event
osr -t examples/targets.yaml estimate "Translate this contract" --monthly 100000 # cost per candidate + projection
osr -t examples/targets.yaml targets # the catalogue as the router sees it
osr -t examples/targets.yaml stats # feedback statistics per target
osr settings --json # effective tunables
# Evaluate and audit
osr -t examples/targets.yaml -r examples/rules.yaml eval examples/eval_dataset.jsonl --frontier --calibration
osr -t examples/targets.yaml audit traffic.jsonl --baseline llm-frontier --monthly 3000000 --markdown
# Routing Audit: savings + violations
osr -t examples/targets.yaml ope decisions.jsonl # off-policy estimate (IPS/SNIPS/DR) of logged decisions
osr -t examples/targets.yaml safety --learned-guard # red-team suite
# Learn
osr train --from rows.jsonl --out models.json # fit signal models
osr catalogue --cache data/catalogue.json --hf "llama instruct" # live model prices + quality
osr collect --cache-dir data --tier frontier=llm-frontier --tier mid=llm-mid --tier small=llm-small # Hub datasets
osr collect --cache-dir data --file routerbench=routerbench.jsonl --preset routerbench # a gated file you hold
osr -t examples/targets.yaml slm train --out slm.json --report # routing SLM (see section 5)
osr slm info slm.json # rows, sources, encoder, calibration, fit history
osr -t examples/targets.yaml --slm slm.json route "parse this csv" # SLM inside the ensemble
osr slm eval slm.json rows.jsonl # SLM accuracy on labelled rows
osr slm predict slm.json "parse this csv" --top 3 # what the SLM alone would rank
osr slm info slm.json # SLM metadata, targets and size
osr -t examples/targets.yaml improve --slm slm.json --cache-dir data # self-improvement cycle
# Serve and integrate
osr -t examples/targets.yaml -r examples/rules.yaml serve # http://127.0.0.1:8000/docs (open, local dev)
osr -t examples/targets.yaml serve --generate-token # prints an osr_local_ token; all routes need it
osr -t examples/targets.yaml serve --require-auth --token "$TOKEN" # production: refuse to start without a token
osr -t examples/targets.yaml mcp # MCP server over stdio for an IDE
python -m opensmartroute -t examples/targets.yaml mcp # the same, when `osr` is not on PATH
osr mcp --remote # stdio bridge to the platform you signed in to
osr mcp --url https://<platform>/mcp --api-key osr_... # ...or an explicit endpoint and key
osr mcp-manifest tools.json --key-env OSR_MCP_KEY --out signed.json # sign an MCP tools/list (--verify to check)
OSR_LLM_BASE_URL=mock:// osr -t examples/targets.yaml serve # every LLM answers in-process: demos and CI, no key
# Launch a coding agent with the router as its model endpoint (the saved sign-in becomes its key)
osr run --list # the tools and how each one is wired
osr run claude-code # ANTHROPIC_BASE_URL + key -> the router, model auto
osr run codex --model osr/auto:cheap -- "fix the failing test" # anything after -- goes to the tool untouched
osr run gemini-cli --print-env # the variables, for your own shell
# Import another gateway's configuration as a catalogue
osr migrate litellm config.yaml --out ./router # targets + providers.json + aliases policy, and what is left to do by hand
# Size self-hosted model servers (CPU / RAM)
osr capacity show qwen3.5:4b glm-4.7-flash --memory 28Gi --cores 4 # footprint, fits?, tokens/s, warm vs cold latency
osr capacity plan qwen3.5:4b qwen2.5-coder:7b deepseek-r1:8b --memory 8Gi --hosts 3 # pack onto hosts + OLLAMA_* env
osr capacity plan glm-4.7-flash --memory 14Gi --shrink --strict # coarser KV / shorter context to fit; exit 2 if not
osr capacity show --url http://ollama:11434 --memory 28Gi # exact shapes from the server's /api/show
osr capacity ps --url http://ollama:11434 # what the server holds in memory right now
# Manifests, stacks and skills
osr validate examples/ocm # Open Capability Manifests
osr -t examples/targets.yaml export-ocm --out manifests/ # catalogue -> OCM manifests
osr stack init --out stack.yaml # starter declarative stack
osr stack validate examples/stack.yaml # check (imports resolved)
osr stack plan examples/stack.yaml --against deployed.yaml # diff against what runs today
osr stack apply examples/stack.yaml --route "refund order 42?" # build the router, route once
osr stack apply registry://support-desk@1.0.0 --registry $OSR # marketplace template
osr skills # validate SKILL.md packagesosr login without --token runs the OAuth 2.0 device authorization grant against the hosted platform
(community or enterprise edition alike): the CLI prints a short code, opens /platform/cli/authorize in the
browser, and after you approve it there the platform mints a workspace API key (osr_live_...) for
that machine. Credentials are stored per profile in ~/.config/opensmartroute/credentials.json
(%APPDATA%\opensmartroute on Windows, OSR_CONFIG_DIR overrides); flags, then OSR_API_URL /
OSR_API_KEY, then the saved profile decide which one a command uses. A self-hosted osr serve has
no accounts: osr token generate (or serve --generate-token) mints an osr_local_... token, the
server accepts it as Authorization: Bearer or X-API-Key on every route except the health,
metrics, /whoami and OpenAPI paths, and OSR_SERVER_AUTH_TOKENS / OSR_SERVER_REQUIRE_AUTH
configure the same thing from the environment (see :class:ServerSettings in
SDK guide).
The Routing Audit replays logged traffic ({"text": ..., "model": ..., "cost_usd": ...} per line)
through the catalogue and reports savings against a baseline target, policy violations the logged
route would have committed, and the route mix; --min-savings makes it a CI gate. In production the
same numbers come from RouterBuilder.with_savings(baseline="max") and
ep.savings.report().to_markdown().
osr run <agent> is the one-line way to put a coding agent or AI client on the router: Claude Code, Codex, the
Gemini CLI, aider, OpenCode (launched), and Cursor, Continue, Zed, LibreChat, Open WebUI (configured in their own
settings - --print-env prints what to paste). The tool's provider variables (ANTHROPIC_BASE_URL,
OPENAI_BASE_URL, GOOGLE_GEMINI_BASE_URL and their keys) are set from the saved sign-in or --url / --token,
--model names what the tool asks for (auto by default: the router decides per request), and the process is
replaced by the tool's, so Ctrl-C and exit codes are the tool's own. Everything after the agent name - or after
-- - goes to the tool untouched.
osr migrate litellm <config.yaml> reads a LiteLLM proxy configuration - its model_list (deployment names,
provider models, API bases, credential variables, prices, context windows, embedding mode, rate limits, budgets)
and the fallback chains - and writes, under --out, the catalogue (targets.yaml; one target per deployment
name with cost, context window, rate_limit / budget metadata and the embeddings tag), providers.json
(providers with base_url / api_key_env per distinct endpoint, models binding each target to
provider/model; a literal key in the config is never copied, the variable it should come from is named) and,
when a deployment name is not a valid target id, a policy file with the aliases that keep callers' model
names working on the hosted platform. The report lists what was skipped and why, and what a proxy keeps in its
database rather than its file - virtual keys, teams, users, budgets - as the steps left to do by hand.
10. Routing latency
Routing is pure Python on the request path. Measured with the reproducible routing benchmark (300 requests per configuration, 2026-10-09) on the 27-target example catalogue with its rules, CPython 3.11, x86-64 Windows laptop:
| Configuration | p50 | p95 | p99 |
|---|---|---|---|
| signals + policy + rules + capability + similarity + bandit | 4.6 ms | 6.6 ms | 8.3 ms |
| + IRT + preference + LinUCB + Markov + health | 7.4 ms | 9.1 ms | 14.1 ms |
+ LLM judge (only when confidence < escalate_llm_judge_below) | + one provider round-trip | n/a | n/a |
Catalogue size: 64 targets route in 14.5 ms p50 and 256 in 16.7 ms with
RouterBuilder.with_retrieval(narrow_above=32, narrow_to=24) (versus 36 ms and 142 ms scoring every
target); 1024 targets route in 24 ms. Cost is linear in scored targets x strategies; signal
extraction is under 1 ms and independent of catalogue size. RouteDecision.trace.elapsed_ms records
the routing time per request, MetricsTelemetry exposes route_latency_ms percentiles and the
OpenTelemetry adapter emits the osr.route_latency_ms histogram. Full tables in
Architecture.
11. Package layout
opensmartroute/
core/ types (RouteTarget, RouteRequest, RouteDecision, Outcome, ...), registry
signals/ lexicon extractors, task ontology, learned hashed models, verbalised difficulty,
draft-response and response-uncertainty features
policy/ hard-constraint filter
strategies/ rules, capability, similarity, bandit, llm_judge, cascade, task_table, defer, progress,
probe (Dirichlet), elastic, edge, auction, protocol, escalation, aggregate
learning/ IRT, preference, LinUCB, Markov, AutoLearner, cold start, task credit,
personal (user adaptation, skill affinity), handoff (mixture cure),
multiturn (history-target joint embeddings)
math/ bandits, irt, preference, markov, calibration, estimators, decision, dirichlet, energy
realtime/ circuit breaker, rate limit, budget, health
enterprise/ RouterBuilder, EnterpriseRouter, middleware, telemetry, audit, stores, ops
security/ InputGuard, learned gadget detector, injection risk, redaction, limits, provenance
adapters/ OpenAI-compatible client, harnesses, MCP (tools + server recommendation), A2A,
frameworks, personas, SKILL.md, semantic-router import, embeddings, OpenTelemetry
eval/ harness, baselines, robustness, frontiers, off-policy evaluation, dataset presets
discovery.py schema-aware matching, cache-preserving selection, skill graph
retrieval.py BM25 + dense retrieval, reciprocal-rank fusion, skill-set selection, meta-tools
execution.py plan executor
sdk.py decorator SDK and plugin discovery
settings.py typed, environment-overridable tunables
config.py load_targets / load_rules (JSON, YAML)
feedback/ outcome store and preference export
router.py orchestrator and plan builder
aio.py AsyncRouter
server.py FastAPI app and OpenAI-compatible proxy
cli.py osr commandSomething wrong or out of date on this page? Tell us - the page is named for you, so a one-line note is enough.
Word add-in
Select a passage in Word: task, language, size, personal data, the recommended model and its cost - then ask, explain, summarise, rewrite or proofread through the router.
SDK guide
Decorators for strategies, rules, signals, middleware and telemetry; settings, error handling, threading and plugins.