# Add a provider and a model

**What you get at the end:** a new vendor your keys can route to, added with four HTTP calls and
no restart, and a paper trail that proves it worked before any real traffic touched it.

The examples use an OpenAI-compatible provider. Replace endpoint, model and credential references with the values for your provider.

## First, the shape of the thing

**A provider is a database row, not a code change.** Anything that speaks the OpenAI chat
completions dialect - DeepSeek, Grok, Groq, Moonshot, a vLLM box in your rack - is
`adapter_type: openai_compatible` plus a base URL. The differences between vendors that matter
(and there are real ones) are declared as `quirks` on the row rather than patched into code.

A **model** is a second row that binds a model id to a provider and carries its prices. The
gateway cannot compute spend for a model it has no prices for, so the prices go in at creation
rather than being an optional extra you add when billing surprises you.

The safe order, and the one this page follows: **create the provider disabled, bind the model,
probe, then enable.** A disabled provider takes no traffic, so everything up to the last step is
consequence-free.

## Log in

The registry door accepts HTTP Basic with the admin credentials the gateway was started with -
no cookie dance needed for scripts:

```bash
A="$POINTSMAN_ADMIN_USERNAME:$POINTSMAN_ADMIN_PASSWORD"
```

## Create the provider, disabled

```bash
PID=$(curl -sf -X POST localhost:8080/admin/registry/providers -u "$A" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "deepseek",
    "adapter_type": "openai_compatible",
    "base_url": "https://api.deepseek.com",
    "credential_ref": "env:DEEPSEEK_API_KEY",
    "quirks": {"reasoningField": "reasoning_content"},
    "timeout_ms": 60000,
    "enabled": false
  }' | jq -r .id)
```

### Credential storage

The API key is not in that payload. `credential_ref` names an environment variable on the
gateway process; the secret itself never travels through the API and never lands in the database.
The response tells you immediately whether the reference resolves:

```json
"credential_ref": "env:DEEPSEEK_API_KEY", "credential_resolves": true
```

`credential_resolves: false` means the variable is not set where the gateway runs - fix that
before probing, because a probe without a credential tests nothing you care about.

## About those quirks

Vendors advertise OpenAI compatibility and then differ in ways that silently corrupt your
numbers. The quirks map is where the differences are declared, per provider, and these three are
the ones the launch vendors actually needed:

- `"reasoningField": "reasoning_content"` - DeepSeek's reasoner puts its chain of thought in a
  field the OpenAI dialect does not have. The gateway drops it by default and forwards it only
  when the caller asks with `X-Include-Reasoning: true`.
- `"reasoningTokens": "excluded_from_completion"` - Grok reports reasoning tokens only in
  `total_tokens`, not in `completion_tokens`. Measured live while onboarding it: 194 prompt, 1
  completion, 334 total. Without this quirk the missing 139 tokens are billed by the vendor and
  invisible to your budget - a 41 percent cost undercount on that request.
- `"streamUsage": "stream_options"` - several vendors (like OpenAI itself) send token counts on a
  stream only when `stream_options` asks for them. Without this quirk a streamed response carries no
  usage: the client gets no usage chunk, and the gateway settles the stream as an ESTIMATE, provenance
  ESTIMATED - the admitted prompt estimate plus (streamed content characters + streamed tool-call
  argument characters) divided by `pointsman.estimate.chars-per-token` (default 4), integer division.
  A reply shorter than the divisor therefore estimates ZERO output tokens - the short reply measured live
  on a row-built OpenAI stream gave 14 prompt / 0 completion ESTIMATED without the quirk, 14 / 1 OBSERVED
  with it (the provider counted one token; the reply had fewer characters than the divisor); a 403-character reply
  estimates 100 and 239 characters of tool-call arguments estimate 59 (fake-provider fixture). The
  estimate is not the provider's bill: set the quirk whenever the provider supports it.

An unknown quirk key or value fails loudly: the admin door refuses the write with a 400, and a
row that carries one anyway fails when its client is built, so the provider shows unavailable.
A valid but wrong quirk does not fail at all. It produces plausible wrong numbers, which is worse - so copy
the vendor's entry from `scripts/onboard-providers.sh` when it is one of the launch four rather
than deriving it fresh.

## Bind a model, with its prices

```bash
curl -sf -X POST localhost:8080/admin/registry/models -u "$A" \
  -H 'Content-Type: application/json' \
  -d '{
    "provider_name": "deepseek",
    "model_id": "deepseek-chat",
    "input_per1k": 0.00027,
    "output_per1k": 0.0011,
    "enabled": true
  }'
```

Prices are dollars per thousand tokens. The model row is also where a context window, aliases,
capabilities (tool calling, vision) and a fallback model live - all optional at creation, all
editable later.

### Kimi K2.6 non-thinking mode

For a Kimi K2.6 model on an openai_compatible provider row, include this
capability in the full model row you create or replace, alongside the
account's current input and output prices:

    {
      "provider_name": "kimi",
      "model_id": "kimi-k2.6",
      "capabilities": {"thinking_mode": "disabled"},
      "enabled": true
    }

