SDK Guide
Decorators for strategies, rules, signals, middleware and telemetry; settings and plugins.
Stability & versioning#
- SemVer. Everything exported from
opensmartroute(top-level) is public API.opensmartroute.enterprise,.security,.learning,.realtime,.math,.eval,.aio,.adapters,.configare public too but may add fields between minor versions. - Modules prefixed
_and anything not in an__all__are private. - The complete list of public modules and names, with one-line summaries, is generated into
REFERENCE.md from the source (
python scripts/api_reference.py). - Deprecations are announced one minor version ahead with
DeprecationWarning.
Installation matrix#
curl -LsSf https://opensmartroute.ai/install.sh | sh (Linux, macOS) and
irm https://opensmartroute.ai/install.ps1 | iex (Windows) install the osr CLI with the yaml and
server extras in an isolated tool environment (uv, pipx or a private venv). For the library, pick
the extras you need:
| Extra | Adds | Use when |
|---|---|---|
| (none) | pure stdlib | embedding in any Python 3.10+ service |
yaml | PyYAML | loading targets.yaml / rules.yaml |
server | FastAPI, uvicorn, pydantic | running osr serve |
embeddings | sentence-transformers | semantic SimilarityStrategy |
otel | opentelemetry-api/sdk | adapters.OpenTelemetryTelemetry |
crypto | cryptography | AES-GCM encrypted state stores |
dev | pytest, ruff, mypy, bandit | contributing |
all | every extra above | the installer's OSR_EXTRAS=all |
Connecting real providers#
adapters.OpenAICompatClient is a stdlib HTTP client for any OpenAI-compatible API (OpenAI, Azure
OpenAI, vLLM, Ollama, LiteLLM, OpenRouter, Groq, Mistral). It has explicit timeouts, exponential
backoff on 408/429/5xx, and maps failures to SDK errors (ConfigurationError for 401/403,
TargetUnavailableError for connectivity/5xx, ExecutionError for the rest). The API key is read
from OPENAI_API_KEY (or OPENAI_API_KEY_FILE) and never appears in logs or exceptions.
from opensmartroute.adapters import OpenAICompatClient, chat_handler, embedder, judge_fn
openai = OpenAICompatClient() # https://api.openai.com/v1
local = OpenAICompatClient("http://localhost:11434/v1", api_key="ollama") # Ollama
registry = TargetRegistry([
RouteTarget("gpt-4o-mini", TargetKind.LLM, cost={"usd_per_1k_tokens": 0.00015},
handler=chat_handler(openai, model="gpt-4o-mini")),
RouteTarget("llama-local", TargetKind.LLM, cost={"usd_per_1k_tokens": 0.0},
constraints=TargetConstraints(data_boundary="on_prem"),
handler=chat_handler(local, model="llama3.1")),
])
router = Router(
registry,
strategies=[CapabilityStrategy(),
SimilarityStrategy(embedder=embedder(openai, model="text-embedding-3-small"))],
llm_judge=LLMJudgeStrategy(judge_fn(openai, model="gpt-4o-mini")),
escalate_llm_judge_below=0.5,
)
decision = router.route(request)
result = router.execute(decision, request) # ChatResult(text, tokens, latency_ms); outcome auto-recorded
chat_handler builds the message list from request.history, and uses request.context["system"]
as the system prompt — which is how a persona slot in a RoutePlan changes model behaviour
without a separate deployment.
Optional adapters (lazy-imported):
from opensmartroute.adapters import sentence_transformers_embedder, OpenTelemetryTelemetry
SimilarityStrategy(embedder=sentence_transformers_embedder("all-MiniLM-L6-v2"))
RouterBuilder(reg).with_telemetry(OpenTelemetryTelemetry())
Loading configuration#
from opensmartroute.config import load_targets, load_rules
registry = load_targets("targets.yaml") # or .json
rules = load_rules("rules.yaml")
Both raise ConfigurationError with the file, the offending entry and the reason. Only
yaml.safe_load is used.
Executing end to end#
Router.route() returns a decision; Router.run() (also on AsyncRouter and EnterpriseRouter)
routes and executes the resulting plan, returning an ExecutionResult. execute(decision, request)
is still available and returns just the primary handler's raw response.
res = router.run("Write SQL for monthly active users by region", task_id="job-17")
res.text # best-effort text of the primary response
res.response # the raw handler response (ChatResult, HarnessResult, dict, str, ...)
res.target_id # primary target
res.steps # [ExecutionStep(role, target_id, latency_ms, ok, note), ...]
res.system_prompt # what was composed into context["system"] (None if nothing was added)
res.effective_request # the RouteRequest the primary handler actually received
res.outcomes # one Outcome per participant, already passed to router.learn()
What run() does, in order:
route(request, plan=True)builds the plan: primary + optionalpersonaandskillslots. A slot is only filled when its best candidate's quality estimate is at leastRouter(slot_quality_floor=0.5)and (forrun) its confidence is at leastmin_slot_confidence.- Skill pre-processing. If the
skillslot's target has ahandler, it is called with the request and may return- a
RouteRequest— replaces the request the primary sees (rewrite / enrich); - a
str— attached ascontext["skill_output"]and appended to the system prompt; None— the skill contributes instructions only. Exceptions are recorded as a failed step (and a failedOutcome) but do not abort the run.
- a
- Prompt composition.
context["system"]is set to: any existingsystem+# Persona: <name>RouteTarget.instructionsof the persona +# Skill: <name>+ skill instructions + the primary's owninstructions(forllm/agenttargets) +# Skill output.chat_handlerand the harness adapters read this key.
- Primary call.
target.handler(request, **kw). Missing handler →ExecutionError. In the sync path a coroutine result raisesExecutionError("use aexecute()");AsyncRouter.run()awaits both skill and primary handlers. - Outcomes. Success is read from
response.success/response["success"](defaultTrue); cost fromresponse.cost_usdortotal_tokens × unit_cost; quality fromresponse.quality. OneOutcomeis created for the primary (role=None) and one per slot (role="persona"|"skill"), all carryingtask_id(argument orrequest.context["task_id"]) so delayed feedback can be joined later. Each is passed tolearn; on a handler exception, failure outcomes are recorded and the exception re-raised.
Targets carry instructions#
- id: persona-legal-counsel
kind: persona
primary: false
instructions: |
You are cautious in-house legal counsel. Cite the governing clause...
- id: skill-sql
kind: skill
instructions: "Always qualify table names with the schema; never SELECT *."
instructions is the persona's system prompt, a skill's body (SKILL.md style) or an agent's
standing instructions. It is disclosed to the model only when the target is part of the plan.
Agent harnesses#
An agent harness is any runtime that takes a task and drives its own loop (coding agent, research
agent, OpenHands, SWE-agent, Claude Code, LangGraph graph, ...). Adapters live in
opensmartroute.adapters and all produce a HarnessResult(text, success, steps, prompt_tokens, completion_tokens, cost_usd, latency_ms, quality, artifacts, raw):
from opensmartroute.adapters import CallableHarness, HTTPHarness, SubprocessHarness, harness_handler
# in-process: fn(task, context, history) -> str | dict | HarnessResult
registry.get("research-agent").handler = harness_handler(CallableHarness(my_graph.invoke))
# HTTP: POST {"task", "context", "history"} -> JSON {"text"|"output", "success", "usage", "cost_usd"}
registry.get("support-agent").handler = harness_handler(
HTTPHarness("https://agents.internal/support/run", api_key_env="AGENT_TOKEN", timeout_s=300))
# CLI: task on stdin, answer on stdout (or JSON with json_output=True); system prompt via a flag
registry.get("coding-agent").handler = harness_handler(
SubprocessHarness(["my-agent", "--repo", "."], system_flag="--system", timeout_s=900))
Non-zero exit codes and "success": false become failed outcomes; HTTP 408/429/5xx, timeouts and
unreachable hosts raise TargetUnavailableError (so HealthRegistry breakers trip); other HTTP
errors raise ExecutionError; a missing binary or non-http URL raises ConfigurationError.
Implement the AgentHarness protocol (run(task, *, context, history) -> HarnessResult) for anything
else.
Skills from SKILL.md#
Folders following the Agent Skills layout load directly:
from opensmartroute.adapters import load_skill, load_skills, skill_from_markdown
for skill in load_skills("skills/"): # skills/<name>/SKILL.md, name must match the folder
registry.add(skill)
Frontmatter name/description become id/examples; the Markdown body becomes instructions
(progressive disclosure: only the description is used for routing, the body is shown to the model
once selected). Routing metadata can be added under metadata: — osr-domains, osr-actions,
osr-languages, osr-tags, osr-quality-prior, osr-primary ("false" for slot-only skills),
osr-latency-ms. license, compatibility, allowed-tools and other metadata are preserved in
RouteTarget.metadata. Give the returned target a handler to make it an executable pre-processor
or primary skill.
Learning from outcomes#
Every learner (bandits, IRT, preference, LinUCB, Markov, task table, judge calibration) consumes the
same Outcome:
from opensmartroute import Outcome
d = app.route(req)
resp = call_provider(d.target, req)
app.learn(Outcome(request_id=req.request_id, target_id=d.target.id, success=True,
quality=grade(resp), cost_usd=resp.cost, latency_ms=resp.ms))
- Plans:
Router.run()records oneOutcomeper participant withrole="skill" | "persona" | "primary"; learners keep a target's record as a skill separate from its record as the primary answerer (signals.extra["role"]). - Cascades:
Cascade.run(request, d.trace.ranked)returns aCascadeResult; feed every step to the learners withapp.learn_cascade(request, result)(rejected steps are failures with the gate score as quality, the accepted step a success).result.total_cost_usdis the spend across steps. - Delayed / task-level rewards: set
Outcome.task_id(andstep) so a reward observed at the end of a multi-step task credits every call in the trajectory (Router(task_credit=...)). - Offline warm start:
learning.warm_start_from_matrix(rows, strategies, weight=0.5)replays a full-information reward matrix(prompt, {target_id: reward})from an offline evaluation into every learner before serving traffic;learning.warm_start()seeds a brand-new target from its neighbours. - Replicas:
learning.merge_learners(local, remote)folds another replica's learners into yours (sufficient statistics only, no prompts);AutoLearner.refresh()re-loads a writer's snapshot from a sharedStateStore. Corrupt state is quarantined on load (AutoLearner.quarantined) instead of raising. - Objective:
Objective(quality, cost, latency, quality_floor, energy, carbon);energyandcarbonweighRouteTarget.unit_energy(cost["wh_per_1k_tokens"]) andunit_carbon(cost["gco2_per_1k_tokens"]or energy xcost["gco2_per_wh"]). - Cold-start exploration: a target declared with only
id,kindandcostis never ranked first and so never earns an outcome.Router(explore_rate=0.1, explore_min_samples=200)(orRoutingSettings.explore_rate/explore_min_samples) serves the least-seen viable candidate with probabilityexplore_rateuntil it has that many outcomes;decision.propensitiesincludes the exploration mass (so off-policy estimates stay unbiased) andsignals.extra["explored"]marks the request. Off by default. - Roadmap exit criteria:
opensmartroute.eval.criteriaturns each offline-measurable exit criterion in ROADMAP.md into a simulation of the real router (cold_start_ratio(),effort_token_savings(),multi_round_vs_best_single(),match_at_1_at_scale(),conformal_coverage(),knapsack_never_exceeds_cap(),ope_within_live_ci()) andopensmartroute.eval.agenticadds the tau-bench-style one (AgentTask,load_agentic_tasks(),synthetic_agentic_tasks(),task_routing_frontier(): per-step routing throughProgressRouteragainst every always-use-agent-X policy on the accuracy-latency frontier).python scripts/exit_criteria.py [--full]runs them all; on your own data,osr eval DATASET --multi-roundruns the multi-round comparison on scored rows,osr eval DATASET --effortthe effort-routing token comparison on rows that carry per-targetscoresandtokens(EvalRow.tokens), andosr eval TASKS.jsonl --agenticthe task-routing frontier on tasks with per-agent per-stepsuccessandlatency_mstables. - Contrastive and policy-gradient learners:
learning.ContrastiveRouter().fit(rows, targets)trains on scoredEvalRows against each row's acceptable set (objective="distilled"uses reward-softened labels); wrap it inlearning.ContrastiveStrategy(model)to keep learning online.learning.PolicyGradientStrategy()learns a routing policy directly fromdecision_reward()(quality minus weighted cost and latency) with no quality predictor in between;learning.decision_regret(rows, targets, choose, predict)compares decision regret with prediction error on the same rows. - Headroom before you learn:
eval.headroom.routing_headroom(rows, targets)tells you how much of the oracle gap is measurable on your data;target_diversity,min_catalogue,scaling_curveandlearnability_by_difficultyanswer whether, and where, a learned router can beat the best single target. - Other target kinds:
strategies.SemanticCache.target()turns a semantic cache into aDESTINATIONtarget scored bySemanticCacheStrategy;strategies.AnnotatorPool+HumanRoutingStrategyroute amongHUMANannotators with Dawid-Skene skill estimates andselect_quorum();strategies.MemoryRouterroutes what to remember across memory tiers under a token budget;strategies.ModalityStrategy/ModalityEscalationhandle text-first escalation to multimodal targets;strategies.SpeculativeCascaderuns draft and strong models in parallel when the learned acceptance rate makes that cheaper.
Error handling#
All exceptions derive from OpenSmartRouteError and carry .code and .details:
from opensmartroute import OpenSmartRouteError, NoRouteError, SecurityError
try:
d = app.route(req)
except NoRouteError as e:
# e.details["rejections"] -> {target_id: reason}
fallback()
except SecurityError as e:
reject_request(e.to_dict())
except OpenSmartRouteError:
log.exception("routing failed")
The SDK never raises bare Exception/RuntimeError; strategy failures (e.g. a broken LLM judge)
degrade to neutral scores rather than failing the route.
Threading & async#
RouterandEnterpriseRouterare safe to share across threads.Router.lock(a re-entrant lock) guards strategy scoring, every learner update (learn,learn_correction,credit_task), task pins, the request memory, retrieval narrowing and the exploration RNG, so a strategy'sscorenever observes a half-appliedupdate. The optional LLM judge is scored outside the lock (network I/O) - put it behindescalate_llm_judge_belowso it runs rarely, and setTimeoutMiddleware.AutoLearnertakes the same lock (EnterpriseRouter/RouterBuilderpassrouter.lock; pass it yourself when wiring by hand) solearn,reset_target,save,loadandrefreshserialise against routing.save()deep-copies a sequence-numbered snapshot under the lock and writes it outside; a separate I/O lock orders writers and readers so a slow save never overwrites a newer snapshot and two saves never race on the state files.- Per-module state elsewhere has its own lock:
SemanticCache,MemoryRouter,AnnotatorPool,ModalityEscalation(posterior) andSpeculativeCascade(acceptance posteriors, history, failure counters). Embedding / hashing runs outside those locks. SpeculativeCascaderunsconcurrencyspeculative requests at once (pool of2 * concurrencyworkers);draft_timeout_sabandons a slow draft and serves the strong answer; a draft that raises escalates instead of failing (details["draft_error"]); a strong future that is no longer needed is cancelled or its late failure swallowed.stats()reports runs, modes, failure counts and per-bucket acceptance.AsyncRouterwraps any router forawait-based apps and awaits coroutine handlers;route/learnrun in the default executor under the same lock.- Routing has no I/O of its own.
Multi-round execution#
ProgressRouter.run_step routes through router.route (middleware and telemetry apply) and
executes with execution.execute; a handler that raises is recorded as a failed step for its
target before the exception propagates, so the next step already denies it after
failures_before_switch. Step cost falls back to target.unit_cost x estimated tokens when the
handler reports none, so budget_usd is tracked even for plain-string handlers. arun_step is
the coroutine twin.
MultiRoundExecutor (route -> execute -> judge -> refine) learns once per round with the
judged quality (no provisional learn + correction double count); a round whose handler raised
becomes a Round with error set, learns a failure for that target and the loop continues on a
different target. stop_reason is one of accepted, budget, deadline (deadline_ms) or
max_rounds; return_best=True (default) returns the highest-quality round when nothing reached
the threshold; credit_task=True delivers the final quality as the task-level reward through
router.credit_task, which re-credits every step (learners then see one judged update per round
plus one credited update). arun awaits coroutine handlers and an async judge.
Type checking#
The package ships py.typed; mypy --strict-friendly signatures on the public surface.
Configuration#
# targets.yaml
targets:
- id: llm-frontier
kind: llm
capabilities: { domains: [math, coding], min_complexity: 0.5, supports_tools: true }
constraints: { data_boundary: public, pii_allowed: false, regions: [us, eu] }
cost: { usd_per_1k_tokens: 0.015 }
latency_ms: 2500
quality_prior: 0.93
examples: ["Prove that sqrt(2) is irrational."]
# rules.yaml (Arch-Router-style preferences)
rules:
- name: escalation-intent
when: { actions: [escalate] }
prefer: [human-escalation]
pin: true # restrict candidates, not just boost
Secrets are never in YAML. Handlers read them at call time:
from opensmartroute.security import load_secret
api_key = load_secret("OPENAI_API_KEY") # env var or OPENAI_API_KEY_FILE
Extending#
Decorator SDK#
Every routing component - targets, strategies, signal extractors, policy rules, middleware and
telemetry sinks - can be declared with a decorator and assembled by a ComponentRegistry. The
decorators are transparent (they return the decorated object unchanged) and the registry stores
blueprints (factories), so one declaration can back many independent routers.
import opensmartroute as osr
@osr.tool("weather", domains=["travel"], actions=["lookup"], cost=0.0, examples=["weather in paris"])
def weather(request, **kw):
"""Current weather for a city.""" # first paragraph -> target.description
return lookup(request.text)
@osr.agent("planner", domains=["travel"], min_complexity=0.3, quality_prior=0.9)
def planner(request, **kw):
"""Plan a multi-day trip."""
return plan(request.text)
@osr.strategy(weight=0.8) # fn(request, signals, candidates) -> {id: score}
def geo_affinity(request, signals, candidates):
region = request.constraints.region
return {t.id: 1.0 if region in t.constraints.regions else 0.4 for t in candidates}
@osr.strategy # or a Strategy subclass; name inferred -> "recency"
class RecencyStrategy(osr.Strategy):
def score(self, request, signals, candidates): ...
@osr.signal(order=-1) # runs before the built-in extractors
def urgency(request, signals):
return {"urgent": "asap" in request.text.lower()} # unknown keys land in signals.extra
@osr.policy_rule # fn(target, request, signals) -> reason | None
def business_hours_only(target, request, signals):
if target.kind is osr.TargetKind.HUMAN and request.context.get("after_hours"):
return "human queues are closed"
return None
@osr.middleware # fn(request, next_) -> RouteDecision
def tag_tenant(request, next_):
request.context.setdefault("tenant", "public")
return next_(request)
router = osr.components.router() # Router: targets + strategies + signals + rules
app = osr.components.builder().with_auto_learning().build() # EnterpriseRouter variant
| Decorator | Accepts | Materialised as |
|---|---|---|
@target(id, kind, ...), @tool, @skill, @agent | handler fn(request, **kw) | RouteTarget(handler=fn) |
@strategy(name=, weight=, order=) | Strategy subclass or scorer function | Strategy / FunctionStrategy |
@signal(name=, order=) | SignalExtractor subclass or fn(request, signals) | SignalExtractor / FunctionSignal |
@policy_rule(name=, order=) | fn(target, request, signals) -> str | None or Policy subclass | rule appended to Policy.with_rules |
@middleware(name=, order=) | Middleware subclass or fn(request, next_) | Middleware / FunctionMiddleware |
@telemetry(name=, order=) | Telemetry subclass | Telemetry |
osr.componentsis the process-wide registry the top-level decorators bind to. Create your ownComponentRegistry()for isolation (tests, multi-tenant apps) - its methods are the same decorators.- Duplicate names raise
ConfigurationError(register(..., replace=True)to override);unregister,clear,mergeandadd(*instances)manage the catalogue. RouterBuilder.with_components(registry, targets=, strategies=, signals=, policy=, middleware=, telemetry=)wires a registry into an existing builder selectively.- Plugins:
registry.include("pkg.module")imports a module so its decorators run ("pkg.module:setup"callssetup(registry)afterwards);registry.discover()loads every installed distribution advertising anopensmartroute.pluginsentry point.
Subclassing#
from opensmartroute import Strategy, StrategyScore
class GeoAffinity(Strategy):
name = "geo"
def score(self, request, signals, candidates):
region = request.context.get("region")
return {t.id: StrategyScore(1.0 if region in t.constraints.regions else 0.5,
f"region={region}") for t in candidates}
app = RouterBuilder(reg).with_defaults().with_strategy(GeoAffinity(), weight=0.8).build()
Custom Policy, SignalExtractor, Middleware, Telemetry, StateStore, AuditSink follow
the same pattern — subclass, implement, pass to the builder (or register with a decorator).
Settings#
Every tunable lives in one immutable Settings object, grouped by consumer
(routing, policy, rules, capability, bandit, weights, slm, server, observability). Components resolve the
process-wide settings unless given an explicit instance.
import opensmartroute as osr
from opensmartroute.settings import RoutingSettings, WeightSettings
osr.configure(routing=RoutingSettings(narrow_above=200), weights=WeightSettings(rules=3.0))
router = osr.Router(reg) # uses the configured settings
isolated = osr.Router(reg, settings=osr.Settings()) # library defaults, unaffected
print(osr.get_settings().env_keys()) # every honoured OSR_* variable
Environment overlay: OSR_<GROUP>_<FIELD> (e.g. OSR_ROUTING_SOFTMAX_TEMPERATURE=0.2,
OSR_WEIGHTS_CAPABILITY=1.5, OSR_POLICY_DATA_BOUNDARIES=public,internal,confidential).
Values are parsed against the default's type; a bad value raises ConfigurationError with
details={"env": key}. osr.configure() with no arguments re-reads the environment.
osr settings [--json] prints the effective values, their OSR_* keys and which ones the
environment overrides.
Naming conventions#
opensmartroute.branding is the single source of truth for every brand-derived name:
| Convention | Value | Helper |
|---|---|---|
| Package / import | opensmartroute | branding.PACKAGE |
| CLI | osr | branding.CLI |
| Environment variables | OSR_<GROUP>_<FIELD> | branding.env_key("routing", "narrow_to") |
| Error codes | OSR_<KIND> (OSR_NO_ROUTE, OSR_CONFIG, ...) | branding.error_code("no_route") |
| Metadata / frontmatter keys | osr-<field> (osr-quality-prior) | branding.metadata_key("quality_prior") |
| State directory | .osr-state | branding.STATE_DIR |
| Loggers | opensmartroute[.component] | branding.logger("enterprise") |
HTTP User-Agent | opensmartroute/<version> | branding.user_agent() |
| Plugin entry-point group | opensmartroute.plugins | branding.ENTRY_POINT_GROUP |
| Config directory | ~/.config/opensmartroute (%APPDATA%\opensmartroute), OSR_CONFIG_DIR | credentials.config_dir() |
| Platform URL / key | OSR_API_URL, OSR_API_KEY | branding.platform_url(), branding.API_KEY_ENV |
| Access tokens | osr_live_ (platform key), osr_local_ (self-hosted server) | credentials.token_kind(), generate_token() |
Strategy names are snake_case nouns (capability, llm_judge); decorator-registered classes
drop the Strategy/Signal/Middleware/Telemetry suffix automatically (GeoAffinityStrategy
-> geo_affinity).
HTTP API (osr serve)#
| Method | Path | Body / response |
|---|---|---|
| POST | /route | {text, context, objective, constraints, kinds, top_k, plan} → RouteDecision.to_dict() |
| POST | /feedback | Outcome fields → {status} |
| GET | /targets | catalogue |
| GET | /stats | feedback aggregates (+ health snapshot when the router has one) |
| GET | /healthz | liveness |
| GET | /whoami | {service, edition: "self-hosted", version, auth_required, authenticated, targets} - what osr whoami shows |
| GET | /v1/models | OpenAI-compatible model list (auto + every primary target) |
| POST | /v1/chat/completions | OpenAI-compatible completion; model: "auto" routes, a target id pins |
create_app(router) takes a plain Router or an EnterpriseRouter (middleware, health, telemetry
and audit then apply to every request). Errors map to HTTP statuses: SecurityError /
ValidationError → 400, NoRouteError → 422, TargetUnavailableError → 503, ExecutionError → 502,
unknown pinned model → 404.
create_app(router, auth_tokens=[...]) (CLI: serve --token, --generate-token, or
OSR_SERVER_AUTH_TOKENS=a,b) turns on access control: every path except /healthz, /readyz,
/metrics, /whoami and the OpenAPI documents needs Authorization: Bearer <token> or
X-API-Key: <token>, compared in constant time; anything else is 401 with WWW-Authenticate: Bearer. ServerSettings.require_auth (OSR_SERVER_REQUIRE_AUTH=1, serve --require-auth) refuses to
start without a token. Tokens are opaque strings; osr token generate mints osr_local_... ones and
osr login --url http://host:8000 --token ... stores one for the CLI.
CLI#
osr login [--url URL] [--token T | --with-token] [--no-browser] [--profile P] [--json]
osr whoami [--json] | osr logout [--all] | osr token generate|create|list|revoke
osr -t targets.yaml -r rules.yaml route "text" [--plan] [--json] [--cost-weight 0.3]
osr -t targets.yaml -r rules.yaml eval dataset.jsonl [--frontier] [--multi-round --threshold 0.8 --max-rounds 3]
osr -t targets.yaml eval rows.jsonl --effort # rows with per-target scores + tokens vs always-think
osr -t targets.yaml eval tasks.jsonl --agentic [--retries 1] # per-step task routing vs best single agent
osr -t targets.yaml targets | stats | serve [--host --port] [--token T ...] [--generate-token] [--require-auth]
osr -t targets.yaml --skills .claude/skills route "text" --plan # add SKILL.md packages as slot targets
osr skills [ROOT] [--json] # validate + list SKILL.md packages
osr settings [--json]
osr login signs in to the hosted platform with the OAuth 2.0 device authorization grant (the
browser opens /platform/cli/authorize, you approve the code, the platform mints a workspace API key) or
stores a pasted token; opensmartroute.credentials (CredentialStore, device_login, whoami,
PlatformClient) is the library behind it and works with any injected transport.
Interop roadmap#
See ROADMAP.md — v0.7 covers MCP tool catalogues, A2A agent cards, the
OpenAI-compatible proxy mode, LangGraph / Microsoft Agent Framework nodes, and Redis/Postgres
StateStore adapters.