# Provision models via API

> Provision providers and models through the Catalog API: upsert provider, set credential, upsert model with pricing and capabilities, allow the provider on restricted projects, assign the model, and verify.

The Admin Console is the usual surface for connecting providers and enabling models, but automation needs the same outcome without clicking through forms. The API is that path: upsert a provider, store its credential, upsert a model with pricing and capabilities, grant the project access (the model always, and the provider too where the project restricts providers), then verify with a read-back and an inference call.

:::tip Two API surfaces
This guide uses the [Management API](/reference/api/) to provision configuration: the Catalog service for providers and models, and the AdminService for project provider grants. Live prompts use the [Gateway APIs](/reference/supported-apis/) on the data plane (`/v1/chat/completions`, `/v1/models`, and related paths). The final verification step switches to that gateway surface; the provisioning steps do not.
:::

**Persona:** Platform operator or platform engineer automating catalog provisioning against the management API.

**Estimated time:** 15 to 25 minutes once a management key, a provider key, and the customer and project identifiers for assignment are in hand.

## When this guide applies

| Situation | Why this guide helps |
| :---- | :---- |
| A script or pipeline must provision providers and models without the Admin Console | The Catalog write RPCs are the programmatic surface for the same configuration |
| An internal source of truth syncs into the organization catalog | Upsert and assign map cleanly onto a repeatable reconciliation loop |
| Pricing and capability fields must be set at create time, not only toggled later in the UI | UpsertModel accepts pricing and capabilities in one request |
| The OpenAPI or SDK reference is available but there is no ordered how-to | This guide sequences the calls and shows how to verify the result |

For the Admin Console equivalent, use [Provision models and providers](/agent-router-enterprise/guides/operate-and-govern/manage-models-and-providers/) instead. For managing the broader configuration baseline as code, see [Manage configuration as code](/agent-router-enterprise/guides/operate-and-govern/manage-configuration-as-code/).

## Outcomes

By the end of this guide:

- A provider record exists with a stored credential, and the provider is enabled.
- A model record exists under that provider, with pricing and capabilities set.
- Where the project restricts providers, the provider is on the project's provider list.
- The model is allow-listed for the project, so the project gateway can route to it.
- The configuration is confirmed with Catalog read-backs and a gateway inference call.

## Prerequisites

