<!-- OpenSmartRoute: Platform guide. https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/PLATFORM -->
# Platform guide

The hosted platform runs OpenSmartRoute as a service. A workspace has an API key and a plan; each
request to the API is routed to a target (model, agent, skill, persona or tool) and answered with the
decision, its trace and, on request, the target's output. An OpenAI-compatible endpoint accepts
existing chat-completion clients.

This page covers calling the platform: authentication, routing, execution, the OpenAI-compatible
endpoint, feedback, plans, organizations, tenants, governance (workspace policy and budgets),
observability (probes, metrics, per-request traces and the event stream), caching, the dashboard and
the MCP server. The
[marketplace](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MARKETPLACE.md) - shared agents, skills, personas, prompts and stack templates - has
its own page. To run the router on your own infrastructure see
[Deploy with Docker and Helm](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/deploy.md); to use it as a Python library see the
[user guide](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/GUIDE.md) and the [SDK guide](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/SDK.md). Every endpoint below is listed in the REST API
reference (`/docs/api`) and in `/openapi.json`.

## 1. Authentication

1. Sign up at `/platform/signup` with Microsoft, Google, GitHub, GitLab, LinkedIn (the providers the deployment
   enables - `GET /api/v1/info` lists them under `sso`; the LinkedIn app the growth engine posts with doubles
   as the sign-in provider unless `OSR_PLATFORM_LINKEDIN_SSO=false`) or an email address - with a password if
   you want to sign in by email later (`OSR_PLATFORM_PASSWORD_LOGIN`, on by default). Signup creates a personal
   workspace on the **free** plan and issues an API key. With Google configured the site also shows Google's One
   Tap prompt ("Sign in to ... with google.com") to visitors who are not signed in; picking an account posts the
   ID token to `POST /api/v1/auth/{provider}/credential` `{credential, next}` (Google only for now), which has
   Google validate it and
   answers with the same browser session as the redirect callback - no page change, a first sign-up still shows
   the key once. The Google OAuth client must list the site's origin (`OSR_PLATFORM_WEB_URL`) under *Authorized
   JavaScript origins* or Google refuses to show the prompt.
2. The key is shown once. Store it in a secret manager. Keys are created, renamed, rotated and revoked in
   the dashboard (`/platform/dashboard/keys`) or with `POST /api/v1/keys`, `PATCH /api/v1/keys/{key_id}`
   `{name}`, `POST /api/v1/keys/{key_id}/rotate` (a new secret for the same key, shown once; the old one
   stops working immediately) and `DELETE /api/v1/keys/{key_id}` - all admin role, or the lead of the team a
   key was minted for (`team` on `POST /api/v1/keys`, [Teams](#teams)). A key does everything its workspace
   can unless you limit it: `scopes` (`read` - every GET; `route` - decisions without execution: `/route`,
   `/estimate`, MCP ranking tools; `execute` - anything that can spend money: `execute: true`, `/v1/*`, the
   MCP `ask` tool; `feedback`; `manage` - every other write) and `expires_in_days` (1-3650) on `POST /api/v1/keys`
   or `PATCH /api/v1/keys/{key_id}` `{scopes}`; `GET /api/v1/keys/scopes` lists them. A scoped key that
   steps outside its scopes is refused with `403` and `X-Key-Scope: <missing scope>` before anything is
   metered; an expired key answers `401 API key expired` until it is revoked (rotation keeps the expiry).
   `limits` narrows what a key may spend and reach: `daily_budget_usd` / `monthly_budget_usd` cap the key's own
   spend (checked like the workspace and tenant budgets - `429` with the `X-Budget-*` headers once reached;
   routing without execution still works), `requests_per_minute` caps its rate below the plan's (`429` with
   `Retry-After` and `X-Key-Limit: requests_per_minute`), `tokens_per_minute` caps the tokens it may send and
   receive in a sliding minute (charged with the request's bound - the prompt plus the output allowance - before
   any provider is called, so a burst is refused rather than billed: `429` with `Retry-After` and
   `X-Key-Limit: tokens_per_minute`), `allowed_ips` pins the key to client addresses or CIDR blocks (at most 32;
   the address is the one past the trusted proxies, never a header the client wrote; a call from elsewhere answers
   `403` with `X-Key-Limit: allowed_ips`) and `allow_targets` lists the target ids the key may route to (the
   router never leaves the list, a pinned `model` outside it answers `403` with `X-Key-Limit: allow_targets`, and
   a caller's own `allow_targets` can only narrow it further). `required_headers` names headers every request with
   the key must carry (a project tag, a ticket id, a cost centre - at most ten; a request without them answers
   `428` with `X-Key-Limit: required_headers`), and `mcp_tools` lists the connected MCP servers' tools the key may
   reach (`server:tool` or `server:*`; section 12). `PATCH /api/v1/keys/{key_id}` `{limits}` replaces
   them (`{}` clears); `GET /api/v1/keys/scopes` lists the limit keys; every change is audited (`key.limited`).
   Give keys that leave your hands - CI, a partner, a demo - the smallest scopes, an expiry and a budget.
3. Send the key with every metered request in either header, and - when you want the row attributed to a cost
   centre without a tenant - the project tag:

```http
Authorization: Bearer osr_...
X-API-Key: osr_...
X-OSR-Project: checkout-service
```

`X-OSR-Project` (letters, digits, `.`, `_`, `-`; up to 64) is recorded on the usage row as `project` and broken
out in `GET /api/v1/usage` as `by_project` - cheaper than a tenant when all that is wanted is a line in the
report. The Google GenAI SDKs' `x-goog-api-key` header is accepted as the key too (section 4).

Public endpoints (`/api/v1/info`, `/api/v1/models`, `/api/v1/llms`, `/api/v1/rankings`,
`/api/v1/catalogue`, `/api/v1/estimate`, `/api/v1/stats/public`, `/api/v1/registry`) need no key.

### Email and password

`POST /api/v1/auth/password/login` `{email, password}` returns the same browser session as a
single sign-on callback (`session_token`, `user`, `account`, `plan`); the dashboard stores it and
sends it as a bearer token. Passwords are at least 8 characters (no composition rules) and are stored
as salted scrypt hashes. A signed-in user sets or changes theirs with `POST /api/v1/auth/password`
`{password, current_password}` - the current one is required once set and every other browser is
signed out - and removes it with `DELETE /api/v1/auth/password` as long as a provider identity remains.
Sign-in attempts are limited per address and per email; `has_password` on the user object says which
users have one. `/platform/dashboard/account` has the form.

**Two-factor authentication.** A person with a password can add an authenticator app (TOTP, RFC 6238: six
digits every 30 s, one step of drift accepted) under *Account › Two-factor authentication*:
`POST /api/v1/auth/mfa/setup` `{current_password}` returns the secret and the `otpauth://` URI,
`POST /api/v1/auth/mfa/enable` `{code}` confirms it and returns eight single-use recovery codes (shown once;
other browsers are signed out), `POST /api/v1/auth/mfa/recovery-codes` `{code}` replaces them and
`DELETE /api/v1/auth/mfa` `{current_password, code}` switches the factor off - each change is e-mailed as a
security notice (`two_factor_changed`). From then on `POST /api/v1/auth/password/login` answers
`mfa_required` with a five-minute signed `challenge` instead of a session, and
`POST /api/v1/auth/mfa/verify` `{challenge, code}` with the authenticator code (or an unused recovery code)
issues it; the sign-in page shows the code step. A password-reset link proves the inbox, not the second
factor, so `POST /api/v1/auth/password/reset` saves the password and answers the same challenge
(`password_changed: true`). Identity providers bring their own second factor and are not asked again; API
keys are the workspace, not a person, and never are. `mfa_enabled`, `mfa_enabled_at` and
`recovery_codes_left` are on the user object; an operator resets a locked-out person with
`PATCH /api/v1/admin/users/{id}` `{"mfa_reset": true}` (sessions end, the person is told). History:
`user.mfa_enabled`, `user.mfa_disabled`, `user.mfa_recovery_used`, `user.mfa_recovery_codes_replaced`,
`user.login_mfa_failed`, `user.mfa_reset`.

### Account lifecycle: confirmation, recovery, deletion

Anyone can create an account with any email address. What happens around it:

| Step | How | Endpoints |
|---|---|---|
| Email confirmation | Signup sends a link to `/platform/verify-email?token=...` (valid three days). Signing in through a provider that vouches for the address, accepting an invitation sent to it or completing a password reset also confirms it. A provider vouches when it sends `email_verified` (Google, any OIDC issuer, Entra's optional `xms_edov`) or when it only reports addresses it verified or owns - GitHub, Microsoft, GitLab and LinkedIn, as the shared button or as an organization's own connection of that kind; a generic OIDC issuer that sends no such claim leaves the address unconfirmed and the confirmation link goes out. `email_verified` on the user object. **Confirmation is required** (`OSR_PLATFORM_REQUIRE_EMAIL_VERIFICATION`, on by default): until it is done every call but `/api/v1/me`, `/api/v1/onboarding`, `/api/v1/auth/*` and `/api/v1/legal` answers `403` with `X-Email-Verification: required` - for the person's session and for the workspace's API keys alike (a key stands on an owner's confirmed address), on the OpenAI-compatible `/v1/*` proxy and the MCP server as on the REST API, and a public endpoint that personalises its answer for a key (`/api/v1/estimate`, the site assistant) treats an unconfirmed key as no key at all - anonymous limits, never the workspace's; `GET /api/v1/me` says so in `email_verification_required` and the dashboard shows the confirmation step in place of its pages, with *Send the link again*. Signups from domains reserved for testing (`example.com`, `example.org`, `*.test`, `*.local`, `*.invalid`, `*.localhost`) are refused unless `OSR_PLATFORM_SIGNUP_TEST_DOMAINS` is on (throw-away CI stacks only), and a user who never confirms is removed after `OSR_PLATFORM_UNVERIFIED_ACCOUNT_TTL_DAYS` (14) with the personal workspace only they belonged to. | `POST /api/v1/auth/verify` `{token}`, `POST /api/v1/auth/verify/send` (signed in, once a minute) |
| Forgot password | `/platform/forgot-password` emails a single-use link to `/platform/reset-password?token=...` (one hour). The answer is always `202` so it never reveals whether an address has an account; limited per address and per email. Resetting revokes every session, confirms the address, signs the person in and sends a notice. | `POST /api/v1/auth/password/forgot` `{email}`, `POST /api/v1/auth/password/reset` `{token, password}` |
| Invitations | `POST /api/v1/workspace/invites` emails the invitee a link to `/platform/invite/<token>` (fourteen days) and returns the same link once to the inviter. | see [Organizations](#7-organizations) |
| Account history | The person's own sign-ins, password and email changes and memberships, newest first. | `GET /api/v1/me/audit` |
| Download my data | One JSON document with everything held about the person and the current workspace: profile, identities, sessions, memberships and history; the workspace with plan, members, teams and their rosters, key prefixes, tenants, policy, SSO, usage, the request log, reported outcomes and the kept evaluation reports (`evaluations`) of up to a year. Prompt text is stored only when the workspace policy turns request logging on (`log_payloads`); whatever was kept is included as `request_logs`. *Download my data* on `/platform/dashboard/account`. | `GET /api/v1/me/export?days=` |
| Delete account | *Delete my account* on `/platform/dashboard/account`: type the email (and the password when one is set). Sessions, linked identities and memberships go, workspaces only this user belonged to are disabled and their keys revoked; an organization with other members needs another owner first. A notice is emailed. | `POST /api/v1/me/delete` `{confirm, password}` |
| Delete organization | *Delete organization* on `/platform/dashboard/workspace`, owners only: type the organization's name or slug (and the password when one is set). Its keys, members, invitations, tenants, policy, SSO connection, usage and outcomes go at once and a live subscription is cancelled immediately - nothing more is billed, the paid remainder is not refunded automatically. Every member keeps their personal workspace and their session moves to it; the deletion is on the audit trail (`account.deleted`). Personal workspaces go with the user. | `POST /api/v1/workspace/delete` `{confirm, password}` |
| Getting started | The checklist of a workspace: e-mail confirmed, API key created, first request routed, first outcome reported (the playground's one-click *Good* / *Poor*), a model provider connected and - for organizations - a teammate invited; each step carries the dashboard link and the API call that completes it, `next` is the first open one and `complete` the share done. The dashboard overview shows it until every step is done. Until the first request is routed the card also asks *What is in your way?* - one click from a short list (no time yet, no provider to connect, not clear how, a different integration, something failed, still comparing, a feature missing) and an optional line; the answer reaches the operator next to the workspace and the thanks link to the page that removes the obstacle. | `GET /api/v1/onboarding`, `GET /api/v1/journey/blocker`, `POST /api/v1/journey/answer` |

Signup, confirmation, password recovery, invitation links and this checklist form the `onboarding`
domain, which a deployment can run as its own service (see [Services](#services)).

Every one of these events - plus sign-ins, failed sign-ins, role changes, removals and operator actions
- is written to the account audit log. Workspace admins read their workspace's entries with
`GET /api/v1/workspace/audit`; operators see everything at `/platform/admin/audit` (`GET /api/v1/admin/audit-log`)
and per user on `/platform/admin/users/<id>`, where they can also issue a reset link, re-send or force the email
confirmation, disable, sign out everywhere or delete the user.

### Sign-in locations and alerts

Every browser session and every account event records the address it came from (`ip`). An address alone tells
a person little - *203.0.113.9* does not say whether that was their own phone on holiday or somebody else - so
the platform can turn it into a place (`location`: country, region, city and a label such as *Lisbon, Portugal*)
from one of two sources the operator chooses; without either, nothing is resolved and the address alone is shown:

| Source | Setting | What it gives |
|---|---|---|
| A header the ingress sets | `OSR_PLATFORM_GEOIP_HEADER` - the header's name, `CF-IPCountry` behind Cloudflare, `X-Country-Code` behind a gateway that resolves it | The country. Free, nothing leaves the platform. |
| A lookup service | `OSR_PLATFORM_GEOIP_SERVICE` - a preset (`ipinfo`, `ipapi`, `ip-api`, `ipwho`) or a URL template with `{ip}` and `{token}`; `OSR_PLATFORM_GEOIP_TOKEN` when the service wants a key | City level. One HTTPS call per new address, two seconds at most (a slow answer never holds a sign-in), cached in the database for thirty days and shared by every replica. Private and loopback addresses are never looked up (*local network*). |

Both settings are also under *Runtime configuration* in the console, so a deployment can switch them on without
a redeploy. Choose a service whose terms fit your use: sending a visitor's address to a third party is a
processing of personal data, and your privacy notice should name it.

**Sign-in alerts.** A sign-in from a device the account has not been seen on in ninety days, or - once places
are resolved - from a country none of its recent sessions came from, is announced to the person by e-mail
(`new_sign_in`, a security message that cannot be switched off) and in their notifications, with the device, the
method, the place and the address; the account history records `user.new_device` or `user.new_location`. A
person who wants to hear about every sign-in chooses *Every sign-in* under *Sign-in alerts* on
`/platform/dashboard/account` (`PUT /api/v1/me/login-alerts` `{mode}`, `new` or `always`; `login_alerts` on the
user object). The very first session of an account is not announced - the welcome covers it.

**What operators see.** The user page shows each session's place next to its address and a *Sign-in places*
card: where the account was signed in from in the last ninety days, with the addresses, the devices, how many
sign-ins and the first and last one - a traveller shows one device in several places, a takeover a new place on
a new device. The deployment audit log carries the place of every event, filters by country (`country=DE`,
`--` for events without a place), searches it with `q` and exports it in the CSV.

**Forgetting.** Addresses and places are kept with the row for `OSR_PLATFORM_NETWORK_DATA_RETENTION_DAYS`
(365; `0` = as long as the event) and then removed by the retention sweep - the event stays, the address and
the place go. *Forget addresses* on the user page (`DELETE /api/v1/admin/users/{id}/network-data`) does the same
for one person at once, for a privacy request; it is audited as `user.network_data_cleared` with the operator's
own address and place, like every operator action.

**Operators too.** A console sign-in records its address and place on the operator's session (shown under
*Operators*), `operator.login`, `operator.login_failed`, `operator.login_mfa_failed` and `operator.logout` are in
the audit log with the place, and every operator action carries the address and place of the console session it
came from. A console sign-in from a browser or a country the operator has not used in ninety days is written as
`operator.new_device` / `operator.new_location` and filed in the console's bell for every operator to see.

### Email: transports, templates, welcome, newsletter and reports

Every message the platform sends is composed once as a
branded HTML card (privacy and terms links in the
footer) with a plain-text alternative: the welcome message (greeting, a four-step checklist, dashboard
and quick-start links - sent by signup, or by the first sign-in of a person who arrived another way: a shared or
an organization's identity provider, an operator-created user, a password-reset link; never twice), address
confirmation (sent by signup; the first sign-in sends it too when the address is still unconfirmed and no link ever
went out), password reset, invitation, account deleted, password changed, the
operator test message, usage reports, alerts, support-ticket mail, product news and newsletter issues. Each is
written to the outbox first and then delivered by whichever transport is configured (the first configured one
in this order wins).

Every message follows the same design so a reader knows what they are holding before they read a word: the
wordmark, a coloured band and a small label naming the kind of message (*Security* in red, *Alert* in amber,
*Account* in ink, *Getting started* and *Report* in blue, *Release notes* and *Product news* in green,
*Newsletter* in violet, *Support* and *Feedback* in teal), one heading, short sentences that read at an
eighth-grade level (`tests/test_email_readability.py` fails a template that does not), one clear action, a
plain-text alternative, and a footer that says why the person is receiving it and where to change that. A
message that could worry someone - a new sign-in, failed attempts, a change to the account - always says what
happened, whether anything is lost, what to do if it was not them, and that a reply reaches a person.

| Transport | Setting | Notes |
|---|---|---|
| Microsoft 365 (Microsoft Graph) | `OSR_PLATFORM_M365_TENANT_ID`, `OSR_PLATFORM_M365_CLIENT_ID`, `OSR_PLATFORM_M365_CLIENT_SECRET` | Mail leaves from the Exchange Online mailbox named by `OSR_PLATFORM_MAIL_FROM` (display name `OSR_PLATFORM_MAIL_FROM_NAME`, default the brand) with `POST /users/{sender}/sendMail`, so DKIM, SPF and the tenant's retention apply as for any other mailbox; nothing is saved to Sent Items. Set-up: in Entra ID register an application, add the **application** permission `Mail.Send` (Microsoft Graph) and grant admin consent, create a client secret, and give the platform the tenant id (GUID or verified domain), client id and secret; the tenant id and client id are validated at start-up and a malformed value disables the transport with the reason in `GET /api/v1/admin/mail`. Restrict the app to the one sender with an Exchange *application access policy* (`New-ApplicationAccessPolicy -PolicyScopeGroupId <mail-enabled group with the sender> -AccessRight RestrictAccess`). Tokens (client credentials) are cached until a minute before expiry and refreshed once on a `401`. |
| Azure Communication Services Email | `OSR_PLATFORM_ACS_CONNECTION_STRING` | `endpoint=https://<name>.<region>.communication.azure.com/;accesskey=...`; the platform signs requests itself (HMAC-SHA256, no SDK). `OSR_PLATFORM_MAIL_FROM` must be a sender of a linked domain, e.g. `DoNotReply@<guid>.azurecomm.net` for an Azure-managed domain or `no-reply@yourdomain` once a custom domain is verified. |
| SMTP | `OSR_PLATFORM_SMTP_URL` | `smtp://user:pass@host:587` (STARTTLS) or `smtps://user:pass@host:465`. |
| Outbox only | none | Nothing is lost: operators read the links at `/platform/admin/mail` (`GET /api/v1/admin/mail`) and hand them over; `mail` in `GET /api/v1/info` tells the web app which case applies. |

**Delivery guarantees.** Sending is at-least-once with bounded retries. Every attempt is recorded on the outbox
row (`attempts`, `error`, `next_attempt_at`); a failure is retried on a back-off ladder (30 s, 2 min, 10 min,
30 min, 2 h - six attempts in all) by a sweeper thread in the API, one leader per Redis lease in a cluster.
A row whose sending process disappeared mid-send is retried after five minutes. Once the ladder is exhausted
the row stays in the outbox as failed for an operator to resend by hand (`POST /api/v1/admin/mail/{id}/resend`
always tries once more). ACS accepts a message with `202` and delivers asynchronously; the platform polls the
operation status briefly and records a terminal `Failed` (unverified sender, suppressed recipient) as an error
instead of a false "sent", keeping the operation id as `provider_id`. `GET /api/v1/admin/mail` returns a
`delivery` block (transport `ok`, last error, rows `retrying`, sweeper state) next to `summary.retrying` and
`summary.abandoned`; `GET /readyz` reports the same under `mail` (informational, never fails readiness).

**The gallery: every message before a customer sees it.** Operators manage what the platform says from
`/platform/admin/mail`: `GET /api/v1/admin/mail/templates` lists every kind of message with its category,
whether people can switch it off and how many went out; `GET /api/v1/admin/mail/templates/{kind}` renders it with
sample data (subject, the HTML card, the plain-text alternative) and the page shows it as a customer would see it;
`POST /api/v1/admin/mail/templates/{kind}/send` `{to}` sends that sample to the operator's own inbox (stored in the
outbox under the kind with a `[Sample]` subject; audit `mail.sample`) to check the words and the design in a real
mail client. Together with the outbox (read, resend, delete), the transport test, the suppression list, each
user's switches (`/platform/admin/users/<id>`), the report schedule (`/platform/admin/reports`) and the campaign
switches (`PATCH /api/v1/admin/campaigns/{campaign}` `{enabled}`, on `/platform/admin/growth`), every message the
platform sends can be seen, tested, held back or re-sent from the console.

**Bounces and the suppression list.** An address the transport rejects for good (SMTP `550`-`554`, a `5.1.x`
status, "user unknown", "mailbox unavailable", an ACS `Suppressed`/`Bounced` status) is not retried: the row is
abandoned at once with the reason and the address goes on the **suppression list** (`email_suppressions`, reason
`bounce`), so no later message - of any category, newsletter included - is composed for it; such a skip is
counted as `suppressed` in `delivery`. Delivery reports keep the list honest after acceptance: with
`OSR_PLATFORM_MAIL_EVENTS_TOKEN` set, `POST /api/v1/email/events/acs?token=…` receives Azure Communication
Services' Event Grid events (subscribe the endpoint to `EmailDeliveryReportReceived` and
`EmailEngagementTrackingReportReceived`; the handshake is answered automatically, a wrong token is `401`, and the
endpoint answers `404` without the setting) and stamps each outbox row with `delivery_status` / `delivery_at`
(`delivered`, `bounced`, `spam`, `quarantined`, `expired`, `failed`); a bounce, quarantine or spam report also
suppresses the address, a soft failure (`expired`) for thirty days only. Operators see and edit the list at
`/platform/admin/mail` - `GET /api/v1/admin/mail/suppressions` (`q`, `limit`), `POST /api/v1/admin/mail/suppressions`
(`email`, `reason`, `detail` - for complaints and requests made out of band) and
`DELETE /api/v1/admin/mail/suppressions/{email}` to allow an address again; `GET /api/v1/admin/mail` carries the
list size by reason under `suppressions`, whether delivery reports are on under `delivery_reports`, and the last
thirty days of reported outcomes under `delivery.delivery_30d`. `GET /metrics` exposes the same as
`osr_mail_delivered_total`, `osr_mail_failed_total`, `osr_mail_suppressed_total`, `osr_mail_reports_total`,
`osr_mail_retrying`, `osr_mail_suppression_list` and `osr_mail_delivery_30d{status=…}`.

**The sender's address.** `OSR_PLATFORM_MAIL_POSTAL_ADDRESS` (one line, e.g. `Acme Ltd, 1 Main St, Springfield`)
is printed in the footer of every message, HTML and text, as CAN-SPAM and most national rules expect of
commercial mail; the console's mail page shows what is configured (`postal_address` in `GET /api/v1/admin/mail`).

**What people receive, and how they say no.** Mail falls into categories. *Security* (address confirmation,
password changes, sign-in and deletion notices, an API key created or revoked - to every owner, naming who did it
and the key's prefix) and *account* (welcome, invitations, payment problems, plan
changes - the owners hear when the plan moves up or down, with the new limits) are always sent. *Usage reports*, *alerts* (quota, budget and error-rate alerts that reach the owners
when a workspace has no alert channel), *support* (ticket confirmations and answers), *product news* and *getting
started* (one reminder with the next step when a workspace has not routed a request a day after signing up - sent
once per workspace, to signups of the last seven days only, by the `accounts` service on the digest cadence) are
optional: every such message carries a **Manage e-mail preferences** link and an **Unsubscribe** link in the
footer plus the `List-Unsubscribe` / `List-Unsubscribe-Post` headers (RFC 8058), so Gmail, Outlook and Apple
Mail show their own unsubscribe button (Graph accepts only `x-` custom headers, so on the Microsoft 365
transport the footer links do the job). The links carry a signed token (HMAC of the user id, valid for six
months, signed with `OSR_PLATFORM_SESSION_SECRET`), so they work without signing in:
`/platform/email/preferences?token=…` shows the switches (`GET`/`PUT /api/v1/email/preferences`), and a mail
client's one-click POST goes to `POST /api/v1/email/unsubscribe?token=…&category=…`. Signed in, the same
switches - categories, the newsletter subscription and, for owners and admins, the workspace's report schedule -
sit under **Account › E-mail** (`GET`/`PUT /api/v1/me/email-preferences`). A message to someone who switched
its category off is skipped before it is composed and counted as `skipped` in `GET /api/v1/admin/mail`; every
change is written to the audit log (`user.email_preferences`, `user.email_unsubscribed`).

**Everyone's own clock.** Scheduled mail is timed by the recipient's time zone, not the server's. The sign-up
form and the newsletter form send the browser's IANA zone (`timezone` on `POST /api/v1/signup` and
`POST /api/v1/newsletter/subscribe`); a signed-in person who never named one gets the browser's zone the first
time they open **Account › E-mail** (and is told so), and the same card lets anyone pick another zone or clear it
(`timezone` on `PUT /api/v1/me/email-preferences` and on the token variant; `schedule` in the answer names the
zone, its current offset and the hours below; operators can set it with `PATCH /api/v1/admin/users/{id}`). Until
a zone is known, UTC applies. What follows the clock: usage digests go out at `OSR_PLATFORM_REPORTS_HOUR`
(default 8) o'clock local over the recipient's own completed day, week or month; the newsletter issue at
`OSR_PLATFORM_NEWSLETTER_HOUR` (default 9) local and the evening issue at `OSR_PLATFORM_NEWSLETTER_EVENING_HOUR`
(default 18) local; the getting-started reminder, the upgrade offer, release notes
and a trial-ending notice that is not urgent only between 08:00 and 20:00 local (a deferred recipient is picked up
by the next sweep, unclaimed; the console's *Run now* / *Send reminders now* buttons do not wait). Alerts,
confirmations, invitations, receipts and support answers are never held back.

**Newsletter.** `POST /api/v1/newsletter/subscribe` (the form on `/newsletter` - the landing page that says what
the daily issue contains and how the address is treated, with the latest posts - and in the site footer of every
marketing page; off with `OSR_PLATFORM_NEWSLETTER=false`, which hides both forms) stores the address, sends a
branded confirmation and always answers `202`;
`POST /api/v1/newsletter/confirm` completes the double opt-in and sends the welcome issue with its unsubscribe
link. Every newsletter message carries a one-click unsubscribe (`POST /api/v1/newsletter/unsubscribe`, token as
JSON from the web page or as `?token=` from a mail client's `List-Unsubscribe` POST). A signed-in user with a
verified address joins straight from the preferences card (no confirmation round trip); an unverified one gets
the usual confirmation. **Registered people read it by default** (`OSR_PLATFORM_NEWSLETTER_SIGNUP_DEFAULT`, on):
the signup form says so next to the terms, `POST /api/v1/signup` files the address as a *pending* reader (source
`signup`, the browser's time zone) without a second confirmation mail, and the account's own proof of the address -
the confirmation link (`POST /api/v1/auth/verify`), a password reset, an accepted invitation, an identity provider
that vouches for it or an operator marking it verified - confirms the reader with a consent record naming that
statement. The welcome mail says the newsletter comes with the account and where to turn it off; the switch on
**Account › E-mail** shows *after e-mail confirmation* until then. An address that unsubscribed is never
re-enrolled, a reader who confirmed through the form keeps their row, and `GET /api/v1/info` reports
`newsletter_signup_default` so the site can word the form accordingly. **The blog asks readers in.** A visitor who
is not signed in and reads on (a few seconds on `/blog` or a post, or a third of the page) sees a sheet at the foot
of the page - *Keep reading with a free account* - with the deployment's identity providers (`sso` from
`GET /api/v1/info`, one click), an e-mail field that lands pre-filled on `/platform/signup`, and the sign-in link,
each carrying the page to come back to (`next`, same-origin paths only, checked again by the API's `safe_next`);
the text says the newsletter comes with the account when `newsletter_signup_default` is on. The post is never
held back: *Not now* keeps the sheet away for a week, a subscription from any newsletter form in that browser
or a signed-in browser hides it for good, and it waits for the cookie banner's answer. After signing in the
welcome page's button reads *Back to the post* / *Continue where you left off* instead of *Go to the dashboard*.
Operators manage subscribers and campaigns at `/platform/admin/newsletter`: add an
address by hand, export CSV, write a campaign in plain text (blank lines separate paragraphs, `## ` starts a
heading), preview the rendered email, send a test to one address, then send once to every active subscriber.
The daily issue follows each reader's clock: the issue of a calendar day is composed from the unmailed posts
when the first reader's zone reaches `OSR_PLATFORM_NEWSLETTER_HOUR` and every later zone receives that issue at
its own hour - one issue per reader per day, each post mailed once (`/platform/admin/content` shows the hour;
its *Send now* delivers to everyone who has not had today's issue whatever their local time, `force` composes a
fresh issue and repeats the day). **Morning and evening.** Daily readers get a second issue once their clock
passes `OSR_PLATFORM_NEWSLETTER_EVENING_HOUR` (default 18, local): it carries the posts written since the morning
issue ("N new posts since this morning", subject *evening: ...*), is composed the same way - once, when the first
zone reaches the hour - and is skipped on a day with nothing new. `-1`, or an hour not after the morning one,
sends the morning issue only; the hour is editable at `/platform/admin/settings`. Weekly readers are not
affected. **How much gets written.** However many feeds deliver, the writer turns at most
`OSR_PLATFORM_CONTENT_POSTS_PER_DAY` (default 6, 0 = no limit) entries into posts in any 24 hours, newest first
and spread evenly over the day's polls, so the blog grows steadily and both issues have news; the entries left
over wait for a later poll.

**Daily or weekly, by topic, and the reader's own page.** Every newsletter message links to `/newsletter/preferences?token=…`
(the same signed token as the unsubscribe link; `GET`/`PUT /api/v1/newsletter/preferences`) where a reader picks
`frequency` `daily` (default) or `weekly`, sets or clears the `timezone`, chooses the `topics` to receive (the blog
categories `models`, `llm`, `agents`, `research`, `ai`; an empty list or all of them means everything), sees what
is stored about them, and can erase the record outright (`DELETE /api/v1/newsletter/preferences` - the row is
gone, not marked). A reader who follows some topics receives the day's issue cut to those posts, with a subject
and opener written for what they get; a day with nothing in their topics sends them no e-mail (the issue is still
noted as theirs, so it is never repeated). `POST /api/v1/newsletter/subscribe` accepts `topics` too - the form on a
blog category page subscribes to that category - and the blog's RSS takes the same narrowing:
`/blog/feed.xml?category=agents` or `?tag=…` is a channel of its own. Weekly
readers get one digest on `OSR_PLATFORM_NEWSLETTER_WEEKLY_DAY` (0 = Monday … 6 = Sunday, default 0) at the
newsletter hour of their own zone, composed from the week's posts ("This week: …"); a day without posts sends
nothing. Consent is recorded with the confirmation: the moment, the IP address, the browser and the exact
statement the reader agreed to (`consent` on the subscriber in the operator listing), so a double opt-in can be
shown later. `GET /api/v1/admin/content/issues` reports for each issue how many distinct readers opened it and how
many clicked (`engagement.opens`, `engagement.clickers`; the single-issue endpoint adds the clicks per link).
The counting uses a one-pixel image (`GET /api/v1/newsletter/o/{issue}/{reader}/{sig}.gif`) and a click redirect
(`GET /api/v1/newsletter/c/{issue}/{reader}/{sig}?u=…`, `302` to the post, `404` on a bad signature) keyed by a
hash of the subscriber id, kept for 180 days and never joined to anything else; `OSR_PLATFORM_NEWSLETTER_TRACKING=false`
turns both off and the issues go out with direct links. Every copy also carries *View this issue in your browser*
above the card: `/newsletter/issues/{issue}` (`GET /api/v1/newsletter/issues/{issue}`) shows the same posts the
mail carried, as the blog lists them, for a client that mangles the HTML - a public page keyed by the random issue
id, never by the reader, so it is not tracked and holds nothing about anyone.

**What an issue - and every other message - reads like.** Everything the platform writes to a person is held
to plain language: sentences under about twenty words, everyday words, one idea per sentence, one action per
message, and a line that says why the person is receiving it. Readability is measured (Flesch-Kincaid
grade level, longest sentence, a short jargon list) and every release is gated so no customer-facing message
reads above grade 9 - roughly an eighth grader on the first pass. The
newsletter issue opens with how many posts there are and how long they take to read; each card carries the
title, one line on what happened, up to three **key points** (`highlights` on the post: three or four concrete
facts from the source - a number, a model name, a date, a limit - so a reader who stops there knows the news),
a **Why it matters** line the writer produces (`why` on the post, also on
`GET /api/v1/blog/{slug}`), the reading time, the source and the link; the issue ends by asking the reader what
they want more of (replies reach the operators). The blog page shows the key points and *Why it matters* above
the article; operators edit both (`PATCH /api/v1/admin/content/posts/{post_id}`, `why` and `highlights`). The
writer's house skill
asks for the same plain language, the key points, a `## Why it matters` section and - when the source allows it -
a `## What to do` section in every post, and records the draft's
readability grade next to the routing decision. **Every post is a long-form article of the blog's own**, not a
rewritten excerpt: the writer reads the source page (up to `OSR_PLATFORM_CONTENT_ARTICLE_SOURCE_CHARS` characters,
default 12,000), asks the model for the brief first - title, summary, key points, *why it matters*, an outline of six
to nine sections, tags and keywords, one small JSON object - then writes the article from that outline in plain
Markdown, aiming at `OSR_PLATFORM_CONTENT_ARTICLE_WORDS` words (default 2,000) and never under
`OSR_PLATFORM_CONTENT_ARTICLE_MIN_WORDS` (default 1,500): a draft that stops short is continued once from where it
ended, covering only the sections still missing. **Every post ends its argument with a `## How OpenSmartRoute
helps` section**, in front of *What to do*: what a team that routes its requests through the router gains from this
particular story and what it would set up - written from a fixed list of product facts and the facts of the story,
nothing else; on the site a card under that section takes the reader to the playground, the catalogue and the
set-up guide. The brief plans it, the article pass writes it, and a post whose article skipped it gets the section
in one more metered pass (`passes` shows `product`); a post that fell back to the excerpt or the brief's own text
carries a short hand-written version angled by the story's tags (`routing.product_section` says `model` or
`template`); posts written before the section existed receive it on the next poll, a few at a time, written by the
model for each story - `POST /api/v1/admin/content/posts/product-sections` (*Add product sections* on the console's
Posts tab) does up to a hundred at once, `model: false` uses the hand-written text only. Both passes are metered requests of the editorial workspace and
both are graded for the learners; the post's `routing` carries them (`passes`) and the measured length (`length`:
words, target, minimum, whether it came in short). When the article pass cannot produce at least 400 words the
post keeps the brief's own text or the attributed excerpt and says so (`reason`). The three lengths are
operator-editable at `/platform/admin/settings` and take effect on the next post. **No picture, no post**: with `OSR_PLATFORM_CONTENT_REQUIRE_IMAGE`
(default `true`) a story becomes a post only when it has a real picture that answers as an image - the feed's
own, or what the article page advertises (`og:image`, `twitter:image`, `<link rel="image_src">`, the JSON-LD
`image`, the first photo inside `<article>`; logos and share banners never count) - otherwise the entry is
marked `no-image` and left alone, and the issue only ever carries posts with a picture, so nobody opens a
digest with a blank or broken image. `POST /api/v1/admin/content/posts/images` keeps probing older posts for
a picture (*Fetch missing images* on the console's Posts tab). The platform keeps its own copy of every post's
picture: when a post is written its image is downloaded (16 MB cap; sources that answer
`application/octet-stream` are sniffed for JPEG, PNG, GIF and WebP), brought to web size - no wider than 1,600
pixels, encoded as WebP, transparency kept, animated GIFs left as they are - and stored; each poll copies up to 25
more of the older posts, newest first, and `POST /api/v1/admin/content/posts/images/store` copies up to 200 on
demand (*Copy pictures* on the Posts tab). The site
serves the copy at `/blog/{slug}/image` (`GET /api/v1/blog/{slug}/image`: a week of cache, `ETag`; the web app
keeps each picture for a day so the API answers once per replica, not once per reader), so pages,
the RSS enclosure and the newsletter never embed a third-party host and a source that later disappears does not
leave a broken picture; until the copy exists the address redirects to the source, and a source that cannot be
copied is tried again after a week. The post's `image.source` keeps the original address. Registering a feed
(`POST /api/v1/admin/content/feeds`) looks at the URL first: a feed is named after its own title unless a name is
given; a blog's home page yields the feed it advertises in its `<head>` (`<link rel="alternate">`) or at one of the
conventional paths (`/feed`, `/feed.xml`, `/rss.xml`, `/rss`, `/atom.xml`, `/index.xml`); a page that is reachable
but has no feed is refused with the reason; a URL that does not answer is registered with the error on its row and
tried again on the next refresh. `POST /api/v1/admin/content/feeds/preview` is the same look without registering
anything - the title, the site, the newest entries and whether the feed is already followed. `POST /api/v1/admin/content/feeds/seed` adds every
curated default feed the deployment does not follow yet (*Add the curated feeds* on the Feeds tab); a release
that adds curated feeds offers them to a running deployment once on its next start (a feed an operator deleted
is never offered again). The curated
list covers about seventy-five sources in four groups - the model vendors and platforms (OpenAI, Google, Hugging Face,
Microsoft Foundry, NVIDIA, AWS, Databricks, Together, Replicate, Qwen, Mistral, Ollama, PyTorch, Cloudflare,
Weights & Biases, Weaviate, Qdrant, Lightning AI, GitHub), the research labs (DeepMind,
Google Research, Microsoft Research, Meta, Apple, Amazon Science, BAIR, EleutherAI, Ai2, MIT, arXiv cs.AI / cs.CL /
cs.LG), practitioners and independent analysis (Simon Willison, Sebastian Raschka, Ethan Mollick, Lilian Weng,
Hamel Husain, Eugene Yan, Andrej Karpathy, Chip Huyen, Jay Alammar, Phil Schmid, Sebastian Ruder, Interconnects,
Latent Space, The Gradient, AI Snake Oil, SemiAnalysis, Last Week in AI, Towards Data Science, KDnuggets) and the
press (Ars Technica, The Verge, Wired, MIT Technology Review, TechCrunch, The Decoder, MarkTechPost, InfoQ,
SiliconANGLE, ZDNET, The Register, Unite.AI, Stratechery); every URL is verified to fetch and parse, and the topic filter keeps the
mixed sources to posts about models and agents.

**How a post credits the router.** Under every article's byline the post page carries the decision that wrote
it, in three tabs. *Decision* names the target and the model, the confidence, the skill layered on the prompt,
the targets also considered or fallen back from, and the router's own reason. *Waterfall* draws one bar per pass
- the brief, the article, and the continuation when there was one - scaled to the slowest, with what each pass
cost, how long it took and how many words it had produced by then, so a reader sees where the time and the money
went. *Evaluation* shows the grade each pass was credited with - the same outcome the learners were taught,
now kept on the post itself as `grade` rather than only on the learner - together with the finished text's
Flesch-Kincaid reading level (`readability_grade`, 9 reads like a ninth-grade textbook) and its length against
the minimum, which says plainly when a post came up short. A tab is only offered when the post carries the data
behind it, so a post written before a measurement existed simply shows fewer. Every panel sits in the HTML
whether or not it is the open tab, so a reader without JavaScript and a search engine still see the facts, and
the tabs move with the arrow keys. A post that no model wrote says so in one line instead.

**Lifecycle campaigns.** Four messages the platform sends by itself (`campaigns.py`; the `accounts`
service runs the sweep on the digest cadence, the *Lifecycle campaigns* card on `/platform/admin/growth` shows
what is due, what went out and runs it now - `GET /api/v1/admin/campaigns`, `POST /api/v1/admin/campaigns/run`
with an optional `campaigns: [...]` list). Each is one message per workspace, claimed in `campaign_sends` before
it is composed, so replicas never double-send and nobody is asked twice:

| Campaign | Who | Category |
|---|---|---|
| `trial_ending` | The owners of a workspace whose coupon trial of a paid plan ends within three days: what happens next and where to keep the plan. | *account* (always sent) |
| `upgrade_offer` | Free workspaces at least a week old that routed 25 or more requests: what the paid plans add, with the live launch coupon (`OSR_PLATFORM_LAUNCH_COUPON`) when there is one. | *product news* (opt-out honoured) |
| `release_news` | Every active user when the platform version changes: *What's new in* followed by the brand and the version (outbox kind `release_notes`), written for two readers at once. First the plain words: *Nothing to do on your side*, an *In short* list of the three headline changes, then each changelog group under a label anyone understands - **New** (things you could not do before), **Changed** (things that work differently now), **Fixed**, **Security**, **On the way out**, **Removed** - with the bullet's bold lead as the title and its first sentence as the summary, at most twelve items with the count of the rest. Last, *For the technical reader*: the version's entry on `/docs/changelog`, the API reference and the diff of every commit. The first sweep on a deployment records the running version without mailing, so a backlog is never announced; a version without a section is recorded silently. | *product news* (opt-out honoured) |
| `apps_reminder` | The owners of a workspace that has routed through the API for three days (and less than sixty) without the browser extension or the desktop app ever reporting in: one note on what each app does for the people on the team - the pre-send card, private data blanked, writing fixed in place; the shortcut in any app - with the download pages, and that the workspace only ever sees counts, never text. | *getting started* (opt-out honoured) |
| `first_value` | The owners of a workspace (younger than thirty days) the first time a model really answered one of its requests - *Your first answer came through*: the target the router chose, requests routed and answered, the amount kept against the dearest model the workspace used, and the one ask that improves the next answer - mark it *Good* or *Poor* in the playground. Sent once, on the next sweep after the answer. | *getting started* (opt-out honoured) |
| `key_expiring` | The owners of a workspace with an active API key whose end date falls within the next seven days, provided the key lived longer than that (a key minted for three days needs no reminder) - *API key … stops working in n days* (outbox kind `api_key_expiring`): the key's name and prefix, the date in the owner's time zone, and what to do - create a key with a later end date, move the apps, revoke this one. Once per key; a key ending within a day waits for nobody's morning. | *security* (always sent) |

**The apps report in.** The first batch of activity an app (`browser-extension`, `desktop`) records for a workspace
sends *Your browser extension / desktop app is connected* (kind `app_connected`, *account*: always sent) to the
person who connected it - the signed-in user, else the owners: what it does, that the workspace records counts only,
the Apps page, the app's documentation, and what to do if they did not install it. Once per app and workspace;
audit `app.connected`.

Every campaign has a switch: `PATCH /api/v1/admin/campaigns/{campaign}` `{enabled: false}` (the *On* column of the
console's card) stops the sweep and *Run now* from sending it deployment-wide - the run reports `disabled` for it
and `release_news` keeps recording the running version, so switching it back on never announces a backlog. On by
default; audit `campaign.disabled` / `campaign.enabled`.

The release announcement does not wait for the digest cadence: the `accounts` service checks every
`OSR_PLATFORM_RELEASE_NEWS_INTERVAL_S` (default 600 s, `0` = digest cadence and the console only) whether the
running version differs from the last one announced, so the mail follows a deploy within minutes; the autonomy
board lists it as the `release-news` job (`GET /api/v1/admin/autonomy`, run it with
`POST /api/v1/admin/autonomy/{job}/run` and `job=release-news`).

**Release notes everywhere else.** The same changelog feeds `GET /api/v1/releases` (public: every released
version newest first with date, sections, highlights and the changelog anchor, plus `current` - the version this
deployment runs; `?limit=`) and `GET /api/v1/releases/{version}`. Signed-in people see the releases they have
not read yet on the dashboard overview (*What's new*, one card per visit until they press *Got it*):
`GET /api/v1/me/whats-new` lists the releases newer than the user's acknowledged version up to the running one
(a first-time reader sees the running release only, never history), `POST /api/v1/me/whats-new/seen` records
the running version on the user, so the card stays away on every device. The footer's version badge asks the
running API for `platform_version` after the page loads, so a cached page or a docs page built before the last
deploy still names the version that is live, and links to the changelog.

Recipients are always people who signed up here. The platform never buys, rents, scrapes or guesses addresses
and sends nothing to anyone who has not either created an account (transactional and opted-in product mail) or
confirmed a newsletter subscription (the daily issue): that is what keeps deliverability - and the platform -
on the right side of the law. Every run is audited (`campaign.trial_ending`, `campaign.upgrade_offer`,
`campaign.release_news`, `campaign.apps_reminder`, `campaigns.run` from the console) and the messages appear in the
outbox with their kind.

**Scheduled reports.** `reports.py` runs inside the API: every `OSR_PLATFORM_REPORTS_INTERVAL_S` (default hourly,
`0` = on demand only) it checks, for each owner of each workspace, whether a daily (yesterday), weekly (Monday) or
monthly (1st) window has completed **on that owner's clock** and it is past `OSR_PLATFORM_REPORTS_HOUR` there,
and sends them the workspace's **value report** for that local window; the operators in
`OSR_PLATFORM_REPORT_RECIPIENTS` get the deployment-wide digest for every period (UTC). The workspace report leads with
what routing saved - the executed requests' provider cost against the baseline model's list price for the same
tokens, the same formula as `GET /api/v1/savings` and the Impact page; a workspace that only asked for decisions
sees *N requests routed* and how to get to the saving instead - then the figures each captioned against the
previous window (requests routed, answered, provider cost, tokens), where the requests came from (browser
extension, desktop app, CLI, API / SDK / playground - the `X-OSR-App` header), the models that answered, what the
guard protected in the window (personal data redacted, requests blocked, answers withheld, boundary refusals,
injection attempts; *tracing off* when there are no trace events), the workspace's activity (keys created, rotated
or revoked, members joining and leaving, providers connected, policy and plan changes, support tickets) and a
button to the Impact report. The subject carries the headline (`weekly report for Acme · $142.60 saved`).
`GET /api/v1/workspace/reports` returns the same composition as `preview` (usage, `previous`, `savings`, `guard`,
`by_app`, `activity`) for the caller's own zone (`timezone`, `hour`). Owners and admins pick the schedule (`daily`, `weekly` - the default -, `monthly` or
`off`) with `PUT /api/v1/workspace/reports` or under **Account › E-mail**; each owner can also switch the
*Usage reports* category off personally. A window is recorded per recipient in `report_deliveries` and per
workspace in `report_runs` and never sent twice, so several
API replicas stay idempotent. `/platform/admin/reports` shows the scheduler, past digests and sends one now
(`POST /api/v1/admin/reports/send`, `POST /api/v1/admin/reports/tick`).

**New registrations.** Every time somebody registers - through `POST /api/v1/signup` or the first sign-in with an
identity provider, which is where an account is created - the operators in `OSR_PLATFORM_SIGNUP_RECIPIENTS` get one
message: who registered, the workspace, how they signed in, whether the provider vouched for the address, the
acquisition `source` and `campaign` when the signup carried them, and a link to that person's page in the console.
The list falls back to `OSR_PLATFORM_SUPPORT_RECIPIENTS` and then `OSR_PLATFORM_REPORT_RECIPIENTS`; with none of
them set nothing is sent. A later sign-in by somebody who already has an account is not a registration and is
quiet. `OSR_PLATFORM_NOTIFY_SIGNUPS=false` switches the message off for a deployment that would rather read the
signup count in the daily digest and the console's activation funnel.

**Support tickets.** A user raises a concern under `/platform/dashboard/support` or with
`POST /api/v1/support/tickets` (`subject`, `body`, `category` question / bug / billing / account / feature /
security / other, `priority` low / normal / high / urgent, optional `request_id` of the routed request it is
about). The answer - and a confirmation e-mail to the requester - carry the ticket **reference**, `OSR-00042`,
which identifies the ticket everywhere (`GET /api/v1/support/tickets/{key}` takes the reference or the id).
Operators are told at once: an e-mail to `OSR_PLATFORM_SUPPORT_RECIPIENTS` (falls back to
`OSR_PLATFORM_REPORT_RECIPIENTS`) and an entry in the console inbox. The conversation lives on the ticket:
`POST /api/v1/support/tickets/{key}/messages` adds the requester's reply (an `answered` or `resolved` ticket goes
back to `open`), `POST /api/v1/support/tickets/{key}/close` ends it, and `GET /api/v1/support/tickets/{key}/stream`
follows it live (server-sent events: the ticket with its conversation now and again on every change, a keep-alive
every 15 s, a new connection every ten minutes - the ticket page uses it, so an answer appears without a refresh);
`GET /api/v1/support/tickets` lists the
workspace's tickets with counts per status (`open` = waiting for support, `answered` = waiting for the
requester, `resolved`, `closed`). Every public answer from the support team and every status change is
e-mailed to the requester with the reference in the subject and the message quoted, and appears in the
workspace inbox (rule `support`); internal notes never leave the console. A workspace may have 25 tickets
waiting at a time; every step is in the audit log (`support.ticket_opened`, `support.ticket_answered`,
`support.ticket_replied`, `support.ticket_updated`, `support.ticket_closed`, `support.note_added`).

**Product feedback.** Every internal page has a *Send feedback* button in the dashboard header (and the page
`/platform/dashboard/feedback`): a bug, a feature idea, an improvement, praise or anything else about the page
the person is on. The browser attaches what an operator needs to reproduce it and the person cannot easily
describe - the page and URL (query strings dropped), the browser and viewport, the platform version and plan,
the last console errors and warnings and the last API calls with their status and `X-Request-Id` (never a request
body, never a credential; anything that looks like a key or a bearer token is redacted in the browser and again
on the API) - and up to three screenshots: captured from the screen (`getDisplayMedia`, the browser asks what to
share), pasted or uploaded, at most 2 MB each, accepted by their bytes (PNG, JPEG, WebP, GIF). The item gets a
**reference**, `FB-00012`, the person receives a confirmation e-mail that quotes it, and operators are told
(e-mail to `OSR_PLATFORM_SUPPORT_RECIPIENTS`, console inbox). `POST /api/v1/product-feedback` (`kind` bug / idea /
improvement / praise / other, `title`, `body`, `page`, `url`, optional `request_id`, `diagnostics`,
`attachments` as data URLs) does the same from any client; `GET /api/v1/product-feedback` lists the workspace's
items with counts per status, `GET /api/v1/product-feedback/{key}` returns one with its conversation, diagnostics
and screenshot list, `POST /api/v1/product-feedback/{key}/comments` adds to it and
`GET /api/v1/product-feedback/{key}/attachments/{attachment_id}` serves a screenshot (workspace-scoped, `nosniff`,
sandboxed). Operators triage in the console (`/platform/admin/feedback`): status `new` -> `reviewing` -> `planned`
-> `in_progress` -> `shipped`, or `declined`; an answer to the person; internal notes; assignment. **Every status
change and every public answer is e-mailed to the person** with the reference in the subject and the note quoted
(category `feedback`, switchable under **Account › E-mail**) and appears in the workspace inbox (rule `feedback`),
so nobody has to come back and check; a shipped or declined item takes no more comments - new feedback that
mentions the reference does. A workspace may have 50 open items at a time; every step is in the audit log
(`feedback.submitted`, `feedback.commented`, `feedback.answered`, `feedback.updated`, `feedback.note_added`).

**Membership and access notices.** People are told about what happens to their account, not only the owners:
whoever sent an invitation hears when it is accepted (`member_joined`), a member whose role changes or who is
removed from a workspace receives a note saying who did it (`member_role_changed`, `member_removed`; a removed
member's sessions on that workspace end and their personal workspace is untouched), and an operator who signs a
person out everywhere, disables or re-enables their sign-in triggers a security / account notice to that person
(`sessions_revoked`, `account_disabled`, `account_enabled`). These are `account` / `security` mail and always go out.
The owners also hear when an API key is rotated (`api_key_rotated`: the old secret stopped working at once), as
they do when one is created or revoked, and when what an active key may do changes (`api_key_changed`: the new
scopes, budgets, rate or allowed targets in plain words, the secret untouched; a rename alone is not news). The
owners hear when a member leaves by themselves (`member_left`: who, and that their keys and work stay), and every
member - the owner who did it included - hears when an owner deletes the organization (`workspace_deleted`: the
keys stopped working, a paid plan was cancelled, everyone was moved to their own personal workspace, and whom to
contact if nobody meant it). The workspace inbox mirrors the roster and the keys for everyone in the
dashboard: `workspace.member_left`, `workspace.transferred` (ownership handed over), `workspace.key_rotated`,
`workspace.key_scoped`, `workspace.key_limited` beside the existing `key_created`, `key_revoked`, `member_role`
and `member_removed` notices; and a request the router handed to a human queue is a `workspace.handoff_pending`
notice (warning) until a person answers it (`workspace.handoff_resolved`), so a team sees a waiting hand-off
without subscribing a channel. When no channel of the workspace listens for `handoff.*` events, the owners are
also mailed (`handoff_waiting`, category *alerts*: the request's first lines, why the router chose a person, how
many wait, the link to answer) - at most once an hour per person, so a busy queue never floods a mailbox; a
channel subscribed to hand-off events keeps the mailbox quiet.

**Sign-in notices.** The account's owner hears about the things that decide who can get in, always (`security`):

| Notice (outbox `kind`) | When | What it says |
|---|---|---|
| `new_sign_in` | A sign-in mints a session from a browser + system (`Chrome on Windows`, `Safari on iPhone` - the same wording as the account page) the account was not seen on in the last 90 days, or - when the deployment resolves places - from a country none of those sessions came from; or any sign-in at all for a person who chose *Every sign-in* (`login_alerts = always`). The very first session and a password-reset sign-in are quiet. | When (in the person's time zone), how (`email and password`, `Google`, the organization connection's label), the device, the place and the IP address the session was opened from; *Review sessions* (`/platform/dashboard/account`) and the reset link. Audit `user.new_device`, `user.new_location` or `user.sign_in_notice`. |
| `sign_in_attempts` | Eight password attempts for one address inside a minute paused password sign-in (`429`). At most once a day per address; an address without an account gets the same `429` and no mail. | Nobody got in; wait or reset the password; turn on two-factor authentication. Audit `user.login_attempts_notice`. |
| `identity_linked` / `identity_unlinked` | A provider that vouched for the address signed in to an existing account and was linked to it (`user.identity_linked`); the person unlinked a provider on the account page (`DELETE /api/v1/auth/identities/{provider}`, `user.identity_unlinked`). | Which sign-in method now opens - or no longer opens - the account; review methods and sessions. |
| `recovery_code_used` | A sign-in used one of the two-factor recovery codes instead of the authenticator (`user.mfa_recovery_used`). | That the code is used up and how many are left; make new ones under Account, or change the password and sign out everywhere if it was not you. |
| `recovery_codes_replaced` | The person made a fresh set of recovery codes (`POST /api/v1/auth/mfa/recovery-codes`, `user.mfa_recovery_codes_replaced`); the bell files `security.recovery_codes_replaced` too. | The old codes stopped working at once; keep the new ones safe; change the password and sign out everywhere if it was not you. |
| `password_removed` | The person switched password sign-in off (`DELETE /api/v1/auth/password`, `user.password_removed`; refused while no provider is linked). | Which linked providers open the account from now on; set a new password right away if it was not you. |

Every browser session records the client address it was opened from (`ip` in `GET /api/v1/auth/sessions`, shown
under the device on the account page and in the operator's session table) and, when the deployment resolves
places, where that was (`location`; see [Sign-in locations and alerts](#sign-in-locations-and-alerts)); older
sessions have none. The account page's history (`GET /api/v1/me/audit`) names each event in plain words -
*signed in from a new device*, *signed in from a new country*, *sign-in method added* - with the device,
provider, place and address behind it.

### Command line sign-in

Install the CLI (`curl -LsSf https://opensmartroute.ai/install.sh | sh`, or
`irm https://opensmartroute.ai/install.ps1 | iex` on Windows) and run `osr login`. The CLI shows an
eight-character code and opens `/platform/cli/authorize`; sign in there, check that the code and computer
name match, choose the key name and approve. The platform mints a new workspace API key for that
machine and the CLI stores it in `~/.config/opensmartroute/credentials.json`. This is the OAuth 2.0
device authorization grant (RFC 8628) and works the same for the community and enterprise editions:

| Step | Endpoint | Notes |
|---|---|---|
| CLI asks for a code | `POST /api/v1/auth/device/code` `{client_name}` | anonymous, rate limited per IP; returns `device_code`, `user_code`, `verification_uri[_complete]`, `expires_in` (900 s), `interval` (5 s) |
| Browser looks it up | `GET /api/v1/auth/device/{user_code}` | signed in; shows client name, status and expiry |
| Browser decides | `POST /api/v1/auth/device/approve` / `deny` `{user_code, name}` | approving needs the `admin` role and a free key slot in the plan |
| CLI polls | `POST /api/v1/auth/device/token` `{device_code}` | `authorization_pending`, `slow_down`, `access_denied`, `expired_token` as RFC 8628 errors; success returns `access_token` (the `osr_live_` key), `account`, `plan`, `edition` |

Only approve codes from a terminal you started yourself. Keys minted this way appear on
`/platform/dashboard/keys` with the chosen name and can be revoked like any other. Pasting a key works too
(`osr login --token osr_live_...`, or `--with-token` from stdin in CI); `osr whoami` shows the
workspace, plan and edition behind the stored credential, `osr token create|list|revoke` manage keys
and `osr mcp --remote` bridges an IDE to this workspace's MCP server without copying the key around.

## 2. Route a request

`POST /api/v1/route` returns a decision: the chosen target, the confidence, ranked alternatives and
the signals extracted from the request. The target is not called unless `execute` is set. `target.provider`
is the `provider/model` this workspace would run it on and `target.model` the reference `vendor/model` id
behind it (both `null` for agents, skills, tools and humans), so a caller that executes elsewhere - LiteLLM,
its own OpenAI client - can hand the chosen model straight to it and report the outcome with `/api/v1/feedback`.

```bash
curl -s "$OSR_URL/api/v1/route" \
  -H "Authorization: Bearer $OSR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Prove that sqrt(2) is irrational.", "top_k": 3}'
```

```json
{
  "request_id": "7f3c...",
  "target": {"id": "llm-frontier", "kind": "llm", "name": "Frontier model", "provider": "azure/gpt-4.1", "model": "openai/gpt-4.1"},
  "confidence": 0.81,
  "alternatives": [{"id": "llm-mid", "kind": "llm", "utility": 0.62}],
  "signals": {"complexity": 0.74, "domains": ["math"], "reasoning_need": 0.9, "contains_pii": false, "...": "..."},
  "ranked": [{"id": "llm-frontier", "utility": 0.79, "quality_estimate": 0.91, "breakdown": {"...": "..."}}],
  "policy_rejections": {},
  "explanation": "llm-frontier: high reasoning need, math domain, quality weight dominates ...",
  "executable": true,
  "elapsed_ms": 1.9
}
```

Request fields:

| Field | Type | Meaning |
|---|---|---|
| `text` | string, required | The user request. May be empty when `history` carries the conversation or `context.event` / `context.workflow` names an event (see `context`); otherwise `422`. |
| `history` | list of `{role, content}` | Prior turns; the router reads them for intent, task type and session affinity. |
| `context` | object | Free-form facts (`app`, `session_id`, `user_locale`, ...). `event` (`order.shipped`, `payment.failed`, ...) or `workflow` routes a system event without any text - the event name supplies the domain and intent and rules can pin on it (`when: { event: order.* }`). Keys starting with `_` are dropped. |
| `objective` | object | Relative weights: `quality` (default 1.0), `cost` (0.15), `latency` (0.05), plus `quality_floor`, `energy`, `carbon`. `cost_weight` is accepted as a synonym of `cost`. |
| `constraints` | object | Hard constraints, never traded off: see below. |
| `profile` | object | Opaque user/tenant features (locale, expertise, preferences). Hashed into the learners; never logged raw. |
| `kinds` | list | Restrict to target kinds: `llm`, `agent`, `skill`, `persona`, `tool`, `human`. |
| `top_k` | int, default 3 | How many alternatives to return. |
| `plan` | bool | Build a persona → skill → model plan instead of choosing a single target (**pro** plan and above). |
| `plan_mode` | `sequential` \| `parallel` | How the plan's companions run. `sequential` (the deployment default): skills run one after another, each seeing the previous one's output, and the model calls its tools in turn. `parallel`: every skill runs on the original request at the same time and their outputs are merged in slot order; the model's tool calls of one round run concurrently. Also accepted as `context.plan_mode`; anything else is `400`. The answer carries `plan_mode` and `plan_stages` - the ordered stages `prepare` (personas and skills), `answer` (the target and the model that runs an instructions-only skill or persona) and `tools`, each with `parallel` and its slots - and an executed result's `steps` carry `stage` (0 prepare, 1 answer, 2+ tool rounds) and `parallel`. |
| `execute` | bool | Route and run the chosen target or plan; the output is returned in `result` (**pro** and above). |

Constraints (`constraints` object):

| Key | Effect |
|---|---|
| `max_cost_per_1k` | Drop targets whose price per 1k tokens is above this. |
| `max_latency_ms` | Drop targets slower than this. |
| `preferred_max_latency_ms` | Soft target: targets whose observed p90 latency is above it are penalised, not dropped. |
| `region` | Only targets served from this region. |
| `data_boundary` | Only targets at least this strict (`public` < `private` < `on_prem`). |
| `contains_pii` | Force the PII handling on or off (`null` lets the sensitivity signal decide). |
| `allow_targets` / `deny_targets` | Explicit allow / deny lists of target ids. |
| `allowed_kinds` | Same as `kinds`. |
| `require_tools` | Only targets that can call tools. |

**Your marketplace installs are in the pool.** Every agent, skill, persona, tool, subagent or command your
workspace installed from the [marketplace](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MARKETPLACE.md) is added to the routing pool of your requests: a
plan picks an installed skill or persona for its slot when it fits better than the catalogue's, and an installed
primary competes for the answer itself. An installed agent or tool whose manifest declares a public `https`
endpoint (`a2a`, `http` or `mcp`) is bound to it and runs when it wins an executed request; one without a
callable endpoint is left out of executed requests so the decision never lands on something that cannot run
(skills and personas always can - a plan runs their instructions through its model). `GET /api/v1/targets`
lists them with `source: marketplace` next to the catalogue's `source: catalogue`, `executable` says whether
the platform can run each one (`metadata.endpoint_unbound` gives the reason when not); another workspace never
sees them.

### Quote before routing

`POST /api/v1/estimate` takes the same body and returns the price of each candidate without calling
anything: `targets[]` with `cost_usd`, `input_tokens`, `output_tokens`, vendor and model, plus the
router's `recommended` pick and the `cheapest`, `best_quality` and `fastest` candidates.
`output_tokens` overrides the assumed reply length; `monthly_requests` adds a `monthly` projection
(`recommended_usd`, `cheapest_usd`, `dearest_usd`, `savings_usd`). Without a key it is rate limited
per client and ignores tenant rules; with a key it is metered as `estimate` and applies your plan
and tenants. `GET /api/v1/models/recommended` returns the router's picks per use case from live
quotes. Details, the SDK form and the `/estimate` page: [MCP.md](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MCP.md).

## 3. Execute the chosen target

With `"execute": true` the platform runs the chosen target (or plan) with the configured providers and
returns the output, the steps that ran and the metered cost:

```bash
curl -s "$OSR_URL/api/v1/route" -H "Authorization: Bearer $OSR_API_KEY" -H "Content-Type: application/json" \
  -d '{"text": "Summarise this contract clause for a non-lawyer: ...", "execute": true, "objective": {"cost": 0.4}}'
```

The response adds `result` with `text`, `target_id`, `steps[]` (`role`, `target_id`, `ok`,
`latency_ms`, `note` - `offered` / `called` for the tools the plan gave the model, `-N tokens` for the compaction
step), `tokens`, `cost_usd`, `tokens_saved` (estimated prompt tokens the compaction removed) and `fallback_from`
(the target ids whose handler failed before
another executable target answered - normally `[]`). With `execute: true` the decision is made among the
targets that can actually run here: models and tools without a handler are left out of the pool, while skills,
personas and agents stay in because a plan runs them through its model slot. Without `execute` the decision
is unrestricted and `executable` on each target tells you what would run.

### Built-in tools

Three kinds of `tool` target execute without a chat model. The catalogue's **calculator** (`tool-calculator`,
or any tool with `metadata.builtin: calculator`) is answered by the platform itself, deterministically and
without a provider: arithmetic in symbols or words (`What is 17% of 2,450?`, `12 * 37 plus 5 percent`,
`sqrt(2) ^ 2`) and unit conversion for length, mass, volume, time, data, speed and temperature
(`Convert 5 miles to kilometers.`). It costs no tokens and reports `builtin/calculator` as its provider
(built-in tools are not listed among the upstream providers). The **web search** tool (`tool-web-search`, or
`metadata.builtin: web-search`) searches the live web for a lookup, the latest news or a price - DuckDuckGo and
Wikipedia without a key, a self-hosted SearXNG instance when `SEARXNG_URL` is set, Brave with `BRAVE_API_KEY` - and
answers the top results as Markdown links with their snippets and the engines used (`builtin/web-search`, no
tokens); the instruction is stripped from the query (`Search the web for the latest EU AI Act news and cite
sources` searches for `the latest EU AI Act news`), hits whose title or snippet reads like an injected
instruction are dropped, and a request with no result fails over to another executable target like any handler
error. **Image tools** - a tool whose capabilities
name the `image` action or modality, such as `tool-image-gen` - execute once the console maps them to a
provider that serves `POST /images/generations` (OpenAI, Azure OpenAI, compatible gateways); the answer is
the picture as Markdown (`!prompt (`docs/url`)`, or an inline `data:` URL when the provider returns base64), so
chat clients and the playground render it, and each image counts as one completion token against the
target's declared cost. A request that asks for a picture on a deployment without an image provider is
routed to another executable target rather than failing with "has no handler".

### Model providers

A target executes when it is mapped to a model on an **OpenAI-compatible endpoint** - OpenAI, Azure OpenAI,
OpenRouter, Mistral, Groq, Together, Fireworks, DeepSeek, a local Ollama, vLLM or LiteLLM, anything that
speaks `/v1/chat/completions` - or on **Anthropic's own API**: a provider whose kind is `anthropic` (or whose
base URL is on `anthropic.com`) is spoken natively over the Messages API (`x-api-key`, `anthropic-version`,
`POST /v1/messages`; tools, tool results, images, streaming and extended thinking translated both ways), so
routing to Claude needs no OpenAI-compatible gateway in between. Two sources define the mapping and are
merged (the console wins on the same
provider name or target):

* the **file** the deployment mounts, `OSR_PLATFORM_PROVIDERS` (YAML/JSON path or inline JSON) with
  `providers.<name>` (`base_url`, `api_key` or `api_key_env`, optional `api_key_header`, `extra_headers`,
  `timeout_s`, `max_retries`, `concurrency` and `queue_timeout_s` - see *Capacity* below) and `models.<target_id>`
  (`provider`, `model`, optional `system_prompt` and
  request defaults). `{"kind": "mock"}` is a provider that needs no endpoint and no key: it answers
  deterministically in-process (the prompt echoed through `reply`, a template over `{model}`, `{prompt}` and the
  call count; JSON when `response_format` asks; a tool call when the prompt names a tool), streams word by word,
  embeds with a stable vector per text and fails on cue (`fail_when` - a substring of the prompt -, `fail_every`,
  `latency_ms`) - the provider for tests, demos and CI pipelines, metered like any other. At start-up the file is imported into the console additively: providers the store does not
  know yet and targets that are not mapped yet are added, rows an operator edited are left alone. A provider
  the file created and later stopped naming is disabled (its offers and mappings stay, and it is enabled again
  the day the file names it) - a deployment that drops a cloud provider takes its credential with it, and left
  enabled every call to it would fail with *authentication failed*. Providers an operator created are never touched.
* the **operator console**, `/platform/admin/providers`: add an endpoint from a preset, store the key inline or name
  the environment variable that holds it, *Check* it (`GET <base_url>/models` with its credentials records
  reachability, latency and the upstream model ids, which the mapping form then offers), map targets to
  upstream models, disable or delete. Every write re-binds the handlers in the API at once, and every other
  API replica re-reads the store within `OSR_PLATFORM_PROVIDERS_RELOAD_S` (30 s) - no restart, no redeploy.
  The providers owner also runs that check on its own every `OSR_PLATFORM_PROVIDERS_CHECK_S` (300 s; 0 = only
  on click) for every enabled provider - one replica per interval - so the health the public catalogue, the
  workspace Providers page and the alerts show is never older than that.

The platform's providers are what **every workspace executes on by default**: a fresh workspace needs nothing
connected - its Providers page lists the included models under *Included with the platform* (per provider, with
health and the target -> upstream model pairs), `GET /api/v1/workspace/providers` returns them as
`platform_providers`, and the getting-started step *Models ready* is ticked. A mapping's request defaults travel
with every call: the deployment's Ollama mappings carry `reasoning_effort: none` on every chat target except the
reasoning-by-design ones (`research-agent`, `gpt-oss`), because a
thinking model asked for a short answer otherwise spends the caller's whole `max_tokens` thinking and returns an
empty message - a request that sends its own `reasoning_effort` overrides the default.

`GET /api/v1/providers` is the public, OpenRouter-style view of what a deployment executes on, rendered
by the site at `/providers`: one row per
executable target with `provider`, `model`, the recognised reference model, `input_usd_per_1m` /
`output_usd_per_1m` (from the reference catalogue when the upstream model is known, else the target's
declared cost), context window, tool and reasoning support, circuit-breaker `health` and seven-day traffic;
plus each provider's host, kind, health and model count. Credentials never appear: the console shows only
whether a key is set and its last four characters (`api_key_set`, `api_key_hint`). Keys stored through the
console live in the platform database next to the organization SSO secrets; prefer `api_key_env` when the
host injects secrets. Endpoints: `GET|POST /api/v1/admin/providers`, `GET|PATCH|DELETE
/api/v1/admin/providers/{id}`, `POST /api/v1/admin/providers/{id}/check`, `PUT|DELETE
/api/v1/admin/providers/models/{target_id}`. Targets without a mapping still route (decisions, plans,
traces, quotes) and report `executable: false`; `GET /api/v1/providers` also counts the `builtin` tools that
run without a provider.

#### Bring your own provider (workspace keys)

Every workspace (feature `byok`, on all plans of the default catalogue) connects its **own** endpoints and keys at
`/platform/dashboard/providers` - Azure OpenAI / Azure AI Foundry, Amazon Bedrock (`bedrock-runtime.<region>.
amazonaws.com/openai/v1` with a Bedrock API key), Google Gemini (`generativelanguage.googleapis.com/v1beta/openai`)
or Vertex AI (`<region>-aiplatform.googleapis.com/v1/projects/<project>/locations/<region>/endpoints/openapi`),
OpenAI, Anthropic (spoken natively over its Messages API - no gateway needed), Mistral, Groq, xAI, Cohere,
OpenRouter, an in-house Ollama, vLLM or LiteLLM gateway, or any
other OpenAI-compatible URL - and maps catalogue targets to models on them (Azure: the deployment name; Bedrock:
the model id). From then on every request of that workspace that executes - `POST /api/v1/route` with
`execute: true`, `/v1/chat/completions`, the MCP `ask` tool - runs the chosen target on the workspace's endpoint
with the workspace's credentials. The router, the trace, quotas, budgets and the learners are unchanged; only
where the call goes differs, so the usage lands on the workspace's own cloud bill and prompts stay inside the
data boundary it chose. Rules: a workspace mapping wins over the platform's for the same target; a target only
the workspace mapped is executable for that workspace alone (`GET /api/v1/targets` and `GET /v1/models` are
per workspace); unmapped targets keep the platform's providers, so a workspace can bring one Azure deployment
and leave the rest to the platform. Disabling a provider withdraws its mappings; deleting it removes them.
Responses name the answering endpoint: `opensmartroute.provider` on chat completions and `result.provider` on
`/api/v1/route` (`<provider>/<model>`).

Credentials are stored AES-256-GCM encrypted under `OSR_PLATFORM_SECRET_KEY` (falling back to the session
secret; set it explicitly on multi-replica deployments), bound to the workspace, never returned by the API
(`api_key_set`, `api_key_hint` only) and never resolved from the API container's environment - a workspace
cannot borrow the deployment's keys by leaving the field empty. A key may also be given **by reference**, so the
secret itself never enters the platform database: `keyvault://<vault>/<secret>[/<version>]` (or the secret's
`https://<vault>.vault.azure.net/secrets/...` URL) is read from Azure Key Vault with the API container's managed
identity when the client is built (the identity needs the *Key Vault Secrets User* role; the value is cached for
five minutes, so a rotation in the vault reaches the router without a redeploy), `env://NAME` reads a
variable of the API container (the operator's store of last resort), and `identity://<resource>` needs no secret at
all: the container's managed identity mints an access token for the resource (`https://cognitiveservices.azure.com`
for Azure OpenAI and Azure AI Foundry, `https://ai.azure.com`) and sends it as the bearer credential - keyless,
rotation-free; give the identity the *Cognitive Services OpenAI User* role on the resource (the console's *Azure
OpenAI / Foundry (managed identity)* preset fills the reference in, and *Hugging Face Inference Providers* is a
preset too). The platform's own file providers may use every form in `api_key`. A reference is not secret: the console shows it whole (`api_key_hint`,
`api_key_reference: true`), and a vault that refuses the read is what *Check* reports. The endpoint must be a **public** host: base
URLs on loopback, private, link-local or reserved addresses, bare hostnames and `.internal` / `.local` names
are refused (`400 base_url must be a public endpoint`), at save time and again before *Check*, so a workspace
cannot aim the platform's network position at its database, Redis, the model servers or the cloud metadata
service; credentials embedded in the URL are refused too. `OSR_PLATFORM_BYOK_ALLOW_PRIVATE=true` lifts the
host rule on a self-hosted deployment whose workspaces are trusted and whose providers live on the internal
network (the development compose stack sets it). Members read the page; adding, changing,
rotating (`api_key`) or removing (`clear_api_key`) a key, mapping and deleting need the `admin` role (API keys
act as the workspace). Every change is in the workspace audit log (`provider.created`, `provider.updated` with
`key_rotated`, `provider.mapped`, `provider.unmapped`, `provider.deleted` - never the key). *Check* calls the
endpoint's `/models` with the stored key and offers the returned ids on the provider card and in the mapping dialog;
the dashboard runs it right after connecting or editing a provider unless told not to. Endpoints:
`GET|POST /api/v1/workspace/providers`, `GET|PATCH|DELETE /api/v1/workspace/providers/{id}`,
`POST /api/v1/workspace/providers/{id}/check`, `PUT|DELETE /api/v1/workspace/providers/models/{target_id}`
(`provider_id`, `model`, optional `system_prompt` and request `defaults`). Up to 20 providers per workspace; a
change is in force on the next request on every API replica and service (the workspace's revision is compared
per request, so a removed provider is never used again).

#### Self-hosted models

The deployment runs its own model servers next to the cloud providers: the [model server](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MODELS.md) (the
OpenSmartRoute model host - Ollama and OpenAI APIs over llama.cpp, CPU or CUDA; the Ollama image remains a
supported alternative, `ollamaImage` / `OSR_OLLAMA_IMAGE` picks) with a curated set of
open weights on a shared file share (`ollamaEnabled`, on by default on Azure; the compose stacks run the same set,
pulled by `OSR_OLLAMA_MODELS`). Every model is stored and mapped under its ollama.com tag; where that tag's blob is
Ollama's own conversion that a standard llama.cpp cannot read (Qwen3.5, gpt-oss, GLM-4.7-Flash, GLM-OCR,
TranslateGemma), `ollamaModelSources` names the stock GGUF on Hugging Face the model server pulls instead
(`qwen3.5:4b=hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`), so the whole set is hosted by the OpenSmartRoute model server
without the Ollama image; `ollamaModelImages` stays for a per-model image exception. On CPU profiles
**every model runs as its own always-warm model server** (one model pinned in memory per
server), so a request never waits for another target's weights to be re-read from the share - on a shared
CPU server that eviction churn, not the inference, is the latency. The set is the strongest open model per job -
by the published benchmarks, not by name - that answers a short request in a few seconds on its hardware, so the
on-prem and specialist targets of the catalogue execute without data leaving the deployment:

| Target | Model | Job |
|---|---|---|
| `llm-onprem` | `qwen3.5:4b` | Private chat, reasoning, extraction and drafting: Qwen 3.5, 201 languages, image input, tool calls, thinking, 256K context. |
| `llm-small`, `llm-mid`, `llm-frontier` | `qwen3.5:4b` on CPU; `gpt-oss:20b` / `glm-4.7-flash` for the two upper tiers on a dedicated or GPU profile; `gpt-oss:120b` / `deepseek-v4-flash` through Ollama Cloud with a key | The cloud tiers of the catalogue. Every request stays on open weights; the dedicated-node and Ollama Cloud sets lift the upper tiers where they apply, and an operator who wants closed models connects them in the console or switches `azureOpenAiEnabled` on. |
| `writer-small` | `qwen3.5:4b` | Content - posts, emails, product copy - and translation. |
| `support-agent` | `qwen3.5:4b` | Fast support replies and classification. |
| `coder-small`, `coding-agent` | `qwen2.5-coder:7b` (`glm-4.7-flash` on a dedicated or GPU profile) | Code generation, explanation and repair; GLM-4.7-Flash is the 30B-A3B mixture-of-experts that leads the 30B class on SWE-bench Verified and τ²-Bench. |
| `vision-ocr` | `glm-ocr` | OCR and document understanding - text, tables, formulas, figures, seals - #1 on OmniDocBench 1.5 with 0.9B parameters. |
| `skill-translate` | `translategemma:4b` | Translation between 55 languages (Gemma 3 based). |
| `research-agent` | `deepseek-r1:8b` (dedicated or GPU profiles) | DeepSeek-R1-0528 distilled into Qwen3-8B: visible chain-of-thought for research and analysis - thinking answers take their time by design. |
| `embed-small` | `qwen3-embedding:4b` | Multilingual text embeddings (2560 dimensions, 100+ languages) for `POST /v1/embeddings`; one forward pass answers fast even on CPU. |
| `gpt-oss` | `gpt-oss:20b` (dedicated or GPU profiles) | OpenAI's open-weight reasoning model - a mixture-of-experts whose ~3B active parameters decode at CPU speed once the weights sit in RAM. |

The frontier open weights no single node hosts - Kimi K2.7 Code, DeepSeek-V4-Flash, gpt-oss-120b - answer through
**Ollama Cloud** (`https://ollama.com/v1`, the same OpenAI-compatible surface) when `ollamaCloudApiKey`
(`OSR_OLLAMA_CLOUD_API_KEY`, an API key from ollama.com/settings/keys) is set: `ollamaCloudTargets` maps
`coding-agent`, `research-agent`, `gpt-oss`, `llm-mid` and `llm-frontier` to them and replaces the local mapping of
those targets; without the key they stay on the local models. Any other ollama.com model is one console mapping
away (provider `ollama-cloud`).

`ollamaWorkloadProfile` picks the hardware. `Consumption` (the default) serves the small set: one always-warm
4 vCPU / 8 GiB app per model, each answering one generation at a time and scaling out to `ollamaMaxReplicas`
(default 4, `OSR_OLLAMA_MAX_REPLICAS`) on bursts. A dedicated size (`E4`, `E8`, ... - E-series memory fits the
19 GB GLM-4.7-Flash) adds the `ollamaLargeTargets` models, **one node per model**: only models that earn a whole
node are in that set - the mixture-of-experts coder and `gpt-oss:20b`, whose ~3B active parameters decode at CPU
speed once the weights sit in RAM, and the visible-reasoning `deepseek-r1:8b`, whose answers are slow by design.
A node costs the same whether it holds one model or four, and Ollama runs each loaded model in its own runner, so
`ollamaNodeGroups` packs models that fit together onto one always-warm server per node with every model resident
and preloaded (`OLLAMA_MAX_LOADED_MODELS` = the group size): a short OCR or embedding call runs beside a long
reasoning generation instead of waiting for it, and each grouped model keeps its own provider name
(`ollama-<model>`) so console mappings and health rows do not move. What limits a group is memory - the resident
working sets must fit the node (about 22 GiB of an E4's 28; the per-model `WorkingSetBytes` metric is the figure
to add up). The default keeps the two 22 GiB models (`glm-4.7-flash`, `gpt-oss:20b`) on nodes of their own and
packs `deepseek-r1:8b` with `translategemma:4b`, `glm-ocr` and `qwen3-embedding:4b`; `qwen3.5:4b`, the busiest
model, stays a Consumption replica that scales out on bursts - three nodes and one replica instead of four nodes
and four replicas, every model as warm as before. `OSR_OLLAMA_NODE_GROUPS` (a JSON array of
`{"name": ..., "models": [...]}`) overrides the grouping; a model named by no group keeps the per-model layout.
A model server that drops out of the template when a model moves into a group is retired by the
post-provision hook (`OSR_OLLAMA_PRUNE=false` keeps it).
Dense 9-12B chat upgrades are deliberately not served on CPU (a dense model that size cannot answer a short
request in seconds there); they return with a serverless GPU (`Consumption-GPU-NC8as-T4`, `Consumption-GPU-NC24-A100`; needs GPU
quota in a region that offers it), where one shared server keeps several models resident and decodes 8 / 32
requests at once per replica (the T4 at 8K context with the KV cache in `q8_0`, the A100 the whole set at 16K),
scaling out to `ollamaMaxReplicas`; the replicas share the model store and pull weights under a
lock. `ollamaTargets` / `ollamaLargeTargets` change the mapping; the model tags are pulled at start-up and kept
on the share, and `GET /api/v1/providers` shows which targets the servers answer for. **Azure OpenAI is opt-in**:
`azureOpenAiEnabled` (`OSR_AZURE_OPENAI_ENABLED`, default `false`) creates the account and the `openAiModels`
deployments and hands the `azure` provider every target the Ollama sets leave unmapped - which also gives image
generation (`tool-image-gen` → `gpt-image-2`) and, as the fallback for `transcribe-small`, `gpt-transcribe` a
home; off, those two wait for a console mapping (the self-hosted speech server covers transcription when
`voiceEnabled`). Ollama serves text, chat, code, vision (image input) and embeddings.

**Sizing a model server.** The numbers behind those profiles are arithmetic, not folklore, and the SDK does it
for you (`opensmartroute.inference`, `osr capacity`). A model in memory is its weights (parameters × bits per
weight: a `Q4_K_M` file is about 4.85 bits, `Q8_0` 8.5, `F16` 16, `gpt-oss`'s `MXFP4` 4.25) plus a KV cache the
server reserves for `OLLAMA_CONTEXT_LENGTH × OLLAMA_NUM_PARALLEL` tokens (2 × layers × KV heads × head size ×
2 bytes per token in `f16`; `q8_0` halves it), plus 5-20 % of runtime buffers. On a CPU, generation is
memory-bandwidth bound - every token re-reads the active weights, so a dense 8B `Q4_K_M` model (4.8 GB) decodes at
about 4 tokens/s on a 20 GB/s node while a 30B-A3B mixture reads only its ~3B active parameters and runs like a
3B model once its 18 GB sits in RAM - and prompt evaluation is compute bound (about 2 FLOPs per active parameter
per token). A cold load streams the weights from the model store, which on a network share is the 40-150 s
that used to dominate answers. `osr capacity show qwen3.5:4b glm-4.7-flash --memory 28Gi --cores 4` prints the
footprint, whether it fits after the OS headroom, tokens/s and the warm and cold latency of a typical request;
`osr capacity plan <models...> --memory 28Gi [--hosts N] [--shrink]` packs a set onto hosts largest first,
shrinks a model that does not fit (coarser KV cache, shorter context, fewer slots) when asked, names what fits
nowhere and prints the `OLLAMA_MAX_LOADED_MODELS` / `OLLAMA_NUM_PARALLEL` / `OLLAMA_CONTEXT_LENGTH` /
`OLLAMA_KV_CACHE_TYPE` / `OLLAMA_KEEP_ALIVE` / `OLLAMA_MAX_QUEUE` lines that pin exactly that set (`--strict`
exits 2 when a model is unplaced, for CI). With `--url http://<ollama>:11434` the shapes come from the server's
`/api/show` (exact layer and head counts, file size, trained context) instead of the tag heuristic, and
`osr capacity ps --url ...` lists what it holds in memory right now, on CPU or GPU, resident or expiring.
Defaults - 8K context, one slot, `f16`, 12 % headroom, 20 GB/s, 40 GFLOP/s per core, 0.3 GB/s cold reads - are
`OSR_INFERENCE_*` settings. The test suite runs the curated set above through the planner against the 8 GiB
Consumption app and the 14 / 28 GiB dedicated nodes, so a model that stops fitting its app fails CI before it
fails a deployment.

**Warm models, not cold loads.** The measured latency of a self-hosted answer was never the inference - it was
the cold load, so the platform now removes it twice over. Every model server loads its set into memory as soon
as the weights are pulled (`OSR_OLLAMA_PRELOAD=1` on the per-model CPU apps and the A100, where the whole set
stays resident) and keeps it there (`OLLAMA_KEEP_ALIVE=-1`). Independently, the providers owner asks every
self-hosted Ollama provider what it holds (`/api/ps`) on each health pass and, with
`OSR_PLATFORM_PROVIDERS_KEEP_WARM_MAX` > 0 (Azure sets it to 1 on CPU profiles and to the resident count on a
GPU), loads any mapped model that is not resident - but only when the provider maps at most that many distinct
models, so a server with more models than memory is never made to evict them in turn. `GET /api/v1/providers`
shows the result: `providers[].loaded` lists what each server holds and `models[].warm` says whether a target's
upstream model answers from memory (`null` when the endpoint cannot say); the operator's *Check* records the same.
`osr capacity ps --url` is the command-line view of the same fact. Voice is a second self-hosted server: `voiceEnabled`
(`OSR_VOICE_ENABLED`, on by default) adds `osr-voice`, a [Speaches](https://speaches.ai) container
(faster-whisper speech to text, Kokoro text to speech, OpenAI-compatible `/v1/audio/*`, CPU, weights preloaded
into its own share) and maps the `transcribe-small` / `speak-small` targets to it; `voiceSttModel`
(`OSR_VOICE_STT_MODEL`, default Whisper large-v3-turbo - the accuracy of large-v3 with a 4-layer decoder, on
4 vCPU / 8 GiB; `Systran/faster-whisper-small` runs on 2 vCPU / 4 GiB) and `voiceTtsModel` pick the models -
the compose stacks do the same with `COMPOSE_PROFILES=llm,voice`. With voice off, map `speak-small` to OpenAI or
Azure OpenAI in the console (`gpt-4o-mini-tts`); the OpenAI Realtime API (a WebSocket session, not a request the
router can score) and video generation always need a cloud provider called directly. The router decides
which target answers either way.

**The router ranks on what the deployment measured and bills, not on the catalogue's word.** A CPU-hosted 4B model
writes about five tokens a second: a one-line classification in ten seconds, an article in minutes. The reference
catalogue declared it at 1.2 s and at five times the price of the small cloud model, so the router, the quotes and
the latency SLO all read a fiction - production routed almost nothing to the six self-hosted servers while the
platform's own writers paid the cloud model. Every routing process now calibrates its executable models every
`OSR_PLATFORM_CALIBRATION_INTERVAL_S` seconds (300; 0 leaves the declared figures) from the last
`OSR_PLATFORM_CALIBRATION_DAYS` (7) of answered requests: the median latency becomes the target's `latency_ms`, and
when enough answers carry a token count and the time grows with it, a least-squares line splits it into a fixed
part and a per-token part (`latency_per_token_ms`), so the router expects the length of *each* answer - the slow
box keeps short requests and loses long drafts, and `max_latency_ms` rejects on the same expectation. The price
becomes the reference card of the model the mapping names (the card the estimate and the usage pricing already
bill) when the catalogue has one; a local model no card prices keeps its declared price, which is what the
deployment bills. The declared figures stay under the target's `metadata.declared`, the pass records itself under
`metadata.calibrated` (samples, fit, where the price came from), `GET /api/v1/targets` shows both, the rank trace
carries the per-request estimate and `GET /api/v1/learning` -> `calibration` lists what the last pass set. Five
answered requests are the least a target needs before its measurement replaces the catalogue.

#### Capacity: admission control and overflow

A model server has a fixed number of generations it can run at once - one on a CPU replica, 8 or 32 on a GPU -
while a burst can bring thousands of chat completions in the same second. The platform therefore never queues
requests on the model server. Every provider carries `concurrency` (the console field or the file key; `0` =
unbounded, the default for cloud endpoints) and `queue_timeout_s` (file key, default 20 s): that many calls run
on the endpoint at once, the next ones wait up to the timeout for a slot, and a call that gets none is
**re-routed** by the fallback loop to the next executable target - a chat pinned to `llm-onprem` with the
self-hosted server saturated answers from a cloud tier and says so in `opensmartroute.fallback_from`; `osr.fallbacks:
false` or a candidate list that is entirely saturated returns `503` with `Retry-After` instead. The re-route is a
routing decision like the first one, so every policy still applies: a request with `constraints.data_boundary:
on_prem` (or a tenant / workspace policy that sets it) is **never** spilled to a cloud target, even when the caller
listed cloud models as fallbacks - it waits for a slot and then answers `503`. The Azure template sets the
Ollama provider's `concurrency` to replicas x parallel slots automatically; the compose stacks set it
next to `OSR_OLLAMA_NUM_PARALLEL`. The gates are per process and visible in
`GET /api/v1/admin/health` (`admission[]`: `concurrency`, `inflight`, `waiting`, `admitted`, `rejected`, `peak`,
`avg_hold_ms`) and `GET /api/v1/admin/providers`.

The API side holds `OSR_PLATFORM_MAX_INFLIGHT` requests per process (default 10 000): route handlers run on the
worker-thread pool, which is widened to that number at start-up (the framework default is 40 threads, i.e. 40
concurrent completions per replica), and the HTTP server answers `503` beyond it rather than queueing sockets. In
cluster mode the API and the `openai` service scale on in-flight requests (`apiMaxReplicas`), so ten thousand
concurrent requests are spread over replicas, admitted to the model servers up to their capacity and spilled
over to the cloud tiers for the rest - the routing decision, trace, budget and quota logic is identical on both
paths.

**Stopping without dropping a request.** A replica that receives its stop signal (a deploy, a scale-in, a node
move) does not close its listener at once: `GET /readyz` answers `503` with `status: draining` so the load
balancer moves new requests to the other replicas, while this one keeps accepting for `OSR_PLATFORM_DRAIN_S`
seconds (default 10 - the readiness probe's period times its failure threshold); then the listener closes and the
requests still in flight - a long streamed completion included - get `OSR_PLATFORM_GRACEFUL_SHUTDOWN_S` seconds
(default 120) to finish before the process stops. `/healthz` stays `200` throughout: the process is healthy, it is
leaving. The Azure template keeps at least `apiMinReplicas` API replicas (default 2) in cluster mode so one can
leave while the other answers, sets the container's termination grace period to cover both windows and probes
readiness every 5 seconds. Set `OSR_PLATFORM_DRAIN_S=0` on a laptop to stop at once.

### Response headers

| Header | On | Meaning |
|---|---|---|
| `X-Request-Id` | every response | The id to quote in feedback and support requests; echoed back when you send one. The same id (and your W3C `traceparent`, when you send one) is forwarded to the provider that answers an executed request, so one id follows a request from your logs through the platform into the provider's. |
| `X-OSR-Data-Protection` | `/api/v1/route`, `/v1/chat/completions` | The data-protection mode that acted on the request (`detect`, `mask`, `block`), only when personal or health data was found (section 9). |
| `Server-Timing` | every response | `app;dur=<ms>` - time spent inside the platform. |
| `X-OSR-Target` | `/v1/chat/completions`, `/v1/embeddings`, `/v1/audio/*`, `/v1/images/generations` | The target id that answered. |
| `X-OSR-Confidence` | `/v1/chat/completions` | The router's confidence in that choice. |
| `X-OSR-Charge-USD` | `/api/v1/route` (executed), `/v1/chat/completions` | What the workspace is charged for the request under the price version in force (see *Usage pricing*). |
| `X-Wallet-Balance`, `X-Wallet-Available` | `402` | The credits balance of a wallet-billed workspace in cents, and what is left after the charges not yet settled and the holds of requests in flight. |
| `X-OSR-Provider`, `X-OSR-Fallback-From` | `/v1/audio/speech` | The `provider/model` that produced the audio and the targets that failed before it (the body is audio, so the decision travels in headers). |
| `Retry-After` | `429`, `503` | Seconds until the per-minute window, the monthly quota or the budget period resets; on `503`, when every candidate model server was at capacity, the estimated wait for a slot. |
| `X-Quota-Limit`, `X-Quota-Used`, `X-Quota-Reset` | `429` (quota) | Your plan's requests per month, how many the workspace used, and the unix time the month rolls over. |
| `X-Budget-Limit`, `X-Budget-Used`, `X-Budget-Period` | `429` (budget) | The exhausted workspace, tenant or key budget in USD (used includes the holds of requests in flight) and its period (`daily` / `monthly`). |
| `X-Upgrade: true` | `403`, `429` (quota) | The feature exists but is not in your plan, or a bigger plan would keep routing. |
| `X-Edition: enterprise` | `403` | Your plan includes the feature (tenants, statistics, audit) but this deployment runs the community image, which cannot serve it. |
| `X-Key-Scope` | `403` | The API key was created with scopes and lacks the named one (`read`, `route`, `execute`, `feedback`, `manage`). |
| `ETag`, `Cache-Control` | public catalogue reads | Conditional requests: send `If-None-Match` and receive `304` when nothing changed. |

## 4. OpenAI-compatible endpoint

Point an OpenAI SDK at `$OSR_URL/v1` and set `model` to `auto`. The router chooses the target per
request, runs it and returns a standard `chat.completion` whose `model` field is the target that
answered. Decision metadata is added in an `opensmartroute` object that other clients ignore.

```python
from openai import OpenAI

client = OpenAI(base_url=f"{OSR_URL}/v1", api_key=OSR_API_KEY)
reply = client.chat.completions.create(
    model="auto",
    messages=[{"role": "user", "content": "Write a haiku about routing."}],
)
print(reply.model)  # e.g. "llm-small" - the target the router chose
print(reply.choices[0].message.content)
```

```json
"opensmartroute": {
  "request_id": "7f3c...", "target": "llm-small", "kind": "llm", "confidence": 0.88,
  "plan": [], "alternatives": ["llm-mid"], "cost_usd": 0.00012, "latency_ms": 412.5,
  "fallback_from": [], "tokens_saved": 0
}
```

`plan` lists the persona, skill and tool targets the router composed with the model (tool slots are offered to the
model as functions and run by the platform when the model calls them, unless the request brought its own `tools`);
`tokens_saved` is the estimated prompt saving of the compaction step (whitespace, repeated lines and stale history
removed before the model was called - only present when it saved anything).

Options outside the OpenAI schema go in the request body and are ignored by other providers:

| Field | Meaning |
|---|---|
| `model` | `auto` / `osr/auto` (default) lets the router choose; `osr/auto:cheap` / `:fast` / `:quality` / `:private` / `:agentic` / `:llm` preset the objective, a data-boundary constraint or the allowed kinds (explicit `osr.*` options win); a target id pins that target. |
| `models` | Candidate list: the router chooses among them and falls back down the list when a target fails. |
| `osr.objective` / `osr.constraints` | Same as on `/api/v1/route`. |
| `osr.tenant` | Tenant slug (or the `X-OSR-Tenant` header); applies the tenant's constraints (**enterprise**). |
| `osr.plan` | `true`/`false` to force or suppress plan composition; omitted = whatever the plan tier allows. |
| `osr.plan_mode` | `sequential` / `parallel`: how the plan's skills and tool calls run (see `plan_mode` on `POST /api/v1/route`). |
| `osr.fallbacks` | `false` disables the automatic retry on the next best target (default `true`). |
| `stream` | `true` streams server-sent events in the OpenAI format: each provider token is relayed as it arrives; the last chunk carries `usage` and the `opensmartroute` metadata. Quota, routing and provider errors before the first token are ordinary HTTP errors; a fallback to another target happens only before the first token. |
| `tools`, `tool_choice`, `response_format`, `stop`, `n`, `seed`, `logprobs`, `top_logprobs`, `parallel_tool_calls` | Forwarded to the provider unchanged. A tool call comes back as `message.tool_calls` with `finish_reason: "tool_calls"`; send the `tool` turn back in `messages` (with `tool_call_id`) and the model finishes the answer. Unknown fields are passed through too. |

Message `content` may be a list of parts: text parts form the request, `image_url` / `input_audio` / `file`
parts reach the provider unchanged and make the router prefer multimodal targets (`ModalitySignal`).

`GET /v1/models` lists `auto`, `osr/auto`, its `:cheap` / `:fast` / `:quality` / `:private` / `:agentic` / `:llm`
variants, every executable target and every catalogue model on offer by the token (OpenRouter's shape with the
price this workspace pays; `model: "openai/gpt-4.1"` routes among its endpoints, `"openai/gpt-4.1@azure"` pins one -
see *Models by the token* under section 6).

`POST /v1/embeddings` serves the OpenAI embeddings API on the same key: `model: "auto"` routes among the
targets tagged `embeddings` (`capabilities.tags: [embeddings]`, or `metadata.embeddings: true`) that a
provider maps; a target id pins one. Requests are metered like chat completions and the `opensmartroute`
metadata block names the target and its cost. Without an embeddings-capable target the endpoint answers `501`.

### Prompt registry

The instruction a request uses can live in the workspace instead of in the calling code. `/api/v1/prompts` keeps
prompts by `name` with immutable numbered versions (`POST /api/v1/prompts` adds version 1 of a new name or the
next version of an existing one), **labels** that deploy a version (`production`, `staging`; a label names one
version, so `PUT /api/v1/prompts/{name}/{version}/labels` moves it - that is the release), `{{variable}}`
placeholders with optional defaults (`{{tone|calm}}`; the variables a version uses are recorded with it) and a
preview (`POST /api/v1/prompts/{name}/render` with `version` or `label` and `variables`) that renders without
routing. Two kinds: a **text** prompt becomes the system instruction, in front of the request's own; a **chat**
prompt (`[{role, content}]`) puts its system turns there and prepends its user / assistant turns to the history
as examples. A request names one in `osr.prompt` (chat completions) or `prompt` (`/api/v1/route`):
`{"name": "support-reply", "label": "production", "variables": {"company": "Acme"}}`; `version` pins a number,
neither label nor version means the newest. A missing variable without a default is a `400` naming it; an unknown
prompt or label a `404`, before anything is routed or metered. The resolved `name@version` is on the response
(`X-OSR-Prompt`) and in the request's context, so the trace and the request log show which wording acted; the
guard and the data-protection pass see the rendered instruction as the model will. `GET /api/v1/prompts` lists
names with newest version, version count and labels; `GET /api/v1/prompts/{name}` the versions with content;
`DELETE` removes a version or a name; the workspace export carries every version. Up to 200 prompts of 100
versions each; writes take the `manage` scope and are audited (`prompt.version_created`, `prompt.labels_changed`,
`prompt.version_deleted`, `prompt.deleted`). The usage row of a request that named a prompt keeps the
tag under `facts.prompt`, and `GET /api/v1/prompts/{name}/stats` (`?days=`, 1-90, default 30) compares the versions
on it: per version the requests, the answered share, tokens, cost, median latency and the outcomes reported on them
(success rate, mean quality) - the figures a prompt change is judged by, next to each version on the dashboard.
Dashboard: *Prompts*.

### Conversations and memory

The desktop app, the browser extension's side panel and the playground share one chat path, so the platform
gives all of them the same three things at once. Two `osr` options on `POST /v1/chat/completions` turn them on:

| Option | Effect |
|---|---|
| `osr.conversation: true` | Start a conversation the platform keeps. The history the client sends with this first message is imported once; the `conversation` object of the answer's `opensmartroute` block names the conversation (`id`). |
| `osr.conversation_id` | Continue that conversation: send **only the new message**. The platform prepends the last 12 turns verbatim and, as a system note, a rolling summary of everything older (extractive - one line per turn that left the window, never invented), so a thread never outgrows the model's context and the same thread continues from any client of the workspace. An id the caller does not own is `404`. |
| `osr.memory` | Place the long-term memory in front of the request and learn from this message (`true` / `false`; unset = the subject's switch, on by default). Works with or without a conversation. |

Every answer then carries a `conversation` object in its `opensmartroute` block: `id`, `new`, `turns`, `topic_shift` (the question shared
no vocabulary or domain with the recent turns and was not a follow-up - a client offers *Move to a new chat*),
`resumed` and `idle_s` (the conversation had been idle for hours), `remembered` (facts learned from this message,
with their kind), `forgot` (memories removed on request), `memories_used` and `memory` (the switch). The
conversation id is also the `X-OSR-Conversation-Id` response header.

**Long-term memory is rules, not a model.** Facts a person states about themselves are extracted from their
own messages - *my name is …*, *I work at …*, *I'm a data engineer*, *I'm based in …*, *we use Python and dbt*,
*I prefer short answers*, *always answer in French*, *I'm working on …*, *remember that …* - and stored per
subject as `name`, `role`, `employer`, `location`, `language`, `preference`, `uses`, `project` or `note`
(one value for name, location and language; at most 200 memories, the least used dropped first). Before a
request the identity facts, the memories whose words the request mentions and the freshest notes (12 at most)
are written into the system message as *What you know about the person you are helping*; *forget that I work
at Acme* removes the matching memory. Nothing is inferred, summarised by a model or shared across subjects.

**Whose memory.** A personal workspace is one person: its memory and conversations belong to the workspace,
whatever key or device is used. Inside an organization each signed-in user has their own; an API key has its
own (`key`), because the platform cannot tell who holds a shared key. `GET /api/v1/memory` says which
(`subject`: `workspace`, `user` or `key`).

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/conversations` | The caller's conversations, newest first (`?client=desktop`, `?q=` searches titles and summaries). |
| `GET /api/v1/conversations/{conversation_id}` | One conversation: title, summary, the stored turns (`turns_list` with target, request id and cost per answer, so feedback keeps working after a thread is reopened). |
| `PATCH /api/v1/conversations/{conversation_id}` | Rename (`{title}`); the desktop and the extension show the same title. |
| `POST /api/v1/conversations/{conversation_id}/split` | A topic change the person confirmed: the turns from `from_seq` on become their own conversation. |
| `DELETE /api/v1/conversations/{conversation_id}`, `DELETE /api/v1/conversations` | Delete one, or every conversation of the subject. |
| `GET /api/v1/memory` | The switch, the subject and every memory (kind, content, uses, disabled). |
| `PUT /api/v1/memory/settings` | `{enabled}` - off: nothing is placed and nothing is learned (stateless chat). |
| `POST /api/v1/memory` | Add a fact by hand (`{content, kind?}`; `409` when already known). |
| `PATCH /api/v1/memory/{memory_id}` | Edit (`content`) or pause (`disabled`) a memory; a paused fact is never re-learned. |
| `DELETE /api/v1/memory/{memory_id}`, `DELETE /api/v1/memory` | Forget one, or everything. |

Conversations and memories are deleted with the workspace. The dashboard's Memory page
(`/platform/dashboard/memory`) lists both; the desktop app's Settings and the extension's settings page show
the memory list with the same switch.

### Knowledge graph and context engineering

Memory remembers what was said; the **knowledge graph** keeps *facts* - typed, sourced, weighted, expiring
triples in one graph per workspace (the SDK's [`opensmartroute.knowledge`](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/GUIDE.md#knowledge-graph-and-context-engineering),
stored in the platform database). It fills itself from the traffic it serves: what a person states about
themselves (`person:<subject>` - `works_at`, `has_role`, `located_in`, `speaks`, `prefers`, `uses`,
`works_on`, `noted`, `mentions`), what their requests were about and where they were routed
(`asked_about`, `routed_to`), what a live web search cited (`cites`, expiring) and what the workspace adds by
hand. Before every routed chat the graph's context engineer chooses, ranks and trims what the model should
know into a token budget (600 tokens by default, per subject) and accounts for every line; the answer's
`opensmartroute.knowledge` block says how many tokens were placed, which sections, what was dropped and which
facts were learned from the message. The personal section is placed by the graph only when conversation
memory is off - otherwise memory already put those facts in front of the model and the graph adds what memory
cannot: the workspace's shared facts that match the request. A person's facts are never shown to another
subject of the same organization.

**Self-managing.** The process that owns the `knowledge` domain (the API, or the `knowledge` service in
cluster mode) runs a maintenance pass every `OSR_PLATFORM_KNOWLEDGE_MAINTAIN_S` (an hour by default; one
replica per interval): unconfirmed facts lose confidence and are dropped under the floor, expired citations go,
and when TensorFlow is on the image a TransE link predictor
([`KnowledgeEmbedding`](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/SDK.md#knowledge-graph-and-context-engineering)) is refitted over each active
workspace's shared facts and stored - `GET /api/v1/knowledge/suggest` answers from it (*which domain is this
target probably good at that nobody declared*), falling back to frequency over peers without it.

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/knowledge` | The switch and budget, the subject's entity, graph statistics (facts, entities, per predicate, per source), the trained model's report, the predicate vocabulary and the export formats. |
| `PUT /api/v1/knowledge/settings` | `{enabled?, budget_tokens?}` - off: nothing is placed and nothing is learned; the budget (0-4000) bounds the engineered context. |
| `GET /api/v1/knowledge/facts` | The facts the caller may see - their own personal facts and the workspace's shared ones (`?q=` word search, `?predicate=`, `?personal=true|false`, `?limit=`). |
| `POST /api/v1/knowledge/facts` | Add a fact by hand (`{predicate, object, subject?, confidence?}`; the subject defaults to the person, a shared fact names any non-person entity such as `target:llm-small`). |
| `DELETE /api/v1/knowledge/facts` | Delete by `subject`, `predicate` and/or `object` (query parameters; another person's facts are refused). |
| `DELETE /api/v1/knowledge` | Delete every personal fact of the subject. |
| `POST /api/v1/knowledge/forget` | `{phrase}` - *forget that I work at Acme* drops the personal facts the phrase names. |
| `POST /api/v1/knowledge/context` | `{text, budget_tokens?}` - preview the engineered context the graph would place for a message: the text, every line placed with its score, tokens and source, and what was dropped and why. |
| `GET /api/v1/knowledge/suggest` | `?subject=&predicate=&k=` - likely objects for a fact the graph does not hold, from the trained link predictor when there is one. |
| `GET /api/v1/knowledge/export` | `?format=jsonld|ntriples|graphml|cypher` (`&personal=false` leaves the personal facts out) - the graph for rdflib / Jena / GraphDB, Gephi / NetworkX or Neo4j / Memgraph. |
| `POST /api/v1/knowledge/train` | Fit the link predictor now (`409` without TensorFlow, `400` with fewer than 24 shared facts). |

The knowledge graph is deleted with the workspace. The dashboard's Memory page (`/platform/dashboard/memory`)
shows it under *Knowledge graph*: the switch and the context budget, the facts the person may see in plain words
with search and delete, *Add a fact*, *Forget that ...*, a preview of the context the graph would place for a
message, the export in the four formats and - when the image has TensorFlow - *Train now*.

### Responses API

`POST /v1/responses` serves the OpenAI **Responses API** - the surface the OpenAI Agents SDK and newer client
code speak - over the same routed chat path and the same key: `input` (a string or a list of message,
`function_call` and `function_call_output` items), `instructions`, flat `tools`, `tool_choice`,
`max_output_tokens`, `text.format` and the `osr` routing options are translated to a chat completion; the
answer is a `response` object (`output` items of type `message` / `function_call`, `usage.input_tokens` /
`output_tokens`, the `opensmartroute` block) and `stream: true` emits the typed events (`response.created`,
`response.output_text.delta`, `response.function_call_arguments.delta`, `response.completed`). Conversation
state is kept per workspace: every response is remembered for `OSR_PLATFORM_RESPONSES_STORE_TTL_S` seconds
(default one hour; `store: false` opts a response out, the answer's `store` says whether it was kept) and a
follow-up turn may name it as `previous_response_id` - its turns come first, the new `input` after. The state
lives on Redis when configured, else in the platform database (`responses` table), so every replica sees it and
it survives a restart either way; a workspace can never resolve another workspace's id, and expired rows are
dropped by the finance pass. An unknown or expired id is refused with `400` telling the caller to send the
full `input`, never silently ignored. Only function tools are proxied: a built-in tool (`web_search`,
`file_search`, `code_interpreter`, ...) is refused with `400` rather than dropped, so a completed response never
implies a tool ran that did not. Metered like a chat completion.

### Anthropic Messages API

`POST /v1/messages` serves the Anthropic **Messages API** - the surface of the Anthropic SDKs, Claude Code and the
agent frameworks built on them - over the same routed chat path and the same key: point the client's base URL at
the platform and send the API key as `Authorization: Bearer` or `x-api-key`. `system` (string or `text` blocks),
`messages` with `text` / `image` (base64 or URL) / `tool_use` / `tool_result` blocks, `tools` with `input_schema`,
`tool_choice` (`auto` / `any` / `tool` / `none`, `disable_parallel_tool_use`), `max_tokens`, `stop_sequences`,
`metadata.user_id` and the `osr` routing options are translated to a chat completion; the answer is a `message`
object (`content` blocks of type `text` / `tool_use`, `stop_reason` `end_turn` / `max_tokens` / `tool_use` /
`stop_sequence`, `usage.input_tokens` / `output_tokens`, the `opensmartroute` block) and `stream: true` emits the
Anthropic events (`message_start`, `content_block_start`, `content_block_delta` with `text_delta` /
`input_json_delta`, `content_block_stop`, `message_delta`, `message_stop`). Errors use the
`{"type": "error", "error": {"type", "message"}}` envelope. `model` is `auto` or a target id as everywhere else.
Extended thinking is accepted and ignored; PDF `document` blocks and server-side tools are refused with `400`.
Metered like a chat completion. `POST /v1/messages/count_tokens` answers `{"input_tokens": n}` from the router's
own token estimate - no provider call, nothing metered, the `route` scope suffices.

### Google Gemini API

`POST /v1beta/models/{model}:{action}` serves the Google **Gemini API** - the surface of the Google GenAI
SDKs and the Gemini CLI - over the routed chat path and the same key, `action` being `generateContent`,
`streamGenerateContent` or `countTokens`: point the client's `api_endpoint` /
`base_url` at the platform and send the key as `x-goog-api-key` (or `Authorization: Bearer`). `contents` with
`text`, `inlineData` / `fileData` (images), `functionCall` and `functionResponse` parts, `systemInstruction`,
`tools` with `functionDeclarations` (Gemini's upper-case schema types become JSON Schema), `toolConfig`
(`AUTO` / `ANY` / `NONE`, `allowedFunctionNames`), `generationConfig` (`temperature`, `maxOutputTokens`,
`stopSequences`, `topP`, `responseMimeType: application/json`) and an `osr` block of routing options translate to a
chat completion; the answer is a `GenerateContentResponse` (`candidates[0].content.parts` of `text` /
`functionCall`, `finishReason` `STOP` / `MAX_TOKENS` / `SAFETY`, `usageMetadata`, `modelVersion`, the
`opensmartroute` block). `:streamGenerateContent?alt=sse` emits the same objects as `data:` frames, the last one
carrying `finishReason` and `usageMetadata`; `:countTokens` answers `{"totalTokens": n}` from the router's estimate
(nothing metered, the `route` scope suffices); `GET /v1beta/models` lists what the key may name. Errors use
Google's `{"error": {"code", "message", "status"}}` envelope. `model` is `auto` or a target id (the SDKs'
`models/` prefix is accepted); the same routes answer under `/v1`. `cachedContent` and Google's built-in tools
(search grounding, code execution) are refused with `400`. Metered like a chat completion.

### Rerank

`POST /v1/rerank` (`query`, `documents` as strings or `{"text": ...}` objects - up to 1 000 -, optional `top_n`,
`return_documents`) answers in the Cohere / Jina shape: `results[]` ordered by `relevance_score` (0-1) with each
document's original `index` and, when asked, its text; `model` is `auto` (the embeddings-capable target the
router picks) or a target id. The query and the documents are embedded in one call and ranked by cosine
similarity, so any embeddings provider gives you a reranker; one metered `rerank` row per call.

### JSON repair

A request whose `response_format` asks for JSON (`json_object` or `json_schema`) gets its answer repaired when the
model left it almost right: a Markdown fence or a sentence around the value, a trailing comma, a string or a
bracket left open when `max_tokens` ran out. The smallest edit that makes the text parse is applied - values are
never invented, a key cut before its value is dropped - and `opensmartroute.json_repaired: true` marks the answer.
On a stream the missing suffix goes out as one more content delta before the finish chunk, so the concatenated
deltas parse; a repair that would have to change bytes the client already holds is not attempted.

### Moderation

`POST /v1/moderations` (`input`: a string or a list of strings / `text` parts) answers in the OpenAI moderation
shape - `results[].flagged`, `categories`, `category_scores` - from the router's own guards rather than a hosted
safety classifier: `prompt_injection` (the instruction-injection lexicon), `router_steering` (sentences that talk
to the router about the request), `confounder_gadget` (token soup appended to inflate complexity), `pii`
(reported under `categories.pii`; `flag_pii: true` also raises `flagged`), `oversize` and `control_characters`.
The `opensmartroute` block of each result lists the reasons and the PII types found. No provider call, nothing
metered; the `route` scope suffices. Use it to screen prompts before spending on a model.

### Voice and images

The same key and the same router serve the OpenAI audio and image APIs, so `client.audio.transcriptions.create`,
`client.audio.speech.create` and `client.images.generate` work unchanged with `model: "auto"`:

| Endpoint | Routes among | Answer |
|---|---|---|
| `POST /v1/audio/transcriptions` (multipart `file` + `model`, optional `language`, `prompt`, `temperature`, `response_format` `json` / `verbose_json` / `text` / `srt` / `vtt`) | targets tagged `transcription` (`capabilities.tags`, or `metadata.transcription: true`) - the catalogue's `transcribe-small` | The transcription object (`text`; `language`, `duration`, `segments` with `verbose_json`; plain text for the text formats) plus `opensmartroute`. Files up to 25 MB; the audio never enters the routed text, only the file name and your `prompt` do. |
| `POST /v1/audio/speech` (`input` up to 4 096 characters, optional `voice`, `response_format` `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm`, `speed`, `instructions`) | targets tagged `speech` (or `metadata.speech: true`) - `speak-small` | The audio bytes with the media type of the format; the decision is in `X-OSR-Target`, `X-OSR-Provider` and `X-OSR-Fallback-From`. |
| `POST /v1/images/generations` (`prompt`, optional `n`, `size`, `quality`, `style`, `response_format` `url` / `b64_json`) | executable image tools (`actions: [image]`) - `tool-image-gen` | The OpenAI images object (`created`, `data[].url` or `b64_json`, `revised_prompt`) plus `opensmartroute`. |

A target id instead of `auto` pins one target (`400` when it cannot do that job, `404` when unknown); without any
capable executable target the endpoint answers `501` and says which tag to add. The voice and image targets
execute once they are mapped to a provider that serves the corresponding OpenAI path - OpenAI, Azure OpenAI, the
self-hosted Speaches server (`voiceEnabled` / the `voice` compose profile) for audio - and are metered under the
endpoints `transcriptions`, `speech` and `images`; a transcription target may declare `metadata.usd_per_minute`
so the cost follows the audio duration the provider reports. Uploads must be an audio container (`flac`, `m4a`,
`mp3`, `mp4`, `mpeg`, `mpga`, `oga`, `ogg`, `opus`, `wav`, `webm`; 25 MB), the file name is reduced to its base
name and the declared content type to an audio or video type; the audio bytes never enter the routed text, the
request memory, the trace or the logs. Calls to the media providers never follow redirects, cap the answer at
64 MB, forward a fixed whitelist of options, and only relay an `audio/*` body as speech. `osr.objective`,
`osr.constraints`, `osr.tenant` and
`osr.fallbacks` apply to speech and images as on chat completions; a transcription (a form upload) carries no
routing options, the workspace policy and the `X-OSR-Tenant` header still apply. Embeddings, transcription and
speech targets are never chosen for a chat completion routed as `auto`; an image tool may be, in which case the
chat answer is the picture as Markdown.

`POST /v1/route` and `POST /v1/feedback` are aliases of `POST /api/v1/route` and `POST /api/v1/feedback`
(same body, same plan gates) for gateways that forward only the `/v1` prefix.

### TypeScript client

`@opensmartroute/sdk` (published on the npm registry with every release -
`npm install @opensmartroute/sdk`) is a zero-dependency TypeScript client for the endpoints above - Node 20+,
Deno, Bun, edge runtimes and browsers, `fetch` based. The same client talks to the hosted platform and to a
self-hosted `osr serve` (`apiPrefix: ""`, because the server mounts the routes at the root). Its version
follows the platform release and its types are generated from that release's OpenAPI document;
the full guide is the [TypeScript SDK](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/sdk-typescript.md) page.

```ts
import { OpenSmartRoute, RateLimitError } from "@opensmartroute/sdk";

const osr = new OpenSmartRoute({ baseUrl: "https://opensmartroute.ai", apiKey: process.env.OSR_API_KEY! });

const decision = await osr.route({ text: "Refund order #123", objective: { cost: 0.6 }, top_k: 3 });
await osr.feedback({ request_id: decision.request_id, target_id: decision.target.id, success: true, quality: 0.9 });

const reply = await osr.chat.completions.create({ model: "auto", messages: [{ role: "user", content: "Hi" }] });
console.log(reply.model, reply.opensmartroute.confidence);          // typed `OsrMetadata`

const stream = await osr.chat.completions.create({ messages: [{ role: "user", content: "Hi" }], stream: true });
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta.content ?? "");

const local = new OpenSmartRoute({ baseUrl: "http://localhost:8000", apiKey: "my-token", apiPrefix: "" });
```

| Method | Endpoint |
|---|---|
| `route(req)`, `feedback(outcome)`, `estimate(req)` | `POST /api/v1/route`, `/feedback`, `/estimate` |
| `targets()`, `me()`, `quota()`, `info()`, `health()`, `whoami()` | `GET /api/v1/targets`, `/me`, `/quota`, `/info`, `/healthz`, `/whoami` (`osr serve`) |
| `chat.completions.create(body)` | `POST /v1/chat/completions`; `stream: true` returns an `AsyncIterable` of `chat.completion.chunk` objects parsed from the SSE stream (`finalCompletion()` assembles the answer) |
| `embeddings.create(body)`, `models.list()` | `POST /v1/embeddings`, `GET /v1/models` |
| `responses.create(body)` | `POST /v1/responses`; `stream: true` returns the typed Responses events (`finalResponse()` assembles the answer, `responseText()` reads it) |
| `messages.create(body)`, `messages.countTokens(body)` | `POST /v1/messages` (Anthropic Messages API); `stream: true` returns the Anthropic events (`finalMessage()` assembles the answer, `messageText()` reads it); `POST /v1/messages/count_tokens` |
| `audio.speech.create(body)`, `audio.transcriptions.create({ file, ... })`, `images.generate(body)` | `POST /v1/audio/speech` (bytes + headers), `/v1/audio/transcriptions` (multipart), `/v1/images/generations` |
| `rest.get/post/put/patch/del(path, { path, query, body })` | every route of this API, typed from the OpenAPI document (`rest.get("/api/v1/keys")`, `rest.del("/api/v1/keys/{key_id}", { path: { key_id } })`) |
| `keys`, `tenants`, `governance`, `providers`, `notifications`, `support`, `handoffs`, `evaluations`, `billing`, `marketplace`, `insights`, `memory`, `knowledge` | convenience groups over `rest` for the workspace surface (`keys.create / rotate / revoke`, `tenants.upsert`, `governance.setPolicy`, `providers.check / map`, `notifications.inbox`, `support.open`, `evaluations.run / replay / sample / get`, `billing.invoices`, `marketplace.search`, `insights.usage / savings / audit / sla`, `memory.get / add / forget / conversations`, `knowledge.facts / add / forget / context / suggest / export / train` ...) |

Options: `tenant` (`X-OSR-Tenant`), `app` (`X-OSR-App`), `headers`, `timeoutMs`, a custom `fetch`; per call
`signal`, `requestId` (`X-Request-Id`) and `tenant`. Non-2xx answers raise `OpenSmartRouteError` with `status`,
`detail`, `requestId`, `retryAfter` and a stable `code`; `AuthenticationError` (401), `PermissionDeniedError`
(403, `upgradeRequired`), `NotFoundError` (404) and `RateLimitError` (429, `budget` from the `X-Budget-*`
headers) are subclasses. Request types (`RouteIn`, `FeedbackIn`, `ChatCompletionIn`, `EmbeddingsIn`) are
generated from this API's OpenAPI document; `RouteDecision`, `ChatCompletion`, `ChatCompletionChunk` and
`EmbeddingsResponse` type the answers. The package README documents the development and live-test workflow.

## 5. Feedback

`POST /api/v1/feedback` reports how a routed answer turned out. The learners (Bradley-Terry, IRT,
LinUCB) update on the workspace's own traffic. Included in every plan of the default catalogue
(feature `feedback`).

```bash
curl -s "$OSR_URL/api/v1/feedback" -H "Authorization: Bearer $OSR_API_KEY" -H "Content-Type: application/json" \
  -d '{"request_id": "7f3c...", "target_id": "llm-frontier", "success": true, "quality": 0.9, "latency_ms": 2100}'
```

Fields: `request_id`, `target_id`, `success` (required); `quality` (0..1), `cost_usd`, `latency_ms`,
`domains`, `complexity`, `preferred_over` (the id of a target this one beat in a comparison), `role` (the plan
slot the outcome grades - `persona`, `skill`, `tool` or `llm` - when it is about a companion of a composed answer
rather than the target that answered). The `request_id` must be one the workspace itself routed (`/route`,
`/v1/chat/completions`, `/estimate`, MCP): an unknown id, or another workspace's, is a 404, as is an unknown
`target_id` - a catalogue target, one of the workspace's installed listings, or a published skill / persona a plan
discovered for the request (section 11) are known.

Every report is kept against its request: `GET /api/v1/activity` summarises it per row (`outcome`),
`GET /api/v1/trace/{request_id}` lists the reports (`outcomes`) next to the spans, and in the enterprise
edition the audit chain records an `outcome` entry after the `decision` entry (section 10). The dashboard
has the same form without curl: expand a row on `/platform/dashboard/activity` (or open its trace) and
report good / poor, a quality score and, optionally, the target that would have done better - the latter
is recorded as a pairwise preference for the Bradley-Terry learner.

**The platform reports what it can see itself.** Almost no client calls `/feedback`, so an execution's own
assessment is kept the same way and needs nobody: when the answer was judged (a handler that rates its own
output, or the implicit reading of the answer - a refusal, a truncation, an empty reply), and when a
**fallback** settled a comparison (the target that answered a request another one had just failed beat it
on that request), the outcome is written against the request and published on the `feedback` topic the SLM
service trains from - for the answering target and, with their `role`, for every persona, skill and tool the
plan composed the answer with, so the companions share the answer's judgement. An answer nothing was found
wrong with is not an assessment and is not recorded.

### The human queue

Routing to a person is a real execution, not an error. When a decision lands on a `human` target
(escalation intent, high risk, low automated confidence - the defer strategy scores this), `execute: true`
and the chat proxy **queue a ticket** instead of failing: the answer names the ticket, costs zero tokens,
and the ticket keeps the request text (with PII restored - human targets declare `pii_allowed`) and the
router's reason for deferring in a few plain sentences: what the request reads as (domain, difficulty,
personal data, jailbreak risk), which targets policy ruled out and why, how the human target ranked against
the runner-up, which strategies carried it, and which targets failed before the router re-routed there.
The full decision trace stays one link away.

| Endpoint | What it does |
|---|---|
| `GET /api/v1/handoffs` | The workspace's tickets, newest first, with per-status counts (`?status=pending`). |
| `GET /api/v1/handoffs/{handoff_id}` | One ticket: text, reason, status, answer. |
| `POST /api/v1/handoffs/{handoff_id}/resolve` | A person answers (`{answer, quality?}`); the resolution feeds an outcome to the learners, so deferral itself is learned. |
| `POST /api/v1/handoffs/{handoff_id}/cancel` | Withdraw a pending ticket; recorded as a failed outcome. |

The dashboard's Human queue (`/platform/dashboard/handoffs`) shows how many tickets wait and for how long,
lists each ticket with the request and the reasons side by side and a *Full trace* link into the activity
log, and lets a teammate answer it - the answer goes back as the response and is recorded as the outcome with
a quality in words (*Solved* ... *Could not help much*) - or cancel it, without curl.

### Evaluation

Outcomes say how the router did on the traffic it got; an **evaluation** says how it would do on the prompts
you choose, before you send them. `POST /api/v1/evaluations` takes a labelled dataset - the JSON-lines file
`osr eval` reads, one object per row - and routes every row on the workspace's own pool: its policy, tenants
(`tenant` in the body), allow / deny lists, API-key limits, installed listings and providers all apply, exactly as
on `/route`. **Nothing is executed and nothing is metered**: a run calls no model, costs nothing, counts against
no quota, and leaves no trace or event in the workspace's history (the rows are not traffic). The dataset is
routed and discarded; only the report is kept - the newest 50 runs per workspace, readable at
`/platform/dashboard/evaluation`.

```bash
curl -s "$OSR_URL/api/v1/evaluations" -H "Authorization: Bearer $OSR_API_KEY" -H "Content-Type: application/json" \
  -d '{"name": "support prompts, week 32", "rows": [
        {"text": "What is 12 * 37?", "expected": "tool-calculator"},
        {"text": "Summarise this clause in plain words: ...", "acceptable": ["llm-mid", "llm-frontier"]},
        {"text": "List customers with no orders this year.", "scores": {"skill-sql": 1.0, "llm-mid": 0.7}, "task_type": "sql"},
        {"text": "Translate good morning into French."}]}'
```

A row is `text` plus how it is judged: `expected` (the target id that should win), `acceptable` (several ids
that all count), or per-target `scores` in `0..1` (RouterBench style; the best score is the label and the
chosen target's score is its *realised quality*). `context`, `history` and `task_type` travel with the row.
Rows without a label are fine - they still give the mix, the price and the confidence. `objective`
(`{"cost": 0.6}`) and `constraints` are those of `/route`. The report carries:

| Field | Meaning |
|---|---|
| `router.accuracy`, `accuracy_ci` | Share of labelled rows whose chosen target is a hit, with the Wilson 95 % interval for that many rows; `hits` and `labelled` are the counts behind it. |
| `router.mean_quality` | Mean score of the chosen target over the rows that score targets. |
| `router.mean_cost_per_1k`, `mean_latency_ms`, `mean_confidence`, `abstain_rate` | Declared list price and latency of the chosen targets, averaged over the routed rows; the router's confidence in its choice; how often it abstained. |
| `router.ece`, `brier`, `reliability` | Calibration on labelled rows - expected calibration error (0 = the router is right exactly as often as it says), Brier score and the ten confidence bins - reported from 20 labelled rows. |
| `savings` | The mix's mean price against always sending every row to the dearest target the policy admitted (`pool`), as a share. |
| `baselines` | The same rows and the same admissible pool routed by the policies every router must beat - random, always the cheapest, always the strongest `quality_prior`, always the dearest - each with accuracy, interval, quality, price and latency (`baselines: false` skips them). |
| `route_mix`, `by_domain`, `confusion`, `misroutes` | Where the rows went (with per-target accuracy and price), accuracy per first domain, the `expected -> got` pairs and the labelled rows the router got wrong with their confidence. |
| `frontier` | With `frontier: true` (up to 500 rows): accuracy and mean price at cost weights 0, 0.3, 0.6, 1 and 2 - the accuracy-against-spend curve of your own prompts. |
| `decisions` | Every row's decision: the prompt's first words, expected, chosen target and kind, hit / miss / unlabelled, confidence, quality, price, latency, domains and complexity (the first 1,000; the aggregates cover all). |

A run routes at most `OSR_PLATFORM_EVAL_MAX_ROWS` rows (default 2000, `413` beyond it) and for at most two
minutes (`truncated: true` with `evaluated` when it stopped early). `GET /api/v1/evaluations/sample` returns
labelled rows made from the catalogue itself (ontology seed prompts scored by capability fit), so a workspace
can see a report before it has a dataset; the page's *Template* button writes the row format. From the command
line, `osr eval rows.jsonl --remote` (after `osr login`, or with `--url` / `--token`) sends the same file to the
signed-in workspace instead of a local catalogue, prints the headline, the misroutes and the baselines, keeps the
run on the Evaluation page and honours `--min-accuracy` for a CI gate; `--baselines`, `--frontier`, `--limit`,
`--preset` and `--json` mean what they mean locally.

That gate is a few lines in any CI. For GitHub Actions the repository ships a composite action
(`.github/actions/osr-eval/action.yml` - copy it into your own repository, or paste the step below): it installs
the package, runs `osr eval` locally against a catalogue or remotely against the signed-in workspace, fails the
job below `min-accuracy` and writes the accuracy to the job summary and to an `accuracy` output.

```yaml
# .github/workflows/routing.yml - fail the build when a catalogue or rule change lowers routing accuracy
on: [pull_request]
jobs:
  routing:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - name: Routing accuracy gate
        run: |
          pip install "opensmartroute[yaml]"
          osr -t targets.yaml -r rules.yaml eval evals/routing.jsonl --baselines --min-accuracy 0.85
      - name: The same rows on the hosted workspace   # optional: what production would decide
        env: { OSR_API_KEY: ${{ secrets.OSR_API_KEY }} }
        run: osr eval evals/routing.jsonl --remote --min-accuracy 0.85
```

Azure Pipelines, GitLab, Jenkins and CircleCI run the same two commands; exit status `2` is the failed gate.

`POST /api/v1/evaluations/replay` is the other direction: it re-routes the prompts the workspace kept under its
`log_payloads` policy (section 9; `409` naming the policy when nothing is kept) through today's router - with
everything the learners took from your outcomes, every catalogue and rule change since - and compares each
decision with the target that answered then: `changed` / `kept` and the `moves` between targets, the kept token
counts priced at the new target's list price against the provider cost metered then (`cost`; decision-only
requests carry no tokens and are left out), and `agreement` with the outcomes people reported - `good_kept`
(accepted answers the router still sends the same way), `good_changed`, `bad_changed` (rejected answers it now
sends elsewhere) and `bad_kept`. `days` (1-90) and `limit` bound the window; a prompt masked by the
`redacted` mode is replayed as kept, placeholders included.

| Endpoint | What it does |
|---|---|
| `GET /api/v1/evaluations` | The workspace's kept runs, newest first, each with its headline `summary`; `max_rows`, `keep_runs` and `frontier_max_rows` say what the deployment allows. |
| `POST /api/v1/evaluations` | Route a labelled dataset (`rows`, `name`, `objective`, `constraints`, `tenant`, `baselines`, `frontier`) and keep the report; `route` scope, `201`. |
| `POST /api/v1/evaluations/replay` | Replay the kept prompts of the last `days` through today's router (`limit` newest first) and keep the comparison; `route` scope, `201`, `409` when nothing is kept. |
| `GET /api/v1/evaluations/sample?n=40` | A labelled sample dataset built from the catalogue, with the row format explained. |
| `GET /api/v1/evaluations/{evaluation_id}` | One run with its full `report`. |
| `DELETE /api/v1/evaluations/{evaluation_id}` | Remove a kept run. |

**Datasets from production.** The prompts your users actually send are the dataset that matters, and the outcomes
they reported are its labels. `POST /api/v1/evaluations/datasets` saves logged requests as a kept dataset - the
*Add to dataset* action of the Activity page: name `request_ids`, or take a window over the prompts kept under
`log_payloads` (`days`, `limit`, `outcome` `good` / `bad` / `rated` / `any`, `target_id`), or send explicit
`rows`, or mix the three. A logged request becomes `{text, expected, scores, context, task_type}`: the kept
prompt, the target that answered as `expected` when its outcome was good (with the reported `quality` as its
score), the request id under `context.source` so the same request is never added twice. `GET
/api/v1/evaluations/datasets` lists them (rows and labelled counts), `GET .../datasets/{id}` returns the rows
(`?format=jsonl` downloads the file `osr eval` reads), `POST .../datasets/{id}/rows` adds to one, `POST
.../datasets/{id}/run` evaluates it on today's pool and keeps the report (the body carries the run options -
`objective`, `constraints`, `tenant`, `baselines`, `frontier`; the report names the `dataset_id`), `DELETE`
removes it. A workspace keeps up to 50 datasets of up to 5 000 rows; all of it takes the `route` scope.

**Scores from other frameworks.** A dataset downloads in the shape Ragas, DeepEval and Langfuse read
(`?format=ragas|deepeval|langfuse`; every row carries its `row` index and, for logged requests, the `request_id` in
the metadata), and the scores those tools compute come back with `POST /api/v1/evaluations/datasets/{id}/results`:
`format` names the shape - `ragas` (the result table, `to_pandas().to_dict("records")`: numeric columns are the
metrics), `deepeval` (test results with `metrics_data`: name, score, success), `langfuse` (run items with their
`scores`) or `scores` (plain `{row | request_id | input, scores: {name: value}, passed}` rows from TruLens, Arize
or a script) - `tool` the framework for the record, `threshold` passes a row whose every metric reaches it when the
tool gave no verdict. Rows join by index, request id or prompt text; the run is kept as an `external` evaluation
(per-metric mean / min / max, the pass rate, the per-row scores; 409 when nothing joins), and a row that came from a
logged request receives the mean score as an outcome with source `eval:<tool>` (`feedback: false` leaves that out),
so the activity page, the trace and the learners see what the other tool thought.

**The answer judge.** The runs above grade *which target was chosen*; the judge grades *the answer the customer
received*. With a `judge` block in the workspace policy (section 9: `sample`, `target`, `threshold`, `endpoints`,
`targets`, `max_per_day`) a reproducible share of the executed answers is graded by the judge model after the
answer has left, the grade becomes the request's outcome (`feedback` rows with `source: judge:<model>`, read by
the learners, the rankings and the replay comparison like a hand-reported one), and every verdict keeps its
reason. `GET /api/v1/evaluations/judge` (`days`, `limit`) returns the block in force, what was judged per
answering target over the window (judged, graded, good, mean score, cost) and the latest verdicts; `POST
/api/v1/evaluations/judge/run` `{request_id}` grades one kept request now (the Activity page's *Judge this
answer*; `execute` scope, `409` naming `log_payloads` when the prompt and answer were not kept). Judging is
metered as its own endpoint (`judge`) on the workspace's usage, never charged to the request it grades.

Every run is recorded in the workspace's audit trail as `evaluation.run`, travels with the data export
(`GET /api/v1/me/export` -> `workspace.evaluations`) and is deleted with the workspace. A tenant-pinned API key
cannot run evaluations (`403`) - they are the workspace's view of its whole pool. The TypeScript client has them
as `client.evaluations.run / replay / sample / list / get / delete`.

**Quality gates.** An evaluation says how good the router is on your prompts *today*; a gate says whether it
is *still* that good after the catalogue moved, a provider was swapped, a model was retired or somebody edited
the workspace policy - before your own customers notice. Keep the dataset you signed off on as a gate (*Keep as a
quality gate* on the Evaluation page, or `POST /api/v1/evaluations/gates` with the same `rows`, `objective`,
`constraints` and `tenant` as a run) with the thresholds it must hold: `min_accuracy` (an absolute floor, 0..1)
and / or `max_drop` (how many accuracy points a run may fall below the run that set the baseline - the first
pass; default 0.05). The gate is routed at once - that first pass is the baseline - and again every
`OSR_PLATFORM_EVAL_GATES_INTERVAL_S` (six hours; `0` = only on request and after a policy change) and whenever
the workspace policy is replaced or removed. Each pass is a kept evaluation named *Gate · …*, so the full report
is one click away. A pass under the floor is `fail`, one too far under the baseline `regressed`; the first
failing pass files a warning in the workspace inbox (`workspace.evaluation_gate_failed`, linking the report) and
the audit trail (`evaluation.gate_failed`), a scheduled pass that keeps failing stays quiet, and the pass that
clears both thresholds again files `evaluation_gate_recovered`. A run on request or after a policy change always
announces a failure. Nothing is executed or metered. A workspace keeps up to ten gates of up to 500 rows each,
at least five of them labelled; creating and deleting a gate takes the admin role, running one the `route` scope.

| Endpoint | What it does |
|---|---|
| `GET /api/v1/evaluations/gates` | The gates with their last verdict (`last_state`, `last_accuracy`, `last_detail`, `last_reason`), the baseline and the counters; `interval_s`, `max_gates`, `max_rows`, `min_labelled` say what the deployment allows. |
| `POST /api/v1/evaluations/gates` | Keep a dataset as a gate (`name`, `rows`, `objective`, `constraints`, `tenant`, `min_accuracy`, `max_drop`) and route it now; `201` with the judged gate. |
| `POST /api/v1/evaluations/gates/{gate_id}/run` | Route the gate now and judge it. |
| `DELETE /api/v1/evaluations/gates/{gate_id}` | Forget the gate (its past runs stay in the list of evaluations). |

**Shadow routing.** A dataset and a gate replay prompts; a shadow candidate rides the *live* traffic. Start one
(*Shadow routing* on the Evaluation page, or `POST /api/v1/shadow`) with the policy you are considering - objective
weights (`quality`, `cost`, `latency`, `quality_floor`) and / or the request-shaping constraint keys of the
workspace policy (`deny_targets`, `allow_targets`, `max_cost_per_1k`, `max_latency_ms`,
`preferred_max_latency_ms`, `region`, `data_boundary`, `contains_pii`) - and from then on every request the
workspace routes is routed a second time under the candidate, inside the same pool and with the same tenant,
key limits and installed listings. The second decision is never served, executed, metered or charged; what is
kept is two target ids and the cost, quality estimate and latency each would have had (no text). `GET
/api/v1/shadow` sums it up: agreement between the two, the spend delta, the quality and latency deltas, where the
candidate moves traffic from and to, the requests it would have refused, and a plain-words `verdict` once a few
dozen requests are in. When the comparison convinces you, `POST /api/v1/shadow/promote` writes the candidate into
the workspace policy - its constraints are merged, its weights become the policy's `objective`, the default for
every request that sets none (an explicit objective on a request still wins) - which is a policy change, so every
quality gate the workspace keeps runs again on it: a promotion that regresses a kept dataset shows up in the
inbox like any other bad edit. One candidate runs at a time; it ends when promoted, stopped (`DELETE
/api/v1/shadow`) or after `max_decisions` requests (20,000 by default), and earlier candidates stay readable
(`GET /api/v1/shadow/{candidate_id}`). Starting, stopping and promoting take the admin role; a tenant-pinned key
is refused.

| Endpoint | What it does |
|---|---|
| `GET /api/v1/shadow` | The running candidate, its `summary` (agreement, cost / quality / latency deltas, `moves`, `refused`, `verdict`, `progress`), the most recent comparisons and the earlier candidates. |
| `POST /api/v1/shadow` | Start a candidate (`name`, `objective`, `constraints`, `sample` = share of requests shadowed, `max_decisions`); a running one is stopped. |
| `GET /api/v1/shadow/{candidate_id}` | One candidate, running or ended, with its summary and comparisons. |
| `POST /api/v1/shadow/promote` | Make the candidate (`candidate_id`, default the running or latest one) the workspace policy. |
| `DELETE /api/v1/shadow` | Stop the running candidate. |

### Observed usage: the browser extension's ledger

The browser extension (`platform/extension`, product page `/extension`) sees calls a team makes *outside* the router - on ChatGPT, Claude,
Gemini, Copilot, Perplexity and the OpenAI, Anthropic, Google and OpenRouter consoles. With "Share observed
usage" turned on it reports the **facts** of each call and never the text:

```bash
curl -s "$OSR_URL/api/v1/usage/observed" -H "Authorization: Bearer $OSR_API_KEY" -H "X-OSR-App: browser-extension" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"ts": 1760000000, "source": "chat", "site": "chatgpt.com", "vendor": "openai", "model": "gpt-5",
                 "prompt_tokens": 1284, "completion_tokens": 310, "task_type": "summarise", "complexity": 0.3}]}'
```

| Endpoint | What it does |
|---|---|
| `POST /api/v1/usage/observed` | Records up to 500 observations (`ts`, `source` = `chat` / `console` / `app`, `model`, optional `vendor`, `site`, token counts, `task_type`, `complexity`). Any other field - a prompt, messages - is refused with `422`. Each row is priced at the observed model's catalogue list price (`estimated_cost_usd`, `null` when the model is unknown) and re-quoted through the router for the same token shape (`recommended_target`, `recommended_cost_usd`). Needs the `feedback` scope. |
| `GET /api/v1/usage/observed` | The stored observations, newest first (`?days=`, `?limit=`). |
| `GET /api/v1/usage/opportunity` | The report: calls, tokens, observed spend, the routed quote for the priced calls, `gap_usd` / `gap_pct`, breakdowns by model, source, task and day, and the ten models with the widest gap with the target the router would have chosen. |

Observations are kept for 90 days and deleted with the workspace. The report compares the same request
shapes on both sides - it is a price difference, not a quality claim - and the dashboard shows it on
`/platform/dashboard/spend`.

### Insights: the figures read back as findings

The dashboard shows numbers; the insights endpoint says what they mean. It reads the workspace's own metered
rows - the window against the one before it, per target and per day - and returns findings, each with the
evidence behind the sentence and the page that acts on it: cost against the previous window with the targets
that moved it, a day whose cost sits far above the average, failures and their rate, speed against the previous
window, one target carrying almost all the traffic, decisions without execution, savings against the baseline
and spend the extension saw outside the router. No model is involved: every sentence is computed, so asking
twice gives the same answer. The dashboard overview carries it as *Ask about your usage*.

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/insights` | `?days=` (1-90, default 7): a `headline`, the `totals` of the window and the one before, the `findings` (`kind`, `severity` = `warning` / `good` / `info`, `title`, `detail`, `evidence`, `action.href`), the `suggestions` people ask and the `method`. |
| `POST /api/v1/insights/ask` | `{question, days?}` - a question in plain words (*why did my cost go up*, *is anything slower*, *where could I save*); the subject is read from the question (`intent`: cost, savings, speed, failures, models, outside, volume) and the matching findings come back with one `answer` sentence. A tenant-pinned key gets its tenant's figures only. |

### Bill analysis: the invoice or usage export you already have

A team that has not routed yet still has a bill. Every provider, gateway and tracing tool exports usage as a
table - a date, a model, request and token counts, often the amount charged - and every provider issues a PDF
invoice. Post either and the platform recognises the columns - or, for an invoice, the vendor, the billing period
and every line that names a model with an amount - aggregates it per day, provider and model and prices every
line twice: what the bill says
(or, when the export carries no amounts, the model's catalogue list price) and what the router's recommended
target would have charged for requests of the same token shape. Nothing in an export is text - dates, names,
quantities and amounts are read, every other column or word (addresses, tax ids, people) is ignored and never
stored.

The layouts recognised (`source_format`) - the tables from their header row, an invoice from its text:

| Format | Where it comes from | What the parser reads |
|---|---|---|
| `openai-usage` | platform.openai.com → Usage → Export | hourly buckets: `n_requests`, `n_context_tokens_total`, `n_generated_tokens_total` per model |
| `openai-costs` | platform.openai.com → Billing → Costs → Export | `line_item` (`gpt-4o, input`) and `amount`; the tokens are backed out of the amount at list price |
| `anthropic-usage` | console.anthropic.com → Usage → Export CSV | `usage_input_tokens_no_cache`, cache reads and writes, `usage_output_tokens` per `model_version` |
| `azure-openai` | Azure Cost Management → Cost analysis → Download | `MeterName` (`gpt-4o-0806 Inp glbl Tokens`), `Quantity` in thousands, `Cost` |
| `google-billing` | Google Cloud Billing → Reports → Download, or the BigQuery billing export | `SKU description` (`Gemini 2.5 Pro Input Tokens`), `Usage amount`, `Cost`, `Service description` |
| `aws-cur` | AWS Cost Explorer CSV or a Cost and Usage Report (Bedrock) | `lineItem/UsageType` (`USE1-Claude3Haiku-input-tokens`), `lineItem/UsageAmount`, `lineItem/UnblendedCost` |
| `openrouter` | openrouter.ai → Activity → Export | one row per generation: `created_at`, `model`, `tokens_prompt`, `tokens_completion`, `cost` |
| `request-log` | LiteLLM spend logs, Langfuse, LangSmith and Helicone exports, this platform's own Activity CSV | one row per request (a timestamp with a clock time, no request count): each row counts as one request |
| `generic` | anything else: CSV, TSV, JSON, JSON lines or an `.xlsx` workbook (first sheet, Excel dates understood) | a date, a model and token or amount columns under any of some sixty header spellings; a file without a model column (this platform's Usage CSV, a finance summary) becomes one line per day |
| `invoice` | the PDF invoice from Billing → Invoices (OpenAI, Anthropic, OpenRouter and Mistral bill through Stripe; Azure, Google Cloud and AWS issue their own), or its text pasted | the issuer, the billing period (`Aug 1 – Aug 31, 2026`, `Billing period: ...`, else the invoice date), and every line that names a model with an amount - `GPT-4o input tokens 12,345,678 $2.50 / 1M $30.86`, `gpt-4o-0806 Inp glbl Tokens 12,340 1K Tokens 0.0025 30.85`, `Vertex AI Gemini 2.5 Pro Input Tokens ... $10.00`; quantities become tokens (Azure's thousands understood), a line without one has its tokens backed out of the amount at list price; totals, tax, credits and payments are not usage, and the notes say when the model lines do not add up to the invoice total |

Cloud bills are roll-ups, so their hourly rows are never counted as requests; a line without a request count is
quoted as requests of about 2 000 input tokens. An invoice dates every line at the start of its billing period
and the projection per month uses the whole period. A scanned invoice (a picture with no text layer) and a
password-protected PDF are refused with what to do instead, and an old `.xls` should be saved as `.xlsx` or CSV.

```bash
curl -s "$OSR_URL/api/v1/usage/imports/upload" -H "Authorization: Bearer $OSR_API_KEY" \
  -F "file=@openai-invoice-2026-08.pdf" -F "name=August, OpenAI"
curl -s "$OSR_URL/api/v1/usage/imports/upload" -H "Authorization: Bearer $OSR_API_KEY" \
  -F "file=@openai-usage-2026-08.csv" -F "name=August, OpenAI (usage export)"
curl -s "$OSR_URL/api/v1/usage/imports?dry_run=true" -H "Authorization: Bearer $OSR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "one line", "provider": "anthropic",
       "text": "date,model,input_tokens,output_tokens,cost\n2026-08-01,claude-sonnet-4,120000,30000,0.81"}'
```

| Endpoint | What it does |
|---|---|
| `POST /api/v1/usage/imports` | Analyses `{name, text, filename?, provider?}` - `text` is the export as the provider gave it (CSV, TSV, JSON or JSON lines, up to 8 MB) or the text of an invoice (its line items with amounts), `provider` names the provider when the rows carry none (`auto` infers it from the model names) - and keeps the analysis unless `?dry_run=true`. The answer is the analysis: `headline` and `story` (the figures in plain sentences), `source_format` (the table above), the columns recognised, totals, `baseline_cost_usd` against `routed_cost_usd`, `savings_usd` / `savings_pct`, the projected monthly figures, `by_provider`, `by_model`, `daily`, the lines (up to 5 000 inline) and `notes` on what was assumed. A file without a date or a token / amount column is refused with `400`, a larger one with `413`. |
| `POST /api/v1/usage/imports/upload` | The same analysis for a multipart upload (`file` plus optional `name` and `provider` fields), for curl, the dashboard and integrations that hold the file rather than its text; the way in for a PDF invoice (read from its text layer with `pypdf`) and for an `.xlsx` workbook. |
| `GET /api/v1/usage/imports` | The analyses kept for the workspace, newest first, with their headline figures and the `formats` and `providers` the parser accepts. |
| `GET /api/v1/usage/imports/samples` | Sample exports in each provider's own layout with invented figures - OpenAI usage and costs, Anthropic, Azure OpenAI, Google Cloud billing, AWS Bedrock, OpenRouter, Mistral, Cohere, xAI, DeepSeek, Groq, Together, LiteLLM, Langfuse, this platform's activity export and the text of an OpenAI invoice (`id`, `provider`, `name`, `source`, `format`, `text`) - to try the analysis before touching a real bill: post one as `text` with `?dry_run=true`. The dashboard's *Try a sample export* menu does exactly that. Every sample is what the parser is tested against. |
| `GET /api/v1/usage/imports/{import_id}` | One analysis with its lines; `?format=csv` downloads the lines, `xlsx` a workbook (summary, by provider, by model, daily, lines), `pdf` the report. |
| `DELETE /api/v1/usage/imports/{import_id}` | Deletes an analysis and its lines (recorded in the audit trail). |
| `GET /api/v1/usage/report` | The workspace's own routed usage as the same report: a `headline` and `story` in sentences, then requests, tokens in / out / cached, provider cost and the workspace's charge per day, model, provider and API key over `?days=` (up to 366, `?tenant=`, `?key_id=`), and the saving against the savings baseline. `?format=csv` downloads the day-model-provider lines, `xlsx` a workbook with every breakdown, `pdf` the report. |

The routed side is the estimator's quote under the catalogue and rules of today, so every figure is labelled an
estimate; lines whose model the router cannot quote are carried over at what they cost today and the notes say
how much of the bill was quoted. Analyses are the customer's own data: kept until they delete them, and deleted
with the workspace.

### Client activity: what the extension and the desktop app did

Most of what the browser extension and the desktop app do happens on the person's own machine and is never a
routed request - the pre-send card, a redaction, a compaction, a writing fix, the command bar opening, an answer
pasted back. Each app keeps a small queue of those moments and posts it to the workspace a few minutes later, so
the dashboard's Apps page (`/platform/dashboard/apps`) shows how the apps are used, what they caught and what
they saved. Every event is **a kind, a hostname, counts and figures - never text**:

```bash
curl -s "$OSR_URL/api/v1/usage/activity" -H "Authorization: Bearer $OSR_API_KEY" -H "X-OSR-App: desktop" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"ts": 1760000000, "app": "desktop", "kind": "answer.received", "target_id": "gpt-4.1-mini",
                 "request_id": "6f1c...", "latency_ms": 2300, "version": "1.5.0"},
                {"ts": 1760000010, "app": "browser-extension", "kind": "pii.redacted", "site": "chatgpt.com", "n": 2}]}'
```

| Endpoint | What it does |
|---|---|
| `POST /api/v1/usage/activity` | Records up to 500 events (`ts`, `app` = `browser-extension` / `desktop`, `kind` as `area.action`, optional bare-hostname `site`, `target_id`, `request_id`, `n`, `tokens`, `saved_usd`, `latency_ms`, `tier`, `task_type`, `version`). A field of any other name is refused with `422`, a `site` with a path or query with `400`. Needs the `feedback` scope. The plan caps events per UTC day: a batch that would cross the cap answers `429` with `Retry-After` and records nothing. |
| `GET /api/v1/usage/activity` | The events, newest first (`?days=`, `?limit=`, `?before=` to page, `?app=`, `?kind=pii.*`). |
| `GET /api/v1/usage/activity/summary` | Totals per app, kind and site, the per-day series, and what the plan keeps (`retention_days`) and allows (`limit_per_day`, `today`). |

What the plan decides: **Free** keeps events 7 days and records up to 5 000 a day, **Pay as you go** and **Pro**
30 days and 50 000 a day, **Team** 90 days and 500 000 a day, **Enterprise** 365 days with no daily cap; a
workspace's `retention_days` override (custom agreements) wins over the tier. The retention sweep purges each
workspace at its own cutoff. The kinds the first-party clients send are listed in [EXTENSION.md](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/EXTENSION.md)
and [DESKTOP.md](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/DESKTOP.md); both apps have a **Log my activity to the workspace** switch (on by default, since
there is no text in the events) and administrators can pin it through the extension's managed storage.

Every request the extension routes through the platform also carries `X-OSR-App: browser-extension`. Any
client may name itself that way (`osr-cli`, `langchain`, your own service name; free text is reduced to a
short slug): the value is stored on each metered row and `GET /api/v1/usage` returns `by_app` - requests,
tokens and cost per app, `null` for calls that sent no header - which the *Usage* page shows as **By app**.

## 6. Plans and quotas

| Plan | Requests / month | Overage | Requests / min | Keys · seats · tenants | Features | Support |
|---|---|---|---|---|---|---|
| **free** | 15,000 | refused (`429`) | 30 | 2 · 5 · 0 | route + trace, OpenAI-compatible proxy, feedback, your own model providers (`byok`) | community |
| **payg** - $0 / month, prepaid credits | unlimited | n/a - paid by the token, `402` when the credits run out | 300 | 10 · 10 · 0 | + plans; every model on offer by the token (*Models by the token*) | community |
| **pro** - $49 / month or $490 / year, 90 days free without a card | 300,000 | $10 per 100k | 300 | 10 · 25 · 0 | + plans (`plan`/`execute`) | e-mail, first answer within 48 h |
| **team** - $199 / month or $1,990 / year, 90 days free without a card | 2,000,000 | $8 per 100k | 1,000 | 25 · 100 · 10 | + tenants, `/stats`, hash-chained `/audit` | priority, first answer within 8 h |
| **enterprise** - from $999 / month, through sales, 90 days free without a card | 20,000,000 | $5 per 100k | 3,000 | 100 · 1,000 · 50 | + organization SSO, region pinning, custom quotas and retention | dedicated, first answer within 4 h, 99.9 % monthly availability target with service credits |

Quotas count metered requests (a decision, a chat completion or embedding through the proxy, an estimate, a
feedback event) per **calendar month (UTC)**; the counter resets on the first. `GET /api/v1/info` (`plans`)
returns the values for the deployment you are calling (`OSR_PLATFORM_PLAN_OVERRIDES` patches them per
deployment), `GET /api/v1/me` returns your plan and `GET /api/v1/quota` where you stand:

```json
{"plan": "pro", "period": "month", "used": 241300, "limit": 300000, "remaining": 58700, "percent": 80.4,
 "state": "warning", "warn_at": 0.8, "resets_at": 1767225600, "today": 9120, "requests_per_minute": 300,
 "overage": {"allowed": true, "usd_per_100k": 10.0, "requests": 0, "estimated_usd": 0.0}}
```

`state` is `ok`, `warning` (80 % and above), `overage` (beyond the quota on a plan with an overage rate and a
live subscription - requests keep flowing and are invoiced) or `exhausted` (beyond the quota on any other plan -
metered calls answer `429`). The dashboard shows the same bar on the overview, the governance page and *Plan
and billing*; the `quota` alert rule fires at 80 % and escalates at 100 % (workspace channels, or the owners
by e-mail when the workspace has no e-mail channel - outbox kind `quota_notice`; the `budget` and `wallet` rules
reach the owners the same way, as `budget_notice` and `credits_notice`). Upgrades are made in the
dashboard (`/platform/dashboard/billing`).

* `429 rate limit exceeded` - per-minute window; wait `Retry-After` seconds.
* `429 monthly quota exhausted` - `X-Quota-Limit`, `X-Quota-Used`, `X-Quota-Reset` and `X-Upgrade: true`.
* `403` with `X-Upgrade: true` - the feature is not in your plan.
* `403 ... requires the enterprise edition` with `X-Edition: enterprise` - the deployment runs the community
  image, which does not expose tenants, statistics or audit regardless of plan.
* `403 this API key lacks the '<scope>' scope` with `X-Key-Scope` - the key was created with scopes.

Every feature a plan sells is live on the workspace the moment the plan applies - there is no separate
activation. *Plan and billing* (`/platform/dashboard/billing`) lists them under **What the plan includes**,
each one a link to the page that uses it (providers, activity, integrations, tenants, health, audit trail,
workspace SSO); a feature the plan lacks names the cheapest plan that has it with an *Upgrade* button. The
sidebar marks pages the plan does not include with a lock, and the page itself explains the gap.

**Support tiers.** `Plan.support` is `community` (free: documentation, the assistant, the issue tracker),
`email` (pro), `priority` (team) or `dedicated` (enterprise, with an SLA). Tickets opened from a workspace on
priority or dedicated support start at `high` priority at least - a `normal` or `low` request is raised, a
higher one is kept - and the operator notice and inbox line name the plan and the tier
(`Acme (team plan, priority support)`).

**Service levels.** Each tier states what it promises and the platform measures against it, so the pricing
page, the portal and the API read one source (`plans.py`): `support_response_hours` is the first-response
target of the support tier (community none, e-mail 48 h, priority 8 h, dedicated 4 h) and `sla_uptime_pct`
the monthly availability target the plan carries (99.9 % on enterprise; 0 elsewhere). The target is a
contractual figure: the hosted platform is one stack per region, the service credits that apply when a
month misses it are defined in the enterprise agreement, and the number the platform reports is its own
measurement - not an external monitor. `GET /api/v1/sla` reports
the calendar month so far from the workspace's own records: `availability` - the share of its metered
requests the platform answered without a 5xx (a refused request - quota, policy, bad input - or a provider
error is not a platform failure) against `target_pct`; `support` - the tickets opened this month, how many
were answered, the median and slowest first response in hours, how many were answered within `target_hours`
and how many are past it (`breached`, answered late or still waiting). `met` is `true`, `false` or `null`
(nothing to measure yet); `committed` says whether the plan promises anything. *Plan and billing* shows the
same figures under **Service level** on every plan, labelled *committed* or *no commitment on this plan*.

**Talk to sales.** Plans sold through sales (`contact_sales`, the enterprise plan) show *Talk to sales* on the
pricing page and in the portal instead of a checkout; the button opens the form on `/contact`
(`?plan=` preselects the plan). `POST /api/v1/sales/inquiries` (`email`, `name`, `company`, `plan`,
`interest` hosted / self-hosted / both / other, `volume`, `message`; no key needed, five per address and day,
a filled `website` field marks a bot) records the inquiry with a reference (`OSR-S-00007`), e-mails the
requester a confirmation (outbox kind `sales_received`), the support recipients a notice (`sales_operator`)
and the console inbox a line; a signed-in caller's workspace is attached. A second request from the same
address within a day returns the open inquiry. Operators work the queue under `/platform/admin/sales`
(`GET /api/v1/admin/sales/inquiries?status=&q=`, `GET|PATCH /api/v1/admin/sales/inquiries/{key}` - status
`new` / `contacted` / `qualified` / `won` / `lost`, `assigned_to`, internal `note`; audit events
`sales.inquiry_opened`, `sales.inquiry_updated`) and close the loop on the workspace page: plan, custom
quotas, retention and region.

**Custom quotas and retention.** Enterprise agreements override a workspace's numbers without a new plan: an
operator sends `limits` with `PATCH /api/v1/admin/accounts/{id}` - any of `requests_per_month`,
`requests_per_minute`, `max_keys`, `max_tenants`, `max_members` and `retention_days` (whole numbers; the object
replaces the previous overrides, `{}` returns the workspace to the plan as sold; audit event
`account.limits_changed`). `GET /api/v1/me` then reports the effective plan with `"custom": true` and the
overridden values, `GET /api/v1/quota` enforces the custom quota, the key, seat and tenant limits apply as the
plan's own would, and *Plan and billing* shows a **custom quotas** badge. `retention_days` gives the workspace
its own request-row retention: the retention sweep purges its rows at that age and leaves them out of the
deployment-wide purge (`OSR_PLATFORM_RETENTION_DAYS`, which must be on for the sweep to run);
`GET /api/v1/governance` reports the effective `retention_days`. The operator console edits the same fields
under **Custom quotas and retention** on the workspace page (`/platform/admin/workspaces/<id>`);
`GET /api/v1/admin/retention` lists the workspaces with their own retention under `usage.overrides`.

### Billing: subscriptions, trials, overage, invoices and cancellation

Paid plans are flat tiers sold as one Stripe subscription per workspace, monthly or yearly. Self-serve billing
is on when the deployment sets all three of `OSR_PLATFORM_STRIPE_SECRET` (the `sk_live_...` key),
`OSR_PLATFORM_STRIPE_WEBHOOK_SECRET` (the `whsec_...` of the endpoint below) and `OSR_PLATFORM_STRIPE_PRICES`
(JSON plan -> price id); `billing: true` in `GET /api/v1/info` tells the web app. Without them every billing
endpoint answers `501` and operators set plans by hand (`PATCH /api/v1/admin/accounts/{account_id}`). The
Stripe calls are plain HTTPS from the API, no SDK.

`OSR_PLATFORM_STRIPE_PRICES` keys: `"pro"` is the monthly recurring price, `"pro:year"` the annual one (sold
when present - the pricing page shows the switch), `"pro:overage"` a **metered** price on a Stripe Billing
Meter whose event name is `OSR_PLATFORM_STRIPE_METER_EVENT` (default `osr_overage_requests`, unit = requests),
and `"pro:overage:year"` the same metered price with a yearly interval - Stripe only accepts items of one interval
per subscription, so a plan with overage is sold per year only when both `"pro:year"` and `"pro:overage:year"` exist
(a yearly subscriber's overage is then invoiced with the annual renewal). Each metered line lives on its own
Stripe product - *OpenSmartRoute Pro - extra requests* with the unit label `request`, *... - metered usage* -
because Checkout and the invoice list every line by product name: a buyer must read *extra requests, $0.0001 per
request*, not *OpenSmartRoute Pro* twice. A setup run on a deployment created before this moves an overage price
left on the plan product to its own (lookup key transferred; set the printed price ids again).
`osr-platform-stripe-setup --webhook <api base url>` (ships with the platform API; run it inside
the API container) creates all of them from the plan
catalogue (products, monthly / yearly / metered prices, the meter, the default Billing Portal configuration the
*Manage subscription* button opens - payment method, billing details, invoices, cancel at period end, no plan changes
- and the webhook endpoint with the seven events below),
idempotently - run it again after changing a price and the lookup keys move to the new prices - and prints the
JSON, its base64 form for the deployment configuration and the webhook signing secret; the key comes from
`STRIPE_SECRET_KEY` in the environment (start with a test-mode key and `--dry-run`):

```json
{"pro": "price_...", "pro:year": "price_...", "pro:overage": "price_...", "pro:overage:year": "price_...",
 "team": "price_...", "team:year": "price_...", "team:overage": "price_...", "team:overage:year": "price_...",
 "enterprise": "price_...", "enterprise:overage": "price_..."}
```

To also invoice what a workspace spent by the token on the same invoice as its plan, run the setup with
`--usage-meter-event` (bare, or with your own event name): it creates a second Billing Meter whose unit is one
cent and a metered price per plan (the `"pro:usage"` / `"pro:usage:year"` keys), and prints
`OSR_PLATFORM_STRIPE_USAGE_METER_EVENT`. With that variable set, a checkout and a plan change carry the usage
item next to the overage one, and the same sync loop reports the month's `charge_usd` in cents
(`usage_charge_reports`, one `billing.usage_reported` audit entry per increment). Without it the charges are
still priced, stored and shown on *Usage and charges* - they simply never reach a Stripe invoice.

**Tax.** Checkouts ask Stripe Tax to compute VAT / GST / sales tax from the buyer's billing address and to collect a
tax id, which is what the pricing page and the terms promise. Stripe only does that once the account's **head
office address and tax registrations** are set (Stripe -> Settings -> Tax); until then Stripe refuses a session that
asks for tax. The platform does not let that stop a purchase: it creates the session again without the tax fields,
remembers the refusal (`Billing.tax_problem`) and raises the deployment alert `stripe_tax_unconfigured` (console
*Health* and *Finance*, `GET /api/v1/admin/health` -> `alerts`) reading Stripe's tax settings
(`Billing.tax_status`, cached an hour) - critical once a checkout had to drop tax, a warning before. Buyers then pay
the list price with no tax line; fix the Stripe account, and the next checkout computes tax again.

**Activation.** A Stripe account that has not finished activation (business details, bank account, card payments
turned on) refuses every paid Checkout Session - yet it *accepts* a free trial ($0 today), so a buyer could type a
card into a trial that would be cancelled at its end without a word. The API reads the
account from Stripe (`Billing.account_status`, Stripe's account object, cached an hour) and raises the critical deployment alert
`stripe_account_inactive` while charges are disabled or card payments are not active, naming what is missing; while
it stands, no Checkout Session is created at all - a plan with or without a trial, credits or a paid listing - and the
buyer gets `503 Payments are not open on this deployment yet - the operators have been alerted.
Contact sales to buy in the meantime.` with `Retry-After` on both `POST /billing/checkout` and
`POST /billing/credits/checkout`, never Stripe's developer sentence. Finish the activation under Stripe ->
Settings -> Business and Settings -> Payment methods; checkouts go through and the alert clears within the hour.

A checkout adds the plan's overage item next to the recurring one; a plan change swaps both. Every
`OSR_PLATFORM_OVERAGE_SYNC_S` seconds (default hourly, `0` = never) the API reports each billed workspace's
requests beyond its quota this month as meter events (one increment per sync, de-duplicated by identifier and a
compare-and-set row in `overage_reports`, so replicas never double-report; each report is a
`billing.overage_reported` audit entry). The first checkout of a workspace starts the plan's `trial_days` (90 on
every subscription plan) **without a payment method**: the plan applies at once and the workspace returns to free
when the trial ends unless it subscribes (see *Free trials, coupons and the launch offer*); `"coupon": ""` pays
now instead. A plan whose `trial_needs_card` is true carries its trial into Stripe Checkout instead (card collected,
the subscription cancels if none is on file when the trial ends). A workspace that had a subscription or a trial
before gets no second trial.

| Endpoint | Who | What |
|---|---|---|
| `POST /api/v1/billing/checkout` `{plan, interval?}` | owner, admin | `interval` is `month` (default) or `year`. A workspace without a live subscription gets a Stripe Checkout URL (`?upgraded=<plan>` on return). A workspace that already has one has the subscription's prices swapped in place with prorations - never a second subscription - the plan applies at once and the answer's `url` points back at `/platform/dashboard/billing?changed=<plan>`. `400` when the plan is not sold for that interval. |
| `GET /api/v1/billing/subscription` | any member | `plan`, `billing_state`, `period_end`, `subscription`, `has_portal`, `invoices_available`, live `cancel_at_period_end` / `stripe_status` / `trial_end` / `interval` (null when Stripe is unreachable), `can_manage`, `purchasable` (per paid plan: intervals sold here and the trial a first checkout starts) and `overage` (`billed`, `usd_per_100k`, `reported_requests` this month). |
| `POST /api/v1/billing/portal` | owner, admin | A Stripe Billing Portal session (`{url}`): payment method, billing address, receipts, resume a scheduled cancellation. `404` before the first checkout. |
| `GET /api/v1/billing/invoices` | any member | The last 24 invoices: `number`, `status`, `amount_due` (minor units), `currency`, `created`, `hosted_invoice_url`, `invoice_pdf`. `404` before the first checkout. |
| `POST /api/v1/billing/cancel` | owner, admin | Cancel at the end of the paid period; the plan stays until Stripe sends `customer.subscription.deleted`. `404` without an active subscription. |
| `POST /api/v1/billing/webhook` | Stripe | Signed events (`Stripe-Signature`, HMAC-SHA256 over `t.payload`, 5-minute tolerance); every event id is processed once. |

Point the Stripe webhook endpoint at `POST /api/v1/billing/webhook` with these events enabled:
`checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`,
`customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`, `charge.refunded` (the last one
credits a refund back to the wallet; `osr-platform-stripe-setup` registers all seven). The workspace's
`billing_state`
(`GET /api/v1/me` -> `account.billing_state`, also `stripe_subscription` and `billing_period_end`) follows
the Stripe subscription status:

| Stripe status | `billing_state` | Plan |
|---|---|---|
| `active`, `trialing` | `active` | the paid plan (`metadata.plan`, or the plan whose price the subscription carries) |
| `past_due` | `past_due` | kept - Stripe is retrying the charge |
| `incomplete` | `incomplete` | unchanged - the first payment has not completed |
| `unpaid`, `paused` | `unpaid` | `free` |
| `canceled`, `incomplete_expired`, subscription deleted | `canceled` | `free` |

`invoice.payment_failed` moves the workspace to `past_due`, writes a `billing.payment_failed` entry to the
workspace's audit trail (`GET /api/v1/workspace/audit`) and sends the owners a dunning message (outbox kind
`billing_payment_failed`, "Update payment method" linking to the hosted invoice); `invoice.paid` moves it
back to `active` and, when the workspace was past due, tells the same owners the invoice is settled (outbox kind
`payment_received`; inbox notice `workspace.payment_received`). The dashboard (`/platform/dashboard/billing`) shows the state badge, a warning banner while
past due, the renewal or end date, *Manage subscription* (portal), *Cancel at period end* and the invoice
table; members see the state and invoices but only owners and admins get the buttons.

### Usage pricing, credits and provider finance

Every metered request carries **two prices**: the provider cost the router optimised (`cost_usd`, what the
executed model cost upstream) and the customer charge (`charge_usd`, what the workspace pays), plus the token
split (`prompt_tokens`, `completion_tokens`, `cached_tokens`), the `provider_id` and `upstream_model` that
executed and the `price_version` the charge was computed under - so a margin report months later reproduces
it. Successful `/api/v1/route` executions and `/v1/chat/completions` answers return the charge in the body
(`result.charge_usd`, and `charge_usd` inside the `opensmartroute` object of a chat answer) and in the
`X-OSR-Charge-USD` header.

The charge comes from a **pricing engine** with two books per model. The *provider price book* holds rates
per unit (`token_in`, `token_out`, `token_cached` in USD per million; `call`, `image`, `second` per unit):
`catalogue` rows are the list prices of the model catalogue (*Seed from catalogue* in the console, and rewritten
by every catalogue refresh of the rankings loop so a vendor's price change reaches the books on its own - never
overwriting the others), `manual` and `contract` rows of a provider beat the list price. *Pricing rules* turn
cost into charge: `markup_pct` (`{"pct": 20}`), `markup_fixed` (`{"usd_per_million": 0.5}`), `per_million`
(`{"usd_per_million": 3.0, "unit": "token_out"}` sets the customer rate outright), `tier` (`{"tiers":
[{"upto_tokens": n, "markup_pct": p}, ...]}` against the month's token volume), `promo` (`{"pct_off": 10}`),
`min_margin` (`{"pct": 5}` - the reseller's floor: after every markup and promotion, no unit is charged under the
provider rate plus that percent, so a tariff typed below a contract rate or a 100 % giveaway can never sell below
cost), `fx` (`{"rate": 0.92}`) and `min_charge` (`{"cents": 1}`). Rules have a scope - `account`
(one workspace), `plan` or `deployment` - and the most specific wins, then `priority`; without any markup rule
`OSR_PLATFORM_DEFAULT_MARKUP_PCT` applies (default `0`: customers pay provider cost). Every change to a book or
rule freezes a new price version (`GET /api/v1/admin/finance/versions`) and writes an audit entry with
`before`, `after` and the `reason` the console requires.

Three semantics the engine guarantees on the metered row. A charge that prices to **zero on purpose** (a 100 %
`promo`, a zero `per_million` tariff) stays zero - only a request that neither the books nor the executing
handler could price falls back to the reported provider cost. **Tiers** read the workspace's token volume of the
month *before* the request being priced (`tier` boundaries are crossed by the first request past them). The
pipeline is **USD-only**: an `fx` rule may scale the charge but `POST /api/v1/admin/finance/rules` refuses one
that names another currency (`400`), and a foreign-currency quote that reaches the engine anyway leaves the row
*unpriced* (`charge_usd` null) rather than mislabelled. Unpriced rows - also the ones a pricing failure left
behind - are retried by the **finance pass** (every `OSR_PLATFORM_FINANCE_SYNC_S` seconds, default 5 minutes, and
on `POST /api/v1/admin/finance/wallets/sync`), each written exactly once; the `finance` block of the analytics
reports how many remain (`unpriced`), the console shows them as a warning, and revenue and margin exclude them until
they are priced.

A workspace pays for usage in one of three **billing modes** (`billing_mode` on the account, set by an operator
with `PUT /api/v1/admin/finance/accounts/{account_id}/billing-mode`): `subscription` (default - the plan's flat
fee plus overage; usage charges reach the same Stripe invoice through a dollar Billing Meter when
`OSR_PLATFORM_STRIPE_USAGE_METER_EVENT` names it, one increment per sync with compare-and-set bookkeeping in
`usage_charge_reports`), `wallet` (prepaid credits, below) or `invoice` (net terms, recorded for the statement).
Credits are bought through a Stripe Checkout in payment mode (`POST /api/v1/billing/credits/checkout`
`{amount_cents}`, whole dollars between `OSR_PLATFORM_CREDITS_MIN_CENTS` and `OSR_PLATFORM_CREDITS_MAX_CENTS`, tax
collected by Stripe Tax); the webhook's `checkout.session.completed` with `metadata[credits_cents]` writes a
`purchase` entry keyed by the event id, `charge.refunded` a `refund` entry keyed by the refund id. The ledger is
append-only - corrections are `adjustment` / `promo` entries with a reason (`POST
/api/v1/admin/finance/accounts/{account_id}/ledger`) - and the balance is its sum.

**Admission and settlement.** Before a call that can incur provider cost runs, an **upper bound** of what it can
cost is held on both sides of the ledger: every target the router may pick is priced by the finance engine at
the prompt's tokens plus the output allowance (`max_tokens` when the caller sets one, else
`OSR_PLATFORM_SPEND_BOUND_OUTPUT_TOKENS`, 4096) under the workspace's tariff, and the dearest quote times
`OSR_PLATFORM_SPEND_BOUND_ATTEMPTS` (fallback attempts that may each bill, default 1) is the bound - provider
cost for the budgets, customer charge for the wallet. A surface that cannot describe its request falls back to the
workspace's mean charge over its last hundred spending requests (at least `OSR_PLATFORM_SPEND_RESERVE_MIN_USD`),
which is an estimate, not a cap, and is labelled as such. Budgets (`daily_budget_usd` / `monthly_budget_usd` of
the workspace, a tenant or a key) are a **strict cap on provider cost**: a request is refused with `429` when
recorded cost + the provider-cost holds of every request still in flight + its own bound reach the limit,
`X-Budget-Used` includes the holds and `GET /api/v1/policy` shows them as `held_usd`. A wallet workspace is
admitted only while balance - charges not yet settled - customer-charge holds in flight - bound does not go
below zero (a bound that exactly meets the remaining credits is admitted; `402` otherwise, `X-Wallet-Balance`
and `X-Wallet-Available` in cents) - so a one-cent wallet never admits a two-dollar first call. The hold is
written, the checks are repeated against every hold now committed (two replicas admitting at the same instant
both see each other and the later one steps back), it is renewed every half TTL while the request is still
running, and it is released when the row is recorded; a hold left by a dead process stops being renewed and
expires after `OSR_PLATFORM_SPEND_RESERVE_TTL_S` seconds. Every metered row also persists the plan it was sold
under and the month's token volume before it, and a row the database refused when the request finished (an
outage after the provider was paid) is written to `usage-journal.jsonl` on the data volume and replayed by the
next finance pass with its original timestamp - a paid-for request is never invisible for longer than the
outage. Repricing (`reprice`) prices such rows under the tariff in force at their own timestamp with those
persisted inputs; only a row that tariff still cannot price (a broken rule closed after the fact) is priced under
the current tariff, and the pass reports it as `approximate` rather than changing a charge silently. Settlement is
cumulative and idempotent: each finance pass debits every wallet workspace *everything charged through today
minus everything ever debited* as one `usage` entry whose idempotency key carries the running total - a
sub-cent day accumulates, a month-end remainder is taken on the first pass of the next month, a row priced late
is owed on the next pass, and two replicas settling the same state write one entry. The `wallet` alert warns at
`OSR_PLATFORM_WALLET_LOW_PCT` of the last top-up.

| Endpoint | Who | What |
|---|---|---|
| `GET /api/v1/billing/usage` | any member | Usage with both prices, `group` = `day` (default) / `model` / `provider` / `key` / `tenant`, filters `key_id`, `tenant`, `model`, `provider_id`, `days`; `format=csv` downloads it. |
| `GET /api/v1/billing/wallet` | any member | `billing_mode`, `balance_cents`, `last_topup_cents`, `pending_cents` (charged, not yet settled), `reserved_cents` (held for requests in flight), `available_cents` (what one more request is admitted against), purchase bounds and the active wallet alerts. |
| `GET /api/v1/billing/wallet/ledger` | any member | The append-only ledger behind the balance, newest first. |
| `POST /api/v1/billing/credits/checkout` `{amount_cents, save_payment_method?, auto_topup?}` | owner, admin | A Stripe Checkout URL (payment mode); returns to `/platform/dashboard/billing?credited=<cents>`. `501` without Stripe. |
| `PUT` / `DELETE /api/v1/billing/wallet/auto-topup` | owner, admin | Auto top-up rule (*Models by the token*, below). |
| `GET /api/v1/billing/statement?month=YYYY-MM&format=json\|markdown` | any member | The monthly credits statement: opening and closing balance, every entry, usage by model. |

The operator side lives under `/api/v1/admin/finance`: `prices` (list, add, end, `prices/seed`), `rules` (list,
add, end), `versions`, `quote` (price a hypothetical request), `alerts` (the money alerts below, as the console's
*Margin watch* shows them), `wallets` and `wallets/sync` (run the finance pass
now: expire dead holds, reprice, settle), per-workspace
`ledger` and `billing-mode`, `providers` (prepaid balance, credit limit, low-balance line, contract terms),
`providers/{id}/reconcile?period_start&period_end` (expected spend from the metered rows against the invoices of
the period, with the models behind it) and `provider-invoices` (record, then move to `disputed` / `paid` -
settlement is recording `paid`). A prepaid provider whose projected balance crosses its low-balance line raises a
deployment alert (`provider_balance`). Two more alerts guard the reseller's margin on the console health page and
in the alert stream: `margin` - a model whose priced requests of the last 24 hours were charged below provider
cost (`critical`) or under `OSR_PLATFORM_MARGIN_ALERT_PCT` gross margin (`warning`, default 5 %) - and
`above_list_price` - one per provider, counting the enabled offers whose storefront rate per million tokens exceeds
the vendor's catalogue list price (the worst one named), so the platform no longer beats buying direct there. Reads are open to every operator; writes need the `finance` role (price
books, rules, ledger, billing mode, invoices, settlement) or `provider_manager` (provider accounts and pricing,
the provider endpoints themselves - create, edit, delete, check, target mappings - and the offers listed on them) -
`superadmin` passes everything. `GET /api/v1/admin/analytics` carries a `finance` block: usage revenue, provider
cost, gross margin, the wallets and the breakdowns by day, provider, model, workspace and plan.

### Models by the token: offers, prices, credits and provider preferences

Besides routing with `auto`, the platform sells named models by the token, the way OpenRouter does. You pick
a model from the storefront, call it under its catalogue id, and pay the price shown per million tokens; the
platform chooses which provider endpoint serves the call unless you pin one.

Three ids appear throughout this section:

| Id | Example | Meaning |
|---|---|---|
| Catalogue id | `openai/gpt-4.1` | A model card in the reference catalogue (`GET /api/v1/llms`): capabilities and the vendor's list price. |
| Provider | `azure` | An endpoint the deployment can call, configured by the operator under *Providers*. |
| Offer | `openai/gpt-4.1@azure` | That model, served by that provider. The `@provider` suffix pins the endpoint. |

An offer is an ordinary routing target: `auto` competes it with the rest of the catalogue, plans may use it,
the learners grade it. Nothing is added to `targets.yaml` to sell a model.

#### Find a model and its price

The storefront at [`/models?view=offers`](/models?view=offers) lists every model on offer with two prices: the
vendor's list price and the price your workspace pays, per million input and output tokens (cached input when
the model supports it). Signed in, you see your own plan's prices; signed out, the deployment's public tariff.
Each row opens the endpoints behind it with their health and last week's traffic.

The same data over the API:

| Endpoint | Returns |
|---|---|
| `GET /api/v1/offers` | One row per model: `prices.input_usd_per_1m` / `output_usd_per_1m` / `cached_usd_per_1m`, `priced_by` (`rules` when a pricing rule shaped the price, `list` when the vendor's price stands), the endpoints. Filters `q`, `vendor`, `provider`. |
| `GET /api/v1/offers/{vendor}/{model}` | One model with its endpoints and its `GET /v1/models` entry. |
| `GET /api/v1/llms/{vendor}/{model}` | The catalogue card, with the same `offers` block. |
| `GET /v1/models` | The OpenAI-compatible list, in OpenRouter's shape (below). |

`GET /v1/models` returns each model on offer with `pricing.prompt` / `completion` / `input_cache_read` in USD
**per token** as strings, `context_length`, `architecture`, `top_provider` and `supported_parameters` - the
fields OpenRouter clients read - plus an `osr` block with the `endpoints` (provider, upstream id, `executable`,
`healthy`, region, data policy) and the vendor `list_price`. Tools written against OpenRouter's listing work
unchanged.

The price you see before the call is the price recorded after it: usage is metered under the catalogue id even
when the provider reports its own name (`gpt-4.1` on Azure is `openai/gpt-4.1`), and the customer price comes
from the pricing engine in the section above - a contract row of the provider in the price book beats the list
price, then rules per deployment, plan or account apply.

#### Call a model by name

`POST /v1/chat/completions` (and the Responses and Messages dialects over it) accepts a catalogue id or an offer
id in `model`, alongside `auto` and target ids. A `models` list may mix all three.

```bash
curl -s https://api.opensmartroute.ai/v1/chat/completions \
  -H "Authorization: Bearer $OSR_API_KEY" -H "Content-Type: application/json" \
  -d '{"model": "openai/gpt-4.1", "messages": [{"role": "user", "content": "Summarise this thread."}]}'
```

| `model` | What happens |
|---|---|
| `openai/gpt-4.1` | The router picks among the enabled endpoints serving that model - by price, health and learned quality - and falls back down them when one fails. |
| `openai/gpt-4.1@azure` | Only that endpoint. `503` with `Retry-After` when it cannot be used right now. |
| `auto` | The whole catalogue, offers included. |

The response says which endpoint answered: `model` and the `X-OSR-Target` header carry the offer id
(`openai/gpt-4.1@azure`), `X-OSR-Charge-USD` and `charge_usd` in the response's `opensmartroute` object carry what
the call cost you, and the usage row records `provider_id` and `upstream_model`.

Key, workspace and tenant `allow_targets` / `deny_targets` may name a catalogue id; it stands for every endpoint
of that model.

#### Steer the choice: provider preferences

Preferences travel in the request body under `osr.provider`. OpenRouter's top-level `provider` object is
accepted as an alias and is never forwarded upstream.

```json
{
  "model": "openai/gpt-4.1",
  "messages": [{"role": "user", "content": "..."}],
  "provider": {"order": ["azure", "openai"], "data_collection": "deny", "sort": "price"}
}
```

| Key | Effect |
|---|---|
| `order: ["azure", "openai"]` | Try the first named provider's endpoint first; the rest are the fallback pool in that order. |
| `only: [...]` | Use only these providers' endpoints. |
| `ignore: [...]` | Never use these providers' endpoints. |
| `data_collection: "deny"` | Skip every endpoint whose provider states that it trains on, logs or retains prompts. |
| `zdr: true` | Zero data retention: use only endpoints whose provider has *declared* that it keeps nothing (`zero_retention: true`, or `retains_days: 0` with neither training nor prompt logging). An endpoint with no data policy at all is skipped - the provider has to say so. |
| `sort: "price"` \| `"latency"` \| `"throughput"` \| `"quality"` | Re-weight the objective: the cheapest, the fastest or the best endpoint that can answer. |
| `max_price: {"prompt": 2, "completion": 8}` | USD per million tokens; dearer endpoints are excluded. |
| `allow_fallbacks: false` | Do not fall back to another endpoint (same as `osr.fallbacks: false`). |

Preferences that exclude every endpoint of the named model are refused with `400` rather than silently routed
elsewhere. An unknown key is a `400` too.

A workspace sets defaults for all of its requests with the policy (`PUT /api/v1/policy`, key
`provider_preferences`, the same object, or the *Providers* block of the policy dialog); a request's own preferences are layered on top.

#### Pay as you go

The `payg` plan has no monthly fee and no request quota. Usage is paid from prepaid credits
(`billing_mode: wallet`); when they run out, requests stop with `402`. The abuse controls are the plan's
`requests_per_minute` and the per-key spend limits below.

- **Switch to it:** `POST /api/v1/billing/checkout` `{"plan": "payg"}` applies the plan at once - no Stripe
  Checkout, no payment method - and puts the workspace on the wallet. It answers `409` while a subscription is
  live; cancel that first. Moving to a subscription plan later takes the workspace off the wallet again.
- **Buy credits:** the billing page offers fixed packs and a custom amount; `POST /api/v1/billing/credits/checkout`
  is the API. `GET /api/v1/billing/wallet` reports the packs, the purchase fee and the balance.
- **Watch the balance from a client:** every priced chat answer of a wallet workspace carries `X-Wallet-Balance`,
  the credits (in cents) one more request would be admitted against, so an integration can top up before the
  `402` arrives.

| Setting | Default | Effect |
|---|---|---|
| `OSR_PLATFORM_DEFAULT_PLAN` | `free` | The plan a self-serve signup starts on; `payg` makes new workspaces pay by the token from day one. |
| `OSR_PLATFORM_CREDITS_PACKS_CENTS` | `1000,5000,20000` | The credit packs offered on the billing page (within the purchase bounds). |
| `OSR_PLATFORM_CREDITS_FEE_PCT` | `0` | Purchase fee, percent of the credits bought. |
| `OSR_PLATFORM_CREDITS_FEE_CENTS` | `0` | Purchase fee, fixed part in cents. |
| `OSR_PLATFORM_AUTO_TOPUP_COOLDOWN_S` | `3600` | After one auto top-up attempt, charged or declined, the wallet is left alone for this long. |

The fee is a second line item in Stripe Checkout and is booked on the ledger as a zero-sum pair of `fee`
entries - collected, not spendable - so the balance carries only the credits and revenue analytics separate
the fee from the spread.

**Auto top-up.** A wallet workspace can have credits bought for it before the balance reaches zero:

1. Buy credits once with `save_payment_method: true` (the billing page: *Keep this card for auto top-up*). The
   Checkout keeps the card on the workspace's Stripe customer for off-session charges; the webhook stores its
   label (`visa 4242`), never the number. Passing `auto_topup: {amount_cents, threshold_cents}` on the same
   purchase switches auto top-up on as soon as it completes.
2. Set or change the rule with `PUT /api/v1/billing/wallet/auto-topup` `{amount_cents, threshold_cents}` - buy
   `amount_cents` whenever the available balance falls to `threshold_cents` or below (whole dollars within the
   purchase bounds, the threshold below the amount; `409` without a saved card). `DELETE` turns it off;
   `?forget_card=true` also detaches the card at Stripe. Owners and admins only.
3. The finance pass (the same one that settles usage, several times a day; `POST /api/v1/admin/finance/wallets/sync`
   runs it now) charges the saved card off-session and credits the wallet under the PaymentIntent id, so the
   `payment_intent.succeeded` webhook for the same intent is a no-op. One attempt per cooldown, whatever the
   outcome: a declined card is recorded as `auto_topup.error`, raises the `wallet:auto_topup` alert on the
   billing page and in the workspace alerts, and is not retried until the cooldown passes or the rule is saved
   again.

`GET /api/v1/billing/wallet` reports the rule under `auto_topup` (`enabled`, `amount_cents`, `threshold_cents`,
`payment_method`, `last_attempt_at`, `error`, `available` - the deployment has Stripe). Every step is in the audit
log: `billing.payment_method_saved`, `billing.auto_topup_changed`, `ledger.auto_topup`, `billing.auto_topup_failed`.

**Referrals: give credits, get credits.** Every workspace has a referral link, `<site>/r/<code>`, on *Refer a
team* in the dashboard (`GET /api/v1/referrals`: the code, the link, the terms, the referrals so far and what
they earned; `POST /api/v1/referrals/invite` e-mails the link to one address with a line from the sender,
twenty a day). The site remembers the code the way it remembers the first touch and hands it to the signup
(`attribution.ref` on `POST /api/v1/signup` and on a sign-in with a provider that creates the account); the
latest referral link a browser opened is the one that counts. A referral is *signed up* when the new workspace
exists, *qualified* when its owner has confirmed the address and the workspace has made its first successful
request, and *rewarded* when the finance pass has credited both wallets - `OSR_PLATFORM_REFERRAL_REWARD_CENTS`
to the referrer and `OSR_PLATFORM_REFERRAL_WELCOME_CENTS` to the new workspace (both `500` by default), as
`promo` ledger entries that name the referral. A referrer earns at most `OSR_PLATFORM_REFERRAL_MAX_REWARDS`
rewards (`20`; `0` = no cap) - a referral past the cap is *capped* and the invited side still gets its welcome
credit. Self-referrals (the same person or address, a member of the inviting workspace) are not recorded and a
workspace is referred once. `GET /api/v1/referrals/lookup?code=` tells a visitor on a link whether it is live
and what they are offered, naming the inviter by initials only; the inviter sees the invited workspaces by
initials. `OSR_PLATFORM_REFERRAL_ENABLED=false` switches the programme off (links keep working as plain sign-up
links); all four are run-time settings. Operators read the programme on `GET /api/v1/admin/referrals` and run
the qualification pass with `POST /api/v1/admin/referrals/sweep`. Events: `referral.signed_up`,
`referral.rewarded`, `referral.capped`, `referral.invited`.

**Money that moves is announced.** Whenever credits land on the wallet - a purchase at checkout or an automatic
top-up - the owners receive *Credits added* (outbox kind `credits_added`: the amount, the balance now, the source
and any purchase fee) and the workspace inbox gets `workspace.credits_added`; a Stripe refund sends *Payment
refunded* (`credits_refunded`, the balance after it) and files `workspace.credits_refunded`. Each goes out once
per ledger entry, whichever of the finance pass or the webhook wrote it. Both are `account` mail and always go out.

#### Per-key spend limits

`limits.spend_limit_cents` (per calendar month) and `limits.spend_limit_daily_cents` (per UTC day) on
`POST /api/v1/keys` cap what one key may **charge** the workspace: charge recorded so far, plus the charge side
of its requests in flight, plus the bound of the request being admitted. A request that would pass the cap is
refused with `429`, `X-Key-Limit: spend_limit_cents` and the `X-Budget-*` headers in dollars. Budgets
(`daily_budget_usd`) remain the cap on provider cost.

#### Operators: list, price and pause offers

The console page **Offers** (`/platform/admin/offers`) does everything in this section without curl.

1. **Connect a provider** under *Providers* and press **Check**. The check calls the provider's `/models` and
   reports, under `offers`, which upstream ids the reference catalogue recognises.
2. **List models.** One click creates an offer for every recognised id. Upstream ids that resolve to the same
   card are one offer (`gpt-4.1` and `gpt-4.1-2025-04-14` both mean `openai/gpt-4.1`). Azure and Bedrock do not
   return deployments from `/models`: name them in the `models` field.
3. **State the data policy** the provider publishes - `trains`, `retains_days`, `logs_prompts`,
   `statement_url`, `stated_at`. This is what `data_collection: deny` reads; an empty policy never excludes the
   endpoint.
4. **Set the price** on the *Finance* page: a contract row for the provider in the price book and the mark-up
   rules. Without either, the vendor's list price stands.
5. **Pause or remove** an offer from the same page when a provider is unreliable or a contract ends. Paused
   offers leave the storefront and `GET /v1/models` at once.

The same over the API:

| Endpoint | Purpose |
|---|---|
| `POST /api/v1/admin/providers/{provider_id}/check` | Probe the provider; `offers.recognised` lists the upstream ids the catalogue knows. |
| `POST /api/v1/admin/providers/{provider_id}/offers` `{models?, region?, data_policy?}` | List models: one offer per recognised id (`models` names them explicitly, else the probe's list is used). |
| `DELETE /api/v1/admin/providers/{provider_id}/offers` | Remove every offer of the provider. |
| `GET /api/v1/admin/offers` | Every offer with its provider, prices and state. |
| `GET /api/v1/admin/offers/desk` | The reseller's desk: every offer with what the deployment pays the provider per million tokens and what a workspace on the public tariff is charged (`rates`, with `priced_from` `contract` when a provider price-book row sets the cost, else `list`), the margin per unit, whether the offer declares zero data retention, and the last seven days - requests, tokens, success rate, realised provider cost, customer charge and margin. `totals` sums the week; `losing` lists the offers that lose money on paper or in practice. |
| `POST /api/v1/admin/offers` `{model, provider_id, upstream_model?, region?, data_policy?, enabled?, notes?}` | Add one offer by hand. |
| `PATCH` / `DELETE /api/v1/admin/offers/{vendor}/{model@provider}` | Pause (`enabled: false`), re-point (`upstream_model`), edit the data policy, or remove. |

`OSR_PLATFORM_OFFERS_AUTO_LIST=true` lists the recognised models on every provider check without the button.

**An Ollama server offers only the models it is mapped to run.** Several Ollama servers often share one model
store, so each one's `/api/tags` lists every model on the disk while only the mapped one is resident and
kept warm; an offer for any other model would send a chat into a cold load of tens of gigabytes and time out.
List models therefore creates offers only for the upstream ids the server's target mappings name (the rest are
returned under `unserved`), and every sync pauses an enabled offer whose server is not mapped to run the
model, with the reason in the offer's notes. A provider is recognised as an Ollama server by its kind, by
port `11434` or by a name starting with `ollama-`. Providers without mappings are not restricted.

**A provider you cannot pay is unhealthy, not expensive.** When a prepaid provider's projected balance is
exhausted (see *Usage pricing, credits and provider finance*), every target it executes - offers and
hand-written mappings alike - leaves the pool `auto` routes over, a request pinning one of them answers `503`
with `Retry-After`, the storefront and `GET /v1/models` mark the endpoint as not executable, and the
`provider_balance` alert names the provider on the console health page and in the alert stream. A workspace's
own provider (BYOK) is never affected: it pays for its own calls.

### Free trials, coupons and the launch offer

A **coupon** grants a paid plan for a number of days without payment - no Stripe, no payment method. Redeeming
one puts the workspace on the plan at once and records `account.trial_end` (and `coupon_code`) on
`GET /api/v1/me`; when the trial ends without a subscription having taken over, the workspace returns to `free`
(on its next request and by a sweep every `OSR_PLATFORM_TRIAL_SWEEP_S` seconds, default 15 minutes; audit entry
`billing.trial_ended`). The owners hear at each step, whether or not the deployment has Stripe: *Your* (plan) *trial
has started* the moment it begins (outbox kind `trial_started`: until when, that no card was taken, what happens
at the end), *Your* (plan) *trial ends on* (date) three days before (`trial_ending`, the lifecycle campaign) and
*Your* (plan) *trial has ended* when the sweep moves the workspace back (`trial_ended`); the workspace inbox carries
the same (`workspace.trial_started`, `workspace.trial_moved` when the trial moves to another plan,
`workspace.trial_ended`) and each is a `plan.changed` event (`reason` `trial`, `trial_moved`, `trial_expired`)
for the subscribed channels. While a coupon trial runs the quota is the plan's, overage is not billed (there is no
subscription), and the workspace may move to any other plan the coupon covers with the same end date. A Stripe
subscription that completes during the trial clears the deadline. Coupons work on deployments without Stripe.

The **plans' own free trials** ride the same machinery. Every subscription plan whose `trial_days` is set and
whose `trial_needs_card` is false (the shipped catalogue: Pro, Team and Enterprise, 90 days each) is carried by
the built-in promotion `FREE-TRIAL`, which the API keeps in step with the catalogue at start and whenever an
operator changes a plan (`PATCH /api/v1/admin/plans/{name}`): it covers exactly those plans, advertises the
longest of their trials and grants each plan its own number of days. The first `POST /api/v1/billing/checkout`
of a workspace therefore starts the trial at once, without Stripe and without a card - on a deployment that has
no Stripe at all, and on one whose Stripe account cannot charge yet. One trial per workspace: a workspace that
had any trial or subscription before is refused (`409`) and pays instead. `GET /api/v1/info` shows the promotion
with `builtin: true`; the running trial reports `coupon: null`; the operator console lists the row as created by
`plans` and cannot type over its terms for long. Set every plan's `trial_days` to `0` and the promotion switches
itself off.

The **launch offer** is an operator's coupon on top, created at start-up from the environment and applied
automatically (the newest live promotion wins a checkout):

| Variable | Default | Meaning |
|---|---|---|
| `OSR_PLATFORM_LAUNCH_COUPON` | unset | The code, e.g. `LAUNCH100` (3-32 letters, digits, dashes). Unset = no launch coupon. |
| `OSR_PLATFORM_LAUNCH_COUPON_DAYS` | `100` | Days of the plan the coupon grants. |
| `OSR_PLATFORM_LAUNCH_COUPON_UNTIL` | unset | `YYYY-MM-DD` (UTC) after which it can no longer be redeemed; unset = until an operator disables it. |
| `OSR_PLATFORM_TRIAL_SWEEP_S` | `900` | How often expired coupon trials are moved back to free (`0` = only on the workspace's next request). |

An **auto-apply** coupon is the promotion: `GET /api/v1/info` advertises it as `promotion` (`code`, `trial_days`,
`plans`, `valid_until`, `description`, `builtin`), the pricing page and the plan cards say "*N*-day free trial", and
`POST /api/v1/billing/checkout` applies it to every eligible workspace **instead of** starting a Stripe Checkout -
nobody has to type the code. Eligible = the coupon covers the plan, is enabled, within its deadline and cap,
the workspace has no live subscription and has not redeemed this code before (for the built-in trial: has never
had a trial). The answer then carries `coupon`, `builtin`,
`trial_days`, `trial_end` and a `url` back to `/platform/dashboard/billing?redeemed=<plan>`. Send `"coupon": ""`
to skip the trial and the promotion and pay (the *Subscribe now* button on the trial card), or `"coupon": "<code>"`
to use a specific one.

| Endpoint | Who | What |
|---|---|---|
| `GET /api/v1/billing/coupons` | any member | `promotion` on offer, the workspace's running `trial` (`plan`, `coupon`, `trial_end`, `days_left`), `eligible` per paid plan and the workspace's `redemptions`. |
| `POST /api/v1/billing/coupons/validate` `{code, plan?}` | any member | The coupon's public terms plus `eligible` and `reason` for this workspace. `404` unknown code. 20 lookups per workspace per minute. |
| `POST /api/v1/billing/coupons/redeem` `{code, plan}` | owner, admin | Apply the coupon: `400` when it does not cover the plan or ran out, `409` when the workspace redeemed it before or already pays for a subscription. |
| `GET|POST /api/v1/admin/coupons`, `GET|PATCH /api/v1/admin/coupons/{code}`, `POST /api/v1/admin/coupons/sweep` | operator | Create (`code`, `trial_days`, `plans` - empty = every paid plan, `auto_apply`, `max_redemptions`, `valid_until`, `description`), list with counters, see who redeemed a code, change its terms or disable it, run the expiry sweep now. |

The dashboard's *Plan and billing* page shows the running trial (days left, end date, *Subscribe now* when
Stripe is configured), marks the plans the promotion covers, and has a *Have a coupon code?* form; the operator
console manages codes at `/platform/admin/coupons`.

## 7. Organizations

Signup creates a personal workspace. An **organization** is a second kind of workspace with its own
members, roles, API keys, tenants, plan and usage; one user can belong to several and switches between
them in the dashboard (`/platform/dashboard/workspace`, `POST /api/v1/workspaces/{account_id}/switch`).
Creating one (`POST /api/v1/workspaces` with `name` and an optional `slug`) makes you its owner and
issues its first API key. A personal workspace that already carries a paid plan, keys and history can
instead become an organization in place: `POST /api/v1/workspace/convert` with `name` (and an optional
`slug`) changes only its kind - plan, subscription, keys, tenants and usage stay on the same row, the
browser session stays on it, and the user receives a fresh free personal workspace alongside (audit
`workspace.converted`). The *Members* page of a personal workspace offers exactly this, so the seats a
team or enterprise plan sells become usable without moving anything.

Roles are `member` < `admin` < `owner`:

| Action | Role |
|---|---|
| Route, read usage, savings and activity; read the roster and the teams | `member` |
| Manage the roster and the keys of a team they lead (below): mint, rename, rotate, revoke | `member` with the team `lead` role |
| Create and revoke API keys, change tenants, invite and remove members, create teams, rename the workspace, checkout, billing portal and cancellation | `admin` |
| Grant or remove `owner`, transfer ownership, configure organization SSO, delete the connection or the organization | `owner` |

API keys belong to the workspace and act with its full rights; the role model applies to signed-in
browser sessions. Members are invited by email (`POST /api/v1/workspace/invites`): the invitee gets a
message with the link when the deployment has a mail server, and the platform returns the same link once
to the inviter so it can be shared directly. `POST /api/v1/workspace/invites/{id}/resend` sends a pending
invitation again with a fresh link and a full validity period (the previous link stops working; audit
`member.invite_resent`); invitations that expired without being accepted are purged by the retention
sweep. Seats and tenants
are limited per plan (`max_members`, `max_tenants` in `GET /api/v1/info`). An organization always
keeps at least one owner. **Transferring ownership** is one call: `POST /api/v1/workspace/transfer` with the new
owner's `user_id` and the role the current owner keeps (`keep_role`: `admin` - the default -, `member`, `owner` to
share ownership, or `leave` to hand over and go), owner only, audited as `workspace.transferred`, both people
e-mailed; the crown button on **Members** does the same. There is no per-workspace "suspend" for a person:
**remove the member** (their sessions on the workspace end at once, the invitation can be sent again later), or
an operator disables the whole user account.

**Organization SSO** (enterprise) lets people sign in with the company identity provider:
`PUT /api/v1/workspace/sso` with `kind` (`github`, `google`, `microsoft`, `gitlab`, `linkedin` or `oidc` with an
`issuer`), `client_id`, `client_secret`, the allowed email `domains` and the `default_role` given to
anyone who signs in from one of those domains. The workspace needs a slug first; the sign-in page
shows the provider as `org-<slug>`. A `role_map` (`{"claim": "groups", "map": {"osr-admins": "admin"},
"require_match": false}`) derives the workspace role from the provider's group claim instead: the mapping is
re-applied at every sign-in (a user added to or removed from a mapped group changes role on the next login,
recorded as `member.role_sso` in the workspace audit; the last owner is never demoted), unmatched users get
`default_role`, and `require_match: true` refuses anyone outside the mapped groups. Entra `groups` / `roles` /
`wids`, Okta `groups`, Keycloak `realm_roles` and Cognito `cognito:groups` are read from the id token and
userinfo; ask the provider for the claim with the connection's `scopes` (`groups`).

**SCIM provisioning** (organizations) is the push side of the same identity provider: single sign-on lets a
person *in* when they sign in, SCIM 2.0 (RFC 7643 / 7644) lets Entra ID, Okta, OneLogin, JumpCloud and the like
create the account before the first sign-in and **remove it the moment HR closes it**. An owner mints the
provisioning token on **Workspace › Single sign-on** (*Provisioning (SCIM)*; `POST /api/v1/workspace/scim/token`,
shown once, valid a year, one live token per organization - minting again replaces it, `DELETE` revokes it) and
pastes it with the base URL `<api>/scim/v2` into the provider's SCIM connector. The connector then calls:

| Call | What happens |
|---|---|
| `GET /scim/v2/ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | What is supported: users, PATCH, `filter`, no bulk, no passwords (sign-in stays with the provider). |
| `GET /scim/v2/Users` | The members as SCIM users; `filter=userName eq "..."` / `externalId eq "..."`, `startIndex`, `count` (up to 200). |
| `POST /scim/v2/Users` | Provision: the person's account is created if it does not exist (with a personal workspace like any signup; the address counts as confirmed because the provider vouches for it) and joins the organization with the SSO connection's `default_role` (`member` without one) - a `roles` entry of `admin` or `member` sets the role, `owner` is refused; the plan's seat limit applies as for an invitation; `409` when already a member. |
| `PATCH` / `PUT /scim/v2/Users/{id}` | `name`, `displayName`, `roles`, `externalId` and `active`: `active: false` **removes the membership and ends the person's sessions on the workspace** - the user and their other workspaces stay - and `active: true` restores it. Paths such as `name.givenName` and `emails[type eq "work"].value` are understood. |
| `DELETE /scim/v2/Users/{id}` | Deprovision, the same as `active: false` (`204`). The last owner is never removed (`409`). |
| `GET /scim/v2/Groups` | Empty: roles come from the SSO `role_map` at sign-in or from `roles` here; teams are not provisioned. |

Errors come back in SCIM's own shape (`urn:ietf:params:scim:api:messages:2.0:Error` with `status`, `detail`,
`scimType`). Every change is in the workspace audit trail with *identity provider (SCIM)* as the actor -
`member.provisioned`, `member.deprovisioned`, `member.role_scim`, `user.scim_updated` - and a deprovisioning files
the same *member removed* notice in the workspace inbox as a removal by hand. `GET /api/v1/workspace/scim`
(admin role) shows whether provisioning is on, the token's age and use and how many people came through it.

Not offered, by decision rather than omission: **SAML** (every supported identity provider - Entra, Okta,
Google, Keycloak, Cognito - also speaks OIDC, which is what the `oidc` connection kind uses; SAML-only
providers can front the platform through an OIDC bridge) and the OpenAI
**Batch API** (asynchronous batch files - the router prices and routes each request live; send them concurrently).

### Teams

A large customer does not want one admin minting every key. A **team** groups some of an organization's
members - a product squad, an agency's client desk, the data-science group - and gives that group its own
place in the workspace:

* **Tenant binding.** A team may be bound to one of the workspace's [tenants](#8-tenants). Keys minted for
  the team are pinned to that tenant, so its constraints, budgets and usage roll-up apply, and the team's
  spend shows on the tenant. `GET /api/v1/workspace/teams` reports each bound team's requests, tokens and
  cost this month and today. A tenant bound to a team cannot be deleted until the team is unbound (`409`).
* **Leads.** A team member with the team role `lead` manages the roster (adds and removes members, not
  other leads) and the team's keys - mints them (`POST /api/v1/keys` with `team: "<slug>"`; the key carries
  `team` and is pinned to the team's tenant), renames, rotates and revokes them - without holding the
  workspace `admin` role. Only admins make leads. This is the delegation that lets a customer run its own
  people: the admin creates the team, binds it and names a lead; the lead does the rest. A team with keys
  cannot be re-bound to another tenant (`409`; revoke its keys first); deleting a team detaches its active
  keys (they keep working as the tenant, managed by admins from then on - `keys_detached` in the answer).
* **Attribution.** `GET /api/v1/workspace/members` lists every person's teams, `GET /api/v1/me` the
  caller's own (`teams`), and the audit trail records `team.created`, `team.updated`, `team.deleted`,
  `team.member_added` and `team.member_removed` (also notification events).

| Endpoint | Role | Notes |
|---|---|---|
| `GET /api/v1/workspace/teams` | `member` | Every team with `members`, `leads`, `keys` pinned to its tenant and `usage`; `mine` names the caller's own teams. |
| `POST /api/v1/workspace/teams` `{name, slug?, description?, tenant?, members?}` | `admin` | `slug` defaults to the name; `members` seeds the roster (`[{user_id, role}]`). `409` when the slug exists. |
| `GET /api/v1/workspace/teams/{slug}` | `member` | The team with its roster (team role and workspace role per person), its active `keys` (prefixes) and `can_manage`. |
| `PATCH /api/v1/workspace/teams/{slug}` `{name?, description?, tenant?}` | `admin` | `tenant: ""` unbinds. `409` while the team has keys and the tenant would change. |
| `DELETE /api/v1/workspace/teams/{slug}` | `admin` | Members stay in the workspace; the team's active keys are detached and keep working as the tenant. |
| `POST /api/v1/workspace/teams/{slug}/members` `{user_id, role?}` | `admin` or team `lead` | The person must already be a member of the workspace. Only admins set `role: "lead"`. |
| `DELETE /api/v1/workspace/teams/{slug}/members/{user_id}` | `admin` or team `lead` | A lead cannot remove another lead (but may leave). |

Leaving or being removed from the workspace drops every team membership; deleting the organization drops
its teams. Personal workspaces have no teams (`400`). The **Members** page carries the *Teams* card
(create, bind, roster, leads) and shows each member's teams; the **Tenants** page names the teams that run
under each tenant; the key dialog on **API keys** offers the teams the caller may mint for. Operators see
the teams and their rosters on the workspace's console page (`teams` on
`GET /api/v1/admin/accounts/{account_id}`), and the data export (`GET /api/v1/me/export`) carries them.

### Regions

The hosted platform runs as independent stacks per region - an EU stack and a US stack - each with its
own database, so a workspace's keys, usage and traces never leave the region it was created in. A stack
declares its region with `OSR_PLATFORM_REGION` (`eu`, `us`; unset = a single-region deployment that
enforces nothing). Every workspace created on a stack - at signup, `POST /api/v1/workspaces` or by an
operator - is pinned to that region (`region` on the account in `GET /api/v1/me` and `GET /api/v1/info`
reports the stack's `region` and `regions_enforced`), and API keys are minted where the workspace lives.

Using a key on the other stack answers `403` with `code: "region_mismatch"`, `region: "<r>"` and
`detail: "this workspace is pinned to region '<r>'; use the <r> endpoint"`; browser sessions get the
same answer, which is how the dashboard tells you your workspace lives in the other region. Every
authenticated response carries `X-OSR-Region` with the answering stack's region, so a client can check
it is talking to the right one. Operators are not pinned, and public endpoints work on every stack.

Deploying two stacks = two deployments of the same template with
`OSR_PLATFORM_REGION` set to `eu` / `us` and DNS such as `eu.api.<domain>` / `us.api.<domain>` in front
of each. Moving a workspace is deliberate: an operator changes its `region` (`PATCH
/api/v1/admin/accounts/{account_id}` with `{"region": "us"}`, audited as `account.region_changed`; `""`
unpins it), and the data itself is exported from one stack and imported on the other by hand
(`GET /api/v1/me/export`). This is not the routing constraint of the same name: `constraints.region` on a
request (or a tenant's `region`) chooses *targets* deployed in a region; the workspace region chooses the
*stack* that accepts the key.

## 8. Tenants

Tenants (enterprise) apply a fixed set of hard constraints per customer, team or region without the
caller repeating them. Create a tenant, then send its slug in `X-OSR-Tenant` (or `osr.tenant`).

```bash
curl -s -X PUT "$OSR_URL/api/v1/tenants" -H "Authorization: Bearer $OSR_API_KEY" -H "Content-Type: application/json" \
  -d '{"slug": "eu-health", "config": {"region": "eu", "data_boundary": "private", "max_cost_per_1k": 0.01, "deny_targets": ["llm-frontier"]}}'

curl -s "$OSR_URL/api/v1/route" -H "Authorization: Bearer $OSR_API_KEY" -H "X-OSR-Tenant: eu-health" \
  -H "Content-Type: application/json" -d '{"text": "Anonymise this discharge summary ..."}'
```

Tenant config keys: `region`, `data_boundary`, `max_cost_per_1k`, `max_latency_ms`, `preferred_max_latency_ms`,
`deny_targets`, `allow_targets`, `contains_pii`, `daily_budget_usd`, `monthly_budget_usd`, `log_payloads`,
`marketplace_discovery`, `data_protection`, `output_guard`, `allowed_ips`. Explicit
request constraints win over the tenant defaults; the workspace policy (next section) applies on top of both. Executed spend
is attributed to the tenant, so `GET /api/v1/governance` shows spend and budgets per tenant, and
`GET /api/v1/tenants` returns each tenant with `usage` (`month` and `today`: requests, tokens,
`cost_usd`), its `budgets` with what is used and remaining, and `keys` - the number of API keys pinned
to it. `GET /api/v1/usage` adds `by_tenant` (requests, tokens and cost per tenant slug; `null` is the
workspace's own traffic) and `?tenant=<slug>` narrows the daily series and the target and endpoint
breakdowns to one tenant; `GET /api/v1/audit?tenant=<slug>` keeps only that tenant's hash-chained
records, so a per-customer audit extract is one export away. Audit reads are bounded: each chain file is read
from its tail and the scan stops once `limit` matching records are in hand, and `next_before` in the answer is
the cursor for the following page (`GET /api/v1/audit?limit=100&before=<ts>`). The *Activity*, *Usage* and *Audit trail*
pages offer the tenant filter.

**Tenant-pinned keys** are the isolation boundary for a customer or team you hand a key to:
`POST /api/v1/keys` with `{"name": "acme", "tenant": "acme"}` mints a key whose every request runs
as that tenant - the header is not needed, naming another tenant answers `403`, and the key can
never act as the whole workspace. Its requests count against the tenant's budgets and appear under
the tenant in usage and audit. The boundary holds on every surface: the key routes, executes and reports
outcomes as its tenant; it reads only its tenant's rows on `/activity`, `/trace/{id}`, `/events`, `/usage`,
`/usage/report`, `/savings`, `/audit`, `/handoffs`, `/keys`, `/tenants`, `/me` and `/governance` (another tenant's
request id answers `404`, `?tenant=<other>` answers `403`); every workspace-management call - keys, policy,
tenants, providers, members, channels, billing, listings and the other reads of the workspace - answers `403`
with `X-Key-Tenant: <slug>`. A tenant with active pinned keys cannot be deleted (`409`); revoke
them first. The *API keys* page offers the tenant on creation and shows the scope of every key.

**Fair share** (enterprise, `OSR_PLATFORM_FAIR_SHARE=true`) keeps one workspace or tenant from crowding out the
others on a shared deployment: the SDK's dominant-resource-fairness middleware watches requests, tokens and cost per
workspace and per tenant over a sliding window (`OSR_PLATFORM_FAIR_SHARE_WINDOW_S`, 300 s), steers whoever takes
more than `OSR_PLATFORM_FAIR_SHARE_SOFT` (1.5) x its share to cheaper targets and caps premium targets above
`OSR_PLATFORM_FAIR_SHARE_HARD` (3) x - the trace shows `fairshare.throttled` / `fairshare.capped` events.
`GET /api/v1/governance` -> `fair_share` reports the window, the counters and the dominant shares of the caller's
own workspace and tenants only; `controls.fair_share` says whether it is on. Operators see every tenant across
workspaces on `/platform/admin/tenants` and can edit a tenant's configuration or delete it there
(`PUT|DELETE /api/v1/admin/accounts/{account_id}/tenants/{slug}`).

## 9. Governance: workspace policy, budgets and controls

A **workspace policy** applies to every request of the workspace, whichever key or tenant sent it. It
takes the same keys as a tenant plus budgets, and needs the `admin` role:

```bash
curl -s -X PUT "$OSR_URL/api/v1/policy" -H "Authorization: Bearer $OSR_API_KEY" -H "Content-Type: application/json" \
  -d '{"config": {"data_boundary": "private", "deny_targets": ["llm-frontier"], "monthly_budget_usd": 250}}'
```

`GET /api/v1/policy` returns the policy, the accepted keys and the current budget lines; `DELETE`
removes it. Constraints merge deterministically: a boundary or region fills in when the request has
none, cost and latency caps take the stricter value, deny lists are merged and allow lists intersected.
Unknown keys, unknown target ids and an id present in both `allow_targets` and `deny_targets` are
rejected with `400`.

**Budgets** count executed spend (`execute: true`, `/v1/chat/completions`, MCP `ask`) - quotes and
route-only calls are free. `daily_budget_usd` resets at midnight UTC, `monthly_budget_usd` on the first
of the month. Once a workspace or tenant budget is exhausted, routed calls return `429 ... budget ...
exhausted` with `Retry-After` and the `X-Budget-*` headers until the period rolls over or the limit is
raised.

**Request logging** is off by default: the platform keeps the decision facts of every request (signals, ranking,
policy rejections, execution steps, tokens, cost) but no prompt or answer text. A workspace that wants to read
them next to each trace opts in with `log_payloads` in the policy (or per tenant): `redacted` stores the prompt
and the answer with PII masked first (`opensmartroute.security.Redactor`: e-mails, phone numbers, card and
ID numbers become `<EMAIL_1>`-style placeholders) and credential shapes masked as well (API keys, cloud and
repository tokens, private keys become `<SECRET_1>`), `full` stores them as sent. Each text is cut to
`OSR_PLATFORM_REQUEST_LOG_MAX_CHARS` characters (`truncated` marks the row). The stored payload appears as
`payload` on `GET /api/v1/trace/{request_id}` and on the trace drawer of the *Activity* page, `GET /api/v1/activity`
marks the rows that have one (`logged`) and reports the mode in force (`logging`), the data export carries every
row (`request_logs`), the rows follow the usage retention, and `DELETE /api/v1/activity/logs` (admin) erases
them all while the activity rows, traces and outcomes stay. The governance posture flags `full` logging as a
warning and `redacted` as an informational finding.

**Data protection** decides what happens to personal and health data *before* the router or any provider sees the
text - the policy key `data_protection` (per workspace, or per tenant in the tenant's config, which wins):

```json
{"config": {"data_protection": {"mode": "mask", "entities": ["pii", "phi"], "restore": true}}}
```

| `mode` | What happens to a request that carries personal data |
|---|---|
| `detect` (default) | Nothing is changed; the request is marked as carrying personal data, so it may only route to targets whose catalogue entry allows it (`pii_allowed`) - a public model pinned by name answers `422`. |
| `mask` | Every detected value becomes a labelled placeholder (`<EMAIL_1>`, `<MRN_1>`, `<DATE_OF_BIRTH_1>`) in the current turn **and** in the conversation history before anything is routed, so the request may run on any target; the same value gets the same placeholder throughout. Targets that are allowed to see personal data still receive the originals. With `restore: true` (default) the placeholders in the answer - streamed too - are turned back into the values for the client; `false` hands the client the placeholders. |
| `block` | A request that carries any of the chosen entities is refused with `422` before any spend, naming the kinds found (`mrn (1), email (2)`), never the values. |
| `off` | The detection is switched off entirely (not even the routing mark). |

`entities` chooses what counts: the groups `pii` (e-mail, phone, card, national ids, passport), `phi` (medical
record number, NPI, DEA, Medicare id, insurance member id, ICD-10 code, date of birth) and `financial` (card, IBAN),
or single names; the default is everything. Every response carries `X-OSR-Data-Protection: <mode>` when something
was found, and the usage row's `facts.pii` records the mode, the entity counts and whether the text was masked
(`GET /api/v1/activity`, the trace drawer) - the values themselves are never stored. Under `mask` the stored
request log (`log_payloads`) keeps the masked text. The same `facts` object carries `cache: hit`, `fallback_from`
(the targets that failed before the answer came from another one) and `upstream_request_id` - the provider's own id
of the call (`x-request-id` at OpenAI, `request-id` at Anthropic, `apim-request-id` at Azure), what a support
ticket with the provider needs.

**Output guard** decides what happens to an answer *before* the customer sees it - the policy key `output_guard`
(per workspace, or per tenant, which wins): `{"mode": "redact", "pii": true, "secrets": true,
"injection_threshold": 0.7}`. Every executed answer, streamed or not, is screened for leaked secrets (cloud and API
keys, tokens, private keys), personal data and instruction injection. `flag` reports the finding and leaves the
text; `redact` replaces secrets and personal data by placeholders (`<SECRET_1>`, `<EMAIL_1>`) - a streamed answer
is held until it was screened, then sent; `block` refuses an answer that carries a secret or an injection with
`400` and redacts personal data otherwise; `off` (default) leaves answers alone. `pii: false` keeps personal data
in place (a support answer may quote an address) while secrets still go. The finding - kinds and counts, never the
values - is on the answer (the `output_guard` field of the response's `opensmartroute` block on chat
completions, `result.output_guard` on `/api/v1/route`),
in the `X-OSR-Output-Guard` header (`redact:secrets=1,pii=2`) and on the usage row as `facts.output_guard`.
Dashboard: *Governance › Controls*, next to the data protection.

**Allowed addresses** fence every routed request of the workspace: `allowed_ips` in the policy (addresses or CIDR
blocks, at most 32) refuses a request from anywhere else with `403` and `X-Policy-Limit: allowed_ips` before
anything is routed, judged on the client address past the trusted proxies - never a header the caller wrote. A
tenant's own `allowed_ips` replaces the workspace's for its requests. Management calls (the policy itself, keys,
billing) are not fenced, so a locked-out address can always be fixed; `allowed_ips` on one key (above) fences that
key everywhere.

**Marketplace discovery** decides which published skills and personas a plan may use *without* an install
(section 11): `marketplace_discovery` is `verified` (the deployment default unless the operator changed it -
only listings an operator marked verified), `all` (every published listing) or `off` (the workspace's installs
only). Discovered companions fill plan slots and never answer on their own; every decision names the listings
it used under `marketplace`, each with `installed: true|false`.

**Default objective.** `objective` in the policy (`{"quality": 1, "cost": 0.6, "latency": 0.1}`, any of
`quality`, `cost`, `latency`, `quality_floor`) is the objective of every request that sets none; a request's own
`objective` still wins. It is what promoting a shadow candidate writes (the *Evaluation* page, section above).

**Response cache.** `response_cache_ttl_s` (seconds, up to seven days) answers an identical chat request the
workspace already paid for from the store instead of a provider: the same model name, messages, options and tenant
hash to one key, the stored answer comes back with zero tokens and cost on its usage row, a `cache` entry
(`{"hit": true, "stored_at": ...}`) in the answer's routing block and an `X-OSR-Cache: hit | miss | bypass` header. A caller shortens the life of one answer with
`X-OSR-Cache-TTL: 60` (never lengthens it past the policy), skips the lookup with `X-OSR-Cache: bypass`, keeps an
answer out of the store with `X-OSR-Cache: no-store`, or sets `osr.cache: false` in the body. `GET /api/v1/cache`
reports hits, misses and what the hits saved; `DELETE /api/v1/cache` (admin) empties the workspace's entries. The
store is Redis when the deployment has one, else the database.

`response_cache_mode: "similar"` adds near-duplicate matching: every entry also keeps a *context key* (the request
with its last user message blanked - model, earlier turns, tools, sampling) and a signature of that message (hashed
character shingles of its normalised text, never the text), and a request that misses the exact key is compared
with the entries of the same context and served from the closest one whose similarity reaches
`response_cache_similarity` (0.5-1, default 0.85). That catches the rewordings that make up most repeats - case,
punctuation, a typo, a word - without an embedding call per request; the answer's `cache` entry says
`"match": "similar"` with the score, which `X-OSR-Cache-Similarity` carries too. It is surface similarity, not
meaning: two questions that differ in one decisive word can score high, so keep the threshold where your traffic
makes it safe and leave `exact` for workspaces where that cannot be tolerated. Both keys are in the policy dialog.

**Session affinity.** `session_affinity: true` keeps a conversation on the target that served it: a request that
carries a session (`context.session_id`, or the hosted `osr.conversation_id`) routes to the target of the previous
turn while that target stays executable, allowed by the policy and not behind an open breaker; an explicit `model`
in the request still wins. Agent loops stop flipping models mid-task and prompt caches on the provider side keep
their prefix.

**Aliases.** `aliases` maps the model names your clients already send to what the router understands - a target id,
a catalogue model, an offer (`vendor/model@provider`) or a preset (`osr/auto:cheap`): `{"gpt-4o": "auto",
"claude-3-5-sonnet": "llm-frontier"}`. Resolution is one step and happens before everything else on
`/v1/chat/completions` (`model` and `models`), so a code base migrates to the router without a search-and-replace
of model names.

**Answer judge.** `judge` turns on the LLM judge for a share of the executed answers:
`{"sample": 0.1, "target": "llm-frontier", "threshold": 6, "endpoints": ["chat"], "targets": ["llm-small"],
"max_per_day": 500}`. The request id decides the sample (reproducibly), the judge model grades the answer against
the prompt (0-10 with a reason), the grade lands in the `feedback` table as `quality` with `source: judge:<model>`
- exactly where a hand-reported outcome lands, so the learners, the rankings and the replay comparison use it -
and the verdict with its reason stays on `GET /api/v1/evaluations/judge`. Judging is a metered call of its own
(`endpoint: judge`) and runs on a thread after the answer has left; `max_per_day` bounds it. Section 5 has the
endpoints.

`GET /api/v1/governance` is the compliance view of the workspace in one payload:

| Block | Contents |
|---|---|
| `controls` | What the deployment enforces: `input_guard`, `pii_redaction`, `steering_strip`, `output_guard` (`flag` / `redact` / `block` or `false`), `metrics`, `tracing`, `audit`, `health_breakers`, `decision_cache`, `learning` (`persisted` or `in-process`). |
| `audit` | Whether the hash-chained audit trail is on, its length and the result of the last verification. |
| `retention_days` | How long usage rows are kept before the background sweep purges them. |
| `policy`, `policy_keys`, `budgets`, `spend` | The workspace policy, its accepted keys, budget lines with used and remaining amounts, spend today and this month. |
| `quota` | The same payload as `GET /api/v1/quota`: requests used this month against the plan's quota, `state`, reset time and the overage so far. |
| `tenants` | Each tenant with its monthly spend and budget lines. |
| `catalogue` | Targets per `data_boundary`, targets that refuse PII, executable targets, providers, and which targets remain reachable under the policy. |
| `keys` | Hygiene of the active API keys: `active`, `unscoped`, `no_expiry`, `expired`, `limited` (budgets or allow lists), `stale` (unused for 90+ days), `tenant_pinned`. |
| `guard_activity` | The guard verdicts of the last 24 hours counted from the workspace's trace events - `guard.blocked` / `guard.flagged` / `guard.redacted` (input guard), `output_guard.flagged` / `output_guard.blocked`, `route.no_route`, `tenant.rejected`; `null` when tracing is off. |
| `posture` | The compliance findings a reviewer would raise, most severe first, each with a stable `code`, a `severity` (`critical` / `warning` / `info`), the reason, the dashboard page that fixes it and a `scope`: `workspace` for what the customer can change (no budget, no reach policy, unscoped / non-expiring / expired / stale keys, an organization without SSO on a plan that includes it or without a role map) and `deployment` for what only the platform operator holds (guards or audit off, output guard only flagging, chain broken, tracing off, unbounded retention). `score` (0-100; a critical costs 25, a warning 10, an info 3) counts the **workspace** findings only; `deployment` carries the platform's own findings with their own `score`, so a customer is never judged by a setting they cannot touch. |

`GET /api/v1/governance/report` (admin role) is the evidence bundle behind that view: the governance payload plus
the members and their roles, the SSO connection (client id and role map, never the secret), every active key with
its scopes, limits, expiry and last use, the platform's identity (brand, versions, region) and who generated the
report when; `?format=markdown` downloads it as a Markdown document for an audit file. Every generation is recorded
in the workspace's audit log (`governance.report`).

The dashboard renders the same data at `/platform/dashboard/governance` - the posture card links each finding to
the page that fixes it, the guard-activity table links into the events page filtered on the event, the key-hygiene
card into the keys page, and "Compliance report" downloads the Markdown evidence - with a policy editor for admins.

## 10. Observability and caching

The platform observes itself: every span, event, counter and alert below is produced, stored and served
by the platform - there is no external metrics system, tracing backend or alerting tool to run. Every
deployment exposes liveness and readiness probes and the counters without a key:

| Endpoint | Meaning |
|---|---|
| `GET /healthz` | Process is up: edition, version, target count, uptime. |
| `GET /readyz` | `200` when the database, catalogue, router and (enterprise) audit file all answer; `503` with the failing check otherwise. Also reports `database_dialect` (`postgres`), `redis` and `events` (the event bus) - those two are informational and never fail readiness. |
| `GET /metrics` | The counters in text exposition format: `osr_platform_http_requests_total{method,route,status}`, `osr_platform_http_request_duration_ms_*`, `osr_platform_http_in_flight`, plus the router's `osr_route_decisions_total`, `osr_route_latency_ms_*`, `osr_outcome_*` and `osr_decision_cache_*`. |

Responses carry `X-Request-Id` (send your own to correlate with your logs) and `Server-Timing`; the
API writes one JSON access-log line per request (`osr.platform.access`) with the route template,
status, latency and account - never the prompt. `GET /api/v1/stats` (enterprise) adds `http`,
`controls`, `cache` and `observability` snapshots; `/platform/dashboard/health` shows them live.

### Time series and alerts

| Endpoint | Returns |
|---|---|
| `GET /api/v1/telemetry/series?window=24h` | The workspace's metered traffic bucketed over `1h` (per minute), `6h` (5 min), `24h` (15 min), `7d` (3 h) or `30d` (per day): requests, failures, `error_rate`, p50 / p95 latency, cost and tokens per bucket, totals per target and per endpoint, and the window total. Computed from the platform's own usage records, so it agrees with the activity log to the row. Plans with `stats` also get `http`: the deployment's requests, 4xx, 5xx and p50 / p95 / p99 latency per bucket from the per-minute counters the process keeps for 24 hours. |
| `GET /api/v1/alerts` | Active conditions, most severe first, each with the dashboard page that shows or fixes it. Workspace rules on every plan: the monthly request quota at 80 % (`warning`), exhausted (`critical`) or in overage (`warning`, with the estimated bill); a daily or monthly budget of the workspace or a tenant at 80 % (`warning`) or exhausted (`critical`); 20 % or more of the last 15 minutes' metered requests failing (10+ requests); prompts or answers blocked by the guards in the last 24 hours (`warning`, `guard_blocked`) and answers the output guard flagged for secrets, PII or injection (`info`, `output_guard_flagged`) - both link into the events page filtered on the guard events. Deployment rules on plans with `stats`: a failing readiness check, 5 % or more 5xx over the last five minutes (20+ requests), p95 above 2 000 ms, an open or half-open circuit breaker per target, a routing-drift alarm in the last hour, a failed autopilot cycle, a routing SLM last fitted more than 30 days ago, and tracing being off. The money rules - a prepaid provider's balance (`provider_balance`), a model sold below cost or under the margin floor (`margin`), an offer priced above the vendor's list price (`above_list_price`), Stripe Tax and the Stripe account (`stripe_tax_unconfigured`, `stripe_account_inactive`) - are the operator's alone: they appear on the console (`GET /api/v1/admin/health`, `/api/v1/admin/finance/alerts`), never on a customer's dashboard - see *Usage pricing, credits and provider finance*. `status_monitor` (`critical`): the status page's sampler has recorded nothing for three intervals, so `/status` says unknown. `backup_stale` (`critical`, operator only): the nightly `osr-db-backup` job records every finished archive in the `backups` table; when the newest is older than 36 hours the console's Health page and the operator notifier say so - a deployment where the job never ran shows nothing. |

The overview shows the last 24 hours (traffic, latency) and the alerts; `/platform/dashboard/health` adds the
deployment series and the same alerts, refreshed with the page.

### Notifications: alert delivery

Alerts are evaluated by the API itself - no external scheduler. Every `OSR_PLATFORM_ALERTS_INTERVAL_S`
seconds (default 60; `0` turns the loop off and leaves *Evaluate now* to operators) one replica - the one
holding the `osr:alerts:leader` lease in Redis, or the only one without Redis - re-evaluates the
workspace rules for every workspace that was active in the last 15 minutes or has a channel or an open
alert, plus the deployment rules. Each condition becomes a **notification episode**: opened when the rule
starts firing, refreshed while it stays on (an escalation from `warning` to `critical` is delivered
again), resolved when it clears. Episodes land in an inbox with unread counts per severity, are published
to the `alerts` Kafka topic in cluster mode, and are delivered to the workspace's **channels**:

| Kind | Target | Delivery |
|---|---|---|
| `email` | An address | Through the platform mailer (Microsoft 365, ACS or SMTP; otherwise the outbox at `/platform/admin/mail`). |
| `webhook` | An `https://` URL | `POST` of `{event, alert, workspace, url}` with `X-OSR-Event` (`alert.firing`, `alert.escalated`, `alert.resolved`, `alert.test`), `X-OSR-Delivery` and, when the channel has a secret, `X-OSR-Signature: t=<unix>,v1=<hex>` = HMAC-SHA256 of `<t>.<body>`; verify like Stripe signatures. |
| `slack` | An incoming-webhook URL | Block Kit message with severity, rule, value against threshold and a link to the page that fixes it. |
| `teams` | An incoming-webhook URL | MessageCard with the same facts. |
| `pagerduty` | An Events API v2 routing key (32 characters) | `trigger` on firing and escalation, `resolve` when the alert clears, one `dedup_key` per episode so PagerDuty keeps a single incident; severity, rule, value and the dashboard link travel as custom details. |

A channel filters by minimum severity (`info`, `warning`, `critical`) and optionally by rule names, can be
disabled, records its last delivery and last error, and has *Send test*. Failed webhook deliveries are
retried on the following evaluation rounds up to three attempts. Up to 20 channels per workspace; creating
or changing them takes the `admin` role. Operators have the same inbox and channels for the **deployment**
scope (readiness, 5xx rate, p95, circuit breakers, drift, autopilot, SLM age, tracing) at
`/platform/admin/notifications`.

**Account notices.** The same inbox also carries what happened to your own account, so a security message
does not depend on a mailbox that may be filtered, forwarded or shared. Each one arrives as an entry with
`state: "notice"` (it never fires or resolves - it is simply read), a `rule` of `security.*`, and a link to
the page that acts on it; the *Account* filter on `/platform/dashboard/notifications` shows only these. Every
one of them is sent by e-mail as well, under the `security` category that cannot be switched off.

| Notice | When |
|---|---|
| `security.new_sign_in` | The account was opened on a browser and system it has not been seen on for 30 days. Carries the device, the method, the address and the time on your own clock. |
| `security.password_changed` / `security.password_reset` | The password was changed from the account page, or a reset link was used. Every other browser is signed out. |
| `security.two_factor_on` / `security.two_factor_off` | The second factor was switched on or off by you. |
| `security.two_factor_reset` | An **operator** reset the second factor (the usual reason is a lost phone) and signed every browser out. |
| `security.sessions_revoked` | An **operator** signed the account out of every browser. |
| `security.sign_in_attempts` | The per-address password limit tripped - wrong passwords in a row. Nobody got in. |
| `security.sign_in_method_added` / `security.sign_in_method_removed` | An identity provider was added to, or removed from, the ways into the account. |
| `security.account_disabled` / `security.account_enabled` | An **operator** closed or re-opened the account. |

A notice belongs to one person: other members of the organization never see it, it follows its owner from one
workspace to the next, and it is not in the operator console's deployment inbox. Notices are not delivered to
channels either - a workspace Slack room is not the place for one person's sign-in.

**Workspace notices.** The same mechanism records what happened to the *workspace*, under `workspace.*` rules
and `scope: "workspace"`, so the people who live in the dashboard see it even though only the owners are
mailed. Every member of the workspace sees these; they stay in the workspace they belong to.

| Notice | When |
|---|---|
| `workspace.key_created` / `workspace.key_revoked` | An API key was minted or revoked, with who did it and the key's prefix. Revocation is a `warning` - anything still signing with it now gets `401`. |
| `workspace.member_joined` | An invitation was accepted, with the role taken. |
| `workspace.member_role` | A member's role changed, from what to what and by whom. |
| `workspace.member_removed` | A member was taken off the workspace and their sessions here ended (`warning`). |
| `workspace.plan_changed` | The plan moved - upgrade, downgrade, cancellation or trial end - with the reason. |

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/notifications?state=firing&unread=true&limit=100` | The workspace inbox (`items`) and unread counts per severity (`unread`). `state` is `firing`, `resolved` or `notice`; the reader's own account notices are included whichever workspace they are in. |
| `GET /api/v1/notifications/unread` | Unread counts only - the dashboard bell polls this. |
| `POST /api/v1/notifications/read` | `{"ids": [...]}` marks those read, `{"ids": null}` marks everything read. |
| `GET|POST /api/v1/notifications/channels` | List channels (with the rule names you can filter on) or create one: `kind`, `name`, `target`, optional `secret`, `min_severity`, `rules`, `enabled`. |
| `PATCH|DELETE /api/v1/notifications/channels/{id}` | Change or remove a channel. |
| `POST /api/v1/notifications/channels/{id}/test` | Deliver a test notification and return `ok`, `status`, `error`. |
| `GET /api/v1/notifications/deliveries` | The last delivery attempts (channel, event, attempt, status, error). |
| `GET|POST /api/v1/admin/notifications` and `.../channels`, `.../deliveries` | The operator (deployment scope) equivalents; `POST /api/v1/admin/notifications/evaluate` runs one round now and returns what it opened, resolved, delivered and failed. |

**Platform events.** Besides alerts, a `webhook`, `slack` or `teams` channel can subscribe to what happens in
the workspace: `events` on the channel is a list of event names or patterns (`"*"`, `"key.*"`), validated
against `event_types` in the channel listing. The event types are `request.completed` / `request.failed`
(one per metered request - high volume), `outcome.reported`, `handoff.pending` / `handoff.resolved` /
`handoff.cancelled`, `key.created` / `key.revoked` / `key.rotated` / `key.scoped` / `key.limited`,
`member.invited` / `member.joined` / `member.left` / `member.removed` / `member.role`,
`team.created` / `team.updated` / `team.deleted` / `team.member_added` / `team.member_removed`, `plan.changed`,
`billing.invoice_paid` / `billing.payment_failed` (as Stripe confirms the subscription invoice),
`account.deleted`, `support.opened` / `support.answered` / `support.replied` / `support.closed`,
`governance.report` and `workspace.reports`. Every event is something the platform recorded - it is queued in
the outbox on the request path (one insert) and delivered by the next evaluation round with the same envelope
and `X-OSR-Event` / `X-OSR-Delivery` / `X-OSR-Signature` headers as alerts; `GET /api/v1/notifications/deliveries`
shows the attempts. The same publications reach the Kafka topics below when a broker is configured, so an
external consumer sees the deployment-wide stream and a workspace only its own events.

### Shared state and replicas

The platform keeps its state in PostgreSQL: `OSR_PLATFORM_DATABASE_URL` is required, and every API replica
and domain service connects to the same database. The data volume only holds what is local to a process
(learner state, the audit chain, the SLM bundle, the autopilot cache). The hash-chained audit trail has one
chain **per process**: the API appends to `audit.jsonl`, each domain service to `audit.<service>.jsonl`
(`audit.routing.jsonl`, `audit.openai.jsonl`, `audit.mcp.jsonl`), because a hash chain has exactly one
writer; `/api/v1/audit`, the trace view and the governance status verify every chain on the volume and merge
the records (`chains` counts them, `problem` names the broken file). On a clustered deployment (Redis set,
several replicas of one role) each replica writes its own file, `audit.<role>.<hostname>.jsonl`, because a
file lock on a network share (Azure Files mounted `nobrl`, NFS) is not honoured across hosts and two replicas
appending to one file interleave and break the chain; the readers merge all of them and the go-live checklist
(*Audit trail*) shows whether every chain verifies. On a single-host deployment two processes of one role
share a chain file safely: every append takes an exclusive file lock and continues from the last line
on disk, so a second writer extends the chain instead of forking it. A chain that no longer verifies (a line
removed, a record edited, a fork written before 1.8.1) is a critical deployment alert (`audit_chain_broken`)
and every workspace's compliance posture shows it; a superadmin resolves it from the console's *Health* page
(`POST /api/v1/admin/audit/rotate` with a reason): the broken file is kept beside the new one as
`<file>.<stamp>.archived`, never edited, and the new chain opens with a `chain.rotated` record naming the
archive, the problem, the reason and the operator - the rotation is itself on the record and in the operator
audit log. Spans and events are persisted by every
process that routes or executes (routing, openai, mcp) and read by the API, so a chat completion served by
the OpenAI service is traceable at `/api/v1/trace/{request_id}` like a `/route` decision. A deployment that runs several
replicas moves the remaining shared parts to services that are themselves containers - no managed cloud
database, cache or broker is required, and the same images run on a laptop, a Kubernetes cluster or Azure
Container Apps:

| State | Service | Setting |
|---|---|---|
| Accounts, users, keys, tenants, policies, usage, feedback, providers, marketplace, notifications | PostgreSQL | \OSR_PLATFORM_DATABASE_URL\ (required) |
| Spans and events (logs) - kept out of the main database so log volume never contends with it | PostgreSQL (own database, created on first start) | \OSR_PLATFORM_TELEMETRY_DATABASE_URL\ |
| Rate-limit windows, learner state (enterprise: every replica learns read-modify-write into one shared state and adopts what the others learned every `OSR_PLATFORM_LEARNER_REFRESH_S` seconds, default 30), `/v1/responses` conversation state | Redis | `OSR_PLATFORM_REDIS_URL` |
| Platform events as JSON topics `osr.<name>`: `usage` (one per metered request), `feedback`, `training` (prompt text for the SLM service, only with `OSR_PLATFORM_TRAINING_EVENTS`), `admin` (account lifecycle and operator actions), `alerts`, `billing` (plan changes, invoices), `handoffs`, `support`, `sales` | Kafka (any Kafka-protocol broker) | `OSR_PLATFORM_KAFKA_BOOTSTRAP`, `OSR_PLATFORM_KAFKA_TOPIC_PREFIX` |
| Photographs operators upload for the public site (`/platform/admin/photos`): the bytes, under a content-addressed name with an immutable cache header, in a container with public read; the database keeps only the address. Without a container the bytes stay in the database and the API serves them. | Azure Blob Storage (REST, no SDK) | `OSR_PLATFORM_SITE_PHOTOS_URL` (the container URL; only an `https://` address on `*.blob.core.windows.net` or a sovereign cloud's blob endpoint is accepted, anything else is ignored - the start-up check and the console's Photographs page both say so, and the start-up check also warns when a container has neither a SAS nor a managed identity to sign with), signed with either `OSR_PLATFORM_SITE_PHOTOS_SAS` (a container SAS with read, write and delete) or, on Azure, the container app's managed identity - `OSR_PLATFORM_AZURE_CLIENT_ID` names the user-assigned identity, which needs *Storage Blob Data Contributor* on the account; the Azure template sets all of this up |

`GET /api/v1/admin/overview` -> `storage` says which mode a deployment runs (`{database, clustered, redis,
events, topics}`; `clustered` is true once Redis shares the rate limits, `topics` is the full list above as
the broker sees it) and the operator console shows it on
`/platform/admin`. The public `GET /api/v1/info` never names where state lives: that is an operator fact.
Event payloads carry the same fields as the activity log and never the prompt text. The
topic list is one constant (`cluster.TOPICS`): the Azure template creates exactly these event hubs for a
managed broker (Event Hubs does not auto-create topics), a self-hosted broker creates them on first use, and
a test fails when code publishes on a topic that is not in the list.

An authenticated broker - Event Hubs, Confluent Cloud, any SASL or TLS endpoint - is reached with
`OSR_PLATFORM_KAFKA_SECURITY_PROTOCOL` (`SASL_SSL`, `SASL_PLAINTEXT`, `SSL`), `OSR_PLATFORM_KAFKA_SASL_MECHANISM`
(`PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`) and the `OSR_PLATFORM_KAFKA_SASL_USERNAME` /
`OSR_PLATFORM_KAFKA_SASL_PASSWORD` pair; for Event Hubs that is the literal user `$ConnectionString` and the
namespace connection string. Such a gateway hangs up on a connection left idle for a few minutes - and so does the
network in front of a self-hosted broker (the internal ingress of Azure Container Apps after four) - while the
driver would hold one for nine, so the clients, whatever the protocol, release theirs first after
`OSR_PLATFORM_KAFKA_CONNECTIONS_MAX_IDLE_MS` (default 180000, three minutes; `0` leaves the question to the
driver). Without it a quiet topic finds the socket already gone on its next read: no event is lost - the producer
retries and the consumer rebuilds - but the log fills with `Connection lost: socket disconnected` and
`ConnectionResetError: [Errno 104] Connection reset by peer` under *Error receiving network data*, once per idle
connection, every few minutes in every service.

Both shared services degrade rather than stop the API. When Redis is unreachable the rate limiter answers
from its per-replica window and stays off Redis for a five-second cool-down before trying again (`/readyz`
`redis` shows `ok`, `latency_ms` or the error); the shared learners keep learning in memory - a refresh that
cannot read Redis leaves the replica's state untouched instead of blanking it, and the next successful save
catches up. A request never waits for Kafka: `emit` only enqueues, one sender thread per process hands the
events to the producer (queue bounded at 10 000 events; beyond that the newest are dropped and counted as
`events.overflow`). Events are acknowledged by every in-sync replica (`acks=all`), retried with back-off and
dropped - counted and reported as `events.dropped` / `queued` / `last_error` - only when the broker stays
unreachable. The SLM service consumes `training` and `feedback` with manual offset
commits after each ingested batch (at-least-once: a record is never committed before it was processed) and
reports the consumer group, commits, errors and per-topic lag under `consumer` in `GET /model`.
The self-hosted Compose stack
starts PostgreSQL with the platform; the Azure template provisions it as a container app with internal TCP
ingress next to the API and web app.

A deployment that predates 1.1 kept its state in a `platform.sqlite3` file on the data volume. Import it
once with `osr-platform-import-sqlite /data/platform.sqlite3 --database-url postgresql://...` (ships
with the platform API; run it inside the API container -
safe to re-run; rows already in PostgreSQL are left alone), then remove the file.

In that stack the routing SLM is its own service too: the API publishes each routed prompt to the
`training` topic (opt-in, `OSR_PLATFORM_TRAINING_EVENTS`) together with the decision it took and the ranked
propensities of the top candidates (`target_id`, `ranked`); the SLM service remembers prompts, decisions and
workspace in `autopilot/requests.jsonl` on the shared volume - a restart or a deploy loses nothing - pairs
prompts with the outcomes reported through `POST /api/v1/feedback` (feedback rows) and distils the ensemble's
ranking on every routed prompt that has no outcome yet (decision rows, newest `OSR_SLM_DECISION_ROWS_MAX`),
trains a challenger on a schedule, keeps it only when it beats the champion on a held-out split, and writes
the promotion to the shared volume from which every API replica reloads it (a timer every
`OSR_PLATFORM_SLM_RELOAD_S` seconds in every process that routes, idle replicas included). Synthetic seed rows are added only
while real rows are scarce (`OSR_SLM_SYNTHETIC_PER_TEMPLATE` rows per prompt template - 8 in the shipped stacks,
about 500 rows and a 100-row holdout, so the gate can tell two architectures apart before real traffic
arrives); `GET /model` reports `prompts_remembered`, `decisions_remembered`, `outcomes` and the
cycle's `sources` (feedback, decisions, datasets, synthetic). The trainer builds every challenger with the
query encoder `OSR_SLM_ENCODER` names (`attention` in the shipped stacks - a transformer block over the token
sequence on top of the hashed features, about five milliseconds per prediction; `hashed` for the bag-of-features
encoder alone): when it differs from the champion's the challenger is a fresh model with the configured
architecture and the gate decides, so the served model changes encoder only where it wins the holdout;
`GET /model` shows `encoder`. `OSR_SLM_EMBEDDER` adds a real embedding model to every challenger as frozen dense
features (`openai:<model>@<base_url>`, any OpenAI-compatible `/embeddings`): the Azure stack points it at the
embedding model the deployment already serves for `embed-small` (its model server's `/v1`, nothing leaves the
environment; `azd env set OSR_SLM_EMBEDDER none` keeps the hashed and attention features only, any other value names
an endpoint of your own), Compose takes it from the same variable (`openai:qwen3-embedding:4b@http://ollama:11434/v1`
with the `llm` profile). The SLM then learns its head on what a sentence transformer knows a prompt means, while the
hashed features keep the surface cues; the promoted file names the embedder, so every API replica rebuilds the same
client on reload (`OSR_SLM_EMBEDDER_TIMEOUT_S` 5 s in the API, 120 s for the trainer's batches) and a request the
endpoint cannot embed in time is scored by the other strategies alone. Both start from the warm-start model the API image ships at `/app/models/slm.json`
(`OSR_PLATFORM_SLM`) - the attention model bundled inside the SDK package, trained on the example catalogue, its
labelled dataset and follow-up conversations and measured on a held-out seed - so a fresh deployment routes with
a learned strategy from the first request and the first promotion has a champion to beat. Operators see the
model in service, the evidence ingested, every cycle and a
prompt probe on `/platform/admin/slm` (`GET /api/v1/admin/slm`, `/slm/reports`, `POST /slm/cycle`, `/slm/predict`).

**The labelled floor.** The holdout a promotion is judged on is self-agreement: the router's own decisions on the
deployment's traffic. On a deployment whose traffic is one kind of job (production's was its own blog and SEO
writers) the served model promoted itself twenty times on holdouts of 0.92-0.97 and routed 23 of the 70
hand-labelled reference rows acceptably - and, heard in the ensemble, pulled it from 66 to 39 of 70. The SDK
package now ships that labelled set next to the bundled model, and every promotion is held to it
(`OSR_SLM_LABELLED_FLOOR`, on): a challenger that routes fewer of the applicable labelled rows acceptably than the
champion, by more than `OSR_SLM_LABELLED_TOLERANCE` (5 %) of them, is refused whatever its holdout says, and the
report carries both scores under `labelled`. At start the service holds the champion it loads from the volume to
the same floor against the packaged model and adopts the packaged model when the champion fell below it (the
replaced model stays as `slm.prev.json`; `GET /model` -> `floor` says what happened). Rows count only when the
deployment's catalogue holds their targets; with fewer than `OSR_SLM_LABELLED_MIN_ROWS` (20) such rows the
floor is off, so a catalogue of your own is judged by its own evidence alone.

**Rehearsal.** A floor alone would leave the loop stuck: a challenger fitted on one workspace's traffic learns that
traffic perfectly (31 holdout wins, 0 losses) and forgets the reference set (44 of 70), so the floor refuses it -
every cycle, for ever, and nothing is ever promoted. The applicable labelled rows therefore also *train* every
challenger, replicated until they are about `OSR_SLM_LABELLED_REHEARSAL` (0.25) of its training rows (0 = off);
the holdout stays the deployment's own rows. The same challenger then keeps 69-70 of the 70 reference rows while
it learns the traffic, and is promoted; the report's `sources` count the rows under `labelled`.

The same cycle trains the model's **slot models**: the outcomes credited to a plan's persona, skill and tool
slots (`role` on the `feedback` topic - the platform's own assessment of every composed answer carries one per
companion, `POST /api/v1/feedback` takes one) become one small encoder per role, saved in the same file and
promoted with the primary, so the ensemble's SLM also votes on which persona or skill a request wants - a
marketplace listing that gathered a record included. A role trains from `OSR_SLM_SLOT_MIN_ROWS` (20) rows on;
each cycle report lists the roles under `slots` (rows, accuracy, skill, or why the role was skipped) and
`GET /api/v1/learning` shows the served slots under `slm.slots`.

Workspaces do not all route alike, so the service also trains **per-workspace models**: once a workspace has
`OSR_PLATFORM_SLM_WORKSPACE_MIN_ROWS` (100; `0` = off) rows of its own - outcomes joined to prompts
through the `training` topic plus its routed prompts' decision rows, attributed by `account_id` - each shared cycle also trains a challenger for it,
starting from the shared champion and seeing only that workspace's rows. It is promoted to
`autopilot/slm.<account_id>.json` under the same gates (a ten-row holdout at least, a significant win over the
model it replaces - the shared champion the first time), and the API replicas pick the file up with the
shared one and serve it for that workspace's requests only; everyone else keeps the shared model. Each of these
models is a multi-megabyte bundle every replica holds in memory and every cycle trains in series, so a
deployment keeps at most `OSR_PLATFORM_SLM_WORKSPACE_MAX_MODELS` (20; `0` = no cap) of them - the workspaces
with the most evidence.
`GET /api/v1/learning` lists them under `workspace_slms`, the service's `/model` under `workspaces`, and
`POST /predict` takes an `account_id` to rank with a workspace's model.

### Services

The API is one image that can run as one process or as several: the documentation JSON (`docs`), the
public model catalogue and rankings (`rankings`), the marketplace (`marketplace`), provider management
(`providers`), signup and account links (`onboarding`), identity and workspaces (`accounts`: sign-in,
sessions, organizations, keys, tenants, policy, governance, usage), Stripe billing (`billing`), the
operator console API (`admin`), the MCP endpoint (`mcp`), the OpenAI-compatible proxy (`openai`), the
routing core with its trace, event and learning reads (`routing`) and the routing SLM (`slm`) each have
their own entry point and port. `/api/v1/info`, `/api/v1/status` and `/api/v1/estimate` always stay
with the API. The API remains the single public origin and forwards a domain's paths to its
service when `OSR_PLATFORM_<NAME>_URL` is set (streamed completions are relayed as they arrive; a service
that stays silent longer than `OSR_PLATFORM_PROXY_TIMEOUT_S`, default 300 s - a cold self-hosted model
loading its weights - answers `503`); the
answer carries `X-OSR-Service` naming the process
that served it. The topology is an operator fact: the public `GET /api/v1/info` does not list it; operators see
the map and live health per service on `/platform/admin/services` (`GET /api/v1/admin/services`).
The Compose stack and the Azure template
deploy every service as its own container next to the gateway.

### Traces and events

The router records every request as a tree of **spans** (`request`, `route`, `plan`, `execute`) with
**events** inside them (`route.signals`, `route.policy`, `route.rank`, `route.fallback`, `guard.*`,
`execute.step`, `learn.outcome`, `cache.hit`, ...). The newest ones stay in an in-memory buffer (1000
by default, `OSR_OBSERVABILITY_MEMORY_EVENTS`) and every one of them is also written to the platform
database by the platform's own telemetry store (`OSR_PLATFORM_TELEMETRY_STORE`, on by default) - off
the request path, from a background writer, so tracing never slows routing. Events and traces therefore
survive restarts, can be read by time range and are purged after `OSR_PLATFORM_TELEMETRY_RETENTION_DAYS`
(14). They are readable per workspace:

| Endpoint | Returns |
|---|---|
| `GET /api/v1/trace/{request_id}` | The activity row plus every span and event of one request, in time order, with `trace_id`, `spans` and the root `duration_ms`; `outcomes` (the feedback reported for the request), `audit` (its hash-chained records, plans with `audit`) and `payload` (the stored prompt and answer when the workspace's `log_payloads` policy kept them, else `null`). `source` says whether the rows came from the buffer or the store. `404` when the request is not the workspace's; `events: []` once the request is older than the telemetry retention. |
| `DELETE /api/v1/activity/logs` | Erases every prompt and answer stored under the `log_payloads` policy (admin role); activity rows, traces and outcomes stay. Returns `deleted` and the `logging` mode still in force. |
| `GET /api/v1/events?name=route.*&kind=&level=&request_id=&since=&until=&limit=200` | Events of the workspace's requests (oldest first, `limit` up to 2000; `name` may end with `*`; `since` / `until` are epoch seconds, default the last 6 hours). Deployment-wide events without a request id (breaker transitions, autopilot) are included on plans with `stats`. `source`, `persisted`, `retention_start` and `retention_days` describe the store. `404` when tracing is off entirely. |
| `GET /api/v1/events/stream?name=&kind=&level=&request_id=&max_s=3600` | The same events pushed as they are recorded, as server-sent events (`text/event-stream`): one `event: <name>` / `data: {…}` frame per event, a `: ping` comment every 15 seconds while quiet, and the stream ends after `max_s` seconds (default and ceiling one hour) - reconnect to continue. Every replica reads the shared store, so the stream sees every replica's events. Same filters and `404` as the list endpoint. |
| `GET /api/v1/status` | Public readiness: the `/readyz` checks plus edition, versions, uptime, controls and tracing state; `503` while a check fails. `Cache-Control: no-store`. The public status page of the site (`/status`) polls it every 15 seconds underneath the board below, so a browser that cannot reach the API says so at once. |
| `GET /api/v1/status/board` | The public status page (`/status`): `overall` (`operational`, `degraded`, `partial_outage`, `major_outage`, or `unknown` when the platform's own checks have not run for three intervals - stale checks are never shown as operational), `monitor` (`enabled`, `last_sample_at`, `stale`, the last pass's `error`), every **component** the platform samples itself - the API's readiness, the database, the cache, the event bus, mail delivery, each remote domain service, the web app and every enabled model provider - with its last check (`status`, `latency_ms`, `checked_at`, `detail`), `uptime_30d_pct` / `uptime_90d_pct` and the per-day `history` (checks, failures, latency), the `incidents` (`open`, `recent` = the last 30 days) with their updates, and `served` / `served_90d`: metered requests per UTC day and the share the platform failed with a 5xx. One replica samples every `OSR_PLATFORM_STATUS_SAMPLE_S` seconds (default 60; 0 = off) and keeps `OSR_PLATFORM_STATUS_HISTORY_DAYS` (default 90) of roll-up; raw samples live a week. The web app is checked at `OSR_PLATFORM_WEB_URL` - or at `OSR_PLATFORM_WEB_PROBE_URL` when the public address does not resolve from the API's own network (compose sets `http://web:3000`). A core component (`api`, `database`) or a domain service that fails three checks in a row **opens an incident on its own** (`automatic: true`, severity `critical` for a core component) and resolves it after three healthy checks; a provider down degrades the banner but never opens one - the router fails over. Cached 10 s (`ETag`). |
| `GET /api/v1/status/incidents?days=90&limit=50`, `GET /api/v1/status/incidents/{id}` | Incidents opened or active in the window, newest first, each with `severity` (`minor` / `major` / `critical`), `status` (`investigating` / `identified` / `monitoring` / `resolved`), `components`, `started_at`, `resolved_at` and every update. |
| `GET /api/v1/status/feed` | RSS 2.0 of incident updates (one item per update) - what "subscribe to status updates" points at. |
| `GET /api/v1/services` | What this deployment offers as a service, computed from its state at the time of the call (no key): every first-party capability - routing, the OpenAI-compatible proxy, quotes, plans, learning, the routing SLM, your own providers, human hand-off, the MCP server, traces, savings, governance, tenants and audit, alerts, usage digests, support, the marketplace, the model catalogue, the blog, the newsletter, single sign-on, billing, the CLI, self-hosting, the browser extension and desktop app - with `status` (`live` works now, `limited` runs with a stated restriction such as no model provider or no mail transport, `off` not on this deployment or edition), `detail` (the reason), `endpoint` (`METHOD /path`, always a real route), `access` (`public` / `key` / `operator`), `plan` (the cheapest plan that includes it), `docs`, `page` and `facts` (small live figures from the same stores the dashboards read). The site renders it at `/services`; the welcome mail and every newsletter issue link to it. Cached for a minute (`ETag`). Nothing about the platform's own editorial use of the router is included. |
| `GET /api/v1/facts` | The public numbers of this deployment, each section carrying its **class** so no page can put a reference-catalogue count, an imported index and a live counter in one sentence by accident (no key): `version` (platform, SDK and the build label), `catalogue` (class `catalogue`: the reference model index - `models`, `vendors` - indexed, not necessarily executable here), `storefront` (class `live`: `models` sold by the token, `endpoints`, `executable_models`), `deployment` (class `live`: `targets`, `executable_targets`, `kinds`, `providers`, `vendors`, whether the routing `slm` is loaded and the `autopilot` runs), `activity` (class `live`: lifetime `requests`, `requests_24h`, `workspaces`, `workspaces_routing`, `executed_requests`, the measured `saved_usd` / `saved_pct` against `baseline`) and `marketplace` (class `imported`: `indexed` listings and `publishers_indexed` in total, `installs`, `reviews`, and `native` / `imported` / `sources` once the manifest scan has run - `null` before). `classes` spells each class out in the words the site prints next to the number. The site renders it at `/whats-live` together with the services catalogue and the provider table. Cached for a minute (`ETag`). |

`POST /api/v1/route` responses include `trace_id` and the `X-OSR-Trace-Id` header when the request was
traced, and `GET /api/v1/activity` marks rows whose spans are kept (in memory or in the store) with
`traced: true` and summarises the feedback reported for each request in `outcome` (`POST /api/v1/feedback`
is stored per request, so the loop from decision to result is visible per row). A caller's **W3C `traceparent`**
header is honoured: the request's spans join the caller's trace (`X-OSR-Trace-Id` returns that trace id), and a
well-formed client `X-Request-Id` (8-64 characters of `A-Za-z0-9._:-`, not yet used by the workspace) becomes
the decision's `request_id` - what `/feedback` and `/trace/{request_id}` take. The TypeScript SDK, the browser
extension and the desktop app send both on every call by default (`trace: false` on the SDK client turns it
off), so every request of every client is one traceable row; the extension's side panel and the desktop's answer
line link *answered by* to the request's trace on the dashboard. Attributes hold ids,
numbers and short labels; the request text is represented only by its digest and length. The dashboard
shows the history at `/platform/dashboard/events` (window of 1 hour to 7 days, search by request or trace id),
opens a trace from any activity row (with a *How it was routed* story: what the request looked like, who
was allowed to answer, how the candidates ranked with their expected quality, price and latency, *why* the
winner beat the runner-up strategy by strategy with each vote's rationale, the plan slots filled or
skipped, the decision and the execution steps with tokens and cost) and adds a *Trace* tab to the
playground. `GET /api/v1/trace/{request_id}` also returns `usage`: tokens (prompt / completion), the metered
cost, the target's list price and what the same tokens would have cost on the savings baseline (the dearest
model the workspace itself ran in the last 30 days, or the catalogue's dearest when it ran none - the same rule
as `GET /api/v1/savings`; `usage.baseline.source` says which), so every request shows what it saved.

The deployment-wide figures the site shows (`GET /api/v1/stats/public`: `requests`, `tokens`, `cost_usd`,
`success_rate`, `top_targets` and the `savings` block, plus `internal` for the deployment's own jobs) are read
from a lifetime ledger (`platform_counters`, one row per target and scope) that every metered request adds to
and nothing subtracts from: the retention sweep, a workspace's own `retention_days` and a deleted workspace
remove request rows, never what has already been counted. `since` is the oldest counted request. The ledger
seeds itself from the request rows a deployment still has the first time it is read, so an upgrade starts from
today's totals rather than from zero. Only the windows (`requests_24h`, `tokens_24h`, the latency figures) and
`accounts_routing` follow the rows that exist.

Public catalogue reads (`/api/v1/info`, `/api/v1/models*`, `/api/v1/rankings`, `/api/v1/llms*`,
`/api/v1/catalogue`, `/api/v1/stats/public`) return an `ETag` and `Cache-Control: public, max-age=60,
stale-while-revalidate=60`; send `If-None-Match` to get `304` when nothing changed. Operators tune this
and the decision cache with:

| Setting | Default | Effect |
|---|---|---|
| `OSR_PLATFORM_METRICS_PUBLIC` | `true` | `false` restricts `/metrics` to requests carrying `X-Admin-Token`. |
| `OSR_PLATFORM_TRUSTED_PROXIES` | `private` | Which `X-Forwarded-For` hops are the deployment's own proxies (ingress, web proxy, gateway) when working out a caller's address for per-IP limits and audit records: `private` skips every RFC 1918, loopback and link-local hop, `none` uses the TCP peer only, or list CIDRs. Entries a client wrote itself are never trusted. |
| `OSR_PLATFORM_GEOIP_HEADER`, `OSR_PLATFORM_GEOIP_SERVICE`, `OSR_PLATFORM_GEOIP_TOKEN` | unset | Where sign-ins come from: a request header the ingress sets with the country code, and / or an IP geolocation service (a preset or a URL template with `{ip}`) with its key. See [Sign-in locations and alerts](#sign-in-locations-and-alerts). |
| `OSR_PLATFORM_NETWORK_DATA_RETENTION_DAYS` | `365` | Days the address and the place of sessions and account events are kept before the retention sweep removes them (the events stay); `0` keeps them as long as the row. |
| `OSR_PLATFORM_EVAL_GATES_INTERVAL_S` | `21600` | How often every workspace's quality gates are routed again and judged (six hours); `0` = only on request and after a policy change. See [Evaluation](#evaluation). |
| `OSR_PLATFORM_HTTP_CACHE_MAX_AGE_S` | `60` | `Cache-Control` lifetime of public reads; `0` sends `no-store`. |
| `OSR_PLATFORM_STATS_ROLLUP_S` | `120` | How often the public aggregates that scan the request table (lifetime latency, workspaces routing, per-target traffic of the week) are recomputed; every replica and service reads the one stored copy, a stale copy is served while one process refreshes it. `0` computes on every read. |
| `OSR_PLATFORM_ROUTE_CACHE_TTL_S` | `0` | Reuse a routing decision for identical requests within this window (`0` = off). |
| `OSR_PLATFORM_CHAT_TOOL_MIN_CONFIDENCE` | `0.5` | A built-in tool (calculator, web search) answers `/v1/chat/completions` only when the router is at least this confident it fits the request; below it the chat is routed again without tools, and a tool is never the fallback for a failed model. When every runnable model's breaker is open the chat answers `503` with `Retry-After`. `0` lets tools compete like any target. |
| `OSR_PLATFORM_FAIR_SHARE` | `false` | Enterprise: dominant-resource fair share across workspaces and tenants (`_WINDOW_S` 300, `_SOFT` 1.5, `_HARD` 3 tune it; section 8). |
| `OSR_PLATFORM_OUTPUT_GUARD` | `flag` | Response-side guard on every model / agent answer (`opensmartroute.security.OutputGuard`): `flag` records leaked secrets (API keys, private keys, JWTs), personal data and instruction injection in the trace and under the `output_guard` entry of the answer's metadata block; `redact` also replaces secrets and PII with placeholders; `block` refuses the answer (`400`); `off` disables it. Under `redact` / `block` a streamed answer is buffered and screened before its first token leaves. |
| `OSR_PLATFORM_RETENTION_DAYS` | `365` | Usage rows older than this are purged every six hours; `0` keeps everything. |
| `OSR_PLATFORM_REQUIRE_EMAIL_VERIFICATION` | `true` | Hold every account at the confirmation step until the signed-in user - or, for an API key, an owner of the workspace - has confirmed the e-mail address (`403`, `X-Email-Verification: required`, everywhere but `/me`, `/onboarding`, `/auth/*`, `/legal`). `false` only on a stack that is thrown away afterwards. |
| `OSR_PLATFORM_SIGNUP_TEST_DOMAINS` | `false` | Accept signups from domains reserved for testing (`example.com`, `*.test`, `*.local`, ...). The smoke, load and e2e suites need it on their throw-away stacks; production keeps it off so test users never land in the customer database - the operator's own doors (`POST /api/v1/admin/users`, `POST /api/v1/admin/accounts`, members added by e-mail) refuse those domains too, and the retention sweep removes anything on such a domain that still got in (audited as `test_data.purged`, actor `system`). Operators list what a run left behind with `GET /api/v1/admin/test-data` and remove it at once with `DELETE /api/v1/admin/test-data` (superadmin, audited). |
| `OSR_PLATFORM_REFERRAL_ENABLED` | `true` | Run the referral programme: a workspace's link earns both sides credits (section 6, *Referrals*). |
| `OSR_PLATFORM_REFERRAL_REWARD_CENTS` | `500` | Credits (cents of the wallet) the referrer earns per qualified referral. |
| `OSR_PLATFORM_REFERRAL_WELCOME_CENTS` | `500` | Credits the invited workspace starts with once it qualifies. |
| `OSR_PLATFORM_REFERRAL_MAX_REWARDS` | `20` | Rewards one referrer may earn; `0` = no cap. |
| `OSR_PLATFORM_CAPTCHA_PROVIDER` | `off` | A bot challenge on the sign-up form: `turnstile` (Cloudflare) or `hcaptcha`. The form renders the provider's widget and the API verifies its token with the provider before anything else runs; a missing or failed token is refused (`400`, `X-Signup-Refused: captcha`). Needs the two keys below. |
| `OSR_PLATFORM_CAPTCHA_SITE_KEY` | - | The provider's public site key (published on `GET /api/v1/info` as `captcha`). |
| `OSR_PLATFORM_CAPTCHA_SECRET` | - | The provider's secret the API verifies tokens with; never leaves the API. |
| `OSR_PLATFORM_UNVERIFIED_ACCOUNT_TTL_DAYS` | `14` | A user who never confirmed the address is removed after this many days, with the personal workspace only they belonged to (the retention sweep); `0` keeps them. The platform's own `*.local` service workspaces are exempt. |
| `OSR_PLATFORM_REQUEST_LOG_MAX_CHARS` | `20000` | Longest prompt / answer kept per request under a `log_payloads` policy (longer texts are cut and marked `truncated`). |
| `OSR_PLATFORM_TELEMETRY_STORE` | `true` | Persist every span and event to the platform database (the events page, traces and `since` / `until` queries outlive the in-memory buffer). |
| `OSR_PLATFORM_TELEMETRY_DATABASE_URL` | unset | A separate PostgreSQL database for the telemetry store (e.g. `postgresql://osr:pw@host:5432/osr_telemetry`), so span/event volume never contends with the main database; created on first start when missing. Unset keeps them in the main database. |
| `OSR_PLATFORM_TELEMETRY_RETENTION_DAYS` | `14` | Persisted spans and events older than this are purged with the same sweep; `0` keeps them. |
| `OSR_PLATFORM_LOG_EXPORT_TICK_S` | `30` | How often the accounts owner looks for request-log export destinations whose interval has passed and ships their next batch (one replica per tick); `0` = only on *Export now*. |

### Request-log export to your own storage

The usage rows and the kept payloads stay queryable through the dashboard and the API; a team that runs its own
data lake or SIEM wants the same rows where its other logs are, continuously, without polling
`GET /api/v1/activity`. A **log export** does that: `POST /api/v1/workspace/log-export` names a destination and
the platform writes every new row there in batches of up to 2 000, on the destination's own `interval_s`
(60 s and up), with a cursor so nothing is sent twice and nothing is skipped when a batch fails (the error is
recorded, the cursor stays, the next pass retries). Destinations: `azure_blob` - a container URL
(`https://<account>.blob.core.windows.net/<container>`) with a container SAS as `credential` (or the API's
managed identity when it is empty), one JSONL blob per batch under `<prefix>/YYYY/MM/DD/<time>-<first id>-<last
id>.jsonl` - and `webhook` - a `POST` of the JSONL batch (`application/x-ndjson`, `Authorization: Bearer
<credential>`, `X-OSR-Export-Batch`) to an HTTPS URL: Splunk HEC behind a shim, Datadog Logs intake, a Kafka
REST proxy, Logstash, a function. Every line is one request with its metering fields (`account_id`, key,
member, tenant, project, endpoint, target, provider and upstream model, tokens, cost and charge, latency,
status); `include_payloads: true` adds `prompt` and `response` from the kept payloads (redacted when the policy
says so, `payload_redacted` marks it); `start_from` exports from a past timestamp (default: from now on). The
credential is stored encrypted like a provider key and never returned (`credential_set` only). `GET` lists the
destinations with cursor, batch and row counts and the last error; `PATCH` renames, re-points, rotates or clears
the credential (`clear_credential`), changes the interval, pauses (`enabled: false`); `POST .../{id}/run` ships
the next batch now and reports what went; `DELETE` removes it. Feature `byok` (Pro and up), up to five
destinations; writes take the `manage` scope and are audited (`log_export.created`, `log_export.deleted`).

**Traces for your observability tool.** Two more kinds ship the same rows shaped as traces, with the outcomes
reported so far and the answer judge's grade as scores: `langfuse` - `url` is the Langfuse host
(`https://cloud.langfuse.com`, a regional or self-hosted one), `credential` is `<public key>:<secret key>`; one
`POST /api/public/ingestion` per batch with a `trace-create` per request (input / output when payloads are
included, user, tags, metadata), a `generation-create` (model, token counts, cost, latency, level) and a
`score-create` per outcome - and `otlp` - `url` is an OTLP/HTTP traces endpoint (the standard `v1/traces` path:
Arize Phoenix at app.phoenix.arize.com, Arize AX at otlp.arize.com, a collector), `credential` a
bearer token or `header=value;header=value` pairs (Arize: `space_id=...;api_key=...`); spans carry OpenInference
attributes (`openinference.span.kind` `CHAIN` for the routing decision and `LLM` for the generation,
`llm.model_name`, `llm.token_count.prompt|completion|total`, `input.value` / `output.value`, `session.id`,
`user.id`, `osr.*` fields and `osr.score.<name>`), which Arize Phoenix, Arize AX and TruLens read natively and
every OpenTelemetry backend stores as attributes. Trace and span ids derive from the request id, so a client that
forwards `X-Request-Id` into its own tracing can join the two. [docs/AGENT_ENGINEERING.md](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/AGENT_ENGINEERING.md)
places this next to the other evaluation tools.

### Monitoring: what is always on, and Application Insights on Azure

Every deployment monitors itself without another tool: the telemetry store above, `GET /metrics` (Prometheus text
exposition: HTTP counters and latency per route, routing decisions, outcomes, cache), `GET /readyz` (database,
catalogue, router, audit path, plus Redis / event bus / mail health), `GET /api/v1/status`, the deployment alerts and
the operator console's *Health* page (`GET /api/v1/admin/health`). On **Azure** the platform additionally exports
to **Application Insights** - the Azure template and the Marketplace offer create a workspace-based resource next
to the Log Analytics workspace and hand every platform container its connection string:

| Setting | Default | Effect |
|---|---|---|
| `APPLICATIONINSIGHTS_CONNECTION_STRING` | unset | The connection string of the Application Insights resource (the variable every Azure SDK reads; set by the Azure template). `OSR_PLATFORM_APPINSIGHTS_CONNECTION_STRING` is the same thing under the platform's own name. |
| `OSR_PLATFORM_APPINSIGHTS` | `auto` | `auto` exports when a connection string is present **and** the process runs on Azure (Container Apps and App Service announce themselves through their environment); `on` exports wherever the string is set - a self-hosted stack reporting into an Azure subscription; `off` never. |
| `OSR_PLATFORM_CLOUD` | detected | `azure` names the cloud explicitly for hosts that do not announce it (an AKS node, a VM); the Azure template sets it. |
| `OSR_PLATFORM_APPINSIGHTS_SAMPLE_RATE` | `1` | Share of operations exported - a request together with its spans and events is kept or dropped as a whole, so traces stay complete. Lower it before the ingestion bill does. |
| `OSR_PLATFORM_APPINSIGHTS_EVENTS` | `true` | Also export the tracer's events (`route.decision`, `guard.*`, ...) as `customEvents`; spans always go as `dependencies`. |
| `OSR_PLATFORM_APPINSIGHTS_LOGS` | `warning` | Lowest log level forwarded as `traces` (logged exceptions become `exceptions`); `off` forwards none. |
| `OSR_PLATFORM_APPINSIGHTS_SQL` | `true` | Every SQL statement a process runs for a request as a `dependencies` row of type `SQL` under that request - the statement text with its placeholders, never the bound values - so a slow table shows under the requests that waited for it. `false` leaves the database out. |
| `OSR_PLATFORM_APPINSIGHTS_SQL_MIN_MS` | `0` | Report only successful statements at least this slow (failed ones are always reported); `0` reports every statement. |
| `OSR_WEB_APPINSIGHTS` | `auto` | The browser's share, served by the web containers: `auto` / `on` export when the container has a connection string (the Azure template gives the two web apps the same one), `off` never. The page asks `/telemetry/config` once and posts straight to the resource's ingestion endpoint. |
| `OSR_WEB_APPINSIGHTS_SAMPLE_RATE` | `1` | Share of browser sessions kept (0..1), decided once per session so a session is whole or absent. |

What arrives, all under the request's operation id so the end-to-end view lines up: `requests` (every HTTP request a
process answered - route template, status, latency, workspace id as the authenticated user, the caller's user agent
and the page it came from as origin and path, never a query string; a refused request carries the reason the
platform gave, the message of a `400` or the fields a `422` named), `dependencies` (every
router span: `route`, `plan`, `execute`, provider calls, with duration and outcome - and every SQL statement the
request ran, type `SQL`, named by verb and table, a failed one carrying the error's type and message), `customEvents` (the routing events with their
attributes - ids, numbers and short labels, never prompt text), `traces` and `exceptions`. Each process reports as
its container app (`cloud_RoleName` = `osr-api`, `osr-routing`, ...). What a replica does to check itself - the
readiness probe routes a sentence through the engine on every check - is not exported. Delivery is batched off the request path with
bounded retries; the console's *Health* page shows the export (`monitoring`: endpoint, sent, dropped, SQL statements
tracked, last error) and nothing about it can fail a request. Nothing is exported from a host that is not Azure
unless you say `on`.

**The browser is in the same picture.** The web app reports to the same resource (`cloud_RoleName` = `osr-web` /
`osr-platform`): one `pageViews` row per navigation named by its route shape (ids and tokens reduced to `*`), the
Core Web Vitals (`web.LCP`, `web.INP`, `web.CLS`, `web.FCP`, `web.TTFB`) as `customMetrics`, uncaught errors and
rejections as `exceptions` - and a page that failed to render as one too, filed by the error boundary that caught
it (`boundary` = `root`, `marketing`, `docs`, `auth`, `dashboard`, `console` or `global`) with the reference the
person was shown on the error page (`digest`, the same one the server log carries, else the page's operation id),
so "quote the reference to support" finds the exception - every API call as a `dependencies` row of type `Ajax`,
a dead address as a `page.not_found` event with the path and the page that linked to it, and the moments of the
user flow as `customEvents` - `sign_up`, `login`, `begin_checkout`, `playground.answered`, `outcome.reported`,
`evaluation.run`, `evaluation.replayed`, the marked calls to action - with the workspace as the user and one
session id per tab. Correlation is by request id: every call carries a browser-minted `X-Request-Id` and a W3C
`traceparent`; the API answers under that id (or its routing decision's id, which the response header carries
back), files its request under it with the browser's span as parent, and its router spans and SQL statements
under the same operation - so the end-to-end view reads page view -> call -> request -> route / execute / SQL,
and a slow page can be followed to the statement that made it slow. The web **server** is in it too: every call a
server-rendered page makes to the API (`cloud_RoleName` the web app, `client_Type` *Server*, the replica as the
instance) is a `dependencies` row of type `Http` under its own request id - memoised per path, so one row per
refresh rather than per visitor - and a call that got no answer or a 5xx is also one line in the container log
(`[upstream] GET /api/v1/services -> TimeoutError ... request_id=...`, at most one per path per half minute), so a
public page that rendered without its data is visible from the web tier whether or not a browser was involved.
Nothing a person typed travels: URLs are
reduced to the allow-listed query keys, the only ids are request, session and workspace. The marketing pages'
Google tag is separate (section 14, consent-gated); the Application Insights copy is the deployment's own
operations telemetry and runs on the product surfaces too.

### Configuration preflight

At start-up every process checks its own configuration for the mistakes an install makes before the first request
- placeholder passwords from `.env.example`, a missing session secret on a multi-replica stack, a public URL still
on `localhost` or plain `http`, e-mail confirmation required with no mail transport, a providers or targets file that
is not in the container, an SLM service without an event bus, Application Insights forced on without a connection
string - and logs each finding with the variable that fixes it. The same list is on the operator console's
*Health* page (`GET /api/v1/admin/health` -> `configuration`, each with `severity`, `code`, `message`, `fix`) and
`python -m osr_platform.app --check` prints it and exits `1` on an error, so a Compose or pipeline step can gate
on it: `docker compose -f platform/docker-compose.yml --env-file platform/.env run --rm api python -m osr_platform.app --check`.
The preflight never touches the network; reachability of PostgreSQL, Redis and the event bus is what `GET /readyz`
reports once the process runs.

**Go-live checklist.** The preflight reads the environment; the *Go live* page of the console
(`GET /api/v1/admin/readiness`) reads the deployment as a buyer meets it. Every promise the public site makes is
an item - the Stripe account can take payments (activation finished, read live), paid plans can be bought (a
recurring price for every sold plan), overage is invoiced (the metered price
on each interval), tax is computed at checkout (Stripe's tax settings, read live), e-mail reaches people (the mail
transport and its last error), inquiries and tickets reach an operator (the recipients), models answer (executable
LLM targets minus providers that cannot be paid), the terms and privacy notice have a published version naming the
text the pages ship (the *Terms and privacy* page shows the shipped version per document and its publish form
arrives prefilled from the checklist), the public URLs are https, operators use two-factor, the status page is
sampled, every audit chain verifies, the storefront's customer prices are not above the vendors' list prices (a
mark-up rule without contract rows shows a buyer a surcharge, not a saving) - each with `status` (`ok`, `action`, `blocked`, `info`), the `promise` in the customer's words, `where` the
site says it, the deployment's `state`, the `fix` and the console page that shows it. `ready` is false while any
item is blocked; the verdict (*Not ready to sell*, *Ready to sell, with caveats*, *Ready to sell*) also heads the
console overview. Read it before selling and after every configuration change; nothing is remembered between calls.

### Learning: the routing SLM and the autopilot

Every deployment learns from `POST /api/v1/feedback`: the bandit, IRT, Bradley-Terry, LinUCB and Markov
strategies update on each outcome (persisted under the data volume in the enterprise edition). A
deployment can also serve a **routing SLM** - the small routing model `osr slm train` / `osr improve`
write to a file - as one more strategy in the ensemble, and run the **autopilot** in-process so the
platform's own feedback keeps retraining it:

| Setting | Default | Effect |
|---|---|---|
| `OSR_PLATFORM_SLM` | none | Path of the model file, or `bundled` for the routing SLM shipped inside the SDK package; it joins the ensemble as the `slm` strategy (weight `OSR_WEIGHTS_SLM`, default 1.0). |
| `OSR_PLATFORM_AUTOPILOT` | `false` | Run `SelfImprover` cycles on a thread: outcomes and remembered prompts become rows, a challenger is trained on a split and promoted only when it beats the champion on the holdout. The promoted model is written to `DATA_DIR/autopilot/slm.json` and outranks the mounted file after a restart. |
| `OSR_PLATFORM_AUTOPILOT_OFFLINE` | `true` | No catalogue refresh, dataset download or web search from the API process; `false` also refreshes `OSR_PLATFORM_AUTOPILOT_SOURCES` (public dataset presets, comma separated) and reads the article feeds in `OSR_SLM_FEEDS` (vendor blogs, release notes; the same variable `osr serve --autopilot` honours), so a model announced in a post is counted (`mentions`) and, once the catalogue prices it, becomes a candidate (`trending`). |
| `OSR_PLATFORM_AUTOPILOT_INTERVAL_S` | `OSR_SLM_AUTOPILOT_INTERVAL_S` (3600) | Schedule; drift alarms from the outcome stream trigger a cycle early (`OSR_SLM_AUTOPILOT_DRIFT_*`, rate-limited by `OSR_SLM_AUTOPILOT_MIN_GAP_S`). |
| `OSR_PLATFORM_AUTOPILOT_MIN_ROWS` | `20` | A cycle needs at least this many rows before it trains a challenger. |
| `OSR_PLATFORM_SLM_WORKSPACE_MIN_ROWS` | `100` | In the SLM service: a workspace with this many feedback rows of its own gets its own challenger and, when it wins, its own promoted model (`slm.<account_id>.json`); `0` keeps one shared model. |
| `OSR_PLATFORM_SLM_WORKSPACE_MAX_MODELS` | `20` | Most per-workspace models a deployment trains and serves (the workspaces with the most evidence); `0` = no cap. |
| `OSR_PLATFORM_LEARNING_FEEDBACK_DAYS` | `30` | Window the per-target outcome statistics of `GET /api/v1/learning` are computed over. |

`GET /api/v1/learning` (any plan) returns the strategy ensemble with its weights, the learner state
(persistence, whether it is `shared` through Redis, drift resets, quarantined state, and `store_degraded` /
`store_errors` when the shared store could not be read or written - the learners keep learning in memory
meanwhile), per-target outcome statistics over every recorded outcome of the last
`OSR_PLATFORM_LEARNING_FEEDBACK_DAYS` days (`n`, `success_rate`, `mean_quality`, `mean_cost_usd`,
`mean_latency_ms` and the pairwise `wins` / `losses` - the whole deployment, not the replica that answered),
the learned
`leaderboards` (enterprise edition: `irt` abilities from graded outcomes and `preference` Bradley-Terry
strengths from `preferred_over` comparisons, best first, each with how many observations back it), the
SLM in service (rows, sources, encoder, calibration, fit history - never the weights) and the autopilot
status with its recent champion-vs-challenger reports. `controls.slm` / `controls.autopilot` appear in
`GET /api/v1/governance` and `GET /api/v1/info`. Operators trigger a cycle with
`POST /api/v1/admin/autopilot/cycle` (`X-Admin-Token`; 404 without an autopilot). Cycles are traced
as an `autopilot.cycle` span with `learn.improve` / `learn.promote` events, so they appear in
`GET /api/v1/events` and on `/platform/dashboard/events`. The dashboard renders all of it at
`/platform/dashboard/learning`.

## 11. Marketplace

The marketplace (`/marketplace`) is where agents, skills, personas, prompts, tools, model profiles and
stack templates are shared. Every listing is a manifest the SDK, CLI and this API understand; installing
records it on your account and returns the manifest and ready-made snippets, and `registry://<slug>`
references in a stack file resolve to `GET /api/v1/registry/{slug}/manifest`. Browsing is public;
installing, rating and publishing need an account. See the [marketplace guide](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MARKETPLACE.md) for
finding, installing, publishing and the review lifecycle; the endpoints are in the
REST API reference (rendered at `/docs/api`) under *Marketplace*.

### Installed listings and discovery in routing

What a workspace installs joins its routing pool at once - on every API replica, because each one checks the
workspace's install stamp before trusting its cached target list - and competes for the plan's persona, skill
and tool slots or, as a primary, for the answer (`GET /api/v1/targets` lists installs with `source:
marketplace`). Beyond installs, every plan **discovers** published skills, personas and prompts that match the
request: the request's domains and salient words are searched in the listings' name, summary, tags and
domains, a listing must work in one of the request's domains, and the best-rated matches (bodies up to 8,000
characters, at most `OSR_PLATFORM_MARKETPLACE_DISCOVERY_LIMIT` of them) are offered to the router as plan-slot
companions that never answer on their own. A prompt is offered as an instructions-only skill (`prompt:<slug>`)
when it takes its input through at most one `{{variable}}` - the placeholder stands for the user's message; a
prompt with several variables is a template for a person to fill and stays out. Which listings qualify is a
trust decision:

| Mode | Discovered |
|---|---|
| `verified` (default) | Listings an operator marked verified - the seeded skills, personas and prompts of the deployment are; *Verified* on `/platform/admin/marketplace` or `POST /api/v1/admin/registry/{slug}/flags` adds one. |
| `all` | Every published listing, the harvested catalogue included. |
| `off` | Nothing; the workspace's installs only. |

`OSR_PLATFORM_MARKETPLACE_DISCOVERY` sets the deployment default, the `marketplace_discovery` policy key
overrides it per workspace (section 9). Agents and tools are never discovered - they execute, so they need an
install. Each searched term is cached for ten minutes per replica; the router's strategies and quality floor
decide whether a discovered companion is used, and the decision reports it under `marketplace` with
`installed: false`.

| Setting | Default | Meaning |
|---|---|---|
| `OSR_PLATFORM_MARKETPLACE_DISCOVERY` | `verified` | Deployment default for what a plan may discover: `off`, `verified`, `all`. |
| `OSR_PLATFORM_MARKETPLACE_DISCOVERY_LIMIT` | `12` | Companions offered to the router per request (0 disables discovery). |

### Rankings by task type and kind

Two public boards rank what this deployment observes, aggregated across every workspace and
anonymised - no workspace id, request id or prompt text ever appears in them:

- `GET /api/v1/rankings/targets?kind=<llm|agent|skill|tool|persona|workflow|human>&domain=<domain>&window_days=30&limit=50`
  ranks routing targets by the outcomes workspaces reported for them (`POST /api/v1/feedback`), optionally
  within one task domain. The rank key is the Wilson lower bound (95 %) of the reported success rate, so a
  target with three perfect outcomes never outranks one with two hundred good ones; each row also carries
  the routed requests and their `share` of the domain, the HTTP success rate, mean reported quality, the
  p50 latency and the mean cost per request. Targets with fewer than `min_samples` reported outcomes
  (`OSR_PLATFORM_RANKINGS_MIN_SAMPLES`, default 20) are listed as `provisional` after the ranked rows.
  `GET /api/v1/rankings/targets/domains` lists the task domains routed in the window with their request
  counts; domains thinner than the threshold are left out.
- `GET /api/v1/rankings/listings?kind=<kind>&sort=installs|rating|newest&limit=50` is the marketplace
  board: published listings by installs, by a Bayesian average rating (the mean shrunk towards 3.5 by
  the weight of five ratings, so one five-star review does not top the chart) or by publication date;
  publishers appear by display name only. `GET /api/v1/rankings/listings/kinds` counts published
  listings, installs and reviews per kind.

Both answer with `ETag` and `Cache-Control` like the model board and are served by the `rankings`
domain. The site renders them at `/rankings/targets` and `/rankings/marketplace`, next to `/rankings`.

## 12. MCP server

The platform is also a [Model Context Protocol](https://modelcontextprotocol.io) server, so an IDE
or agent (VS Code, Cursor, Claude, Windsurf) can route, quote and explain through it. `GET /mcp`
describes the server without a key; `POST /mcp` accepts JSON-RPC 2.0 messages with the same
`Authorization` header as the REST API. Tools: `route`, `estimate`, `recommend`, `explain`,
`list_targets`, `feedback`, `ask` (pro plan and above; routes and executes) and `marketplace_search` /
`marketplace_get`. Routed calls are metered like `/api/v1/route`. (`marketplace_get`'s `install` flag is for
the local `osr mcp` server, which owns its catalogue; on the platform, install a listing from the marketplace
page or `POST /api/v1/registry/{slug}/install`.)

Clients that only speak stdio use the CLI as a bridge:

```json
{"mcpServers": {"opensmartroute": {"command": "osr", "args": ["mcp", "--url", "https://<platform>/mcp", "--api-key", "osr_..."]}}}
```

The endpoint is a full Streamable HTTP transport and an OAuth 2.1 resource server: `initialize` hands out a
signed `Mcp-Session-Id` (`DELETE /mcp` ends it), `Accept: text/event-stream` on `POST /mcp` streams progress
and log notifications before the result, `GET /mcp` with the same header is the server's notification stream
(`tools/list_changed`, `resources/list_changed`), a browser `Origin` must be the deployment's own, and a
client without a key is pointed at `/.well-known/oauth-protected-resource/mcp` so it can sign the person in
at the organization's identity provider; the token is verified against the issuer's keys and mapped to the
platform user, and a provider whose credential is `obo:<scope>` is then called with a token exchanged **on
behalf of** that person (Entra on-behalf-of or RFC 8693). Variables: `OSR_PLATFORM_MCP_OAUTH_ISSUERS`,
`OSR_PLATFORM_MCP_OAUTH_AUDIENCES`, `OSR_PLATFORM_MCP_OBO_CLIENT_ID` / `_CLIENT_SECRET` / `_TOKEN_URL`,
`OSR_PLATFORM_MCP_OAUTH_EMAIL_MATCH`, `OSR_PLATFORM_MCP_STREAM_MAX_S` - all defaulted from the `microsoft`
single sign-on provider.

Per-client configuration, the tool reference and the SDK side are in [MCP.md](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MCP.md).

### MCP gateway: your own servers' tools in the routing pool

The other direction: a workspace connects the MCP servers it already runs - an internal ticketing server, a
database agent, a vendor's hosted server - and their tools join the workspace's routing pool as `tool` targets
named `<server>:<tool>`. A chat completion or a `/route` decision may then pick one the way it picks a model,
under the same policy, trace and metering; a plan may give one a tool slot next to the model that answers.
`POST /api/v1/workspace/mcp-servers` connects a Streamable-HTTP server (`name` - the tool id prefix -, `url`, an
optional bearer `credential` stored encrypted like a provider key and never returned, `extra_headers`, the
`approval` mode): the server is initialised and its tools listed at once and kept, so routing never waits on
`tools/list`; `POST .../{id}/sync` refreshes the list; `GET .../{id}/tools` shows the tools as the calling key
sees them (whether each executes, its `readOnlyHint`, the description risk the SDK's injection guard measured -
tools that read like instructions to the model are dropped); `PATCH` re-points, rotates the credential, changes
the mode, pauses; `DELETE` disconnects. Approval modes: `auto` executes every tool the router picks (agent
mode); `read_only` executes only tools the server marks read-only - the others are routable, so a decision may
name them and a plan may show them, but the caller runs them itself; `manual` lists and ranks only. Per key,
the `mcp_tools` limit (`server:tool` or `server:*`, section 1) narrows what a key may reach. Feature `byok`
(Pro and up), up to 20 servers of up to 200 tools each; connecting takes the `admin` role, every change is
audited (`mcp_server.connected`, `mcp_server.removed`).

## 13. Dashboard

The site has two halves on one hostname. The **website** - landing page, `/docs`, `/models`, `/vendors`,
`/rankings`, `/pricing`, `/marketplace`, `/compare`, `/roi`, `/estimate`, `/extension` - needs no account.
The **platform**
is everything under `/platform`: sign-in and sign-up (`/platform/login`, `/platform/signup`, password
recovery, invitations, `/platform/cli/authorize`), the dashboard (`/platform/dashboard/...`), the operator
console (`/platform/admin/...`), the playground (`/platform/playground`) and the marketplace publish flow
(`/platform/marketplace/publish`). On the hosted service the two are separate deployments; the older
paths without the prefix (for example /dashboard/keys, /login or /auth/callback) redirect permanently to their
`/platform` counterpart with the query string intact, so bookmarks, emailed links and OAuth redirect URIs
registered before the split keep working.

Signed-in users have a dashboard for the data the API exposes:

| Page | What it shows |
|---|---|
| `/platform/dashboard` | A guided tour opens on the first arrival from signup (or with `?tour=1`) and replays from *Take the tour* on the getting-started card: the sidebar, the figures and how each is computed (the *i* on every card), the checklist, the quick start, the header tools and the account menu. Requests, tokens and spend for the last 7 days; the monthly quota bar with a warning banner from 80 % (`GET /api/v1/quota`); *Ask about your usage* - the window's findings in plain words with a question box (`GET /api/v1/insights`, `POST /api/v1/insights/ask`); the last 24 hours as a traffic and latency series (`GET /api/v1/telemetry/series`) and the active alerts (`GET /api/v1/alerts`). On a phone a bottom bar holds Home, Activity, Usage, Keys and the full menu. |
| `/platform/dashboard/usage` | Per-day, per-target, per-endpoint and per-app usage (`GET /api/v1/usage`; the *By app* table appears once a client named itself with `X-OSR-App`); CSV export of the daily series and every breakdown. |
| `/platform/dashboard/savings` | Routed cost against a baseline model (`GET /api/v1/savings?baseline=<id>`). Without `baseline` the report uses the dearest model the workspace's executed requests actually ran on (`baseline_source: executed`) and falls back to the catalogue's dearest only when none of them is priced (`catalogue`); `baseline_name` is the display name. Pick the model the team used before routing and keep it for the pilot. CSV export per day and per target for finance. |
| `/platform/dashboard/impact` | What routing did for the workspace in one report (`GET /api/v1/usage`, `/savings`, `/governance`, `/usage/opportunity`): provider cost saved against the baseline, requests routed and their success rate, personal data protected by the guard (24 h verdict counts) and the governance score with its findings; the same figures per API key (a person in a team workspace - CSV export), per tenant with its data boundary (enterprise) or per client app, and the spend observed outside the router. |
| `/platform/dashboard/apps` | The browser extension and the desktop app for this workspace: install buttons from the published release manifests (`/extension/release.json`, `/desktop/release.json`), whether each client is installed or connected, what each client sent through the router in 30 days (`by_app` of `GET /api/v1/usage`), whether the extension shares observations, and the CLI / SDK sign-in. |
| `/platform/dashboard/spend` | Two tabs (`?tab=observed|bill`). *Observed by the extension*: what the browser extension observed the team spend on chat sites and provider consoles (`GET /api/v1/usage/opportunity`, `GET /api/v1/usage/observed`): observed spend against the router's quote for the same requests, the models with the widest gap and the target the router would have picked, breakdowns by model, site and task, the per-call history (never the text) and a CSV export; an install prompt when nothing has been observed. *Your provider bill*: drop the invoice PDF a model provider already sends, or its usage export, or paste either (`POST /api/v1/usage/imports/upload`, `POST /api/v1/usage/imports`, *Preview only* for `?dry_run=true`), see the bill against the router's quote for the same requests - the saving and its monthly projection, the share of the bill the router could quote, day by day, by provider and by model with the target the router would have picked, the notes on what was assumed - keep it, download it as CSV, Excel or PDF, and the list of every kept analysis (`GET` / `DELETE /api/v1/usage/imports/{id}`). |
| `/platform/dashboard/prompts` | The prompt registry (*Prompt registry* under section 4): every prompt with its versions and change notes, labels moved by a click to deploy a version, a new version or a new prompt (text, or chat with examples) and a preview that renders a version with the variables filled in. |
| `/platform/dashboard/memory` | What the routed chat keeps in mind for you (*Conversations and memory* under section 4): the long-term memory switch, every remembered fact with its kind and how often it shaped an answer - pause, forget or add one; the workspace's knowledge graph (*Knowledge graph and context engineering*) - its switch and context budget, the facts in plain words with search and delete, a fact added by hand, *forget that ...*, a preview of the context placed for a message, the export and the link predictor; and the conversations the platform holds for the desktop app, the browser extension and the playground, with where each was started; delete one or all. |
| `/platform/dashboard/activity` | Request-level activity log: endpoint, target, domain, complexity, tokens, reported outcome - never the prompt text; filters by endpoint, target, API key and tenant (`GET /api/v1/activity?tenant=`). Rows still in the tracer buffer open a span waterfall with the decision story (signals, admission, ranking, why the winner, plan, decision, execution and what crossed the boundary: PII redaction or restoration, prompt compaction, output-guard findings), outcome and audit records; the trace can be downloaded as JSON and shared as a link (`?request_id=` opens the drawer). A request whose prompt was kept offers *Add to dataset* (a row of a kept evaluation dataset, new or existing) and *Judge this answer* (a metered judge call whose grade becomes the outcome). CSV export of the loaded rows. |
| `/platform/dashboard/events` | The router's event history for the workspace (`GET /api/v1/events`, persisted by the platform): a window of 1 hour to 7 days, spans and events with level and stage filters, search by request id / trace id / name, most frequent names, click-through to the request trace, CSV export. The **Live** refresh option follows `GET /api/v1/events/stream` and prepends events as they happen. |
| `/platform/dashboard/evaluation` | Measure the router on your own prompts without executing anything (`POST /api/v1/evaluations`, section 5 *Evaluation*). Four tabs (`?tab=dataset|replay|datasets|judge`): *Labelled dataset* - drop or paste the JSON-lines file `osr eval` reads (or load the catalogue's sample dataset), pick the cost weight, compare with the baselines, sweep the cost weight; *Replay your traffic* - re-route the prompts kept under the request-logging policy through today's router; *Kept datasets* - the datasets saved from logged requests (`GET /api/v1/evaluations/datasets`), each with *Evaluate* on today's pool, *Download JSONL* and *Delete*, and *From your traffic* to save a window of kept prompts (by outcome, target and size); *Answer judge* - the judge in force, what it graded per answering target over the window and the latest verdicts with their reasons (`GET /api/v1/evaluations/judge`). The report shows accuracy with its interval, realised quality or confidence, the price of the mix against always using the dearest admitted target, calibration, the baselines on the same rows, where the rows went, accuracy by domain, the misroutes to read, the reliability bins and every decision (searchable, filterable, CSV); a replay shows what would move, the projected cost, whether accepted answers are kept and rejected ones moved, the then / now mixes and every request with a link to its trace. The newest 50 runs are kept and listed below the form. |
| `/platform/dashboard/learning` | How the router learns (`GET /api/v1/learning`): strategy weights, outcomes per target, the IRT ability and Bradley-Terry preference leaderboards (enterprise edition), the routing SLM (rows, sources, the targets it ranks, accuracy history, calibration) and the autopilot (schedule, drift, cycles, a retraining-in-progress indicator, champion vs challenger; operators can run a cycle). Leaderboards, fits and cycles export as CSV. |
| `/platform/dashboard/keys` | Create, name and revoke API keys; per-key scopes, expiry and limits (daily budget, requests per minute, allowed targets - a bare catalogue id such as `openai/gpt-4.1` means every endpoint of that model - the monthly spend limit that caps what the key is charged, the headers every request must carry (`required_headers`, refused with 428 before anything is spent) and the connected MCP servers' tools the key may reach (`mcp_tools`, `server:tool` or `server:*`)). |
| `/platform/dashboard/mcp-servers` | Your own MCP servers' tools in the routing pool (`/api/v1/workspace/mcp-servers`, every plan with `byok`): connect a Streamable-HTTP server (name, URL, optional bearer token, extra headers, approval mode) and its tools join the pool as `name:tool` targets at once; the list shows each server's health from its last sync, its tool count (click for the tools as the router sees them for your key - target id, whether it executes, read-only, a flagged description), the approval mode (*auto* - every tool runs; *read-only* - only tools the server marks read-only execute; *manual* - listed and ranked, never run) and *Sync*, with enable / disable, a change of approval, clearing the token and removal (confirmed) in the row menu. |
| `/platform/dashboard/providers` | Bring your own model providers (every plan of the default catalogue), in three tabs. *Your providers*: connect Azure OpenAI, Amazon Bedrock, Google Vertex AI / Gemini, OpenAI, Anthropic or any OpenAI-compatible endpoint from a preset picker - the preset's placeholders (resource, region, project) are asked for as fields and the URL is assembled - with the workspace's key, extra headers, timeout and retries, and check the connection on save; each provider card shows health with the time of the check, the masked key, its settings, the models the endpoint reported (click one to map it) and the targets mapped to it; *Edit* changes every stored field and replaces or removes the key; enable, disable or remove (confirmed). *Targets*: every model and tool the router can choose from with where it executes for this workspace (yours / platform / not executable), searchable and filterable, with *Map*, *Change* and *Unmap* per row; the mapping dialog takes the model, an optional system prompt and JSON request defaults. *Included with the platform*: the deployment's own providers with health and the targets each one runs. |
| `/platform/dashboard/tenants` | Tenant constraints and budgets (enterprise), in two tabs: *Tenants* (one card per tenant with its constraints in words, this month's usage, budgets and pinned keys; create and edit in a sectioned dialog, delete after confirmation) and *Usage and setup* (totals and the `X-OSR-Tenant` curl example). |
| `/platform/dashboard/governance` | Five tabs (`?tab=overview|controls|budgets|data|audit`). *Overview*: spend today and this month, quota, reachable targets, the workspace policy in readable rows with *Edit* / *Remove*, compliance posture with linked findings and score. *Controls*: controls in force (including the output guard mode), guard activity of the last 24 hours and, where the deployment runs fair share across tenants (`OSR_PLATFORM_FAIR_SHARE`), the *Fair share* card - the window and the soft / hard multiples, how many requests were steered or capped, and each tenant's requests and dominant share in the current window. *Budgets*: workspace and tenant budgets against their spend. *Data*: the response cache (`GET /api/v1/cache` - live entries, hits served, the window in force; *Clear* for an admin) and the request-log export (`/api/v1/workspace/log-export` - destinations with rows sent, last run and state; add an Azure Blob container or an HTTPS endpoint with its credential, prefix, interval and whether prompts and answers ride along; *Export now*, pause / resume, remove). *Audit and keys*: API-key hygiene, audit status and retention, catalogue data boundaries. The policy dialog groups its fields into where requests may run, cost and latency limits, budgets, privacy and *how requests are answered* - model aliases, the response-cache window, conversation stickiness (`session_affinity`), the default objective and the answer judge - offers the executable targets as chips, previews what the policy will do in sentences, and edits every policy key - the response cache (window, and whether rewordings count), the providers block (try first / only / never, tie-breaker, price ceilings, no data collection, zero retention only), data protection and allowed addresses - so nothing is lost across a save. "Compliance report" download (`GET /api/v1/governance/report?format=markdown`) in the header. |
| `/platform/dashboard/health` | Deployment readiness (`GET /api/v1/status`), the active alerts, HTTP traffic and latency percentiles over time (`GET /api/v1/telemetry/series`), live counters, routing decisions and latency, circuit breakers, decision cache (enterprise). |
| `/platform/dashboard/notifications` | The alert inbox (firing and resolved episodes, mark read), delivery channels (email, webhook, Slack, Teams) with severity and rule filters and *Send test*, and the delivery log. The bell in the header shows the unread count. |
| `/platform/dashboard/audit` | Hash-chained decision trail with chain verification (enterprise); export as CSV or as the JSONL the offline verifier reads. |
| `/platform/dashboard/billing` | Four tabs, one question each (deep-linkable with `?view=overview|plans|credits|invoices`; a return from Stripe opens the right one). **Overview** - a running coupon trial (days left, end date, *Subscribe now*), the subscription (state, trial end, renewal, portal, cancel), the monthly quota card (used, limit, state, reset date, overage so far), what the plan includes and the **Service level** card (`GET /api/v1/sla`: availability and support first-response this month against the plan's commitments). **Plans** - the plan cards with a monthly / annual switch (plans the promotion covers start a free trial without payment; plans sold through sales link to the sales form) and the **Pay as you go** card (switches to the prepaid plan without Stripe), the coupon-code form, and on deployments without Stripe or a promotion the operator command that sets a plan. **Credits** - the **Credits** card (balance, pending charges, last top-up, *Buy credits* through Stripe Checkout with the deployment's credit packs and the purchase fee, auto top-up, the ledger and the monthly statement) and the **Usage and charges** table (`GET /api/v1/billing/usage`: provider cost and the workspace's charge by day, model, provider, key or tenant, CSV). **Invoices** - every invoice Stripe issued, with the hosted page and the PDF, and a plain explanation when the deployment issues none. |
| `/platform/dashboard/referrals` | *Refer a team*: the workspace's referral link and code with copy, e-mail and share buttons, an *Invite by e-mail* form (twenty a day, a line in your words), the figures (signed up, rewarded, credits earned), the invited workspaces by initials with each one's status and reward, how the programme works, and - for a workspace that came through a referral - who invited it and when its welcome credit arrives. | `GET /api/v1/referrals`, `POST /api/v1/referrals/invite` |
| `/platform/dashboard/support` | Support tickets of the workspace: open one (subject, category, priority, optional request id), the confirmation with its reference, the list by status, and the conversation of a ticket - reply (reopens a resolved ticket), close. E-mailed links (`?ticket=OSR-00042`) open the ticket directly. |
| `/platform/dashboard/feedback` | Feedback the workspace sent from the platform's pages (bugs, ideas, improvements, praise) with the status trail of each (received, being reviewed, planned, in progress, shipped or declined), the conversation, the screenshots and the page details that went with it; add to an open item. The *Send feedback* button in the header opens the form from any page with screen capture, paste or upload and the browser diagnostics. E-mailed links (`?item=FB-00012`) open the item directly. |
| `/platform/dashboard/workspace` | The current workspace in tabs: *General* (name, slug, region, plan), *Single sign-on* (owners of an organization: set up or edit the connection in three steps - provider, credentials next to the redirect URI, who gets in - and share the direct sign-in link; admins see it) and *Leave or delete*. "New organization" opens a dialog and shows the organization's first API key once. |
| `/platform/dashboard/members` | Members, roles and pending invitations of an organization in one list - search by name or e-mail, filter by role or by joined / invited. |
| `/platform/dashboard/listings` | Marketplace listings you published and everything you installed. |
| `/platform/dashboard/integrations` | SDK snippets with your key (the TypeScript client from npm, the OpenAI Python client against `/v1`, curl) and MCP configuration for VS Code, Cursor, Windsurf, Claude Code and Claude Desktop. |
| `/platform/dashboard/content` | The content publishing service of the workspace (docs/PUBLISHING.md): write a post from a brief, the posts with *Publish*, the feeds you follow with *Fetch now*, the destinations your posts are delivered to (signed webhook, WordPress, Ghost) with *Send a test*, the editorial profile and the delivery log with *Retry*. |
| `/platform/dashboard/account` | Your sign-in identities, password, browser sessions, e-mail preferences and agreements (which version of the terms and the privacy notice you accepted, when). |

Every table with an *Export CSV* button downloads exactly the rows shown (UTF-8 with BOM, RFC 4180 quoting,
cells that start with `=`, `+`, `-` or `@` are prefixed so spreadsheets do not run them as formulas). A failed
read shows what it means - session expired, feature not on the plan, budget exhausted with the amounts, rate
limit with the retry delay, API unreachable - with a *Retry* button; an expired session returns to sign-in and
back to the page afterwards.

Lists read the same way everywhere. A list the API returns whole - API keys, members and invitations, the audit
trail, hand-off tickets, support tickets, feedback items, events, the learning leaderboards, usage per tenant and
per target, the observed-spend history, the impact report's per-key and per-tenant tables, your marketplace
listings and installs, and in the console operators, coupons, leads, offers, digests, listings, operator runs,
the price book, wallets and provider invoices, growth keywords, campaigns, search queries and posts, a
provider's model mappings, ranking runs and the members, keys, tenants, ledger and audit history of a
workspace or user - gets a search box, one or two filters (role, status, source) and a count above the rows
once it is longer than a handful, and a pager under the rows
(10 / 25 / 50 / 100 per page, "11-20 of 42") once it is longer than a page; short lists show none of it.
Lists the API pages itself (activity, the console's users, workspaces and audit log) keep their *Load more*
or server-side pages. Forms show the essentials and fold the rest behind one **More options** row - a
support ticket asks for the subject and what happened, with category, priority and the request id behind the
row; a coupon asks for the code, the trial length and the plan, with the redemption cap, the deadline, the
description and auto-apply behind it. The closed row states what is set inside, so nobody has to open it
to check.

The header of every dashboard page carries the same tools: a search (Ctrl K) that jumps to any dashboard page by
its name or by what people call it ("token", "upgrade", "invite"), then to models and documentation; the
notification bell; the theme; *Send feedback*; help; the playground; the docs. The month's quota figure links to
plan and billing. A long page introduction folds behind *More about this page* so the page's own content stays
above the fold.
Secondary sections of a long page - the usage heatmap, the health page's HTTP routes and raw snapshot, the stdio
bridge and tool list under Integrations, the account signals under Savings - are foldable cards: closed by default,
the title, a one-line summary and the action stay in view, and the browser remembers what each person opened.
Every dashboard and console page ends with the same footer - edition, plan (or version and operator in the
console), the guides, status, support and legal links - and a *Back to top* control; a floating one appears after
half a screen of scrolling.

### How the Impact report counts

The Impact page is the number a finance lead, a team lead or an auditor takes away, so every figure on it is
this workspace's own metering, never an estimate from a model, and every one can be reproduced from the API.

| Figure | Computed from | Notes |
|---|---|---|
| Provider cost saved | `GET /api/v1/savings?days=N&baseline=<model>` | The provider cost of every *executed* request (`execute=true`, the platform's own key or a BYOK provider) against the list price of the baseline model for the same tokens. Decisions that were not executed carry no provider cost and add nothing, which is why a workspace that only asks for routes sees a flat line. |
| Requests routed and success | `GET /api/v1/usage?days=N` | `requests` and `ok`; success is `ok / requests`. |
| Data protected | `GET /api/v1/governance` -> `guard_activity` | The guard verdicts of the last 24 hours of trace events: redactions, blocks, boundary refusals and injection stops. Older verdicts are in `/platform/dashboard/events`. |
| Governance score | `GET /api/v1/governance` -> `posture` | The posture score and its findings, the same as `/platform/dashboard/governance`. |
| Per API key | `GET /api/v1/usage` -> `by_key`, names from `GET /api/v1/keys` | In a team workspace one key per person makes this the per-person view. A key that was deleted keeps its rows under its id. |
| Per tenant | `GET /api/v1/usage` -> `by_tenant`, boundaries from `GET /api/v1/me` | Enterprise edition; every row shows the tenant's region and data boundary so the report doubles as evidence that the boundary held. |
| Per client app | `GET /api/v1/usage` -> `by_app` | `browser-extension`, `desktop`, or the API, SDK and playground (no app header). |
| Spend outside the router | `GET /api/v1/usage/opportunity` | What the extension observed on provider consoles and chat sites that did not go through the router, and what routing it would have cost. |

Rows are isolated per workspace: the API key (or browser session) that reads the page fixes the account, and
every query above is filtered by that account before anything else. Operators see a workspace's figures only
through the console (`/platform/admin/workspaces/{id}`).

The public pages
`/models` (every routing target with request, token, cost, latency and health statistics; also
`GET /api/v1/models`, and the reference LLM catalogue with vendor prices and context windows at
`GET /api/v1/llms`), `/vendors` (one page per LLM vendor with prices, context windows and benchmarks),
`/rankings` (targets ordered by observed quality per domain, and the model leaderboard: models ranked by
published benchmarks - the Artificial Analysis indexes of the model listing, the Hugging Face Open LLM
Leaderboard, Chatbot Arena and Hub downloads - combined into one index; the platform refreshes those
sources three times a day, `GET /api/v1/rankings/models` returns the board with day-over-day movement
and `GET /api/v1/rankings/models/{vendor}/{model}` one model's rank history), `/roi` (estimated
saving against always calling one model), `/compare` (alternatives side by side) and `/marketplace` need
no account; `/platform/playground` (route a request in the browser - *Get an answer* streams the reply token by
token from `POST /v1/chat/completions` with **Stop** keeping what arrived, *See the decision*,
*Compare models*, *Embeddings, voice and images* - `POST /v1/embeddings`, `POST /v1/moderations`,
`POST /v1/images/generations`, `POST /v1/audio/speech` and `POST /v1/audio/transcriptions` from the browser, each
with the target that answered, the request id and the equivalent `curl` - and copy the equivalent `curl`) asks for the free sign-in first, because decisions are
metered on the workspace. Under every decision and every chat answer the playground asks *Was this the right
choice?* - **Good** or **Poor** is one click and lands as `POST /api/v1/feedback` (quality 0.85 / 0.20), the
first outcome of the checklist; the activity page keeps the full form for an exact score and a better target.
Machine-readable companions: `/llms.txt` (documentation map for AI assistants, llmstxt.org;
every documentation page is also served as Markdown at `/docs/<page>.md` and advertised with a
`text/markdown` alternate link), `/llms-full.txt` (the whole documentation as one Markdown file),
`/feed.xml` (Atom feed of releases), `/sitemap.xml` and `/openapi.json` (the complete OpenAPI 3.1
document of the API - the committed snapshot the REST reference renders, not the subset one gateway
process happens to serve).

### Operator console

The people who run a deployment sign in at `/platform/admin/login` with an operator **username and password**
(`POST /api/v1/admin/auth/login` returns an `osr_op_` session token valid for twelve hours; send it as
`Authorization: Bearer`). In the browser the console does not keep that token where a script can read it:
right after sign-in it moves into an **HttpOnly, SameSite=Strict cookie** scoped to `/api/v1/admin` (the web
tier's `POST /api/admin-session` verifies the token against the API first), and the web proxy injects it as
the Bearer credential only for admin calls that carry the console's own marker header - a cross-site form or
navigation cannot set custom headers, so the cookie never rides them. Sign-out revokes the API session and
clears the cookie. Automation keeps sending the token as a plain `Authorization: Bearer` header, which always
takes precedence. The first operator comes from the environment: set
`OSR_PLATFORM_ADMIN_USERNAME` and `OSR_PLATFORM_ADMIN_PASSWORD` and the API creates it - or resets its
password - at start-up as a `superadmin`. The static `OSR_PLATFORM_ADMIN_TOKEN` (`X-Admin-Token`)
keeps working for automation and counts as a superadmin. Operators are separate from workspace users.

**Two-factor authentication.** Every operator can add an authenticator app (TOTP, RFC 6238: six digits every
30 s, one step of drift accepted) under *Operators › Two-factor authentication*: `POST /api/v1/admin/auth/mfa/setup`
(current password) returns the secret and the `otpauth://` URI, `POST /api/v1/admin/auth/mfa/enable` confirms a
code and returns eight single-use **recovery codes** (shown once, stored hashed). From then on
`POST /api/v1/admin/auth/login` answers `mfa_required` with a five-minute signed `challenge` instead of a token,
and `POST /api/v1/admin/auth/mfa/verify` with the challenge and a code (or a recovery code) issues the session.
`DELETE /api/v1/admin/auth/mfa` (password + code) switches it off; a superadmin resets a locked-out operator with
`PATCH /api/v1/admin/operators/{id}` `{"mfa_reset": true}` (their sessions end). `OSR_PLATFORM_OPERATOR_MFA_REQUIRED`
= `off` (default), `superadmin` or `all` makes enrolment mandatory: an operator without a factor gets a session
that answers only the `/api/v1/admin/auth/*` endpoints (`403`, `X-MFA-Required: enrol` elsewhere) and the console
shows the enrolment card until it is done; the factor cannot be switched off while the policy covers the account.
The static admin token is automation and is never asked for a factor. Audit: `operator.mfa_enabled`,
`operator.mfa_disabled`, `operator.mfa_recovery_used`, `operator.mfa_reset`; failed codes appear on the `admin`
event topic as `operator.login_mfa_failed`.

| Page | What it does | Endpoints |
|---|---|---|
| `/platform/admin` | A guided tour of the console opens after the first sign-in through the form (or with `?tour=1`) and replays from *Take the tour*: the grouped navigation, the eight deployment figures (each with an *i* saying what is counted), search, sign-in configuration, storage and scaling, the operator guide. Deployment counters: users, workspaces, keys, tenants, operators, sessions, requests, errors and provider cost of the last seven days, plan mix, sign-in configuration, and outgoing mail at a glance (transport, health, queued, retrying, gave up, skipped by opt-out). | `GET /api/v1/admin/overview`, `GET /api/v1/admin/plans` |
| `/platform/admin/analytics` | The whole deployment over 7, 30 or 90 days: requests and failures per day with mean latency, cost per day, endpoint share, workspaces per plan, signups per day, the busiest workspaces and the targets that carried the traffic. *Before the signup: the visitor journey* counts distinct anonymous visitors of the public site through visited, looked at the price or the docs, opened the signup form, started it, signed up - with the step conversion, the signup attempts that failed and why (address already registered, terms not accepted, rate limit, rejected, server error), the landing pages and referrers with their signup rate and the calls to action clicked. *What people told us* aggregates the one-question surveys - what holds visitors back (pricing and signup pages) and what is in the way of a workspace that has not routed yet (dashboard) - with the answers in their own words. The *Activation funnel* card follows the workspaces that signed up in the range through the same stages as their getting-started checklist - signed up, e-mail confirmed, first request routed, a target answered, an answer reported as good, came back on another day, on a paid plan - with the share of the cohort, the conversion from the previous stage and the median time from signup to each stage; the funnel per acquisition source (UTM source of the signup); and *Stuck before the first request*, the newest workspaces that signed up more than a day ago and never routed, linked to their console page, each with what it said is in its way (or what the platform can see: unconfirmed e-mail, no key, failed calls, no executable model) and marked when its getting-started reminder went out. *Send reminders now* runs the reminder sweep on demand. | `GET /api/v1/admin/analytics?days=`, `GET /api/v1/admin/analytics/journey?days=`, `GET /api/v1/admin/analytics/activation?days=`, `POST /api/v1/admin/analytics/activation/nudge` |
| `/platform/admin/health` | This replica of the routing process: readiness checks, HTTP traffic and latency series (1 h / 6 h / 24 h), the last five minutes, circuit breakers per target, the deployment alerts in force and the alert evaluator; auto-refresh. *Status page incidents*: what `/status` tells customers - open an incident (title, severity with what the banner will say, the affected components, the first update), post updates and resolve, *Edit* the title, severity or components of an open one in place (the banner and the feed follow), see what the monitor opened on its own. | `GET /api/v1/admin/health?window=`, `GET|POST /api/v1/admin/status/incidents`, `POST /api/v1/admin/status/incidents/{id}/updates`, `PATCH /api/v1/admin/status/incidents/{id}` |
| `/platform/admin/activity` | Every metered request across workspaces, newest first: workspace, endpoint, target, status, latency, tokens, cost, request id; filter by endpoint, failures, workspace, target (click a target) or request id; load older pages; CSV export. *Trace* on a row opens the request's trace as the workspace sees it (decision story, spans, usage, outcomes; read-only, audited as `activity.trace`); `?request_id=` opens it directly - the search page and the workspace page link there. | `GET /api/v1/admin/activity?limit=&before=&account_id=&endpoint=&target=&failed=&request_id=`, `GET /api/v1/admin/trace/{request_id}` |
| `/platform/admin/users` | The user directory: search by email or name, filter by status, email confirmation and sign-in method (password or SSO / key only), sort by joined, last sign-in, email or name, export the matching rows as CSV. Select rows for a bulk action: disable, enable, mark the email verified, sign out everywhere, send a password reset, delete (each user is reported as done or failed). Create one with a personal workspace, plan, optional password and first key (shown once; without a password a reset link is issued so the person chooses one). Per user: workspaces and roles, linked identities, sessions, lifecycle history; rename, disable, reset password or send a reset link, re-send or force the email confirmation, sign out everywhere, *Reset two-factor* for a person who lost the authenticator and the recovery codes (`mfa_reset`; sessions end, they are told by e-mail), delete (workspaces they alone belonged to are disabled and their keys revoked); an *E-mail* card with the person's optional categories and newsletter status - switchable on their behalf after a bounce or a complaint, audited as an operator action - and the last messages addressed to them. | `GET|POST /api/v1/admin/users` (`q`, `disabled`, `verified`, `method`, `sort`, `order`, `format=csv`), `POST /api/v1/admin/users/bulk`, `GET|PATCH|DELETE /api/v1/admin/users/{id}`, `DELETE /api/v1/admin/users/{id}/sessions`, `POST /api/v1/admin/users/{id}/reset-link`, `POST /api/v1/admin/users/{id}/verify-link`, `GET /api/v1/admin/users/{id}/audit`, `GET /api/v1/admin/users/{id}/email`, `PUT /api/v1/admin/users/{id}/email-preferences` |
| `/platform/admin/workspaces` | The workspace directory: search, filter by kind, plan and status, sort by name, plan or created, export CSV; create an organization for an owner email. Per workspace: plan, name, slug, disable, delete; members with roles (add by email, change role, remove - the last owner stays); API keys (mint, revoke); tenants (create or replace a validated configuration, delete); policy (in words), SSO and usage over a window of 7, 30 or 90 days (`?days=` on the usage read); *Connected beyond providers* - the workspace's MCP servers with health and approval mode, its request-log export destinations with rows sent and state, the response cache in force and what the answer judge graded in the last seven days (`integrations` on `GET /api/v1/admin/accounts/{id}`, read-only). *Open dashboard* (superadmins) mints a one-hour browser session on the workspace as its owner and switches to the workspace portal, which shows an *Operator view* banner with the way back; the view sees every plan feature (statistics, audit, tenants) whatever the workspace's plan, while quotas stay the workspace's; the owner sees the session (provider `operator`) in their session list and the audit log records `workspace.open`. A *Getting started* card shows the workspace's own checklist state (the setup steps and first-value milestones of `GET /api/v1/onboarding`, as `onboarding` on the detail), whether it is stuck before its first request and when its getting-started reminder went out; *Send reminder* sends that reminder to the owners now, whatever the workspace's age, once (`409` afterwards). | `GET|POST /api/v1/admin/accounts` (`q`, `kind`, `plan`, `disabled`, `sort`, `order`, `format=csv`), `GET|PATCH|DELETE /api/v1/admin/accounts/{id}`, `.../usage`, `.../members[/{user_id}]`, `.../keys[/{key_id}]`, `.../tenants/{slug}`, `POST /api/v1/admin/accounts/{id}/session`, `POST /api/v1/admin/accounts/{id}/nudge` |
| `/platform/admin/tenants` | Every tenant across workspaces with its configuration. | `GET /api/v1/admin/tenants` |
| `/platform/admin/leads` | Product-qualified leads: workspaces scored on volume, tenants, EU or private data boundaries, agentic traffic, several teams and a company email domain; CSV export. | `GET /api/v1/admin/pql?days=&qualified_only=` |
| `/platform/admin/sales` | Sales inquiries from the *Talk to sales* form: queues (open, new, contacted, qualified, won, lost), search by reference, e-mail or company, the requester's message and workspace, status, assignee and internal notes; won deals are applied on the workspace page. | `GET /api/v1/admin/sales/inquiries?status=&q=`, `GET|PATCH /api/v1/admin/sales/inquiries/{key}` |
| `/platform/admin/support` | The support desk: queues (needs an answer, waiting for the requester, resolved, closed), search by reference, subject, e-mail or request id, desk statistics (unassigned, median first response, oldest open) and the recipients of the notices; per ticket the whole conversation including internal notes, reply to the requester (e-mailed, optionally setting the status), add an internal note, change status with a note for the requester, priority, category, take or unassign. | `GET /api/v1/admin/support/tickets` (`status`, `q`, `account_id`, `assigned_to`, `priority`, `limit`, `offset`), `GET|PATCH /api/v1/admin/support/tickets/{key}`, `POST /api/v1/admin/support/tickets/{key}/messages` |
| `/platform/admin/feedback` | The feedback desk: queues (open, new, reviewing, planned, in progress, shipped, declined), filter by kind, search by reference, title, e-mail, page or request id, the figures (open and unanswered, median first answer, kinds and pages of the last 30 days); per item the conversation including internal notes, the screenshots, the browser details (viewport, last API calls with request ids linked to the request log, console errors), answer the person (e-mailed, optionally setting the status), add an internal note, change status with a note for the person, kind, take or unassign. | `GET /api/v1/admin/product-feedback` (`status`, `kind`, `q`, `account_id`, `assigned_to`, `page`, `limit`, `offset`), `GET|PATCH /api/v1/admin/product-feedback/{key}`, `POST /api/v1/admin/product-feedback/{key}/comments`, `GET /api/v1/admin/product-feedback/{key}/attachments/{attachment_id}` |
| `/platform/admin/plans` | The plan catalogue - monthly quota, overage rate, per-minute limit, keys / tenants / members, annual price, trial, support tier, feature matrix on this edition - and the workspaces on each plan. A superadmin edits a plan in place (quotas, prices, overage, trial, SLA, support tier, features, description): the change is checked, stored as a run-time override that wins over `OSR_PLATFORM_PLAN_OVERRIDES`, in force at once for every workspace on the plan and adopted by every process; *customised* marks such plans, *env* the fields the environment sets, and *Reset* returns a plan to the deployment's definition. | `GET /api/v1/admin/plans`, `PATCH|DELETE /api/v1/admin/plans/{name}`, `GET /api/v1/admin/overview` |
| `/platform/admin/billing` | Billing operations: the Stripe configuration in force (mode, price keys, meter, intervals each plan is sold for - secrets never shown), live subscriptions per plan with an MRR estimate, past-due / incomplete / unpaid / canceled counts, the overage reported to Stripe this month and the last overage sync; every workspace with a billing history (past due first) with this month's requests against the quota, the overage still to report, Stripe dashboard links and *Sync* (re-read the subscription from Stripe after a missed webhook or a change made in the Stripe dashboard - a subscription Stripe no longer has sends the workspace to free); *Report overage now*; the webhook events processed, filtered by type. The workspace page has the same *Sync from Stripe*. | `GET /api/v1/admin/billing`, `GET /api/v1/admin/billing/subscriptions` (`state`, `plan`, `q`, `limit`, `offset`), `GET /api/v1/admin/billing/events` (`kind`, `limit`), `POST /api/v1/admin/billing/accounts/{id}/sync`, `POST /api/v1/admin/billing/overage/sync` |
| `/platform/admin/finance` | The money view (*Usage pricing, credits and provider finance*): usage revenue, provider cost and gross margin over 7 / 30 / 90 days with breakdowns by provider, model, workspace and plan; *Margin watch* - the `margin`, `above_list_price` and `provider_balance` alerts, or the line saying every priced request covered its cost; the pricing rules and the provider price book (add a rule - each kind explained next to its parameters - or a contract price with a reason, end one through a dialog that records the reason, seed the catalogue's list prices); the price-book versions, each with *What it froze* - the prices and rules the version pinned and the raw snapshot, so a disputed charge can be read against the rules in force when it was computed; the credits wallets with *Debit now*; provider prepaid balances and low-balance lines, provider invoices (record, reconcile against expected spend, *Mark paid*). Reads for every operator; writes for the `finance` and `provider_manager` roles. | `GET /api/v1/admin/analytics` (`finance`), `GET /api/v1/admin/finance/alerts`, `GET|POST /api/v1/admin/finance/prices`, `DELETE .../prices/{id}`, `POST .../prices/seed`, `GET|POST .../rules`, `DELETE .../rules/{id}`, `GET .../versions`, `GET .../versions/{version}`, `POST .../quote`, `GET .../wallets`, `POST .../wallets/sync`, `GET|POST .../accounts/{id}/ledger`, `PUT .../accounts/{id}/billing-mode`, `GET .../providers`, `PUT .../providers/{id}`, `GET .../providers/{id}/reconcile`, `GET|POST .../provider-invoices`, `PUT .../provider-invoices/{id}` |
| `/platform/admin/coupons` | Coupon codes that grant a paid plan for a number of days without payment: create (days, plan, cap, deadline, auto-apply), enable or disable, make one the promotion, see who redeemed it, end expired trials now. | `GET|POST /api/v1/admin/coupons`, `GET|PATCH /api/v1/admin/coupons/{code}`, `POST /api/v1/admin/coupons/sweep` |
| `/platform/admin/slm` | The routing SLM service: model in service, prompts and outcomes ingested, improvement cycles (champion vs challenger), run a cycle, probe the model with a prompt. | `GET /api/v1/admin/slm`, `GET /api/v1/admin/slm/reports`, `POST /api/v1/admin/slm/cycle`, `POST /api/v1/admin/slm/predict` |
| `/platform/admin/offers` | Models by the token: every offer (one catalogue model on one provider endpoint) with its upstream id, list prices, data policy and status; filter by model or provider, pause / resume or remove an offer, list a provider's recognised models in one go, add a single offer with its region and data policy. Customer prices are set on the Finance page; the storefront (`/models?view=offers`) and `GET /v1/models` follow every change at once. | `GET|POST /api/v1/admin/offers`, `PATCH|DELETE /api/v1/admin/offers/{vendor}/{model@provider}`, `POST|DELETE /api/v1/admin/providers/{id}/offers` |
| `/platform/admin/providers` | Model providers: add an OpenAI-compatible endpoint from a preset, check it (latency, upstream model list and how many of those models the reference catalogue recognises), **List models** (offer every recognised upstream model by the token under its `vendor/model` id - the storefront and `GET /v1/models` pick them up at once), map catalogue targets to upstream models with an optional system prompt, disable or delete; shows the mounted file, what is attached on this replica and the targets that cannot execute yet. | `GET|POST /api/v1/admin/providers`, `GET|PATCH|DELETE /api/v1/admin/providers/{id}`, `POST /api/v1/admin/providers/{id}/check`, `POST|DELETE /api/v1/admin/providers/{id}/offers`, `GET|POST /api/v1/admin/offers`, `PUT|DELETE /api/v1/admin/providers/models/{target_id}` |
| `/platform/admin/services` | Where each domain runs (in the API or as its own service) with a live health probe of every remote service. | `GET /api/v1/admin/services` |
| `/platform/admin/model-servers` | The self-hosted [model servers](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MODELS.md) behind the on-prem and specialist targets, inspected live: for each server its kind (the OpenSmartRoute model server with engine, device, memory budget and store usage, or Ollama), the models in memory (size, pinned or expiring, slots busy and waiting), the models in its store with the targets they serve, *mapped but not stored* and *stored but cold* warnings; pull a model (followed as a job with progress), load and pin, unload, delete (refused while a target maps to it), the engine log of a loaded model. | `GET /api/v1/admin/model-servers`, `GET /api/v1/admin/model-servers/{id}`, `GET /api/v1/admin/model-servers/{id}/logs?model=`, `POST /api/v1/admin/model-servers/{id}/pull`, `GET /api/v1/admin/model-servers/jobs/{job_id}`, `POST /api/v1/admin/model-servers/{id}/load`, `POST /api/v1/admin/model-servers/{id}/unload`, `DELETE /api/v1/admin/model-servers/{id}/models/{model}` |
| `/platform/admin/growth` | The SEO / SEM engine (section 17): signup funnel by channel, activation and retained-value cohorts (first execution, accepted outcomes, time to first value, week-two / month-two return, denominators shown), keywords (add, pause, delete; coverage, Search Console impressions and position), the writer and IndexNow state, LinkedIn (connect a profile or company page, pick the page to post as, post now, every post with its permalink or LinkedIn's refusal), search campaigns with their router-written ads, budgets and metrics (enable, pause, rewrite the ad, delete), CSV export for Google Ads Editor and Microsoft Advertising, *Push to Google Ads*, *Run a cycle now* or single steps, Search Console queries; the *Lifecycle campaigns* card (trial ending, upgrade offer, release news - due, handled, last run, the coupon the offer quotes; *Run now* for all or one). | `GET /api/v1/admin/growth`, `GET /api/v1/admin/growth/funnel`, `GET /api/v1/admin/growth/cohorts`, `GET /api/v1/admin/growth/runs`, `POST /api/v1/admin/growth/cycle`, `GET|POST /api/v1/admin/growth/keywords`, `PATCH|DELETE /api/v1/admin/growth/keywords/{term}`, `GET /api/v1/admin/growth/queries`, `GET /api/v1/admin/growth/campaigns`, `PATCH|DELETE /api/v1/admin/growth/campaigns/{cid}`, `POST /api/v1/admin/growth/campaigns/{cid}/ads`, `GET /api/v1/admin/growth/campaigns/export`, `POST /api/v1/admin/growth/campaigns/sync`, `GET|POST /api/v1/admin/growth/indexnow`, `GET|POST /api/v1/admin/growth/social`, `POST /api/v1/admin/growth/social/linkedin/connect`, `GET /api/v1/admin/growth/social/linkedin/organizations`, `PATCH|DELETE /api/v1/admin/growth/social/linkedin`, `GET /api/v1/admin/campaigns`, `POST /api/v1/admin/campaigns/run` |
| `/platform/admin/referrals` | The referral programme across workspaces: the terms in force, how many workspaces hold a link, signups through one, rewarded and capped, credits paid; the recent referrals with both workspaces (linked to their console pages), status and what each side received; *Qualify now* runs the qualification pass without waiting for the finance pass. | `GET /api/v1/admin/referrals?limit=`, `POST /api/v1/admin/referrals/sweep` |
| `/platform/admin/search-listings` | Search engines and directories (section 17): IndexNow, Google Search Console and Bing Webmaster submissions with the index verdict per key page, *Submit now*; the listing kit (router-written tagline, descriptions at the directories' lengths, categories, tags, links, logos, facts - each with a copy button, *Rewrite*); the directory register with status, listing address, last check and the visits and signups each listing brought - open the submission page, e-mail the kit, mark submitted, paste the listing's address, check, skip, add your own directory. | `GET /api/v1/admin/growth/search-engines`, `POST /api/v1/admin/growth/search-engines/submit`, `GET|POST /api/v1/admin/growth/directories`, `PATCH|DELETE /api/v1/admin/growth/directories/{id}`, `POST /api/v1/admin/growth/directories/check`, `POST /api/v1/admin/growth/directories/{id}/check`, `POST /api/v1/admin/growth/directories/{id}/submit`, `GET|POST /api/v1/admin/growth/kit`, `GET /api/v1/admin/growth/kit/email` |
| `/platform/admin/social` | Syndication (section 19): the channels every published article is shared on - LinkedIn, X, Facebook, Threads, Discord, Telegram, Mastodon, Bluesky, Reddit, Dev.to, Hashnode, Medium, Blogger, Tumblr, WordPress, Ghost, webhooks - each with its credentials (sealed, never shown again), posting schedule (at once or a window of weekdays and hours in a time zone, a minimum gap, a daily cap), message template, hashtag count, draft mode for blog platforms and re-share cadence; *Send a test*, enable / disable, edit, remove; share any post on chosen channels now or at a chosen time with your own text; the share log with each network's answer and permalink, *Retry* and *Cancel*; *Send what is due*. | `GET /api/v1/admin/social`, `GET /api/v1/admin/social/networks`, `POST /api/v1/admin/social/preview`, `GET|POST /api/v1/admin/social/channels`, `PATCH|DELETE /api/v1/admin/social/channels/{channel_id}`, `POST /api/v1/admin/social/channels/{channel_id}/test`, `GET /api/v1/admin/social/shares`, `POST /api/v1/admin/social/share`, `POST /api/v1/admin/social/shares/{share_id}/retry`, `DELETE /api/v1/admin/social/shares/{share_id}`, `POST /api/v1/admin/social/run` |
| `/platform/admin/marketplace` | Marketplace moderation: the review queue and every other status, approve, reject or unpublish with a note, featured and verified flags, the imported catalogue per source and the scheduled refresh (run it now). | `GET /api/v1/admin/registry?status=`, `POST /api/v1/admin/registry/{slug}/approve`, `POST /api/v1/admin/registry/{slug}/reject`, `POST /api/v1/admin/registry/{slug}/unpublish`, `POST /api/v1/admin/registry/{slug}/flags`, `GET /api/v1/admin/registry/import`, `POST /api/v1/admin/registry/import/refresh` |
| `/platform/admin/notifications` | Deployment alerts as an inbox (readiness, error rate, latency, breakers, drift, autopilot, SLM, tracing), operator channels (email, webhook, Slack, Teams), the delivery log and *Evaluate now*. | `GET /api/v1/admin/notifications`, `POST /api/v1/admin/notifications/read`, `POST /api/v1/admin/notifications/evaluate`, `GET|POST /api/v1/admin/notifications/channels`, `PATCH|DELETE /api/v1/admin/notifications/channels/{id}`, `POST /api/v1/admin/notifications/channels/{id}/test`, `GET /api/v1/admin/notifications/deliveries` |
| `/platform/admin/reports` | The report scheduler: state, deployment recipients, daily / weekly / monthly workspace counts, every digest sent (kind, window, recipients); *Send a digest now* for the deployment or one workspace to the configured recipients or an address; *Run the scheduler now*. | `GET /api/v1/admin/reports`, `POST /api/v1/admin/reports/send`, `POST /api/v1/admin/reports/tick` |
| `/platform/admin/automation` | The job board (section 18): every schedule the deployment runs for itself with its cadence, owning service and the state its component reports, the writers' crew (sources, house skills, marketplace picks, prompt size cap), the operator-triggered runs with their outcome and *Run now* per job (bypasses the lease and cadence, audited as `autonomy.run`). | `GET /api/v1/admin/autonomy`, `POST /api/v1/admin/autonomy/{job}/run` |
| `/platform/admin/newsletter` | Subscribers (filter, active only, add by hand, remove, CSV export) and campaigns: draft in plain text, live preview of the branded email, send a test to one address, send once to every active subscriber; sent campaigns stay as records. | `GET /api/v1/admin/newsletter`, `GET /api/v1/admin/newsletter/subscribers.csv`, `POST /api/v1/admin/newsletter/subscribers`, `DELETE /api/v1/admin/newsletter/subscribers/{id}`, `POST /api/v1/admin/newsletter/campaigns`, `GET|PATCH|DELETE /api/v1/admin/newsletter/campaigns/{id}`, `POST .../campaigns/{id}/test`, `POST .../campaigns/{id}/send` |
| `/platform/admin/operators` | Operator accounts (superadmins create, disable, promote and delete them; the last enabled superadmin cannot be removed) with each one's two-factor state and *Reset 2FA*, your own two-factor authentication (set up an authenticator, recovery codes, turn off), your own password and sessions. Superadmins open any operator's console sessions and revoke one or all of them - a compromised console account is locked out at once. | `GET|POST /api/v1/admin/operators`, `PATCH|DELETE /api/v1/admin/operators/{id}`, `GET|DELETE /api/v1/admin/operators/{id}/sessions`, `POST /api/v1/admin/auth/password`, `POST /api/v1/admin/auth/mfa/setup`, `POST /api/v1/admin/auth/mfa/enable`, `DELETE /api/v1/admin/auth/mfa`, `POST /api/v1/admin/auth/mfa/verify`, `GET /api/v1/admin/auth/sessions`, `POST /api/v1/admin/auth/logout` |
| `/platform/admin/audit` | Deployment-wide account-lifecycle log: signups, sign-ins and failed attempts, password and email changes, invitations, roles, removals, deletions and every operator action (actor `operator`). Filter by action (the menu lists every action with its count), actor type, time window, country, user, workspace or free text over actor, IP, place and details; each row shows where it came from; page back with *Load older*; export the matching events as CSV. | `GET /api/v1/admin/audit-log` (`action`, `actor_type`, `actor_id`, `user_id`, `account_id`, `q`, `country`, `after`, `before`, `limit`, `format=csv`) |
| `/platform/admin/mail` | Every email the platform composed with its transport (Microsoft 365, ACS, SMTP or outbox - a malformed setting is called out with the reason), delivery state and counters (composed, awaiting delivery, retrying, failed, skipped because the recipient opted out); each row carries its category (security, account, usage reports, alerts, support, product news); filter by recipient, kind, category or delivery; open a message to see the rendered HTML and plain text, its `List-Unsubscribe` headers, copy its link, resend it (with a transport) or delete it; the *Categories and opt-outs* table counts stored mail and users who switched each optional category off; send a test message to prove the transport. Without a transport this is the delivery channel. | `GET /api/v1/admin/mail` (`to`, `kind`, `unsent`, `failed`, `limit`, `offset`), `GET|DELETE /api/v1/admin/mail/{id}`, `POST /api/v1/admin/mail/{id}/resend`, `POST /api/v1/admin/mail/test` |
| `/platform/admin/settings` | The effective configuration grouped (sign-in, storage, services, learning, marketplace, mail, billing, observability) with the variable that sets each value; secrets show only whether they are set. The manageable settings (below) are edited in place by a superadmin - a switch for booleans, a bounded number, a choice, a list or text - with *Reset* back to the deployment's value, who changed it and when, a *restart* mark on the ones a background job read at start-up, and *Changed only* / *Editable only* filters. Retention: request rows, persisted telemetry, audit log and outbox sizes, and *Run the sweep now*. Test data: the users and workspaces signed up with a reserved testing domain (`example.com`, `localhost`, `*.test`) that a smoke, load or e2e run left behind, whether the deployment accepts such signups, and *Delete all test data* (superadmin). | `GET /api/v1/admin/settings`, `PUT|DELETE /api/v1/admin/settings/{name}`, `GET /api/v1/admin/retention`, `POST /api/v1/admin/retention/purge`, `GET|DELETE /api/v1/admin/test-data` |
| `/platform/admin/legal` | Terms of service and privacy notice (section 14): the version in effect and an announced one per document, the history with effective dates and summaries, how many users accepted; publish a new version (document, version, what changed, effective date with a warning under the 30-day notice, notify every user) and resend a notice. | `GET /api/v1/admin/legal`, `POST /api/v1/admin/legal/versions`, `POST /api/v1/admin/legal/versions/{version_id}/notify`, `GET /api/v1/admin/users/{user_id}/legal`, `POST /api/v1/admin/users/{user_id}/legal/accept` |
| `/platform/admin/photos` | The photographs of the public site: every picture the pages show with its caption, crop focus, photographer and licence, and the pages it appears on. Replace a picture (JPEG, PNG, WebP, GIF, BMP or TIFF up to 24 MB, at least 1200 px on the long side; stored at 2400 px as WebP; the caption, photographer, source page and licence are all optional, and a credit shows under the picture only when a photographer is named), move the focus with two sliders over a live preview, correct the caption or the credit, or put the shipped picture back; the pages show the change on their next load (the browser and the web server each keep the list of changes for ten seconds), and each card links the pages the picture is on and the audit log keeps who changed what. Slots marked *Placeholder* (the page-not-found and error pages) ship a brand gradient with no credit and are listed first so a deployment can put its own photograph there. The public manifest and the replaced pictures are read by every visitor without a key. Where the pictures are kept: with an object-storage container configured (`OSR_PLATFORM_SITE_PHOTOS_URL`, the Azure template provisions one) an upload goes to the container under a content-addressed name with a year-long immutable cache header, the row keeps its address and the pages embed that address, so no picture request reaches the API or the database and the site's image optimiser caches each size for a year; without a container the picture stays in the database and the API serves it. The page says which applies, and when pictures uploaded before the container was configured still sit in the database, *Move them to storage* moves them (idempotent, audited). The public picture address redirects to the storage URL only when that URL is inside the configured container; a row that names any other host is served as the shipped picture instead. The form checks the source page and the file size before the upload starts and says what to fix. The review of this path, and what it changed, is docs/reviews/SITE_PHOTOS.md (`docs/reviews/SITE_PHOTOS.md`). | `GET /api/v1/admin/site/photos`, `PUT /api/v1/admin/site/photos/{key}` (multipart: `file`, `alt`, `focus`, `photographer`, `page`, `license`, `clear`), `DELETE /api/v1/admin/site/photos/{key}`, `POST /api/v1/admin/site/photos/offload`; public `GET /api/v1/site/photos`, `GET /api/v1/site/photos/{key}` |
| `/platform/admin/search` | Support search from the header box: users by email or name, workspaces by name, slug or email, API keys by prefix, operators, tenants by slug and request ids - each hit links to its page. | `GET /api/v1/admin/lookup?q=` |

Every `/api/v1/admin/*` call is written to the access log with `operator:<username>` (or
`operator:admin-token`) as the actor. The console is served by the web app but every action is an API
call, so the same administration works from scripts with the static token. Operators keep both portals
open at once: the console's account menu leads to the workspace dashboard (a superadmin opens any
workspace from `/platform/admin/workspaces`) and the dashboard's account menu leads back to the console
while an operator session exists.

### Runtime configuration

Every setting comes from the process environment (`OSR_PLATFORM_*`; the deployment guides in
[QUICKSTART_PLATFORM.md](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/QUICKSTART_PLATFORM.md) and `platform/README.md` list them).
The tunable subset - sign-in and signup policy, the operator two-factor policy, plan and pricing defaults,
spending bounds and reservations, digest and support recipients, newsletter and blog publishing, the growth
engine (posts per day, auto-publish, writer targets, keywords, IndexNow, the paid-search budget, LinkedIn
sharing), marketplace review and the harvest cadence, rankings refresh, the router's decision cache, the
`/v1/responses` conversation store, the routing model's reload and per-workspace training thresholds, provider
reload and keep-warm, the gateway timeout, the finance, overage and trial sweeps, the launch coupon, retention
windows, the trusted proxies, the output guard and the Application Insights export - can also be managed from the
console without a redeploy. `GET /api/v1/admin/settings` marks those entries `editable` with their
`kind` (`bool`, `int`, `float`, `str`, `list`, `choice`), bounds and choices, whether a change is `live`,
the `source` of the effective value (`default`, `env` or `override`), the environment's own value and
who changed it when. `PUT /api/v1/admin/settings/{name}` with `{"value": ...}` (superadmin) checks the
value against the setting's kind and bounds (400 with the reason; 404 for a setting that is not manageable
at run time), stores it in `settings_overrides`, applies it to the process that took the call at once and
writes `settings.updated` to the audit log; `DELETE` removes the override (`settings.reset`) so the
environment's value is in force again. The overrides travel as one document: `GET /api/v1/admin/settings/export`
(superadmin) returns `{"overrides": {name: value}}` - keep it in version control, and apply it to another
deployment with `PUT /api/v1/admin/settings` `{"overrides": {...}, "replace": false}`; every value is checked
first and one refused value stops the whole import with the list of problems (400), otherwise the process
reconfigures once and the reply names what `changed`, what was `unchanged`, what was `removed` (`replace: true`
drops the overrides the document does not name) and which changes wait for a `restart`; one `settings.imported`
audit entry carries the same lists. The console's *Overrides as code* card does both. An override wins over the
environment, survives restarts and
reaches every other process - API replicas and the domain services - on their next reload
(`OSR_PLATFORM_PROVIDERS_RELOAD_S`, 30 s by default, the cadence console-managed state is re-read at).
Settings that are read per request (`live`) take effect immediately; the ones a background job captured
when it started (the retention sweep, the alert and digest schedulers, the feed poller, `/metrics`
access) are stored and marked *restart* - they take full effect after the next deploy or restart.
Secrets, storage URLs, Kafka, the service topology and every other value stay in the deployment and
are read-only in the console.

The plan catalogue is managed the same way: `PATCH /api/v1/admin/plans/{name}` (superadmin) changes a
plan's `requests_per_month`, `requests_per_minute`, `max_keys`, `max_tenants`, `max_members`,
`retention_days`, `activity_days`, `activity_events_per_day`, `trial_days`, `trial_needs_card`, `price_usd_month`,
`price_usd_year`, `overage_usd_per_100k`, `sla_uptime_pct`, `contact_sales`, `support`, `features` or
`description` (400 names an invalid field or value; 404 for an unknown plan), stores the change as the
`plan_overrides` override (merged per plan onto `OSR_PLATFORM_PLAN_OVERRIDES`), rebuilds the catalogue every
process shares so the workspaces on the plan get the new quota on their next request, and writes
`plans.updated` to the audit log; `DELETE /api/v1/admin/plans/{name}` (`plans.reset`) removes a plan's
run-time changes. `GET /api/v1/admin/plans` lists each plan with its `overrides` and `env_overrides`.

## 14. Privacy and data handling

* Prompt text is routed in memory and, when execution is requested, forwarded to the configured
  provider. The platform stores request metadata (endpoint, target, domain, complexity, token and cost
  counts), not the prompt or the answer.
* `profile` values are hashed before they reach the learners.
* PII detection runs on every request; `contains_pii` together with a tenant `data_boundary` keeps
  sensitive requests on private or on-prem targets. Details: [security model](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/SECURITY.md).
* On a deployment that exports to Application Insights the web pages report their own operation facts to the
  operator's resource (section 10 *Monitoring*): page views by route shape, Core Web Vitals, uncaught errors, API
  calls by method, path and status, and the named moments of the flow - with the workspace id as the user and a
  per-tab session id, never a prompt, an answer, a key or a free-text field. It is first-party operations data of
  the deployment, held in the operator's own Azure subscription, not an advertising or marketing service.
* The public web pages load Google Analytics 4 in consent mode: no analytics or advertising cookies
  until the visitor accepts the banner, IP addresses truncated, nothing on the dashboard. The choice is
  stored in `localStorage` (`osr-consent`) and can be changed on the privacy page. Besides page views the
  tag records Core Web Vitals, clicks on outbound links and calls to action, code-snippet copies and the
  sign-up / sign-in events. Operators of a
  self-hosted web image leave `NEXT_PUBLIC_GA_MEASUREMENT_ID` empty to ship no tag at all.
* The site also keeps its own, first-party **visitor journey** (`POST /api/v1/journey`): page views on the
  public pages, calls to action and the signup form's progress for a random id that lives in the browser tab's
  `sessionStorage` (`osr-journey`, gone when the tab closes) and is stored hashed with the deployment secret. No
  cookie, no IP address, no e-mail; a path keeps none of its query string but a few harmless keys (`plan`,
  `tab`), a referrer only its origin. A signup joins the visitor to the new workspace so the console can count
  how many visitors sign up and from which pages; the rows are deleted after 400 days and a workspace's rows go
  with the workspace. The one-question surveys (`POST /api/v1/journey/answer`: *what is holding you back?* for a
  visitor, *what is in your way?* for a signed-in workspace) store the chosen reason and the person's optional
  line; a workspace's answer is replaced by its next one and audited as `journey.answered`.

### Where a request's data goes

The same picture a security reviewer asks for first: what the hosted platform holds, what passes through it, and
what leaves it. Solid arrows carry request text; dashed arrows carry metadata only (ids, counts, prices, places).

```mermaid
flowchart LR
  subgraph you["Your side"]
    B["Browser extension / desktop app<br/>local analysis, redaction before send"]
    A["Your application<br/>SDK, REST, OpenAI-compatible, MCP"]
  end
  subgraph platform["Hosted platform (your region)"]
    G["API gateway<br/>keys, sessions, tenant boundary"]
    R["Router<br/>signals, policy, scoring - in memory"]
    X["Executor<br/>input guard, PII redaction, output guard"]
    DB[("PostgreSQL<br/>metadata: endpoint, target, tokens, cost,<br/>latency, outcome, audit, sessions")]
    L[("Request logs<br/>only with policy log_payloads;<br/>redacted or full, per workspace")]
    T[("Telemetry<br/>spans and events: ids, digests, lengths")]
    C[("Cache<br/>SHA-256 keys, per workspace")]
  end
  subgraph out["Model providers you connected"]
    P["Provider APIs<br/>cloud, on-prem, self-hosted"]
  end
  B --> G
  A --> G
  G --> R
  R --> X
  X --> P
  P --> X
  R -. decision .-> DB
  X -. usage, outcome .-> DB
  X -. spans, events .-> T
  X -- when the policy says so --> L
  X <-. hashed key .-> C
```

What the arrows mean in practice: prompt text enters the router in memory and goes to **the one provider the
decision chose** - a provider outside a tenant's `data_boundary` or marked `pii_allowed: false` for a request that
carries personal data is never a candidate (section 9), and the redactor masks personal data before the model sees
it when the guard is on. The database keeps **metadata**: which endpoint, which target, token counts, cost, latency,
the outcome you reported, the hash-chained audit entries, sessions with their address and place (section 1
*Sign-in locations*). Prompt and answer text is stored only where a workspace admin turned `log_payloads` on, in
the mode chosen (`redacted` masks personal data first), and is deleted with `DELETE /api/v1/activity/logs` or the
retention sweep. Telemetry carries a text digest and length, never the text. The cache, when enabled, keys on a
SHA-256 of the request inside the workspace. Backups (below) copy the database - so never prompt text unless
request logging is on. Mail goes to the transport you configured; the public pages' analytics are described above.

### Incidents: what happens when something breaks

* **Detection.** Every component - API readiness, database, cache, event bus, mail, each service and each
  connected provider - is sampled every `OSR_PLATFORM_STATUS_SAMPLE_S` (60 s) by one replica; three failed samples
  in a row open an incident on the public status page (`/status`, `GET /api/v1/status/board`, RSS at
  `/api/v1/status/feed`) and three good ones resolve it. Deployment alerts (readiness, 5xx share, latency,
  breakers, drift, stale backups, broken audit chain, margins) fire to the operators' channels
  (section 10 *Notifications*) and the console's bell; workspace alerts (budgets, quota, wallet, failed requests,
  failing quality gates) go to the workspace inbox and its owners.
* **Communication.** Operators write updates on the incident from the console (`/platform/admin/health`,
  `POST /api/v1/admin/status/incidents/{id}/updates`); each update is on the status page and in the feed with its
  time. Customers see the component state on the status page and the dashboard banner while it lasts.
* **Containment.** A failing provider is isolated by its health breaker and traffic re-routed to the next
  admissible target (`execute.fallback` in the trace); a bad release is rolled back by re-activating the previous
  revision; a compromised key is revoked (`DELETE /api/v1/keys/{id}`) and a compromised account signed out
  everywhere by its owner or an operator - both audited.
* **Recovery.** The database is restored from the nightly archive or, on managed state, to a point in time
  (*Backups and recovery targets* below); the daily restore drill proves the archive before it is needed.
* **Review.** Every incident keeps its timeline (opened, updates, resolved) for the 90-day history the status page
  shows; the audit log holds what operators did meanwhile, with the place they did it from. Vulnerabilities are
  reported as [SECURITY.md](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/security-policy.md) describes; a confirmed security incident that affects customer data is
  announced on the status page and by e-mail to the owners of the affected workspaces.

### Access control, announcements and four-eyes approvals

Three controls an operator reaches for during an incident or a change, each on its own console page:

* **Access control** (`/platform/admin/access`, `GET /api/v1/admin/access`). An **IP block** refuses an address
  or CIDR range at the door - before sign-in, rate limits or routing see the request - with a reason and an
  optional expiry (`POST /api/v1/admin/access/ip-blocks`, `DELETE /api/v1/admin/access/ip-blocks/{id}`); the answer
  is `403` with `X-Blocked: ip` and the hits are counted so the operator sees whether the block is still being
  tried. A range wider than a /8 (IPv4) or /32 (IPv6) is refused. **Maintenance mode**
  (`PUT /api/v1/admin/access/maintenance`, superadmin) pauses customer traffic: every request answers `503` with
  `Retry-After`, `X-Maintenance: 1` and the operator's message, except reads when *Keep reads working* is on
  (`GET` keeps serving so dashboards stay readable while writes and routing pause). Operators, health probes,
  the status page, `/api/v1/info` and `GET /api/v1/maintenance` - the public state the dashboard shows as a
  banner - are never blocked: they are how the incident is run. Blocks and the switch reach every replica
  within ten seconds. **Emergency revocation** (`POST /api/v1/admin/access/keys/revoke`) revokes API keys across
  workspaces by filter - workspaces, key prefixes, created before, unused since - with a dry run first (the
  default) that lists what would go; a real run needs the superadmin role and a reason and is audited with the
  filter and the counts (`access.keys_revoked`, plus one `key.revoke` row per workspace touched).
* **Who may sign up** (on the same page; `GET /api/v1/admin/signup-guard`). Self-serve registration is the most
  abused door of a hosted platform, so it has its own guard, run by every path that creates a person - the
  sign-up form, OAuth sign-in, the change-of-address flow and SCIM provisioning. A **blocklist**
  (`POST /api/v1/admin/signup-guard/rules` `{kind: email|domain, value, reason?, expires_in_s?}`,
  `DELETE .../rules/{id}`) refuses an exact address or a domain *and its subdomains* with `403` and
  `X-Blocked: email` / `X-Blocked: domain`, counts the hits and expires when asked. The **policy**
  (`PUT /api/v1/admin/signup-guard/policy`, superadmin) refuses the well-known temporary-inbox domains (on by
  default, with a curated list the operator extends through domain rules), refuses `+tag` aliases, limits
  sign-ups to approved domains (a private deployment lists its own; subdomains count) and caps the sign-ups one
  *registered* domain may make per day - `a.cc.cd` and `b.cc.cd` count as one registrant, so a bot on a fresh random
  subdomain per account does not slip under the cap (freemail providers are never capped, so a team arriving from one
  company is not mistaken for abuse; `429`). **Machine-generated addresses** (`block_generated`, on by default) are
  refused on the shape of the registration alone: digits woven into the local part (`q3ztzslrq8-7`), a subdomain label
  mixing letters and digits (`we7d6.cc.cd`), a display name of a word and a hex tag (`Hermes 57ffe3`), a long run of
  consonants or a long dotless local part add up to a score; two or more is refused as `generated`, while
  `tom4ever@gmail.com`, `christophbrandt@gmx.de` or `jsmith@mail2.corp.io` stay under it. The sign-up form carries a
  **honeypot** field no person sees; a filled one is a bot
  and is refused with a generic `400`. With `OSR_PLATFORM_CAPTCHA_PROVIDER` set, the form also renders a **bot
  challenge** (Cloudflare Turnstile or hCaptcha) whose token the API verifies with the provider first. `auto_block_after`
  in the policy turns repeated refusals from one address - that many within an hour - into a 24-hour **IP block** on
  the access-control blocklist (created by *signup guard*, visible and liftable there), so a script that keeps trying
  is shut out at the door. Every refusal is logged with the domain, a hash of the address, the client
  address, the reason and the rule - never the address itself - and the page shows the last week by reason and
  by domain with a one-click *block it*; `POST .../check` `{email, name?}` says what the guard would do with an address
  - the verdict, the score and the registered domain - without logging anything. On the Users page the bulk actions
  *Block their e-mail domains* / *Block their addresses* (`POST /api/v1/admin/users/bulk` `block_domain` /
  `block_email`) clear a page of spam registrations in one move: a rule for each registered domain (freemail providers
  are refused - block the address instead) or address, and the users disabled; the rules are lifted on the Access page.
  An organization's own identity provider is trusted for the policy (its people are
  not disposable inboxes) but not past the blocklist. Rules and the policy reach every replica within ten
  seconds; the per-address rate limit (`OSR_PLATFORM_SIGNUP_PER_IP_PER_DAY`) and the reserved testing domains
  keep applying underneath. Audited as `signup_guard.blocked` / `signup_guard.unblocked` / `signup_guard.policy`;
  the refusals themselves are `user.signup_refused` lifecycle events.
* **Announcements** (`/platform/admin/announcements`, `GET|POST /api/v1/admin/announcements`,
  `PATCH|DELETE /api/v1/admin/announcements/{id}`, `POST /api/v1/admin/announcements/{id}/notify`). A notice with
  a title, a body, a severity (`info` / `warning` / `critical`), an audience - every workspace, the workspaces on
  chosen plans, or named workspaces - and a window (`starts_at` / `ends_at`). Within its window it is served to
  the signed-in users of the audience by `GET /api/v1/announcements` and shown as a banner above the dashboard;
  a dismissible one goes away per person once dismissed (`POST /api/v1/announcements/{id}/dismiss`), a
  non-dismissible one stays for the whole window (a scheduled maintenance, a breaking change). *Notify* opens an
  in-app notification in every workspace of the audience as well, for the people who are not looking at the
  dashboard that day. Maintenance mode appears through the same endpoint as a `critical` item, so a client has
  one place to look. At most 20 announcements may be live or scheduled at once.
* **Approvals** (`/platform/admin/approvals`, `GET /api/v1/admin/approvals`). The actions named in
  `OSR_PLATFORM_APPROVAL_ACTIONS` (editable at runtime under *Configuration*; the catalogue is `account.delete`,
  `account.plan`, `keys.bulk_revoke` and `maintenance.set`) are not carried out by the operator who asks for
  them: the handler files an approval request - `202` with the request for a deletion, `approval` in the answer
  for a plan change, the maintenance switch and a revocation - and a **different** operator approves
  (`POST /api/v1/admin/approvals/{id}/approve`, superadmin; the action runs as the approver) or rejects
  (`POST /api/v1/admin/approvals/{id}/reject`) it with a note; the requester may withdraw it
  (`DELETE /api/v1/admin/approvals/{id}`). A request nobody decides within 72 hours expires. The audit log carries
  both operators (`approval.requested`, `approval.approved` / `approval.rejected` / `approval.failed` /
  `approval.cancelled`) next to the action's own row. With a single enabled operator nothing can be approved - the
  page says so; the configuration page is where the gate is switched off again.

### Terms of service and privacy notice: versions and acceptance

The documents live on the site (`/terms`, `/privacy`); the platform keeps *which version applies* and
*who accepted what, when*. Each document has published versions with the date they take effect (a
version label is normally that date). The versions the site ships with are seeded on first start from
`OSR_PLATFORM_TERMS_VERSION` and `OSR_PLATFORM_PRIVACY_VERSION` (defaults `2026-10-01` and `2026-09-21`, the dates under
the page titles); later versions are published from the operator console.

* **Accepting.** The sign-up form has an "I have read and agree" box that must be ticked; the API records
  the acceptance when `POST /api/v1/signup` carries `accept_terms: true` (source `signup`). People who
  arrive through a sign-in provider, an invitation or an SDK/CLI signup have not accepted anything yet:
  `GET /api/v1/me` carries `legal` for browser sessions (`pending` = documents whose version in effect is
  not covered) and the dashboard shows a blocking prompt until they accept (`GET /api/v1/me/legal`,
  `POST /api/v1/me/legal/accept` with an optional `documents` list; source `review`). Every acceptance
  is stored with version, time, address and browser and appears under **Account › Agreements** and in
  the person's audit trail (`legal.accepted`). API keys are not people: `legal` is `null` for a key and
  a key is never blocked, so running systems are not interrupted by a change.
* **Changing a document.** Update the page text, deploy, then publish the version on the console page
  **Terms and privacy** (`POST /api/v1/admin/legal/versions` with `document`, `version`, `summary`,
  `effective_at`, `notify`). With an effective date in the future the change is announced and applies on
  that date - the terms promise at least 30 days and the console warns below that; with today's date it
  applies at once. `notify` e-mails every user (category `account`, cannot be switched off; outbox kind
  `legal_update`) with the summary, the date and the link, and opens an in-app notification per workspace
  (`rule: legal_update`). Until the date the dashboard shows the announced change as a banner that can be
  accepted early; once it is in effect the prompt reappears for everyone who has not. `GET /api/v1/legal`
  (public) lists current, announced and past versions - the legal pages render their "Last updated"
  date and version history from it; `GET /api/v1/admin/legal` adds acceptance counts,
  `POST /api/v1/admin/legal/versions/{version_id}/notify` resends a notice, and
  `GET /api/v1/admin/users/{user_id}/legal` / `POST /api/v1/admin/users/{user_id}/legal/accept` show a
  person's history and record an acceptance on their behalf (a signed agreement; source `operator`,
  audited).

### Backups and recovery targets

The hosted platform keeps every durable record (accounts, keys, usage, ledger, telemetry, audit chains
on the data share) in PostgreSQL. What protects it, and what a customer can count on:

| Control | Where | What it gives |
|---|---|---|
| Nightly logical backup | A scheduled job dumps both databases (`osr`, `osr_telemetry`) at 03:00 UTC to the deployment's backup share, 14-day retention | **Recovery point objective: 24 hours** for a logical restore - the most recent nightly archive. |
| Managed-state deployments | Azure Database for PostgreSQL flexible server with point-in-time restore, retention `OSR_POSTGRES_BACKUP_RETENTION_DAYS` (default 7), optional zone-redundant HA (`OSR_POSTGRES_HIGH_AVAILABILITY`) | RPO of minutes (continuous WAL backup) when `OSR_MANAGED_STATE=true`. |
| Database shares | The container databases keep their data on Azure Files shares; `OSR_STATE_FILES_PREMIUM=true` places them on a Premium (FileStorage) account - billed per provisioned GiB with no per-operation meters, lower latency. The move is a copy with the databases stopped (`infra/hooks/migrate-state-shares.ps1`), then a provision. | A database that pays per write is the storage account's largest meter; the Premium account removes it without changing the data service. |
| Daily restore drill | Every day the newest archive of each database is restored into a fresh PostgreSQL and the platform is booted against the restored data (migrations must apply, reads must succeed). An unrestorable backup raises an alarm the same morning. | **Recovery time objective: 1 hour** for a full restore of the hosted database; drills complete in minutes. |
| Revision rolls | Every platform deploy creates a new revision and shifts traffic once its probes pass | A bad release is rolled back by re-activating the previous revision, without touching the database. |

Point `OSR_PLATFORM_BACKUP_DIR` at that share, mounted read-only on the API role, and the go-live checklist on the
[operator console](#operator-console) carries the age of the newest archive beside every other promise:
*ok* under 30 hours, *action* beyond that, *blocked* when the directory holds nothing or the newest archive has
passed a week. Without the variable the item is absent - a deployment that backs up some other way is not asked
about this one. It is there because a job that stops writing is otherwise invisible: production once ran five
nights on a `pg_dump` that aborted on a single corrupt row, and only a failing build said so.

These are the targets the deployment is operated to; they are not a contractual SLA unless the plan's
[SLA report](#13-dashboard) says so. Prompt text and answers are never in a backup - they are not
stored (see the top of this section).

## 15. Errors

All errors are JSON: `{"detail": "..."}` with a conventional status code and an `X-Request-Id` to quote.

| Status | Typical cause |
|---|---|
| `400` | Invalid `objective`/`constraints`/policy keys, unknown target id in a policy, malformed email, an organization-only call on a personal workspace. |
| `401` | Missing, invalid or revoked API key (`WWW-Authenticate: Bearer`). |
| `403` | Feature not in plan (`X-Upgrade`), role too low, signup disabled. |
| `404` | Unknown target, tenant, model or listing; enterprise-only feature on a community deployment. |
| `409` | Duplicate signup, tenant or workspace slug; removing the last owner. |
| `422` | Body failed validation (the response lists the offending fields). |
| `429` | Rate limit, monthly quota or an exhausted workspace/tenant budget (`Retry-After`, `X-Quota-*`, `X-Budget-*`). |
| `501` | `/v1/chat/completions` on a deployment with no model provider configured and none connected by the workspace; or a named `model` / candidate list that no provider serves for this workspace (`llm-mid is not executable on this deployment: map it to a provider ...`). |
| `502` | The web app could not reach the API. |
| `503` | `/readyz` when a dependency is not ready (the body names the failing check). |

## 16. Support assistant

The **Help** button on every public page and in the portal opens the support assistant
(`osr_platform.assistant`). It answers from the product documentation - the same `docs/*.md`, `README.md` and
`CHANGELOG.md` the documentation site renders, minus the internal planning pages - through the platform's own
router: a BM25 index over the documentation sections (rebuilt when a file changes, no external service) selects
the passages, and the deployment's models answer from them - the `support-agent` target (a small self-hosted
model) first, any other executable LLM as fallback, metered on a dedicated *support desk* workspace like every
other request. The model is told to answer only from the passages, to cite them (`[1]`, `[2]`; the reply carries
the cited sources with links) and to say so when they do not cover the question. Without any executable model the
assistant still answers with the matching pages.

When a question needs a person - billing, an account, a bug, "talk to someone" - or the documentation does not
answer it, the reply carries `suggest_ticket: true` and a ready `ticket_draft` (subject, category, the
conversation), and the widget opens the ticket through the support desk (`POST /api/v1/support/tickets`, see
*Support tickets* above) for a signed-in user; a visitor is asked to sign in first and the draft is kept until then.

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/api/v1/assistant/chat` | `{message, conversation_id?, page?, stream?}` -> the reply, its sources, `suggest_ticket`, `ticket_draft`, `mode` (`router` or `docs`). With `stream: true` the answer arrives as server-sent events - `meta` (conversation id), `delta` per token, `done` with the same JSON - which is how the widget types the answer out. Public; a signed-in caller's threads belong to the workspace. Visitors: 20 questions per minute per IP. |
| `GET` | `/api/v1/assistant/conversations/{id}` | The thread (a visitor's by its unguessable id, a workspace's with its credential). |
| `GET` | `/api/v1/assistant/kb/search?q=` | The passages the assistant would read, with scores and excerpts. |
| `GET` | `/api/v1/assistant/status` | Model in use (or documentation-only mode) and the number of passages indexed. |

The assistant runs in the process that serves the documentation (`docs` domain in a split deployment) and needs no
configuration beyond a model provider; conversations are stored in `assistant_conversations` /
`assistant_messages`.

## 17. Growth: SEO and search advertising on autopilot

The growth engine (`osr_platform.growth`, console page `/platform/admin/growth`) brings visitors and turns them
into workspaces without an operator in the loop. It closes one loop, keyword -> page -> visitor -> signup ->
paying workspace, and every step of it runs through the platform's own router.

**Attribution.** On a visitor's first public page view the site remembers the first touch - the `utm_source` /
`utm_medium` / `utm_campaign` / `utm_term` / `utm_content` parameters, a `gclid` or `msclkid` click id (which
implies the paid network), the external referrer (scheme and host only) and the landing path - in the browser's
own storage, first party, no cookie, nothing sent anywhere. `POST /api/v1/signup` accepts it as `attribution`
and stores one row per workspace; the single sign-on paths (`POST /api/v1/auth/{provider}/callback` and
`/credential`) accept the same field and record it only when the sign-in creates a user, so a workspace that
arrived through GitHub or Google is attributed like one that typed an address; malformed or missing attribution
never blocks a signup. The funnel
(`GET /api/v1/admin/growth/funnel?days=`) then counts, per source, medium and campaign, the signups, how many
confirmed their e-mail, routed at least one request and moved to a paid plan - the reward the rest of the engine
optimises against. The signup's audit entry (`user.signup`) carries `source` and `campaign` too.

**Activation and retained value.** The funnel is acquisition; whether a workspace reached a useful outcome and
came back is a separate report (`GET /api/v1/admin/growth/cohorts?weeks=`, the *Activation and retained value*
card on the growth page; `osr_platform.cohorts`). Signup-week cohorts carry: *executed* (the first metered request
that ran on a provider - a routing decision alone is not value), *accepted* (the first feedback with `success` or
`quality >= 0.5`), median and p90 time to each with the number of non-completers, *activation* (executed within
seven days of signup, over signups at least seven days old), *week-two* and *month-two retained value* (another
execution or accepted outcome on days 7-13 / 30-36 after activation, over activated workspaces fully observed
through the window's last day) and *paying* (a live Stripe subscription state - a plan state, never collected
revenue). Every ratio ships with its denominator; probe workspaces (`.local` e-mails) are excluded.

**Keywords.** A seed list (the terms the go-to-market plan wants to own: "llm router", "ai gateway open source",
"openrouter alternative self-hosted", ..., and the business phrases a buyer types - "ai cost optimization platform",
"llm cost management", "ai governance platform", "enterprise ai gateway", "avoid llm vendor lock-in" - each with the
page it should land on; the same vocabulary is added to every public page's search keywords by route, so the ads
and the pages agree) plus `OSR_PLATFORM_GROWTH_KEYWORDS`, the phrases the blog's posts
already carry, and - once Google Search Console is connected (`OSR_PLATFORM_GSC_SITE`, the property as
`sc-domain:example.com` or a URL prefix, and `OSR_PLATFORM_GSC_CREDENTIALS`, a service-account JSON with read
access, inline or a file path) - every query the site gets impressions for at position 30 or better. Proving
ownership of the site to Search Console and Bing Webmaster Tools is a website build setting: give the web
image `NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION` / `NEXT_PUBLIC_BING_SITE_VERIFICATION` and every page carries the
verification tag; when the site already sends Google Analytics, Search Console's *Google Analytics* method
verifies the property with no tag at all. Without a verified property nobody sees impressions, indexed pages or
crawl errors, and the keyword feed above stays off. Each
keyword has an intent (informational, commercial, comparison, navigational), a priority, an optional landing
page and a status: `active` (waiting for a page), `covered` (a public post names it; the engine records which)
or `paused`. Operators add, pause and delete keywords from the console.

**Content.** Every cycle the engine writes guides for the highest-priority uncovered keywords - at most
`OSR_PLATFORM_GROWTH_POSTS_PER_DAY` (2) a day so the blog grows steadily - *through OpenSmartRoute*: the router
chooses the writing model among the executable LLMs (`OSR_PLATFORM_GROWTH_WRITER_TARGETS`, small self-hosted
writers first), the `seo-writer` Agent Skills package shapes the prompt, and the documentation passages the
support assistant indexes are the only facts the model may use. The result is a blog post with a title the writer is asked to keep under 60 characters (cut at a word
within 70), a 50-160 character meta description (cut at a word within 160), Markdown sections, an FAQ, two to four internal links
and the keyword set, published at once (`OSR_PLATFORM_GROWTH_AUTO_PUBLISH=false` leaves it as a draft for the
blog console) and metered on a dedicated *growth* workspace. Posts without keywords get phrases mined from their
own text, and the console shows how many public posts have a snippet problem (long title, short description).
Every blog post - the feed articles too - carries its SEO metadata from the moment it is written: keywords (the
writer's, or phrases mined from the post's own text when it gave none), a meta description cut at a word within
160 characters, Open Graph and Twitter cards with the lead image, `article:published_time`, `news_keywords` and
`Article` plus breadcrumb JSON-LD on the page.

**Pages.** Every public page of the site keeps the same contract, and a test in the web app's suite enforces it
on the file tree, so a new landing, glossary term or use case cannot ship without it:
a title between 15 and 128 characters once the site name is appended, a description cut at
a word within 160 characters for the search snippet (200 for the social card - the page keeps its full lead on
screen), a canonical on the public origin that names the page's own route, an Open Graph and Twitter card, an
index/follow robots line, `BreadcrumbList` structured data, a title and a description no other page uses, and a
place in the sitemap. Filtered and paginated views of the marketplace, its kind pages and the blog are
`noindex, follow` so crawlers still reach every listing and post while only the clean address ranks; a record
the API does not have answers `noindex`, never a half page. The pages built from the live catalogue - models,
vendors, listings, posts, newsletter issues and the sitemap itself - read the API in strict mode: during an
outage they serve the last good copy or fail with a server error (which crawlers retry) instead of 404, so a blip never tells the engines a
thousand pages are gone. The sitemap also lists each blog category (`/blog?category=`), and a deployment can
submit every page of the live sitemap to IndexNow right after a release with the key the site serves at
`/indexnow.txt` (`INDEXNOW_KEY` on the web container; the marketplace chunks are left out by default, so the
daily quota goes to the pages that changed). Two families of pages are computed from the reference catalogue
rather than written: *head-to-head* comparisons (`/models/vs/<vendor>/<model>/<vendor>/<model>`, one page per
published pair - the strongest models against their nearest rivals from other vendors and their closest sibling,
with verdicts, the specification side by side, what three monthly workloads cost on each and the tiered rule that
uses both) and *best models for a task* (`/models/best-for/<task>`: coding, agents, reasoning, long documents,
high-volume chat, vision, structured output, self-hosting, summarisation, customer support, translation, free -
each ranking the models that meet the task's requirements by the index that measures it, with the best value and
the cheapest, and the rule that sends the hard requests to the strongest and the rest to the best value). The
glossary covers the terms people search for around the product - routers, gateways, semantic routing, prompt
injection, PII redaction, observability, context windows, structured output, reasoning models, Agent Skills, A2A,
the routing trace and the learning loop - each with a definition an assistant can quote, FAQ structured data and
links to the pages that do the thing.

**Indexing.** A published URL is pushed to the IndexNow engines (Bing, Yandex, Seznam, Naver and the others that
share the protocol) the moment it is public - posts published within a minute go as one batch - and, with Google
Search Console or Bing Webmaster Tools connected, the sitemap is submitted to them again right after (at most once
an hour), so Google re-reads it for the new address instead of waiting for its next crawl. The site also serves a
Google News sitemap of the posts published in the last two days at `/blog/news-sitemap.xml` (title, publication
date, keywords and the lead image of each), named in `robots.txt` and submitted next to the sitemap index. Each sweep also submits, once, every address the site's own
sitemap lists apart from the marketplace - the pages, the documentation, the comparisons, the model and vendor
pages - so a new page is known to those engines without waiting to be crawled; the key is derived from the
deployment secret and served by the
site at `/indexnow-key.txt` (`GET /api/v1/growth/indexnow-key`), so no configuration is needed
(`OSR_PLATFORM_GROWTH_INDEXNOW=false` turns it off). Google has no equivalent push for ordinary pages; it reads
the sitemap: `/sitemap.xml` is an index whose first file lists the site's pages, documentation, model and vendor
pages, followed by one file per thousand blog posts (the archive included) and one per five thousand marketplace
listings, each with the real modification date where one exists. Every submission and its status is listed in
the console (`GET /api/v1/admin/growth/indexnow`; `POST` re-submits given URLs or every public post).

**Search engines, beyond IndexNow.** The console page `/platform/admin/search-listings` (*Search and directories*)
shows what every engine received and what Google says is indexed. With Google Search Console connected
(`OSR_PLATFORM_GSC_SITE` + `OSR_PLATFORM_GSC_CREDENTIALS`) the sitemap index is **submitted** to the property
once a day (`sitemaps.submit`; the service account needs owner access for that - a refusal is recorded with its
reason and retried on the next cycle) and the **URL Inspection API** is asked about the pages that matter - the
home page, pricing, documentation, models, marketplace, downloads, the blog and comparisons, then the newest
posts - twenty a cycle, each read again after a week, so the console shows a verdict (`PASS`, `NEUTRAL`,
`FAIL`), the coverage state and the last crawl per address instead of assuming a submitted page was found. With
a **Bing Webmaster Tools** API key (`OSR_PLATFORM_BING_WEBMASTER_API_KEY`, *Settings > API access* in Webmaster
Tools) the sitemap goes to Bing's `SubmitFeed` daily as well, and every address IndexNow accepted is also sent to
Bing's `SubmitUrlBatch` within the site's daily quota (read first; what does not fit waits for the next cycle).
Three steps of the cycle - `search_engines`, `index`, `directories` - run this; `POST
/api/v1/admin/growth/search-engines/submit` runs them now.

**Directories.** The same page keeps the register of the directories a product like this is listed in - Product
Hunt, AlternativeTo, G2, Capterra, SaaSHub, SourceForge, OpenAlternative, LibHunt, the awesome-MCP list, the MCP
Registry, Glama, Smithery, There's An AI For That, Futurepedia, Toolify, TopAI.tools, BetaList, Indie Hackers,
Crunchbase, Wikidata - and of the stores and registries the packages are published on (PyPI, npm, the Visual
Studio Marketplace, the Chrome Web Store, Edge Add-ons, Firefox Add-ons, Artifact Hub, the Azure Marketplace);
operators add their own rows and skip the ones that do not apply. The **listing kit** is written once through the
router (`growth-directories`; the documentation's own wording until a writing model is executable): a tagline
under 60 characters, descriptions at 160, 300 and 1,000, categories and tags, next to the links, the logos and
the facts every form asks for, each with a copy button (`GET /api/v1/admin/growth/kit`; `POST` rewrites the
texts; `/kit/email` is the plain-text version). Nothing fills in a third party's form on your behalf: a row
links to the directory's submission page, and where a directory accepts listings by e-mail the kit is sent from
the platform's mailbox and the row marked submitted (`POST /api/v1/admin/growth/directories/{id}/submit`). From
then on the platform **verifies** the listing itself: paste the listing's address (or leave the directory's search
page the register knows) and every cycle reads it for the site's address - daily while pending, weekly once live -
marking the row *live*, or *missing* when a listing disappears; a package registry is live when its address
answers. Next to every row stand the visits and signups whose referrer is that directory (the visitor journey and
the signup attribution), so an operator sees which listings pay. Everything is audited
(`growth.directory_submitted`, `growth.directory_updated`, `growth.search_engines_submitted`, `growth.kit_written`).

**Social.** The engine keeps the company's LinkedIn presence going without anyone writing posts. Connect the
deployment's LinkedIn app once from the console (*Connect LinkedIn* on `/platform/admin/growth`;
`OSR_PLATFORM_LINKEDIN_CLIENT_ID` and `OSR_PLATFORM_LINKEDIN_CLIENT_SECRET` are the app's credentials, the
redirect URL to register on the app is `<API>/api/v1/growth/linkedin/callback`) - as your own profile
(*Share on LinkedIn* product, scope `w_member_social`) or as a company page (*Community Management API*,
`w_organization_social` + `r_organization_admin`; after consent the console lists the pages the member
administers and you pick the one to post as, or name its id up front). The OAuth tokens are sealed at rest
with the deployment secret, renewed with the refresh token when LinkedIn issues one, and never shown to the
console; a fixed token works too (`OSR_PLATFORM_LINKEDIN_ACCESS_TOKEN` + `OSR_PLATFORM_LINKEDIN_AUTHOR`, a
`urn:li:organization:<id>` or `urn:li:person:<id>`). Then, every `OSR_PLATFORM_LINKEDIN_INTERVAL_S` (4 h;
0 = only from the console) the engine publishes one post: the newest public blog post not shared in the last
30 days first, then the documentation pages in rotation. The commentary is written *through the router* (the
same writer targets and skill as the guides; a plain title + summary + link when no model is executable),
escaped for LinkedIn's *little text* format, capped at 3,000 characters with up to five hashtags, and the
URL travels as an article card (title, description). A blog post is also shared the moment it is published -
by the engine, the content curator or an operator - unless something was shared in the last hour
(`OSR_PLATFORM_LINKEDIN_SHARE_ON_PUBLISH=false` turns this off; the cadence then picks it up first). Every
post, success or LinkedIn's refusal, is recorded with its permalink (`GET /api/v1/admin/growth/social`) and
audited (`growth.social_posted`, `growth.social_failed`, `growth.social_connected`, ...); one replica posts
at a time (Redis lease) and a refusal never resets the cadence. `POST /api/v1/admin/growth/social` posts now:
the engine's pick, a given blog `slug`, or a given `text`. `OSR_PLATFORM_LINKEDIN_API_VERSION` is the
`LinkedIn-Version` header (YYYYMM). The same app signs people in (`Continue with LinkedIn`, section 1) unless
`OSR_PLATFORM_LINKEDIN_SSO=false`. When the syndication service (section 19) has an enabled LinkedIn channel that
shares automatically, the engine's own LinkedIn sharing stands down (`delegated: true` on
`GET /api/v1/admin/growth/social`) so a post is never shared twice; a LinkedIn channel without a token of its
own posts with the connection made here.

**Advertising.** Commercial, comparison and navigational keywords with a landing page - and informational ones once a
guide covers them - are grouped by landing page into one search campaign per network (`OSR_PLATFORM_ADS_NETWORKS`, `google,microsoft`)
with phrase-match keywords and a responsive search ad whose ten headlines (<= 30 characters) and four
descriptions (<= 90) the router writes from the documentation. A Thompson-sampling allocator spreads
`OSR_PLATFORM_ADS_DAILY_BUDGET_USD` across the *enabled* campaigns in proportion to how often each one has the
highest signups-per-click rate (Beta posterior over the network's clicks and conversions plus the signups
attributed to the campaign's `utm_campaign`), with a 5 % floor so every campaign keeps being explored. Campaigns
are always exportable as Google Ads Editor and Microsoft Advertising bulk CSV
(`GET /api/v1/admin/growth/campaigns/export?network=`). With the Google Ads REST credentials
(`OSR_PLATFORM_GOOGLE_ADS_DEVELOPER_TOKEN`, `_CLIENT_ID`, `_CLIENT_SECRET`, `_REFRESH_TOKEN`, `_CUSTOMER_ID`,
optional `_LOGIN_CUSTOMER_ID` for a manager account) and a budget above zero, the engine creates and updates the
Google campaigns itself (`POST /api/v1/admin/growth/campaigns/sync` does it now) and pulls their impressions,
clicks, cost and conversions back. Safety: a new campaign is `draft` until an operator enables it, nothing is
pushed while the daily budget is 0, and campaigns are created **paused** in Google Ads unless
`OSR_PLATFORM_GOOGLE_ADS_ENABLE_CAMPAIGNS=true` - three switches an operator flips on purpose. The loop is
closed in both directions. Every campaign carries a tracking suffix (`utm_source`, `utm_medium=cpc`,
`utm_campaign=<campaign>`, `utm_term={keyword}`) - set on the Google campaign and written into the bulk CSV
addresses - so a click lands with the campaign named and the signup's first-touch attribution counts towards it;
the allocator's reward is those signups plus the network's own conversions. With a conversion action
(`OSR_PLATFORM_GOOGLE_ADS_CONVERSION_ACTION`, its resource name or id) every signup that arrived through a Google
click id is uploaded back to Google Ads as an offline conversion, dated, once (the `conversions` step; a refused
batch stays pending for the next pass), so *maximize conversions* bids on real signups instead of a landing-page
tag the platform cannot place. And a sync pushes more than status and budget: the ad group's keywords are
reconciled with the campaign's list (added and removed to match), and copy rewritten in the console after the
last push replaces the ad upstream (new ad created, the old one removed).

The cycle - Search Console, keywords, guides, snippet repairs, IndexNow, the search-engine sitemaps and URL
submissions, the index inspection, the directory checks, LinkedIn, campaign planning, metrics,
budget, sync - runs every `OSR_PLATFORM_GROWTH_INTERVAL_S` (6 h; 0 = only from the console) in the process that
owns the documentation, one leader per Redis lease; every step is isolated so one failing integration never
stops the others, and every run is recorded (`GET /api/v1/admin/growth/runs`). `POST /api/v1/admin/growth/cycle`
runs it now, optionally only some `steps`. Everything the engine does is in the audit log
(`growth.guide_written`, `growth.keyword_added`, `growth.campaign_updated`, `growth.campaigns_synced`, ...).

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/v1/growth/indexnow-key` | The IndexNow key as `text/plain` (the site serves it at `/indexnow-key.txt`). Public. |
| `GET` | `/api/v1/growth/linkedin/callback` | Where LinkedIn returns the operator's browser after consent (the app's redirect URL); stores the connection and goes back to the console. Public. |
| `GET` | `/api/v1/admin/growth` | Engine status (writer, IndexNow, Search Console, advertising mode, LinkedIn, keyword counts, snippet audit, last run) and the 30-day funnel. |
| `GET` | `/api/v1/admin/growth/funnel?days=` | Signups per channel with verified / active / paid counts. |
| `GET` | `/api/v1/admin/growth/cohorts?weeks=` | Signup-week cohorts: first execution and first accepted outcome (time to each, non-completers), activation within seven days, week-two and month-two retained value with explicit denominators, paying as a plan state. |
| `GET` | `/api/v1/admin/growth/runs` | The recorded cycles, newest first. |
| `POST` | `/api/v1/admin/growth/cycle` | Run a cycle now; `{steps: [...]}` limits it. |
| `GET`, `POST` | `/api/v1/admin/growth/keywords` | List (with counts, `?status=`) and add (`term`, `intent`, `landing`, `priority`). |
| `PATCH`, `DELETE` | `/api/v1/admin/growth/keywords/{term}` | Change intent, status, landing or priority; remove. |
| `GET` | `/api/v1/admin/growth/queries?days=` | Search Console queries aggregated over the window. |
| `GET` | `/api/v1/admin/growth/campaigns` | Campaigns with their ad, budget and metrics. |
| `PATCH`, `DELETE` | `/api/v1/admin/growth/campaigns/{cid}` | Enable or pause, set the budget or landing page; remove. |
| `POST` | `/api/v1/admin/growth/campaigns/{cid}/ads` | Write the responsive search ad again through the router. |
| `GET` | `/api/v1/admin/growth/campaigns/export?network=` | Google Ads Editor or Microsoft Advertising CSV. |
| `POST` | `/api/v1/admin/growth/campaigns/sync` | Create or update the Google campaigns now. |
| `GET`, `POST` | `/api/v1/admin/growth/indexnow` | Recent submissions; submit URLs (or every public post) again. |
| `GET` | `/api/v1/admin/growth/search-engines` | IndexNow, Google Search Console and Bing Webmaster: sitemap submissions, URL counts, the index verdicts per page, the directory counts. |
| `POST` | `/api/v1/admin/growth/search-engines/submit` | Submit the sitemaps and the pending addresses now (`force` resubmits today's sitemaps; `inspect: false` skips the index read). |
| `GET`, `POST` | `/api/v1/admin/growth/directories` | The register with each listing's status, last check and referrals; add a directory of your own. |
| `PATCH`, `DELETE` | `/api/v1/admin/growth/directories/{id}` | Change the status, the listing or submission address, notes; remove a directory you added (built-ins are skipped, not deleted). |
| `POST` | `/api/v1/admin/growth/directories/check` | Re-read every listing now. |
| `POST` | `/api/v1/admin/growth/directories/{id}/check` | Re-read one listing now. |
| `POST` | `/api/v1/admin/growth/directories/{id}/submit` | E-mail the kit to the directory (or record that its form was submitted). |
| `GET`, `POST` | `/api/v1/admin/growth/kit` | The listing kit; rewrite its texts through the router. |
| `GET` | `/api/v1/admin/growth/kit/email` | The kit as the plain-text e-mail a submission sends. |
| `GET`, `POST` | `/api/v1/admin/growth/social` | LinkedIn status (author, cadence, connection, next due) and the recorded posts; post now (`slug`, `text`). |
| `POST` | `/api/v1/admin/growth/social/linkedin/connect` | Start the consent flow (`author` = a page id or URN, or `pages: true` to pick one afterwards); returns the URL to open and the redirect URL to register. |
| `GET` | `/api/v1/admin/growth/social/linkedin/organizations` | The company pages the connected member administers. |
| `PATCH`, `DELETE` | `/api/v1/admin/growth/social/linkedin` | Post as another page or person (`author`); forget the stored connection. |

## 18. Autonomy: the platform's schedules route through the router

Everything the platform does for itself on a schedule - the blog posts written from the news feeds, the daily
newsletter, the SEO guides, the responsive search ads, the LinkedIn posts, the model rankings, the marketplace
harvest, the routing autopilot, the usage digests and the alert evaluation - is a **job** on one board, and every
job that produces text is a customer of the router in the same way a workspace is. The editorial and growth
writers do not pick a model: they call `route(plan=True)`, so the decision composes a persona, one or more
skills and the model around the job's own house skill (`editorial-writer` for the blog and newsletter,
`seo-writer` for guides, ads and social posts), and they offer the router a **crew** of companions as
`extra_targets` - the deployment's own Agent-Skills packages, whatever the
editorial / growth workspace installed from the marketplace, and the best-rated published marketplace skills,
personas and prompts whose listing matches the job's topics (a prompt without `{{variables}}` becomes an
instructions-only skill; the OpenSmartRoute [Marketplace](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MARKETPLACE.md) harvests them daily). The composed
system prompt is what the plan executor builds from those slots, every slot is credited with an
outcome, and once the job has parsed what came back it **grades** the run: a publishable article, ad or post is
a high-quality outcome (`source: response:grade`) for the model *and* its companions, an unusable answer a
failure. Those grades reach the learners, the per-target statistics on `/platform/dashboard/learning`, and - with
`OSR_PLATFORM_TRAINING_EVENTS` - the SLM service's training feed, so the routing SLM is trained on the
platform's own traffic exactly as on a customer's. The plan a post was written with travels with it
(`routing.plan` on `GET /api/v1/blog/{slug}` and in the console).

Two things distinguish the platform's own writers from a customer's request. Their material is public - the
news feeds, the documentation, the site's own pages - so a support address quoted in a documentation passage
is not a customer's PII and does not pin the job to on-prem models (`RequestConstraints.contains_pii=False`).
And the router judges the **assignment, not the quoted material**: a news story about jailbreaks or a security
page that spells out an injection is what the writer is asked to write *about*; judged on the whole prompt, the
signals read it as an attack and the policy would leave the job with no admissible target (seen live on the
search-ad writer). The route decision is therefore made on the prompt without its quoted block, while the
model receives the full prompt.

**The product pages' walk-throughs are answered by the router too.** The desktop and extension pages play a
person typing a draft into an app and the answer landing; the quotes on those pages were always real
(`POST /api/v1/estimate`), and the answers now are as well. The `showcase` job answers each of the walk-through
drafts through the same path as every other job - routed with a plan among the executable models, run, metered on
the platform's own `showcase` workspace, graded - and `GET /api/v1/showcase` serves them anonymously with the
target, model, tokens, price and latency each one really cost. A draft with personal data is answered in the form
the app sends it (redacted, `<PHONE_1>`). The answers are re-written every `OSR_PLATFORM_SHOWCASE_REFRESH_S`
(a day; 0 = only from the board) and kept in `<data_dir>/showcase.json`; until the first run - or on a deployment
without an executable model - the endpoint says so (`answer: null`, `error`) and the page shows that rather than
an invented answer.

| Setting | Default | Meaning |
|---|---|---|
| `OSR_PLATFORM_AUTONOMY_CREW` | `true` | Offer the crew to the router. `false` routes with the house skill alone. |
| `OSR_PLATFORM_AUTONOMY_CREW_SIZE` | `12` | Marketplace picks per job (best-rated first; the catalogue is searched once an hour per job). |
| `OSR_PLATFORM_AUTONOMY_CREW_MAX_CHARS` | `8000` | Longest companion body the writers load into a prompt; long documentation skills stay out of a blog post's context. |
| `OSR_PLATFORM_AUTONOMY_SLOT_MIN_CONFIDENCE` | `0.5` | A persona or skill the router proposed joins the prompt only at or above this slot confidence (the house skill always does): a doubtful companion next to the house skill can push a small model into an unusable draft. |
| `OSR_PLATFORM_SHOWCASE_REFRESH_S` | `86400` | How often the product pages' walk-through answers are written again through the router (0 = only from the board). |

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/v1/showcase` | The walk-through drafts with the answer the router wrote for each and what it cost (anonymous, cached five minutes). |
| `GET` | `/api/v1/admin/autonomy` | Every job (`content`, `newsletter`, `growth`, `social`, `syndication`, `showcase`, `rankings`, `marketplace`, `autopilot`, `reports`, `alerts`) with its cadence, owning service, state and writer (model, skill, crew offered, grades); the crew's summary; the recent operator runs. The console shows it on `/platform/admin/automation`. |
| `POST` | `/api/v1/admin/autonomy/{job}/run` | Run one job now, bypassing its lease and cadence (audited as `autonomy.run`); 404 for a job this deployment does not run (no autopilot, harvest off). |

## 19. Syndication: every article on your social networks, chat channels and blogs

Everything the blog publishes - the posts the curator writes from the news feeds, the SEO guides the growth
engine writes, anything an operator publishes by hand - goes out to the **channels** an operator sets up on
`/platform/admin/social` (`osr_platform.social`; `OSR_PLATFORM_SOCIAL=false` removes the service). A channel is
one account on one network:

| Group | Networks | What goes out |
|---|---|---|
| Social networks | LinkedIn (a profile or a company page), X, Facebook page, Threads, Mastodon, Bluesky, Reddit (a subreddit) | A message - title, summary, link and hashtags from the post's tags by default, or the channel's own template with `{title}`, `{summary}`, `{url}`, `{hashtags}`, `{brand}`, `{why}` - fitted to the network's limit without ever cutting the link (X counts a link as 23 characters), with the article attached as a link card where the network has one. |
| Chat channels | Discord (a webhook), Telegram (a bot in a chat or channel) | The message plus an embed or link preview with the title, summary, link and lead image. |
| Blog platforms and sites | Dev.to, Hashnode, Medium, Blogger, Tumblr, WordPress, Ghost, any site through a signed webhook | The article itself (Markdown or HTML as the platform wants it, the tags, the lead image) with the blog page as the **canonical URL**, published or left as a **draft** there (`post_status`). |

Each network lists the credential fields it needs (`GET /api/v1/admin/social/networks`: an access token and an
author URN for LinkedIn, the four OAuth 1.0a values *or* an OAuth 2 user token for X, a page id and page token for
Facebook, a webhook URL for Discord, a bot token and chat id for Telegram, an instance and token for Mastodon, a
handle and app password for Bluesky, a script app and account for Reddit, an API key for Dev.to, a token and
publication id for Hashnode, an integration token for Medium, a blog id and OAuth client with refresh token for
Blogger, the OAuth 1.0a values for Tumblr, a user and application password for WordPress, an Admin API key for
Ghost, a URL and optional signing secret for a webhook) and the console shows the form for the network picked.
Credentials are sealed with the deployment secret (`OSR_PLATFORM_SECRET_KEY`), never returned - a channel only
says which fields are set - and a PATCH merges new values into the stored ones (an empty value removes a field).
A LinkedIn channel may leave its token empty and post with the connection made on the Growth page (section 17).
URLs a channel will call (webhooks, instances, sites) must be public endpoints unless the deployment allows
private ones (`OSR_PLATFORM_BYOK_ALLOW_PRIVATE`).

**Schedules.** Every channel has one. `immediate` shares a post the moment it is published; `window` waits for
the next allowed weekday and time of day in the channel's time zone (`days`, `start`, `end`, `timezone` - an IANA
name); `gap_minutes` keeps two posts on the same channel at least that far apart and `max_per_day` caps a
channel's posts per local day. The slot of a new share is computed after the last post sent *or planned* on that
channel, so three articles published within a minute land, say, Monday 09:00, 09:30 and 10:00 instead of all at
once, and the fourth on Tuesday when the cap is three. A channel may also keep a **cadence** (`cadence_hours`):
when nothing went out on it for that long and nothing is queued, the newest public post not shared there in the
last 30 days is queued - a steady drumbeat between articles. An operator can share any published post on any
channels **now** (waiting for each network's answer) or **at** a chosen time, with their own text instead of the
composed message, cancel a queued share, retry a failed one and send a clearly marked **test post** to a channel.

**Delivery.** One replica per `OSR_PLATFORM_SOCIAL_INTERVAL_S` (60 s; 0 = only from the console) sends the shares
whose time has come (Redis lease) and queues the cadences; a publication also kicks a pass at once so an
`immediate` channel posts within seconds. A share the network did not answer, failed on its side or rate-limited
is retried after 1, 5, 30 and 120 minutes; one it refused (a bad token, a rejected post) is marked **failed**
with the network's message. Every attempt is on the share log with the permalink of what went out
(`GET /api/v1/admin/social/shares`), every outcome in the audit log (`social.shared`, `social.share_failed`,
`social.channel.added`, `social.channel.changed`, `social.channel.removed`, `social.share_requested`,
`social.run`). At most `OSR_PLATFORM_SOCIAL_MAX_CHANNELS` channels (30).

The same networks are **destination kinds of the workspace content service** ([Content publishing](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/PUBLISHING.md)):
a workspace adds its own LinkedIn page, X account or Dev.to profile on `/platform/dashboard/content` with the
same credential fields and schedule, and its published posts reach its own accounts on its own schedule.

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/v1/admin/social` | Everything on one page: the scheduler, the network catalogue with its fields, the channels with their schedules and next slot, share counts, the newest shares, the posts to share. |
| `GET` | `/api/v1/admin/social/networks` | The networks with the credential fields each needs and its limits. |
| `POST` | `/api/v1/admin/social/preview` | The message a share would carry on a network for a post (`network`, `slug`, `template`, `hashtags`). |
| `GET`, `POST` | `/api/v1/admin/social/channels` | The channels (fields set, never values); add one (`network`, `name`, `credentials`, `schedule`, `template`, `hashtags`, `post_status`, `cadence_hours`, `auto_share`). |
| `PATCH`, `DELETE` | `/api/v1/admin/social/channels/{channel_id}` | Change a channel (credentials merge; `enabled`, `auto_share`); remove it with its share log. |
| `POST` | `/api/v1/admin/social/channels/{channel_id}/test` | Post a test message now and return the network's answer. |
| `GET` | `/api/v1/admin/social/shares` | The share log (`channel_id`, `status`, `slug`, `limit`). |
| `POST` | `/api/v1/admin/social/share` | Share a post (`slug`) on every enabled channel or `channel_ids`: now, or queued for `at`; optional `text`. |
| `POST` | `/api/v1/admin/social/shares/{share_id}/retry` | Try a failed, cancelled or waiting share again now. |
| `DELETE` | `/api/v1/admin/social/shares/{share_id}` | Withdraw a queued share (a sent one leaves the log only). |
| `POST` | `/api/v1/admin/social/run` | Run a scheduler pass now: due shares and cadences. |

## Related

* REST API reference (rendered at `/docs/api`) - every endpoint, parameter and schema.
* [Marketplace](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MARKETPLACE.md) - find, install and publish agents, skills, personas, prompts and templates.
* [User guide](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/GUIDE.md) - targets, rules, plans and learning.
* [Deploy](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/deploy.md) - run the same router on your own infrastructure with `osr serve`.
