# Routok — contract for another project

> Feed this URL to an agent that must call Routok from another service: <https://routok.net/for-agents.md>
>
> This file is the whole integration contract. Canonical origin: **https://routok.net**. Index for agents that look for `/llms.txt`: <https://routok.net/llms.txt>. If that index and this file disagree, follow this file.

Routok (ROO-tok) is an OpenAI-compatible proxy billed in **Routs** (ticker **RTK**). A consumer spends Routs. A seller posts an upstream key they own and earns Routs. The caller never sees the upstream key.

Another project talks to the running service at `https://routok.net`. It does not reimplement Routok, and it does not read the Routok source to guess endpoints.

## Wire it

1. `GET https://routok.net/v1/health`. Proceed when `ok` is true and `matching` is true.
2. A human signs in at <https://routok.net> with Telegram, opens **Consumer**, creates a project, creates an API key, and copies the secret once into the other project's secret store.
3. Set the other project's environment to the values below. The base URL already includes `/openai/v1`. The official SDK appends `/chat/completions` itself.
4. Call the official OpenAI SDK against that base URL. Treat a Routok `429` with `code: insufficient_quota` as an empty wallet, and stop. Add Routs before retrying.
5. Treat `401` `invalid_api_key` as a wrong, revoked, or project-mismatched key. Mint a new key in the Consumer desk. The old secret is not recoverable.

```text
OPENAI_BASE_URL=https://routok.net/openai/v1
OPENAI_API_KEY=sk-rtk-<secret>
```

Optional project-pinned base URL. The key must belong to that project. `<public_id>` is the project's `public_id` (about 12 URL-safe characters), which is a different field from the project UUID `id`.

```text
OPENAI_BASE_URL=https://routok.net/openai/p/<public_id>/v1
OPENAI_API_KEY=sk-rtk-<secret>
```

`https://routok.89.124.118.75.sslip.io` reaches the same deployment. New integrations use `https://routok.net`. `https://www.routok.net` redirects to the apex.

Store `sk-rtk-` secrets and session bearers in a secret store. Log a consumer key only as its prefix (`sk-rtk-` plus 8 characters). Log an upstream key only as its last 4 characters. The create-key response is the only time the full consumer secret appears.

## Two credentials

| Credential | Looks like | Authorizes |
|---|---|---|
| Consumer key | `sk-rtk-…` | `https://routok.net/openai/…` only |
| Session bearer | opaque URL-safe token from `/v1/auth/…` | `https://routok.net/v1/…` except the public health route |

Sending a session bearer to `/openai` returns `401`. Sending `sk-rtk-` to `/v1` returns `401` `{"detail":"invalid token"}`.

Almost every other backend only needs the consumer key (mode A). Use the session (mode B) when that other app must read the wallet, mint keys, or post seller capacity for a logged-in Routok user.

There is no password login and no API key for `/v1` other than the session. Production does not enable local login. `POST /v1/auth/login-link/{nonce}/complete` responds `404`.

## Money

The ledger unit is the **millirout**, an integer. **1 Rout = 1,000 millirouts.** Balances are never floats. OpenAI token counts are metered usage. Routs are the wallet. Speak of them as two different things.

On each successful proxied call:

1. Read usage from the upstream response (`prompt_tokens` / `input_tokens`, `completion_tokens` / `output_tokens`, and image count for image routes).
2. Price that usage with the operator's model table. Units are `input_1k`, `output_1k`, and `image` (millicents per image). Lookup is the exact model id, then the longest stored prefix, then `_default`.
3. `1 USD-cent = 1,000` millicents. Millirouts charged (`gross`) are `ceil(usd_millicents * price_millirouts_per_usd_cent / 1_000)`, integer division rounding up.
4. The matched ask's `price_millirouts_per_usd_cent` is the rate used for the charge. An ask of `10` is 10 millirouts per USD-cent of list cost, which is **1 Rout per 1 USD** of list cost. If no healthy ask can take the call, the proxy returns `503` `no_capacity` and does not charge. When a house desk is configured, its ask is posted at the operator default rate and competes in the book like any other ask.
5. `fee = gross * fee_bps / 10_000` (integer). The consumer pays `gross`. The seller receives `gross - fee`. The default fee is 250 bps (2.5%) until an operator changes it.

