# Add a provider TokenTriage doesn't ship

Two ways to reach a provider with no built-in descriptor, what each gives up, and how to tell which one your provider needs.

Source: https://tokentriage.com/docs/build/providers-custom

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

TokenTriage ships 95 built-in provider descriptors, and that is still not every provider. A list of
supported vendors is a poor place to discover that yours is missing.

The generated matrix names the test behind each gated provider-by-endpoint cell and prints the
ungated ones rather than hiding them; ten of the 95 carry no gated cell at all, and
`openai_compatible` — route one below — is one of the ten. Where a test does exist it runs
against a mock shaped like that provider, not the provider's live endpoint.
[The provider reference](/docs/build/providers-reference) prints the evidence cell by cell.

If your provider speaks the OpenAI chat wire, you can reach it today without waiting for anybody,
and you can decide whether its rows carry a dollar.

Two routes exist. Both are configuration; neither needs a build.

| Route | Use it when | Rows are |
|---|---|---|
| `provider: openai_compatible` | you want it working in four lines | unpriced, unless you assert free |
| a `providers:` block | you want it priced under a namespace you nominate | priced under that namespace |

If your provider does not speak the OpenAI wire, neither route helps, and
[the coverage page](/docs/build/providers-coverage) says why and what would be needed.

## Route one: the generic base

`openai_compatible` is a built-in descriptor with the OpenAI wire, bearer authentication, and chat,
embeddings and models declared.

```yaml
upstreams:
  newvendor:
    provider: openai_compatible
    base_url: https://api.newvendor.example   # host only — your client already sends /v1
    api_key_env: NEWVENDOR_API_KEY

tiers:
  - name: cheap
    upstream: newvendor
    model: nv-fast-1
```

That request is authenticated, forwarded, relayed byte for byte, and written to the ledger with its
usage counts. What it does not get is a price: the descriptor's namespace is not one the vendored
catalog carries, so every row comes back `unpriced` with `model_unknown`.

That outcome is visible rather than silent. If the vendor charges you nothing, or you have a flat
arrangement, say so with an inline descriptor and the rows become an asserted zero instead:

```yaml
providers:
  newvendor:                      # the descriptor id you name below
    dialect: openai
    pricing: { free: true }       # your arrangement, asserted

upstreams:
  newvendor:
    provider: newvendor
    base_url: https://api.newvendor.example
    api_key_env: NEWVENDOR_API_KEY
```

The assertion has to live on a descriptor, not on the upstream. An
`upstreams[…].pricing: free` key validates and warns about catalog collisions, but nothing
forwards it to the transport, so rows through that upstream still resolve against the catalog and
still come back `model_unknown`. Route two below is the same mechanism with more of it filled in.

<Callout type="key">
**What the escape hatch gives up is the price, and the coverage arithmetic says so out loud.** The
catalog prefix `openai_compatible` binds is not one of the 107 prefixes in the vendored price
table, so the generator that counts coverage records this descriptor as covering nothing on either
side of its sum. It is the way out of the catalog, not a row in it. Everything else survives the
trip: routing, governance, byte-faithful relay, usage counts and a ledger row.
</Callout>

## Route two: your own descriptor, in config

The `providers:` block declares a descriptor without touching Go. It is the same shape the built-in
ones have, restricted to OpenAI-compatible refinements.

```yaml
providers:
  newvendor:                        # the descriptor id you will name in upstreams
    dialect: openai
    auth: { scheme: bearer }
    pricing: { catalog_prefix: newvendor }
    endpoints: [chat, embeddings]

upstreams:
  newvendor:
    provider: newvendor
    base_url: https://api.newvendor.example
    api_key_env: NEWVENDOR_API_KEY
```

The fields available to you:

| Field | Required | What it does |
|---|---|---|
| `dialect` | yes | one of the known dialects; v1 inline providers are OpenAI-compatible refinements |
| `pricing.catalog_prefix` | one of the two | the namespace every lookup for this provider starts with |
| `pricing.free` | one of the two | declared-free instead of a lookup |
| `endpoints` | no | defaults to chat |
| `auth.scheme` | no | defaults to `bearer`; any implementable scheme is accepted, and each brings its own required upstream fields |
| `paths` | no | per-endpoint URL templates, over the closed variable set |

**Naming a `catalog_prefix` does not create prices.** It says where to look. If the vendored
catalog carries no entries under that prefix, you get `model_unknown` as before. The prefix is a
promise about *which* namespace, not that one exists.

That makes this route worth taking in two cases: your provider *does* have a prefix in the catalog
that no descriptor binds, or you maintain your own rate table through pricing overrides and want
the rows keyed somewhere stable.

**On `auth.scheme`, one thing to know before you type it.** Every scheme the authenticator
implements is accepted here, including `oci_sig`, which validates and boots and then injects no
credential at all, because its signer was specified and not built. If you write it you will get a
401 from the provider and a credential health state that reads `ok`, meaning only that your key
file parsed. [The scheme list](/docs/build/providers-auth) says what each of them does.

<Details summary="A provider whose URL shape differs">

`paths` takes an object per endpoint, with a `template` and an optional `stream_template`, over the
same closed variable set the built-in descriptors use.

