# Nexfer Inference — documentation

Nexfer Inference (`https://nexference.com`) is an OpenAI-compatible
inference gateway. Point any OpenAI client at `/v1`, authenticate with a
`nxf_live_…` key, and pay per token in USD from a prepaid balance. Model prices
are final retail rates in USD per million tokens; the platform handles routing,
metering, caps and key permissions for you.

Two surfaces:

| Plane | Base | Auth | Purpose |
|---|---|---|---|
| Data plane | `https://nexference.com/v1` | `Authorization: Bearer nxf_live_…` | run inference; what your agents call |
| Control plane | `https://nexference.com/api` | session cookie (browser) or `nxf_live_…` key (machine) | accounts, keys, usage, admin |

All money is USD. Money fields in JSON are decimal numbers rounded to 6 places
(`0.001234`). The error shape is identical on every JSON route:

```json
{ "error": { "code": "invalid_request", "message": "human readable", "type": "invalid_request" } }
```

Data-plane (`/v1/*`) errors additionally follow OpenAI's shape:
`{"error":{"message":…,"type":…,"code":…,"param":null}}`.

## 01 / Authentication

- **API keys** — `nxf_live_<prefix>_<secret>`. Send as `Authorization: Bearer …`.
  The prefix is safe to display; the secret is shown once at creation. Keys carry
  scopes, an optional model allowlist, caps, an optional expiry, an optional IP
  allowlist, and their own rpm/concurrency ceilings.
- **Browser sessions** — sign up, verify your email, and log in at `/signup` and
  `/login`. The session is a `HttpOnly`, `SameSite=Lax`, `Secure` cookie named
  `nx_s` with a 30-day lifetime. `GET /api/session` returns the current user plus
  a `csrf` token.
- **CSRF** — every cookie-authenticated mutation (`POST`/`PATCH`/`DELETE` under
  `/api`) must echo that token in the `x-nexfer-csrf` header.

## 02 / Getting a key

**On the site.** Sign up at `/signup`, verify the emailed code, then create a key
under `/app/keys`. New accounts receive a signup credit.

**From an agent, in one call.** No browser required:

```bash
curl -X POST https://nexference.com/api/agent/register \
  -H 'Content-Type: application/json' \
  -d '{"name":"my-agent"}'
```

Request fields are all optional: `name`, `email`, `models` (an allowlist). The
response is `201` with the account, the key, the one-time `secret`, and the URLs
you need (`base_url`, `models_url`, `docs_url`, `llms_txt`). Agent registration is
rate limited per IP and gated by a process-wide daily cap. If you register
without an email a placeholder address is used and nothing is emailed.

`GET /api/agent/quickstart` returns the same onboarding facts as JSON, including
ready-made `curl`, Python and Node first-call snippets.

## 03 / OpenAI compatibility