- A management key with administrative scope for Catalog writes, issued from the Admin Console or an equivalent management API path. Every request below uses bearer auth.
- For the provider writes, a platform grant. The provider catalog is shared by every organization, so each provider write is authorized at platform level: it accepts the `admin` scope, or a platform binding of a role that carries the provider permissions, such as [Platform Provider Admin](/agent-router-enterprise/guides/identity-and-access-management/manage-roles-and-permissions/#platform-provider-admin). An organization or project grant does not authorize a provider write.
- For the model writes (CreateModel, UpsertModel, and DeleteModel), a platform grant for the same reason: the `admin` scope, or a platform binding of a role that carries the model permissions, such as [Platform Model Admin](/agent-router-enterprise/guides/identity-and-access-management/manage-roles-and-permissions/#platform-model-admin).
- The management API base URL for the deployment (the host that serves `/v1/catalog/...`). Replace `https://management.example.com` in the examples with that host.
- Upstream provider credentials (API key, bearer token, or whatever the provider expects) and the provider's API base URL.
- The customer and project identifiers used when assigning a model to a project.
- For the final inference check: a **client** API key minted for that project (CreateClientWithKey), not a CreateApiKey user token. See [Which API key for which surface](/reference/api/#which-api-key-for-which-surface).

The `tetrate agentrouter api catalog` CLI exposes the same operations as flags, except CreateProvider, which has no CLI command. CreateModel is `tetrate agentrouter api catalog models create`. This guide uses HTTP so the request bodies are explicit; either surface is valid.

## Step 1: upsert the provider

Create the provider record, or update an existing one, with [UpsertProvider](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-upsert-provider/). A repeated call with the same body leaves the same record, so the call fits a reconciliation loop.

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/providers" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "acmeopenai",
    "displayName": "Acme OpenAI",
    "baseUrl": "https://api.openai.com/v1",
    "supportedAuthSchemes": ["api_key"]
  }'
```

When the call creates the provider, three rules apply:

- `id` is a canonical slug: lowercase alphanumeric only (`[a-z0-9]+`), no hyphens.
- `displayName` is required.
- `baseUrl` is required, except when the auth scheme is `aws` or `none`.

`supportedAuthSchemes` takes one entry, the provider's auth type: `api_key`, `aws`, `vertex`, `vertex_anthropic`, `none`, `google_ai_key`, or `azure`. Only the first entry is stored. A new provider is disabled until Step 2 enables it.

On an update, the create rules are not checked, and a field left out keeps its stored value. Thus `{"id": "acmeopenai", "isEnabled": false}` alone disables a provider, and a request without `isEnabled` keeps the stored state. A provider with an older slug form stays editable. A request that does not pass the create rules never creates a provider: for an unknown `id`, it is refused with `INVALID_ARGUMENT`.

`metadata` is merged key by key: a top-level key in the request replaces the stored key, and a key not sent is kept. A `null` value is stored as a value and does not remove the key. To remove keys, name them in `removeMetadataKeys`, which is applied after the merge:

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/providers" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "acmeopenai",
    "removeMetadataKeys": ["first_party"]
  }'
```

`removeMetadataKeys` removes whole top-level keys only, and a key that is not stored is ignored. `credential` and `credential_suffix` are reserved: they are ignored in `metadata` and dropped from `removeMetadataKeys`. The credential is changed only with the calls in Step 2.

### Create without editing

CreateProvider (`POST /v1/catalog/providers:create`) adds a provider and never changes an existing one. When a provider with the same `id` exists, the call returns `ALREADY_EXISTS` and writes nothing. It suits a script that must not edit a provider by accident.

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/providers:create" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "acmeopenai",
    "displayName": "Acme OpenAI",
    "baseUrl": "https://api.openai.com/v1",
    "supportedAuthSchemes": ["api_key"]
  }'
```

The fields are those of UpsertProvider, with these differences:

- `supportedAuthSchemes` must hold exactly one of the auth types listed above.
- `displayName` is optional and defaults to `id`.
- The provider is created disabled unless `isEnabled` is `true`. For an auth type that needs a key, `isEnabled` is left out, and the provider is enabled after its credential is stored (Step 2).

CreateProvider is not idempotent. A retry after a lost response can receive `ALREADY_EXISTS` for its own first write, so the retry reads the provider with GetProvider and compares it with the request.

## Step 2: set the credential and enable the provider

Store or rotate the upstream credential with [SetProviderCredential](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-set-provider-credential/). The plaintext is never returned; the provider's `credentialSuffix` reflects the last four characters after a successful write.

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/providers/acmeopenai/credential" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "credential": "'"${PROVIDER_API_KEY}"'"
  }'
```

Credential rotation uses the same call with the new value. In-flight requests that already held the old credential complete with it; subsequent requests use the new one.

Then enable the provider. The data plane fetches the credential of every enabled provider, so a provider that needs a key is enabled only after its credential is stored. An update keeps every field it does not send, so `id` and `isEnabled` are enough:

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/providers" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "acmeopenai",
    "isEnabled": true
  }'
```

### Remove a stored credential

DeleteProviderCredential (`DELETE /v1/catalog/providers/{id}/credential`) removes the provider's stored credential. Afterwards `hasCredential` is `false`, `credentialSuffix` is empty, and the data plane stops sending the key. The call is idempotent: on a provider with no stored credential, it succeeds and changes nothing.

```bash
curl -sS -X DELETE "https://management.example.com/v1/catalog/providers/acmeopenai/credential" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}"
```

When the provider is enabled and its auth type needs a key, the call is refused with `FAILED_PRECONDITION` until the provider is disabled. The `aws` auth type (the data plane then uses its IAM role) and the `none` auth type are exempt.

## Step 3: upsert the model (pricing and capabilities)

Create or update the model with [UpsertModel](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-upsert-model/). Pricing fields are decimal USD strings. Capabilities declare what the model supports (for example `chat` or `embeddings`); that is the API counterpart of choosing a model mode in the UI.

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/models" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "providerId": "acmeopenai",
    "name": "gpt-4o",
    "upstreamModel": "gpt-4o",
    "inputPerMillion": "2.50",
    "outputPerMillion": "10.00",
    "cacheReadPerMillion": "1.25",
    "maxCostPerRequest": "",
    "maxContextTokens": 128000,
    "capabilities": ["chat"]
  }'
```

