Skip to content
OpenSmartRoute

SDK Guide

Decorators for strategies, rules, signals, middleware and telemetry; settings and plugins.

docs/SDK.md

Stability & versioning#

  • SemVer. Everything exported from opensmartroute (top-level) is public API. opensmartroute.enterprise, .security, .learning, .realtime, .math, .eval, .aio, .adapters, .config are 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:

ExtraAddsUse when
(none)pure stdlibembedding in any Python 3.10+ service
yamlPyYAMLloading targets.yaml / rules.yaml
serverFastAPI, uvicorn, pydanticrunning osr serve
embeddingssentence-transformerssemantic SimilarityStrategy
otelopentelemetry-api/sdkadapters.OpenTelemetryTelemetry
cryptocryptographyAES-GCM encrypted state stores
devpytest, ruff, mypy, banditcontributing
allevery extra abovethe 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:

  1. route(request, plan=True) builds the plan: primary + optional persona and skill slots. A slot is only filled when its best candidate's quality estimate is at least Router(slot_quality_floor=0.5) and (for run) its confidence is at least min_slot_confidence.
  2. Skill pre-processing. If the skill slot's target has a handler, it is called with the request and may return
    • a RouteRequest — replaces the request the primary sees (rewrite / enrich);
    • a str — attached as context["skill_output"] and appended to the system prompt;
    • None — the skill contributes instructions only. Exceptions are recorded as a failed step (and a failed Outcome) but do not abort the run.
  3. Prompt composition. context["system"] is set to: any existing system + # Persona: <name>
    • RouteTarget.instructions of the persona + # Skill: <name> + skill instructions + the primary's own instructions (for llm/agent targets) + # Skill output. chat_handler and the harness adapters read this key.
  4. Primary call. target.handler(request, **kw). Missing handler → ExecutionError. In the sync path a coroutine result raises ExecutionError("use aexecute()"); AsyncRouter.run() awaits both skill and primary handlers.
  5. Outcomes. Success is read from response.success / response["success"] (default True); cost from response.cost_usd or total_tokens × unit_cost; quality from response.quality. One Outcome is created for the primary (role=None) and one per slot (role="persona"|"skill"), all carrying task_id (argument or request.context["task_id"]) so delayed feedback can be joined later. Each is passed to learn; 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 one Outcome per participant with role="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 a CascadeResult; feed every step to the learners with app.learn_cascade(request, result) (rejected steps are failures with the gate score as quality, the accepted step a success). result.total_cost_usd is the spend across steps.
  • Delayed / task-level rewards: set Outcome.task_id (and step) 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 shared StateStore. Corrupt state is quarantined on load (AutoLearner.quarantined) instead of raising.
  • Objective: Objective(quality, cost, latency, quality_floor, energy, carbon); energy and carbon weigh RouteTarget.unit_energy (cost["wh_per_1k_tokens"]) and unit_carbon (cost["gco2_per_1k_tokens"] or energy x cost["gco2_per_wh"]).
  • Cold-start exploration: a target declared with only id, kind and cost is never ranked first and so never earns an outcome. Router(explore_rate=0.1, explore_min_samples=200) (or RoutingSettings.explore_rate / explore_min_samples) serves the least-seen viable candidate with probability explore_rate until it has that many outcomes; decision.propensities includes the exploration mass (so off-policy estimates stay unbiased) and signals.extra["explored"] marks the request. Off by default.
  • Roadmap exit criteria: opensmartroute.eval.criteria turns 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()) and opensmartroute.eval.agentic adds the tau-bench-style one (AgentTask, load_agentic_tasks(), synthetic_agentic_tasks(), task_routing_frontier(): per-step routing through ProgressRouter against 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-round runs the multi-round comparison on scored rows, osr eval DATASET --effort the effort-routing token comparison on rows that carry per-target scores and tokens (EvalRow.tokens), and osr eval TASKS.jsonl --agentic the task-routing frontier on tasks with per-agent per-step success and latency_ms tables.
  • Contrastive and policy-gradient learners: learning.ContrastiveRouter().fit(rows, targets) trains on scored EvalRows against each row's acceptable set (objective="distilled" uses reward-softened labels); wrap it in learning.ContrastiveStrategy(model) to keep learning online. learning.PolicyGradientStrategy() learns a routing policy directly from decision_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_curve and learnability_by_difficulty answer whether, and where, a learned router can beat the best single target.
  • Other target kinds: strategies.SemanticCache.target() turns a semantic cache into a DESTINATION target scored by SemanticCacheStrategy; strategies.AnnotatorPool + HumanRoutingStrategy route among HUMAN annotators with Dawid-Skene skill estimates and select_quorum(); strategies.MemoryRouter routes what to remember across memory tiers under a token budget; strategies.ModalityStrategy / ModalityEscalation handle text-first escalation to multimodal targets; strategies.SpeculativeCascade runs 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#

  • Router and EnterpriseRouter are 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's score never observes a half-applied update. The optional LLM judge is scored outside the lock (network I/O) - put it behind escalate_llm_judge_below so it runs rarely, and set TimeoutMiddleware.
  • AutoLearner takes the same lock (EnterpriseRouter / RouterBuilder pass router.lock; pass it yourself when wiring by hand) so learn, reset_target, save, load and refresh serialise 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) and SpeculativeCascade (acceptance posteriors, history, failure counters). Embedding / hashing runs outside those locks.
  • SpeculativeCascade runs concurrency speculative requests at once (pool of 2 * concurrency workers); draft_timeout_s abandons 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.
  • AsyncRouter wraps any router for await-based apps and awaits coroutine handlers; route / learn run 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