| Method | Path | Notes |
|---|---|---|
| GET | `/v1/models` | list models; public, cached 60s, IP rate limited (60/min) |
| POST | `/v1/chat/completions` | chat completions, streaming and non-streaming |
| POST | `/v1/completions` | legacy text completions |
| POST | `/v1/embeddings` | embeddings passthrough |
| POST | `/v1/responses` | responses API passthrough |
| POST | `/v1/systemone` | decisions models (the exact path is each model's `endpoint`) |
| GET | `/v1/key` | this key's metadata and limits (needs `models` scope) |
| GET | `/v1/usage?days=30` | this key's usage summary (needs `usage` scope) |

The gateway is **not an open forwarder**: it only forwards the static OpenAI
surface above plus every `endpoint` the catalogue publishes (the decisions models).
A request to any other `/v1/…` path returns `404 not_found`. Sending a decisions
model to a chat path, or a chat model to a decisions endpoint, returns
`400 invalid_request` with a hint naming the correct path.

Bodies are the standard OpenAI bodies. With the official SDKs:

```python
from openai import OpenAI
client = OpenAI(base_url="https://nexference.com/v1", api_key="nxf_live_…")
print(client.chat.completions.create(
    model="<model-id>", messages=[{"role": "user", "content": "ping"}]).choices[0].message.content)
```

The gateway replaces the outbound credential, clamps `max_tokens` /
`max_completion_tokens` to the model's own output limit and the account ceiling
(32768 by default), enforces a 2 MiB
request-body cap, and — when `stream: true` and `stream_options` is absent — adds
`stream_options.include_usage = true` so usage can be metered.

Response headers on inference calls:

| Header | Meaning |
|---|---|
| `x-nexfer-request-id` | request id, also recorded in usage events |
| `x-nexfer-key` | the key prefix used |
| `x-nexfer-balance-usd` | account balance after the call |
| `x-nexfer-cost-usd` | charged cost of this call, USD (non-stream, and last chunk on stream) |
| `x-nexfer-tokens` | `prompt+completion` token counts (non-stream) |

## 04 / Models and discovery

`GET /v1/models` returns `{ "object": "list", "data": [Model, …] }`. Each model:

```ts
type Model = {
  id: string;                     // send verbatim as `model`
  object: "model";
  name: string;
  kind: "chat" | "decisions";
  owned_by: "nexfer";
  context_length: number;
  free: boolean;                  // free models cost nothing to call
  free_until?: string | null;     // if set, free until this date (YYYY-MM-DD)
  uncensored: boolean;
  created: number;
  endpoint?: string;              // decisions models: the path to call them on
  pricing: {                      // retail USD per 1,000,000 tokens
    prompt_usd_per_m: number;
    completion_usd_per_m: number;
    cache_read_usd_per_m: number;
    long_context?: {              // optional tiered rates (see Pricing)
      above_tokens: number;
      prompt_usd_per_m: number;
      completion_usd_per_m: number;
      cache_read_usd_per_m: number;
    }[];
  };
};
```

Filter the catalogue with `GET /api/models?q=&limit=` (returns `{models, count,
cached_at, stale}`). A key with a `models` allowlist only sees (and may only call)
the models on that list.

### Decisions models

Models with `kind: "decisions"` score or classify the `state` you give them and are
**not** called at `/v1/chat/completions`. Each publishes the `endpoint` it is served
on (currently `/v1/systemone`); call it there:

```json
POST /v1/systemone
{
  "model": "clef",
  "state": "The team shipped the feature on time and every test passed.",
  "questions": {
    "quality": { "type": "score", "min": 0, "max": 10 },
    "tone": { "type": "choice", "options": ["formal", "casual"],
              "criteria": { "formal": "uses formal language", "casual": "uses casual language" } },
    "flag": { "type": "noul" }
  }
}
```

Question types: `score` (a numeric score, optional `min`/`max`), `choice` (one of
the `options` labels, with optional per-label `criteria`) and `noul` (a yes/no
probability). Each answer's `type` matches its question:

```json
200 OK
{
  "answers": {
    "quality": { "type": "score", "score": 3.7239, "confidence": 0.5694,
                 "probabilities": { "0": 0.0099, "4": 0.7909 },
                 "legend": { "0": "very unlikely", "4": "very likely" } },
    "tone": { "type": "choice", "choice": "formal", "confidence": 0.7419,
              "probabilities": { "formal": 0.9307, "casual": 0.0693 } },
    "flag": { "type": "noul", "noul": 0.9415 }
  },
  "id": "dec-0f0977b3030caf45b8b378e0", "model": "clef", "object": "decision",
  "usage": { "prompt_tokens": 308, "completion_tokens": 0, "total_tokens": 308 }
}
```

Decisions billing is **input tokens only**, and `usage` is normalised to OpenAI
names (`prompt_tokens` / `completion_tokens` / `total_tokens`).

## 05 / Streaming

Send `"stream": true` to receive server-sent events in the OpenAI SSE format,
terminated by `data: [DONE]`. The gateway adds `stream_options.include_usage=true`
unless you set it yourself, and the final events carry a `usage` object. A trailing
SSE comment `: nexfer usage prompt_tokens=… completion_tokens=…` carries the meter
too and is ignored by every standard OpenAI client. Streaming bills exactly the
tokens produced, including a stream the client aborts early.

## 06 / Pricing, billing and caps

- Prices in each model's `pricing` are final retail rates (USD per million tokens);
  the markup is already included and there are no extra platform fees.
- `free: true` models cost nothing; `free_until`, when present, marks a model that
  is free until that date.
- If a model carries `pricing.long_context` tiers, the tier with the highest
  `above_tokens` that the request's prompt exceeds replaces the base rates for that
  request, and metering follows the same rule.
- Every call debits the prepaid balance. The balance may go slightly negative
  after a call; the next call is refused with `insufficient_balance` (402) until
  you top up.
- Spend is enforced against: the account balance (> 0), the account monthly cap,
  the per-key monthly cap, then the per-key daily cap.
- `x-nexfer-cost-usd` (and the trailing SSE usage comment) report the charged
  amount; `GET /api/usage` reports aggregates.

Default limits (deployment-tunable):

| Limit | Default |
|---|---|
| Requests per minute (rpm), per key | 60 (agent keys 20) |
| Concurrent in-flight requests, per key | 4 (agent keys 2) |
| Per-key monthly cap | $10 |
| Per-key daily cap | $5 |
| Account monthly cap | $20 (agent accounts $1) |
| Max output tokens (`max_tokens` clamp) | 32768, or the model's own limit when lower |
| Request body cap | 2 MiB |
| `/v1/models` IP rate limit | 60/min |
| `/api/*` cookie rate limit | 300/min per user |
| Agent registration | 5/hour per IP; process-wide daily cap |

## 07 / Key permissions

A key is fully self-describing. Fields (also returned by `GET /api/keys` and
`GET /v1/key`):

```ts
type ApiKey = {
  id: string;                        // key_…
  name: string;
  prefix: string;                    // nxf_live_ab12cd34  (safe to display)
  status: "active" | "rotating" | "revoked";
  scopes: ("chat" | "models" | "usage")[];
  models: string[] | null;           // allowlist, null = every model
  rpm: number | null;
  concurrency: number | null;
  monthly_cap_usd: number | null;
  daily_cap_usd: number | null;
  expires_at: string | null;
  allowed_ips: string[] | null;
  created_at: string;
  last_used_at: string | null;
  grace_until: string | null;        // only while status === "rotating"
  revoked_at: string | null;
  spend_month_usd: number;
  spend_today_usd: number;
  requests_month: number;
  upstream: "provisioned" | "pending" | "shared" | "none";  // capacity state
};
```

- **Scopes** — `chat` permits inference; `models` permits `GET /v1/key` and
  catalogue reads; `usage` permits `GET /v1/usage`. Default is all three.
- **Model allowlist** — `models` restricts which model ids the key may call; `null`
  allows every model. Up to 200 ids.
- **Caps** — `monthly_cap_usd` and `daily_cap_usd` bound this key's spend.
- **Expiry** — `expires_at` is an ISO-8601 timestamp in the future (up to ten
  years out).
- **IP allowlist** — `allowed_ips` accepts up to 50 IPv4/IPv6 addresses or CIDRs.
- **rpm / concurrency** — per-key overrides (rpm 1–6000, concurrency 1–64).

Create a key with `POST /api/keys` — the `secret` is returned **once**. Update any
mutable field with `PATCH /api/keys/:id`.

## 08 / Rotation and revocation

```bash
curl -X POST https://nexference.com/api/keys/key_…/rotate \
  -H "x-nexfer-csrf: $CSRF" -H 'Content-Type: application/json' \
  -d '{"grace_minutes":1440}'
```

Rotation keeps the same key `id` but issues a new secret/prefix. The old prefix
keeps working until `grace_until` (default 1440 minutes, max 10080), after which
it returns `401 key_grace_expired`. The capacity behind the key rotates on the
same schedule. The response also returns `previous: { id, prefix, grace_until }`.

`POST /api/keys/:id/revoke` sets status `revoked` and stops the key immediately.
An already-revoked key can be permanently removed with `DELETE /api/keys/:id`
(deleting a live key returns `409 conflict`).

## 09 / Safety model

Permissions, caps and rate limits are all enforced **before** the request reaches
a model, in this order:

1. key syntax → key lookup → key status / expiry / IP allowlist
2. key scopes → key model allowlist
3. account balance (> 0) → account monthly cap → per-key monthly cap → per-key daily cap
4. per-key rpm → per-key concurrency

Every rejection is recorded with `status: "rejected"` and a reason; successes are
recorded with tokens and cost. Response bodies are normalised to the OpenAI shape
(brand-neutral): vendor-specific fields and identifiers are stripped from every
JSON body and SSE event before they leave the gateway.

## 10 / Error codes

Every error uses this vocabulary. `/v1/*` adds `param` (always `null`).

| code | HTTP | meaning |
|---|---|---|
| `invalid_request` | 400 | malformed body, missing or invalid field, or a model sent to the wrong endpoint (chat vs decisions) |
| `unauthorized` | 401 | missing or invalid API key |
| `key_revoked` | 401 | the key was revoked |
| `key_expired` | 401 | the key passed its `expires_at` |
| `key_grace_expired` | 401 | a rotated key's grace window has closed |
| `forbidden` | 403 | not permitted for this key or account |
| `scope_missing` | 403 | key lacks the required scope |
| `model_not_allowed` | 403 | the key's allowlist excludes this model |
| `ip_not_allowed` | 403 | request from a disallowed address |
| `account_paused` | 403 | account is paused |
| `insufficient_balance` | 402 | balance exhausted; top up to continue |
| `monthly_cap` | 402 | monthly spend cap reached (key or account) |
| `daily_cap` | 402 | per-key daily spend cap reached |
| `rate_limited` | 429 | requests-per-minute limit reached |
| `concurrency_limited` | 429 | too many in-flight requests on this key |
| `not_found` | 404 | unknown endpoint (any `/v1/…` path not published here), unknown key or model |
| `conflict` | 409 | illegal state change (e.g. deleting a live key) |
| `upstream_error` | 502 | the model produced an error |
| `upstream_unavailable` | 503 | inference is temporarily unavailable |
| `timeout` | 504 | the model timed out |
| `internal` | 500 | unexpected server error |

## 11 / Control-plane API

### Session and accounts

| Method | Path | Body | Result |
|---|---|---|---|
| POST | `/api/signup` | `{email, password, name?}` | `201 {user, requires_verification}` |
| POST | `/api/verify` | `{email, code}` | `200 {user, csrf}` + cookie |
| POST | `/api/verify/resend` | `{email}` | `202 {ok}` |
| POST | `/api/login` | `{email, password}` | `200 {user, csrf}` + cookie |
| POST | `/api/logout` | – | `200 {ok}` clears cookie |
| GET | `/api/session` | – | `200 {user, csrf, limits, config}` or `401`; `?optional=1` returns `200 {user:null}` |
| POST | `/api/password/reset` | `{email}` | `202 {ok}` |
| POST | `/api/password/confirm` | `{email, code, new_password}` | `200 {user, csrf}` |
| GET | `/api/me` | – | `200 {user, running, limits, keys}` |
| PATCH | `/api/me` | `{name}` | `200 {user}` |
| POST | `/api/me/password` | `{current_password, new_password}` | `200 {ok}` |
| GET | `/api/me/sessions` | – | `200 {sessions:[…]}` |
| DELETE | `/api/me/sessions/:id` | – | `200 {ok}` |
| DELETE | `/api/me/sessions` | – | `200 {ok, revoked}` (all other sessions) |

### API keys

| Method | Path | Body | Result |
|---|---|---|---|
| GET | `/api/keys` | – | `200 {keys:[ApiKey]}` |
| POST | `/api/keys` | `{name, scopes?, models?, rpm?, concurrency?, monthly_cap_usd?, daily_cap_usd?, expires_at?, allowed_ips?}` | `201 {key, secret}` (secret shown once) |
| GET | `/api/keys/:id` | – | `200 {key, usage:{totals, by_day, by_model, recent}}` |
| PATCH | `/api/keys/:id` | any mutable field above | `200 {key}` |
| POST | `/api/keys/:id/rotate` | `{grace_minutes?}` | `200 {key, secret, previous}` |
| POST | `/api/keys/:id/revoke` | `{reason?}` | `200 {key}` |
| DELETE | `/api/keys/:id` | – | `200 {ok}` (only a revoked key) |

### Usage, models, playground

| Method | Path | Notes |
|---|---|---|
| GET | `/api/usage?days=30` | `{days, totals, byDay, byModel, byStatus, byKey, recent}` |
| GET | `/api/models?q=&limit=` | `{models:[Model], count, cached_at, stale}` |
| POST | `/api/play/chat` | session-authenticated streaming chat for the playground; SSE identical to `/v1/chat/completions`, billed to your balance, 20/min |

### Agent onboarding

| Method | Path | Body | Result |
|---|---|---|---|
| POST | `/api/agent/register` | `{name?, email?, models?}` | `201 {account, key, secret, base_url, models_url, docs_url, llms_txt, note}` |
| GET | `/api/agent/quickstart` | – | `200 {base_url, auth_header, models_url, docs, first_call, signup}` |

### Owner / admin (role `owner`)

| Method | Path | Notes |
|---|---|---|
| GET | `/api/admin/overview` | platform totals, revenue, health |
| GET | `/api/admin/users?q=&limit=&offset=` | user list with key counts |
| POST | `/api/admin/users/:id/credit` | `{amount_usd, note?}` — positive credits, negative debits |
| POST | `/api/admin/users/:id/status` | `{status:"active"\|"paused"}` |
| POST | `/api/admin/users/:id/limits` | `{monthly_cap_usd?, rpm?}` |
| GET | `/api/admin/users/:id/ledger?limit=` | balance ledger for a user |
| GET | `/api/admin/users/:id/keys` | a user's keys (owner view) |
| GET | `/api/admin/audit?limit=&before=` | audit log |
| GET | `/api/admin/upstream` | capacity provisioning status (owner view) |

### Status

| Method | Path | Notes |
|---|---|---|
| GET | `/api/status` | `{version, env, publicBase, markupPercent, signupOpen, agentSignupOpen, requireEmailVerification, signupCreditUsd, agentSignupCreditUsd, defaultRpm, defaultConcurrency, defaultMonthlyCapUsd, models_cached_at, models_count, uptime_s, config}` |
| GET | `/healthz` | `{ok, version, db, models_cached, uptime_s, upstream}` — no auth, no secrets; add `?deep=1` to probe capacity |

## 12 / Playground

`/app/play` is an in-site chat playground. It is authenticated with your session
(not a key) and streams responses exactly like `/v1/chat/completions`, billing the
call to your balance at the same retail prices. It is rate limited to 20/min.

## 13 / Admin console

`/admin` (role `owner`) shows platform totals, live usage and revenue, the user
list with credit/status/limit controls, the audit log, and capacity provisioning
health. Admin routes live under `/api/admin/*` and require the `owner` role plus a
CSRF token.

## 14 / Self-hosting

Nexfer Inference runs as a single Node process (Node ≥ 22.5). The deploy assets
live in `deploy/`.

```bash
cp deploy/env.example deploy/env && chmod 600 deploy/env   # fill in values
node src/server.mjs                                          # or: npm start
```

Every knob is an environment variable with a safe default; see `deploy/env`
(`deploy/env.example` lists them all — process, pricing/credit, limits, feature
switches and SMTPS mail). The master secret is generated on first boot under
`secrets/` (mode 600).

Serve it under systemd with a Cloudflare tunnel:

- `deploy/nexfer-inference.service` — the long-running app unit
  (`EnvironmentFile=deploy/env`, `WorkingDirectory=<project>`), restarts on failure,
  and gives in-flight streams time to finish on stop.
- `deploy/cloudflared-nexfer.service` — the tunnel that publishes the process at
  `nexference.com`; `scripts/setup-tunnel.mjs` provisions it.
- `deploy/nexfer-backup.service` + `.timer` — scheduled backups
  (`scripts/backup.mjs`).

Keep the data directory, `secrets/` and `deploy/env` off any public surface.

## 15 / FAQ

**What does it cost?** The rates in each model's `pricing` (USD per million
tokens), markup included; if a model lists `pricing.long_context` tiers, the tier
the prompt exceeds applies to that request. `free: true` models are free. No other
fees.