```yaml
providers:
  newvendor:
    dialect: openai
    pricing: { catalog_prefix: newvendor }
    endpoints: [chat]
    paths:
      chat:
        template: /api/v2/models/{model}/completions
        stream_template: /api/v2/models/{model}/completions/stream
```

A bare string in place of that object is a parse error, so you get the shape wrong once and not
again. A template naming a variable outside the set is turned away with its position:

```console
$ tokentriage validate --config tokentriage.yaml
Error: configuration is invalid: providers["newvendor"].paths["chat"]: path template "/api/v2/models/{deployment_id}/completions": unknown variable {deployment_id} at offset 15 (allowed: model, deployment, project, location, region, api_version)
```

`model` comes from the tier or the request. Any other variable in a template becomes a required
`params:` key on every upstream naming this descriptor.

</Details>

## Checking it

The same three checks as any provider, and one extra question at the end.

```console
$ tokentriage validate --config tokentriage.yaml
$ tokentriage doctor --config tokentriage.yaml
```

Then send one request and read the row. If you expected a price, check `cost.pricing_key` and
`cost.unpriced_reason`:

| What you see | What it means |
|---|---|
| `unpriced_reason: model_unknown` | nothing under your prefix matches that model string: either the catalog has no entry, or the vendor's published id differs from what you sent |
| a `pricing_key` under your prefix | you are done |
| `pricing_key` empty, no `unpriced` flag | your descriptor's `pricing: { free: true }` took effect |

## When neither route works

Three shapes cannot be reached from config, however patient you are.

**A different wire.** A provider whose request body is not OpenAI-shaped needs a driver, and a
driver is Go code rather than a config block. This is the most common reason a provider is absent.

**A provider that answers later and nobody has written the poll for.** The execution model itself
is no longer the obstacle: submit-and-poll ships, with a job journal, a poller and a settlement row
that carries the dollar when it arrives. What is per-provider code is the protocol, because how you
submit a job, what handle comes back and how you read a terminal result differ at every vendor. So
a new asynchronous provider is a driver, even though asynchronous providers in general are now a
solved shape here.

**An authentication scheme the authenticator does not implement.** `gigachat` is the live example:
its blocker is a TLS client certificate, which belongs to the shared HTTP transport rather than to
the per-provider authenticator, so no config field reaches it. A scheme that needs new behaviour, as
opposed to new values, is code.

[What is not covered](/docs/build/providers-coverage) groups every absent prefix by the blocker
behind it, with a count for each group. If yours is there, the page tells you which blocker applies
rather than leaving you to guess.

<Details summary="Two things this page used to list here, and how they came off">

This section carried two more entries until 2026-08-24, and what happened to them is worth a
paragraph, because one of them did not resolve the way the page predicted.

**Submit-and-poll used to be the largest thing config could not reach**, and this page said one
piece of infrastructure would open all of it. That infrastructure was built. `replicate`, `fal_ai`,
`black_forest_labs`, `runwayml`, `stability`, `assemblyai` and `soniox` now ship as built-in
descriptors, and OpenAI's Batch API rides the same machinery. What remains, above, is narrower than
what was written here: the shape is solved, the per-vendor protocol is not.

**Two credential paths were listed as schemes we did not have**: Oracle Cloud's request signing,
and Snowflake's account-scoped keypair JWT. Both providers now ship, and only one of them arrived by
the predicted route. Snowflake did get a new scheme, `snowflake_jwt`, which signs a short-lived
token with your key pair. Oracle did not: Oracle documents a bearer credential for its Generative
AI service and states it is different from an IAM API key, so `oci` landed on the `bearer` scheme
that already shipped and no signer was written. The scheme constant for request signing exists and
nothing declares it.

The lesson worth keeping is that "this needs a new scheme" was a guess about a vendor's
documentation, and reading the documentation was cheaper than building the scheme.

</Details>

## What can go wrong

| Symptom | Cause | Fix |
|---|---|---|
| `does not name a known provider` | the `providers:` key and the `provider:` value disagree | they must match exactly |
| `dialect: required` | an inline provider with no dialect | `openai` for an OpenAI-shaped vendor |
| every row is `model_unknown` | the prefix has no catalog entries | expected; assert `free`, or add overrides |
| `is not a template variable` | a `paths` template using an unknown variable | only the six closed variables are allowed |
| requests 404 at the vendor | the vendor's path differs from the driver's default | declare it under `paths` |
| a 401 with `auth_health` reading `ok` | a scheme whose credential loaded but injects nothing | check the scheme you declared against the scheme list |

<NextSteps items={[
  {title: "What isn't covered, and why", href: "/docs/build/providers-coverage", description: "Whether the provider you are about to declare is absent for a reason you can act on."},
  {title: "What a provider descriptor carries", href: "/docs/build/providers-descriptors", description: "The five facts you declared by hand here, and when one of them warrants a descriptor of its own."},
  {title: "Self-hosted and declared-free upstreams", href: "/docs/build/providers-self-hosted", description: "If what you run is a model server rather than a vendor, one of these may already name it."},
  {title: "Which pricing key your model lands under", href: "/docs/build/providers-pricing-keys", description: "What the prefix you nominated does and does not promise about a rate."}
]} />