Illustration with the seed table, which the operator may have replaced: gpt-4o input is 250 millicents per 1K tokens (`$2.50` per 1M). 1,000 input tokens are 250 millicents. At ask `10`, gross is `ceil(250 * 10 / 1_000) = 3` millirouts. Read live charges from `GET /v1/projects/{id}/usage` (`gross_millirouts`), and live asks from `GET /v1/book`.

Before the upstream call, Routok reserves an estimate against **available** Routs (balance minus open holds). The chat estimate uses message text (minimum 32 tokens) and `max_tokens` or `max_completion_tokens`, capped at 16,384 output tokens. Embeddings estimate from the input text. Images estimate `n` images (default 1). If available Routs are below that estimate, the response is `429` `insufficient_quota` and upstream is not called. Set `max_tokens` to the completion size you actually want so the hold stays small. After a successful response the hold settles to actual usage and the leftover is released. A failed upstream call releases the hold and does not debit the wallet. `GET /openai/v1/models` is free.

If a stream ends without a usage object, the reservation amount is charged.

## Data plane

Base: `https://routok.net/openai/v1`

Header: `Authorization: Bearer sk-rtk-…`

CORS allows any browser origin. The bearer is still required. Server-side callers ignore CORS.

| Caller path | Upstream path |
|---|---|
| `POST /openai/v1/chat/completions` | `/chat/completions` |
| `POST /openai/v1/completions` | `/completions` |
| `POST /openai/v1/embeddings` | `/embeddings` |
| `POST /openai/v1/images/generations` | `/images/generations` |
| `POST /openai/v1/images/edits` | `/images/edits` |
| `GET /openai/v1/models` | `/models`, filtered to prefixes the matched seller allows |

The same six paths exist under `https://routok.net/openai/p/<public_id>/v1/…`. Any other path is `404`:

```json
{"error":{"message":"Unknown path","type":"invalid_request_error","code":"not_found"}}
```

There is no audio, moderation, files, batches, or responses route. A model id the seller's prefix list rejects is skipped while matching; it is not a separate error code.

JSON bodies are forwarded. Routok replaces a client `user` field with its own hash. Put identity in your own logs, not in `user`. When `stream` is true, Routok sets `stream_options.include_usage` to true so the last SSE chunk can carry usage. Non-JSON bodies (image edit uploads) are forwarded as bytes with the caller's `Content-Type`.

Non-streaming JSON responses have `organization`, `organization_id`, `org_id`, `account`, `account_id`, `project_id`, and `api_key` fields replaced, and `org-…`, `user-…`, and `sk-…` substrings redacted. Streaming chunks are forwarded as the upstream sent them. Keep a long client read timeout. Streams can run for minutes. Nginx allows an hour.

Matching picks the lowest-priced healthy ask whose key allows the model, is under its daily USD-cent cap, and still has remaining size (`null` size means unlimited). Ties break by oldest order. On upstream `401`, `429`, or a connection error, that key is marked unhealthy and Routok tries one other ask. Then it stops.

### Official SDK

Python:

```python
from openai import OpenAI

client = OpenAI(
    base_url="https://routok.net/openai/v1",
    api_key="sk-rtk-<secret>",
)

completion = client.chat.completions.create(
    model="gpt-4o-mini",
    max_tokens=256,
    messages=[{"role": "user", "content": "hi"}],
)
```

Node:

```javascript
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://routok.net/openai/v1",
  apiKey: process.env.OPENAI_API_KEY,
});

const completion = await client.chat.completions.create({
  model: "gpt-4o-mini",
  max_tokens: 256,
  messages: [{ role: "user", content: "hi" }],
});
```

curl:

```bash
curl https://routok.net/openai/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}'
```

Stream with `"stream": true`. The body is `text/event-stream` and ends with `data: [DONE]`.

### Idempotency

Non-streaming `POST` may send `X-Routok-Idempotency-Key`. The same key on the same consumer key replays the stored status and body for 24 hours. Streaming requests are not cached. Use a new idempotency key for a genuinely new call.

