<!-- OpenSmartRoute: SDK guide. https://opensmartroute.ai/docs/SDK -->
# SDK Guide

## 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](https://opensmartroute.ai/docs/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.

```python
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):

```python
from opensmartroute.adapters import sentence_transformers_embedder, OpenTelemetryTelemetry
SimilarityStrategy(embedder=sentence_transformers_embedder("all-MiniLM-L6-v2"))
RouterBuilder(reg).with_telemetry(OpenTelemetryTelemetry())
```

## Loading configuration

```python
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.

```python
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`

```yaml
- 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)`:

```python
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](https://agentskills.io/specification) layout load directly:

```python
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`:

```python
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](https://opensmartroute.ai/docs/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 `EvalRow`s 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`:

```python
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

```yaml
# 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."]
```

```yaml
# 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:

```python
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.

```python
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.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

```python
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.

```python
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](https://opensmartroute.ai/docs/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.