Required fields are `providerId` and `name`. `upstreamModel` defaults to `name` when empty. Capture the model id returned in the response; it is required for the model assignment in Step 5.

For an embedding model, set `capabilities` to include `embeddings` and verify with the embeddings endpoint instead of chat completions.

The model is identified by `providerId` and `name`. A request with a pair that exists updates that model; it does not create a second one. A model cannot be renamed: to change `name`, create a new model and delete the old one.

### Clear values on an update

On an update, a field that is left out or empty keeps its stored value, and `metadata` is merged key by key, as for providers in Step 1. To clear a stored value, name the field in `clearFields`:

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/models" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "providerId": "acmeopenai",
    "name": "gpt-4o",
    "clearFields": ["cache_read_per_million", "backend_urls"],
    "removeMetadataKeys": ["fallback_policies"]
  }'
```

Each entry in `clearFields` is a request field name, written as in the API reference. The fields that can be cleared are:

- The prices: `input_per_million`, `output_per_million`, `cache_read_per_million`, `caching_per_million`, `additional_price_per_million`, and `max_cost_per_request`.
- `max_context_tokens`, `capabilities`, `input_modalities`, `output_modalities`, `backend_urls`, and `limits`.
- `upstream_model`. The provider then receives `name` as the model identifier.

The request is refused with `INVALID_ARGUMENT` when an entry is not in this list, or when the same request also sets that field. `mode` cannot be cleared, because every model needs a mode to be routed.

`removeMetadataKeys` removes whole top-level `metadata` keys, and a key that is not stored is ignored. `upstream_model` and `max_cost_per_request` are refused there: clear them with `clearFields`. A request that sets neither `clearFields` nor `removeMetadataKeys` behaves as before, so an existing sync script needs no change.

### Create without editing

CreateModel (`POST /v1/catalog/models:create`) adds a model and never changes an existing one. When a model with the same `providerId` and `name` exists, the call returns `ALREADY_EXISTS` and writes nothing. It takes the fields of UpsertModel.

```bash
curl -sS -X POST "https://management.example.com/v1/catalog/models:create" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "providerId": "acmeopenai",
    "name": "gpt-4o",
    "inputPerMillion": "2.50",
    "outputPerMillion": "10.00",
    "capabilities": ["chat"]
  }'
```

CreateModel is not idempotent. A retry after a lost response can receive `ALREADY_EXISTS` for its own first write, so the retry reads the model with ListModels (filtered by `providerId`) and compares it with the request.

## Step 4: check the project's provider list

:::note Providers restrict, models allow-list
The two project grants are not symmetrical. Models are allow-listed: a model the project has not been granted is refused at request time (Step 5). Providers are a restriction: a project with an empty provider list may use any provider the organization catalog has enabled, and a non-empty list confines the project to those providers only. The Admin Console workflow for both grants is [Add providers and models to a project](/agent-router-enterprise/guides/operate-and-govern/manage-models-and-providers/add-providers-and-models-to-projects/).
:::

Project provider grants live on the AdminService, under `/admin/v1/...`, not on the Catalog paths used so far. List the project's providers with [ListProjectProviders](/reference/api/admin/agentrouter-admin-v-1-admin-service-list-project-providers/):

```bash
curl -sS \
  "https://management.example.com/admin/v1/customers/${CUSTOMER_ID}/projects/${PROJECT_ID}/providers" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}"
```

An empty list is the absence of a restriction, and this step is then complete: the project keeps following the organization catalog as the catalog changes. Adding a provider to an empty list changes the rule from "any enabled provider" to "these providers only", so a list is only introduced when the intent is to confine the project.

Where the list is already non-empty, a model from any provider it does not name is not routable, however it is assigned. Add the provider from Step 1 with [AddProjectProvider](/reference/api/admin/agentrouter-admin-v-1-admin-service-add-project-provider/):

```bash
curl -sS -X POST \
  "https://management.example.com/admin/v1/customers/${CUSTOMER_ID}/projects/${PROJECT_ID}/providers" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"providerName": "acmeopenai"}'