The fragment shows the generation setting; supply the other required model
fields and verified prices in the full admin request. It sends native
thinking: {"type":"disabled"} on sync and stream requests. The only accepted
values are enabled and disabled. Omitting the setting preserves the provider
default, which is thinking enabled for Kimi K2.6. It can be set only on an
openai_compatible model row and cannot coexist with reasoning_effort.
Invalid writes receive 400; a hand-edited invalid row cannot replace the
last good registry snapshot. Callers cannot override this setting in a chat
request. Kimi documents its K2.6 parameters at
[Kimi model parameters](https://platform.kimi.ai/docs/api/models-overview).

## Probe before you enable

```bash
curl -sf -X POST "localhost:8080/admin/registry/providers/$PID/probe" -u "$A"
```

The probe sends a one-token request through the real adapter with the real credential and
records the verdict on the row:

```json
"probe_status": "ok", "probe_at": "2026-08-31T22:13:39Z"
```

### Verify provider connectivity

The probe is advisory, not a gate. If it fails and you know better - the vendor is briefly
down, or the probe model is not one you bound - you can enable anyway; enabling is just the PUT
below. But a failed probe on a fresh row is almost always the base URL or the credential, and
finding that out now costs one curl instead of a stretch of failing production traffic.

## Enable it

`PUT` replaces the row, so send the full payload with `enabled` flipped. A partial body is
refused rather than merged - `{"enabled": false}` alone comes back
`400: openai_compatible providers need a base_url` - which is deliberate: a merge that quietly
kept old values would make it impossible to tell what a row contains from the request that last
wrote it.

```bash
curl -sf -X PUT "localhost:8080/admin/registry/providers/$PID" -u "$A" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "deepseek",
    "adapter_type": "openai_compatible",
    "base_url": "https://api.deepseek.com",
    "credential_ref": "env:DEEPSEEK_API_KEY",
    "quirks": {"reasoningField": "reasoning_content"},
    "timeout_ms": 60000,
    "enabled": true
  }'
```

The registry reloads without a gateway restart. Verify a request after enabling the row.

## Prove a request actually lands there

```bash
curl -si localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer $YOUR_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}]}' | head -12
```

Two headers on the response answer different questions, and the difference matters:

```
X-Served-Provider: deepseek
X-Provider-Model: deepseek-chat
```

`X-Served-Provider` is the gateway's own record of which provider row served the request - this
is the one that proves your new row took the traffic. `X-Provider-Model` is whatever identifier
the *vendor* reported back, which may be a dated snapshot name or an internal alias that differs
from what you asked for. When the two look inconsistent, the vendor's report is the honest one
about what ran; the gateway forwards it rather than papering over it.

## When it goes wrong

- **`credential_resolves: false`** - the environment variable is not set on the gateway process.
  In compose, that means the variable must be exported *and* passed through in
  `docker-compose.yml`; a variable set only in your shell does not reach the container.
- **Probe fails with a 401/403** - the key is set but wrong, or the vendor wants it in a header
  this adapter does not send by default. `extra_headers` on the provider row exists for the
  latter; values that look like credentials are refused there, deliberately - the credential
  belongs in `credential_ref`.
- **Probe fails with a 404** - almost always the base URL. `openai_compatible` appends the
  standard paths (`/v1/chat/completions`), so the base is the vendor's API root without them:
  `https://api.deepseek.com`, not `https://api.deepseek.com/v1/chat/completions`. Vendors differ
  on whether `/v1` belongs in the base (Groq wants `https://api.groq.com/openai`); the vendor's
  own curl example is the reference.
- **A request for your new model returns `400 model_unresolvable`** - the name your client sent
  matches nothing this key may use: a typo, a missing or disabled model row, or a `provider_name`
  that does not exactly match the provider's `name`. The shipped Compose profiles **fail closed**
  here (`POINTSMAN_RESOLUTION_UNKNOWN_MODEL=resolve-only`), as OpenAI, Bedrock, LiteLLM and
  OpenRouter do, so a typo is an error you see rather than answers from another model.

  If you deliberately want availability over refusal, opt in - globally with
  `POINTSMAN_RESOLUTION_UNKNOWN_MODEL=fallback-to-allowed`, or for one key with
  `PUT /admin/keys/{keyId}/policy` and `{"unknown_model": "fallback-to-allowed"}`. Then the same
  request is answered by an allowed model (the key's default, else the cheapest allowed) and
  disclosed - asking for `no-such-model` that way returned 200 with:

  ```
  X-Model-Resolution: fallback (unresolvable "no-such-model")
  X-Resolved-Model: gpt-4o-mini (smart:cost)
  ```

  The fallback never leaves the key's allowed models, and the response body's `model` and the
  ledger name the model that actually served. The bare application (outside the Compose
  profiles) still defaults to `fallback-to-allowed` in this release.

## Turning it off again

Disable is the same PUT with `enabled: false`. Nothing is deleted and the probe history stays
on the row - but note what happens to in-flight demand: requests for its models do not start
failing, they take the fallback ladder to another provider, disclosed on the response. For example, a fallback response can report:

```
X-Served-Provider: openai
X-Model-Resolution: fallback(demo-model -> gpt-4o-mini, rung 3, upstream_error)
```

That is the designed behaviour - availability over refusal - but it means disabling a provider
moves its spend to whatever the ladder lands on rather than stopping it. Disabling the model
rows behaves differently: the name just becomes unresolvable, which the shipped profiles refuse
with `400 model_unresolvable` (with `fallback-to-allowed` opted in, it is answered by an allowed
model, disclosed the same way).

### Model resolution policy

The registry cannot make a name refuse. If you mean *this key must not generate this
traffic at all*, that is admission control, not registry state: restrict the key's allowed
models, or put a limit rule in front of it. The registry decides where traffic goes; the
control plane decides whether it goes.

Full wire shapes and every refusal for these doors: [the admin API reference](../reference/admin-api.md).