### Proxy errors

OpenAI-shaped JSON. Branch on `error.code`.

| HTTP | `error.code` | Meaning | What to do |
|---|---|---|---|
| 401 | `invalid_api_key` | Missing bearer, unknown key, revoked key, disabled consumer, or project URL does not own this key | Stop. Fix the key. |
| 429 | `insufficient_quota` | Available Routs cannot cover the reservation. Upstream was not called | Stop. Add Routs, or lower `max_tokens`. |
| 429 | `rate_limit_exceeded` | More than 60 requests in the current minute on this key, or 120 on this user | Back off until the next minute. |
| 503 | `no_capacity` | No healthy ask can take the model | Retry later. |
| 503 | `matching_paused` | The operator paused matching | Retry later. |
| 502 | `upstream` | The upstream connection failed after the one retry | Retry later with a new idempotency key if you used one. |
| 404 | `not_found` | Path is not one of the six routes | Call a supported path. |

Other `4xx`/`5xx` bodies are the upstream error after redaction (non-stream). A second ask is tried only for upstream `401` and `429`.

`GET https://routok.net/openai/health` needs no auth and returns `{"ok": true}`. It does not check the database.

### Live book

`GET /v1/book` needs a session, not an `sk-rtk` key.

```json
{
  "asks": [
    {"price_millirouts_per_usd_cent": 10, "remaining_usd_cents": null, "seller": "S-8F3A"}
  ],
  "best_ask": 10,
  "matching_paused": false
}
```

`remaining_usd_cents: null` means unlimited. `seller` is an anonymous slot (`S-` plus 4 hex characters), not a username. `best_ask` is the cheapest price, or `null` when the book is empty.

## Control plane

Origin: `https://routok.net`. Every route below except health requires:

```text
Authorization: Bearer <session>
```

JSON. Errors are `{"detail": "<code>"}` for a string failure, or a `422` list when the body fails validation. Sessions last 30 days.

### Sign-in (a human, once)

`POST /v1/auth/telegram` body `{"init_data":"<Telegram.WebApp.initData>","client":"mini_app"}`.

`POST /v1/auth/widget` body is the Telegram Login Widget payload (`id`, `first_name`, `last_name`, `username`, `photo_url`, `auth_date`, `hash`) plus optional `client` (default `web`).

Both return:

```json
{"token":"<session>","user":{}}
```

Pass the payload Telegram signed. Routok checks the signature. A failed check is `401`.

Website login without the widget:

1. `POST /v1/auth/login-link` → `{"nonce","telegram_url","expires_in"}`. `expires_in` is 300 seconds. `telegram_not_configured` or `telegram_unreachable` is `503`.
2. The human opens `telegram_url` and starts the bot.
3. `GET /v1/auth/login-link/{nonce}` until `status` is `ok`. Other statuses: `pending`, `expired`, `unknown`. `ok` includes `token` and `user`.

`GET /v1/me` returns the user plus `millirouts`. Fields: `id` (UUID), `telegram_id`, `username`, `first_name`, `last_name`, `language_code`, `is_admin`, `is_seller_enabled`, `is_consumer_enabled`, `millirouts`, `created_at`, `last_seen_at`.

### Wallet

`GET /v1/wallet`

```json
{
  "millirouts": 10000,
  "held_millirouts": 0,
  "available_millirouts": 10000,
  "spend_24h_millirouts": 0
}
```

`available_millirouts` is what the proxy can reserve. `spend_24h_millirouts` is proxy debits in the last 24 hours.

`POST /v1/wallet/transfer`

```json
{"to_username_or_id": "someuser", "millirouts": 500}
```

`to_username_or_id` is a Telegram username (with or without `@`), a Routok user UUID, or a numeric Telegram id. Returns `{"ok": true, "ref": "<uuid>"}`.

| HTTP | `detail` | Meaning |
|---|---|---|
| 404 | `user_not_found` | Recipient has never signed in |
| 400 | `insufficient_routs` | Available balance is below the amount |
| 400 | `cannot transfer to self` | Same sender and recipient |
| 400 | `transfer must be positive` | `millirouts` is not a positive integer |