```

`providerName` is the provider id slug from Step 1. [RemoveProjectProvider](/reference/api/admin/agentrouter-admin-v-1-admin-service-remove-project-provider/) (`DELETE .../providers/{provider_name}`) lifts a grant again, and removing the last entry returns the project to the unrestricted rule.

Skipping this step on a restricted project fails silently: the model assignment in Step 5 still returns 200, and inference then fails with `404 No matching route found`.

## Step 5: assign the model to the project

:::note Projects are the isolation boundary
A **[project](/reference/glossary/#project)** owns the models and API keys an application uses. The address the application calls comes from the data planes labelled with the project. Catalog enablement makes a model available to grant, not callable: the gateway refuses a model that has not been assigned to the caller's project. A default project is created during onboarding and is enough for a first verification; creating additional projects and granting models in the Admin Console is covered in [Create a project and grant models](/agent-router-enterprise/guides/operate-and-govern/project-and-data-plane-management/create-a-project/). For the full concept, see [Key concepts → Projects](/product-architecture/key-concepts/#projects).
:::

Allow-list the model for the project's gateway with [AssignModelToProject](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-assign-model-to-project/). The call is idempotent: re-assigning the same model is a no-op.

```bash
curl -sS -X POST \
  "https://management.example.com/v1/customers/${CUSTOMER_ID}/projects/${PROJECT_ID}/models" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "modelId": "'"${MODEL_ID}"'"
  }'
```

`modelId` is the catalog model id from the UpsertModel response. In the Admin Console, enablement and project scoping are the UI counterparts of the upsert-plus-assign sequence.

## Step 6: verify

Confirm configuration first, then traffic.

### Read-back

1. Fetch the provider: `GET /v1/catalog/providers/{id}` ([GetProvider](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-get-provider/)). Confirm `baseUrl`, that `isEnabled` is `true`, and that `hasCredential` is `true`.
1. Fetch the model: `GET /v1/catalog/models/{id}` ([GetModel](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-get-model/)). Confirm pricing, `maxContextTokens`, and `capabilities`.
1. List project models: `GET /v1/customers/{customer_id}/projects/{project_id}/models` ([ListProjectModels](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-list-project-models/)). Confirm the new model appears.
1. On a project with a non-empty provider list: `GET /admin/v1/customers/{customer_id}/projects/{project_id}/providers` ([ListProjectProviders](/reference/api/admin/agentrouter-admin-v-1-admin-service-list-project-providers/)). Confirm the model's provider is listed, per Step 4.

```bash
curl -sS "https://management.example.com/v1/catalog/providers/acmeopenai" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}"

curl -sS "https://management.example.com/v1/catalog/models/${MODEL_ID}" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}"

curl -sS \
  "https://management.example.com/v1/customers/${CUSTOMER_ID}/projects/${PROJECT_ID}/models" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}"
```

`hasCredential` is `true` when the credential that the data plane reads is stored, whichever surface stored it, and `false` when it is not. The field is optional: when the credential check cannot run, the read still succeeds and `hasCredential` is absent. An absent value means the status is unknown, never that no key is stored. CreateProvider and UpsertProvider responses do not include it. A GetProvider read after the write includes it.

### Inference

End-to-end verification switches from the **management** (Catalog) API to the **gateway** inference surface: a different host and a **project client** key (CreateClientWithKey), not the management key used for Catalog writes. Gateway paths, formats, and auth are documented in [Gateway APIs](/reference/supported-apis/).

Use the gateway base URL for the deployment (for Fully Managed, often `https://api.router.tetrate.ai/v1`). First list the models routable for that client key with [`GET /v1/models`](/reference/supported-apis/#models-api-v1models). Each returned `id` can be passed as `model` on an inference call. Confirm that the newly provisioned and assigned model appears in the list.

```bash
curl -sS "https://api.router.tetrate.ai/v1/models" \
  -H "Authorization: Bearer ${CLIENT_API_KEY}"
```