DecoratorAcceptsMaterialised as
@target(id, kind, ...), @tool, @skill, @agenthandler fn(request, **kw)RouteTarget(handler=fn)
@strategy(name=, weight=, order=)Strategy subclass or scorer functionStrategy / FunctionStrategy
@signal(name=, order=)SignalExtractor subclass or fn(request, signals)SignalExtractor / FunctionSignal
@policy_rule(name=, order=)fn(target, request, signals) -> str | None or Policy subclassrule appended to Policy.with_rules
@middleware(name=, order=)Middleware subclass or fn(request, next_)Middleware / FunctionMiddleware
@telemetry(name=, order=)Telemetry subclassTelemetry
  • osr.components is the process-wide registry the top-level decorators bind to. Create your own ComponentRegistry() for isolation (tests, multi-tenant apps) - its methods are the same decorators.
  • Duplicate names raise ConfigurationError (register(..., replace=True) to override); unregister, clear, merge and add(*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" calls setup(registry) afterwards); registry.discover() loads every installed distribution advertising an opensmartroute.plugins entry 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:

ConventionValueHelper
Package / importopensmartroutebranding.PACKAGE
CLIosrbranding.CLI
Environment variablesOSR_<GROUP>_<FIELD>branding.env_key("routing", "narrow_to")
Error codesOSR_<KIND> (OSR_NO_ROUTE, OSR_CONFIG, ...)branding.error_code("no_route")
Metadata / frontmatter keysosr-<field> (osr-quality-prior)branding.metadata_key("quality_prior")
State directory.osr-statebranding.STATE_DIR
Loggersopensmartroute[.component]branding.logger("enterprise")
HTTP User-Agentopensmartroute/<version>branding.user_agent()
Plugin entry-point groupopensmartroute.pluginsbranding.ENTRY_POINT_GROUP
Config directory~/.config/opensmartroute (%APPDATA%\opensmartroute), OSR_CONFIG_DIRcredentials.config_dir()
Platform URL / keyOSR_API_URL, OSR_API_KEYbranding.platform_url(), branding.API_KEY_ENV
Access tokensosr_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)#

MethodPathBody / response
POST/route{text, context, objective, constraints, kinds, top_k, plan}RouteDecision.to_dict()
POST/feedbackOutcome fields → {status}
GET/targetscatalogue
GET/statsfeedback aggregates (+ health snapshot when the router has one)
GET/healthzliveness
GET/whoami{service, edition: "self-hosted", version, auth_required, authenticated, targets} - what osr whoami shows
GET/v1/modelsOpenAI-compatible model list (auto + every primary target)
POST/v1/chat/completionsOpenAI-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.