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:
A="$POINTSMAN_ADMIN_USERNAME:$POINTSMAN_ADMIN_PASSWORD"
Create the provider, disabled#
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:
"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 withX-Include-Reasoning: true."reasoningTokens": "excluded_from_completion"- Grok reports reasoning tokens only intotal_tokens, not incompletion_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 whenstream_optionsasks 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 bypointsman.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#
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.
Probe before you enable#
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:
"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.
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#
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 indocker-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_headerson the provider row exists for the latter; values that look like credentials are refused there, deliberately - the credential belongs incredential_ref. - Probe fails with a 404 - almost always the base URL.
openai_compatibleappends the standard paths (/v1/chat/completions), so the base is the vendor's API root without them:https://api.deepseek.com, nothttps://api.deepseek.com/v1/chat/completions. Vendors differ on whether/v1belongs in the base (Groq wantshttps://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 aprovider_namethat does not exactly match the provider'sname. 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.