Routs enter a wallet three ways: an operator grant, a transfer, or seller earnings. There is no card checkout.

### Projects and consumer keys

`GET /v1/consumer/urls` (session):

```json
{
  "base_url": "https://routok.net/openai/v1",
  "project_base_url": "https://routok.net/openai/p/{id}/v1"
}
```

`{id}` here is a literal placeholder. Substitute `public_id`.

`GET /v1/projects` → array of `{id, public_id, name, status, created_at}`.

`POST /v1/projects` body `{"name":"my-app"}` (`name` 1–128 characters). Response is the project. `404` `project_not_found` means the UUID is missing or belongs to someone else.

`GET /v1/projects/{project_uuid}/keys` → array of `{id, prefix, label, revoked_at, last_used_at, millirouts_spent, created_at}`. No secret.

`POST /v1/projects/{project_uuid}/keys` body `{"label":"default"}`. Response adds `key`, `base_url`, and `project_base_url`. Persist `key` immediately. A later GET will not return it.

`POST /v1/projects/{project_uuid}/keys/{key_uuid}/revoke` returns the key row with `revoked_at` set. A revoked key gets proxy `401`. `404` `key_not_found` when the key is not on that project.

`GET /v1/projects/{project_uuid}/usage` returns up to 100 newest fills:

```json
{
  "id": "uuid",
  "created_at": "2026-01-01T00:00:00Z",
  "model": "gpt-4o-mini",
  "input_tokens": 10,
  "output_tokens": 20,
  "image_count": 0,
  "usd_millicents": 1,
  "gross_millirouts": 1,
  "fee_millirouts": 0,
  "seller_slot": "S-8F3A",
  "upstream_status": 200,
  "latency_ms": 120
}
```

The consumer paid `gross_millirouts`. `fee_millirouts` already came out of the seller's credit.

### Seller

The session user's `is_seller_enabled` must be true (`403` `seller_disabled` otherwise).

`GET /v1/seller/keys` lists keys that are not disabled. The body never includes the upstream secret. Each row: `id`, `last4`, `label`, `base_url`, `status` (`pending`, `healthy`, `unhealthy`, `disabled`), `last_error`, `last_ok_at`, `last_latency_ms`, `daily_cap_usd_cents`, `allowed_model_glob`, `attested_at`, `created_at`.

`POST /v1/seller/keys`

```json
{
  "key": "sk-…",
  "label": "upstream",
  "base_url": "https://api.openai.com/v1",
  "daily_cap_usd_cents": null,
  "allowed_model_glob": "gpt-,o,dall-e,gpt-image",
  "attested": true
}
```

`attested: true` is required (`400` `attestation_required`). It records that this user is allowed to use that key. `base_url` defaults to `https://api.openai.com/v1`. `allowed_model_glob` is a comma-separated list of model-id prefixes; `*` or empty allows every model. Default glob is `gpt-,o,dall-e,gpt-image`. `daily_cap_usd_cents` null means no daily cap. At most 20 active keys (`400` `too_many_keys`). `503` `encryption_not_configured` means the server cannot store keys; stop and report it. The response is the same shape as the list row.

`DELETE /v1/seller/keys/{key_uuid}` disables the key, cancels its open asks, and returns `{"ok": true}`. `404` `key_not_found`.

`GET /v1/seller/orders` lists this seller's asks: `id`, `upstream_key_id`, `side` (`ask`), `price_millirouts_per_usd_cent`, `remaining_usd_cents`, `status` (`open`, `cancelled`, `depleted`), `created_at`.

`POST /v1/seller/orders`

```json
{
  "upstream_key_id": "uuid",
  "price_millirouts_per_usd_cent": 10,
  "remaining_usd_cents": null
}
```

Price is a positive integer. `remaining_usd_cents` null means unlimited; otherwise a positive integer of USD-cents of list cost. Sellers do not pre-fund Routs to post an ask. `404` `key_not_found` if the key is missing, someone else's, or disabled.

`DELETE /v1/seller/orders/{order_uuid}` cancels and returns `{"ok": true}`.