**Which models can I call?** Everything in `GET /v1/models` — the ids there are
exactly what you pass as `model`. A key with a `models` allowlist sees a subset.

**Do I need a browser session?** No. `POST /api/agent/register` returns a working
key in one call. Sessions are only for the website console.

**What happens when my balance runs out?** Calls are refused with
`insufficient_balance` (402) and can resume once the balance is topped up. Your
balance may dip slightly negative after a call that started while you had credit.

**What happens when I rotate a key?** The same key id gets a new secret; the old
prefix keeps working until `grace_until`, then returns `key_grace_expired`.

**Can I limit which models a key may use?** Yes — set `models` (an allowlist) at
creation or via `PATCH /api/keys/:id`. Calls to other models return
`model_not_allowed`.

**Is streaming supported?** Yes, on `/v1/chat/completions` and `/v1/completions`.
Set `stream: true`; usage is metered from the stream.

**What are decisions models and how do I call them?** Models with
`kind: "decisions"` (about 31 of them) score or classify a `state` you provide.
They are called on the `endpoint` each one advertises in the catalogue (currently
`/v1/systemone`), not on `/v1/chat/completions`; see “Decisions models” above. Any
other `/v1/…` path returns `404 not_found` — the gateway is not an open forwarder,
and a model sent to the wrong endpoint returns `400 invalid_request` with a hint.

**How do I check spend?** `GET /v1/usage` (per key, needs `usage` scope) or
`GET /api/usage` (whole account), plus the `x-nexfer-cost-usd` response header.

**Which credentials are used to authenticate?** Only your `nxf_live_…` key (or a
browser session on `/api`). Keys are hashed at rest; the secret is shown once.

## 16 / Machine-readable endpoints

| Path | Content |
|---|---|
| `/llms.txt` | agent instructions (this file's agent-facing sibling) |
| `/docs.md` | this document |
| `/openapi.json` | OpenAPI 3.1 for `/api/*` and `/v1/*` |
| `/security.txt` | RFC 9116 security contact |
| `/changelog.md` | release history |
| `/healthz` | liveness/status JSON |
| `/api/status` | public configuration and uptime JSON |

© 2026 Nexfer Inference — `Next/Inference = Nexfer`.