Then send a Chat Completions request ([`POST /v1/chat/completions`](/reference/supported-apis/#chat-completions-api-v1chatcompletions)) using one of those `id` values:

```bash
curl -sS -X POST "https://api.router.tetrate.ai/v1/chat/completions" \
  -H "Authorization: Bearer ${CLIENT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o",
    "messages": [{"role": "user", "content": "ping"}]
  }'
```

A successful response confirms credentials, catalog configuration, and project grants end to end. If the model is missing from `GET /v1/models`, or the completion call returns `404 No matching route found`, the usual causes are a missed upsert, a missed model assignment (Step 5), or, on a project with a non-empty provider list, the model's provider missing from that list (Step 4). An upstream authentication error on the completion call usually points at the provider credential. For more gateway call shapes, see [Make an API call](/agent-router-service/quickstarts/make-an-api-call/).

## Delete a model

[DeleteModel](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-delete-model/) (`DELETE /v1/catalog/models/{id}`) removes the model from the catalog. In the same transaction, it removes the model from every project it was assigned to (Step 5), so no project keeps an assignment to a model that does not exist. From that point, a request that names the model is refused.

```bash
curl -sS -X DELETE "https://management.example.com/v1/catalog/models/${MODEL_ID}" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}"
```

A model created again later with the same `name` receives a new id, and each project needs a new assignment.

## Read the model catalog

[ListModels](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-list-models/) (`GET /v1/catalog/models`) returns every model in one response. The list is not paginated: the `page` fields are ignored, and `nextPageToken` is always empty. Only enabled models are returned unless `includeDisabled=true` is set, and `providerId` limits the list to one provider. This path, like `GET /v1/catalog/models/{id}`, needs a platform grant or the `admin` scope.

A caller whose grant is inside one organization reads the same catalog on the customer path. Any caller that holds `models.read` in that customer can use it, for example a user with the Viewer role:

```bash
curl -sS "https://management.example.com/v1/customers/${CUSTOMER_ID}/catalog/models" \
  -H "Authorization: Bearer ${USER_TOKEN}"
```

- `GET /v1/customers/{customer_id}/catalog/models/{id}` reads one model on the same path.
- The optional `projectId` query parameter authorizes the read at a project in that customer. A `projectId` without a customer is refused with `INVALID_ARGUMENT`.
- A caller with no grant in the named customer, and no platform grant, is refused.
- The models are the same global rows on both paths. The customer path removes the `aigw-headers` key from `metadata`, because its header values can be credentials. Every other field is kept.

## Read the provider catalog

[ListProviders](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service-list-providers/) (`GET /v1/catalog/providers`) returns every provider in one response. The list is not paginated: the `page` fields (`pageSize`, `pageToken`, `filter`, and `orderBy`) are ignored, and `nextPageToken` is always empty. Only enabled providers are returned unless `includeDisabled=true` is set. This path, like `GET /v1/catalog/providers/{id}`, needs a platform grant or the `admin` scope.

A caller whose grant is inside one organization reads the same catalog on the customer path. Any caller that holds `providers.read` in that customer can use it, for example a user with the Viewer role:

```bash
curl -sS "https://management.example.com/v1/customers/${CUSTOMER_ID}/catalog/providers" \
  -H "Authorization: Bearer ${USER_TOKEN}"
```

- `GET /v1/customers/{customer_id}/catalog/providers/{id}` reads one provider on the same path.
- The optional `projectId` query parameter authorizes the read at a project in that customer. A `projectId` without a customer is refused with `INVALID_ARGUMENT`.
- A caller with no grant in the named customer, and no platform grant, is refused.
- The providers are the same global rows on both paths. The customer path hides the platform-level account detail: `credentialSuffix` is empty, and `metadata` keeps only `type`, `backend_schema`, `api_key_flavor`, `operation_variants`, and `first_party`. `hasCredential` is kept.

Where to go next

Use the Admin Console for the same catalog and grant work, or stay on the API and IaC path below. Catalog method detail is in [CatalogService](/reference/api/catalog/agentrouter-catalog-v-1-catalog-service/).

  <Link to="/agent-router-enterprise/guides/operate-and-govern/manage-models-and-providers/" className="tare-nav-card">
    Provision models and providers
    The Admin Console workflow for providers and model enablement.
  </Link>
  <Link to="/agent-router-enterprise/guides/operate-and-govern/manage-models-and-providers/add-providers-and-models-to-projects/" className="tare-nav-card">
    Add providers and models to a project
    The Admin Console workflow for both project grants, with the restriction and allow-list semantics.
  </Link>
  <Link to="/agent-router-enterprise/guides/operate-and-govern/project-and-data-plane-management/create-a-project/" className="tare-nav-card">
    Create a project and grant models
    Grant catalog models to a project, then provision its gateway and keys.
  </Link>
  <Link to="/agent-router-enterprise/guides/operate-and-govern/manage-configuration-as-code/" className="tare-nav-card">
    Manage configuration as code
    How the admin API fits UI and IaC ownership of the catalog.
  </Link>