`GET /v1/seller/fills` is the same fill shape as project usage, newest 100.

`GET /v1/seller/pnl`

```json
{"realized_millirouts": 0, "fill_count": 0, "fees_already_deducted": true}
```

`realized_millirouts` is the sum of `gross - fee` already credited.

A new key stays `pending` until the health worker probes `GET {base_url}/models`. Only `healthy` keys are eligible to match.

## Operator routes

`/v1/admin/…` requires a session whose user has `is_admin`. Anyone else receives `403` `{"detail":"admin only"}`. Another product does not call these routes and does not request admin.

| Method and path | Body | Result |
|---|---|---|
| `POST /v1/admin/grant` | `{"user_id","millirouts","reason"}` | `{"ok":true,"millirouts"}`. `404` `user_not_found` |
| `GET /v1/admin/users?q=` | | Up to 50 users, each with `millirouts`. `q` matches username or first name |
| `GET /v1/admin/prices` | | Rows `{id, model, unit, usd_millicents, is_default}` |
| `PUT /v1/admin/prices` | array of `{model, unit, usd_millicents, is_default}` | Inserts or updates those pairs |
| `POST /v1/admin/prices/import/preview` | empty | Fetches the OpenAI list and stores a diff for 15 minutes. Writes nothing. `502` `pricing_page_unreadable` |
| `POST /v1/admin/prices/import/apply` | `{"preview_id","model_set":"apply"\|"decline","prices":"apply"\|"decline"}` | Replays that preview. `409` `preview_expired` |
| `GET /v1/admin/settings` | | `{fee_bps, matching_paused, default_rate}` |
| `PUT /v1/admin/settings` | any of `fee_bps` (0–10000), `matching_paused`, `default_rate` | Same object |
| `GET /v1/admin/audit` | | Newest 200 audit rows. Secrets are not in the payload |

Preview JSON includes `preview_id`, `source`, `added`, `removed`, `changed`, `unchanged_count`, `added_models`, `removed_models`, `unmapped`, `conflicts`. Apply with `model_set: apply` inserts `added` and deletes non-default `removed`. Apply with `prices: apply` updates `changed`. Decline leaves that side unchanged. `_default` rows are left alone. The client does not send millicent numbers on apply; the server replays the stored preview.

The Telegram bot is a human UI (balance, a button to the site, and an operator `/grant`). It does not accept upstream keys. Other services use the HTTP API above. The site the bot opens is <https://routok.net>.

## Health

`GET https://routok.net/v1/health` needs no auth.

```json
{
  "ok": true,
  "db": true,
  "redis": true,
  "matching": true,
  "house_key_configured": true,
  "local_auth": false
}
```

`ok` means database and Redis answered. `matching` is false while the operator has paused the book. `house_key_configured` means an operator upstream key exists; it does not reveal that key. `local_auth` is false on production.

`GET https://routok.net/openai/health` → `{"ok": true}`.

## Rules for the integrating agent

- Point `base_url` / `OPENAI_BASE_URL` at `https://routok.net/openai/v1` (or the project form). The host root and `https://api.openai.com/v1` are the wrong base for an `sk-rtk-` key.
- Create the consumer secret in the Consumer desk, or with `POST /v1/projects/{id}/keys` using a real session. Generate neither token yourself.
- Keep OpenAI token counts and millirouts apart in any UI you add.
- On `insufficient_quota`, tell the human the Routok wallet needs Routs. The transfer endpoint is how one Routok user pays another.
- On `invalid_api_key`, check revocation and, for a project URL, that `public_id` is the one in `project_base_url`.
- Seller keys are pasted only into `POST /v1/seller/keys` over HTTPS, with `attested: true`. They never go in chat, logs, or the Telegram bot.
- When this contract and an example in another repo disagree, follow this file and the live health URLs.

## Prompt to paste into Cursor

```text
Read https://routok.net/for-agents.md and wire this project to Routok.
Follow that file. Use https://routok.net/openai/v1 with an sk-rtk- key
from the secret store. Do not invent endpoints, and do not call /v1/admin.
```
