AI Providers
The credentialed model vendors a project generates through — and the models and prices each one carries.
Overview
An AI provider is a project-scoped record naming a vendor (naturali, openai,
anthropic, bedrock, ollama, …), a default_model, and — for a vendor of
your own — the secret holding the credential to authenticate
with. An agent either pins one provider directly
(ai_provider_id) or generates through a model route whose
targets each name one.
There are two kinds:
provider: "naturali"— the managed offering, and the only way to turn it on. It runs on naturali's own model access, so it takes no credential at all: name it, give it adefault_modelfrom the catalog, and it serves every model that catalog lists for the same vendor. Usage is metered at the catalog'spricing.- Everything else — bring your own key. Register your credential as a secret and the provider bills you directly; naturali adds no LLM cost.
This module is otherwise a verbatim mirror of the runtime: every field, method,
status code and error shape is the runtime's own, re-rooted under the project in
the path. naturali is the one value that is naturali's own.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → AI Providers.
Data Model
AiProvider
| Field | Type | Description |
|---|---|---|
id | string | Public provider ID (aip_ prefix). |
project_id | string | The owning project. |
name | string | Human-readable name. Required on create. |
provider | string | naturali for the managed offering, or a vendor slug you bring a credential for: openai, anthropic, google, xai, groq, ollama, azure, bedrock, vertex, gateway, custom. Required on create. |
default_model | string | Model used when an agent names none. Required on create. For naturali, a catalog model. |
secret_id | string, nullable | The secret holding the credential. Absent on a naturali provider. |
base_url | string, nullable | Override the vendor's base URL — a self-hosted or gateway endpoint. Absent on a naturali provider. |
config | object, nullable | Vendor-specific configuration (a region for bedrock, for instance). Absent on a naturali provider. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Key Concepts
The naturali provider, in one call
provider: "naturali" is the whole configuration. There is no credential to
register, no region to pick and no vendor to choose — the model you name decides
what serves it.
Enable naturali models
creates one and
reads the price book it gets.
This is the only way to provision the offering. It used to also have a route of
its own on Models —
POST /v1/projects/{project_id}/models/providers — which asked for the vendor
that the default_model already implies, and answered a repeat call with the
existing provider while this create makes a new one. One resource, two doors,
two behaviours; the slug is the door that remains.
- CLI
- SDK
- curl
naturali create-ai-provider --project-id proj_V1StGXR8Z5jdHi6B \
--name oneclick-ai-provider \
--provider naturali \
--default-model nova-lite-v1
const { data } = await naturali.aiProviders.createAiProvider({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
name: 'oneclick-ai-provider',
provider: 'naturali',
default_model: 'nova-lite-v1',
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/ai-providers \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "oneclick-ai-provider",
"provider": "naturali",
"default_model": "nova-lite-v1"
}'
The provider is priced for the whole catalog before the call returns, so every
model GET /v1/models lists is usable from an
agent's model from the first generation — and metered from it.
Sending secret_id, config or base_url alongside provider: "naturali" is
a 400: the offering owns all three, and silently ignoring a credential you
sent would be worse than refusing it. So is a default_model that is not a
catalog model the listing shows as available — including the vendor's own
invocation string for that same model, which the runtime would accept and which
is exactly why it is refused here.
Three answers this create has that no other provider create does, none of them expressed in the generated spec:
503 catalog_not_ready— the catalog has no models to price the provider for, so nothing is created. Not your mistake and not a wrongdefault_model: the daily sync fills the catalog, and a retry shortly after succeeds.503 managed_source_unavailable— the model you named is real and offered, and the source that serves it is not configured on this deployment. Also not a wrongdefault_model: no value from that source would work, so there is nothing to retype. Name a model from another source, or wait for the deployment to gain the one you want.502 upstream_unavailableafter a pricing failure — the provider is rolled back rather than left in place. A managed provider that was never priced would meter nothing, so none is ever handed back.
A provider serves the models of one vendor, so the default_model you name
decides which vendor's models this provider can run. A project that wants models
from more than one creates more than one naturali provider — each with a
default_model from that set — and points each agent at the right one.
An agent or a model-route target naming a model from another source than its
naturali provider's is refused with 400 bad_request; details names the
model, its source and the provider_source.
Bring your own key, in two calls
Credentials are never passed inline. Store the key as a secret first, then create the provider naming that secret:
- CLI
- SDK
- curl
SECRET_ID=$(naturali create-secret \
--project-id proj_V1StGXR8Z5jdHi6B \
--name openai-production \
--value "$OPENAI_API_KEY" | jq -r .id)
naturali create-ai-provider \
--project-id proj_V1StGXR8Z5jdHi6B \
--name openai-production \
--provider openai \
--default-model gpt-4o-mini \
--secret-id "$SECRET_ID"
const { data: secret } = await naturali.secrets.createSecret({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: { name: 'openai-production', value: process.env.OPENAI_API_KEY! },
});
const { data: provider } = await naturali.aiProviders.createAiProvider({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
name: 'openai-production',
provider: 'openai',
default_model: 'gpt-4o-mini',
secret_id: secret!.id,
},
});
SECRET_ID=$(curl -s -X POST \
https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/secrets \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{ \"name\": \"openai-production\", \"value\": \"$OPENAI_API_KEY\" }" | jq -r .id)
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/ai-providers \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"openai-production\",
\"provider\": \"openai\",
\"default_model\": \"gpt-4o-mini\",
\"secret_id\": \"$SECRET_ID\"
}"
A provider that needs no credential — a local ollama, say — takes a
base_url and no secret_id.
bedrock and vertex are the exception: they must carry a credential of
their own, either a secret_id or an express-mode config.apiKey. Creating one
without either is a 400, as is an update that would strip the last credential
from an existing record. Both vendors' SDKs fall back to whatever credentials the
serving environment holds when a record links none, which would run your
generations on the platform's own cloud access rather than yours — so the API
refuses the shape instead of silently doing something neither side intended. To
generate without credentials of your own, create a provider: "naturali"
provider instead — see above.
The same rule holds for a provider declared in a
formation template, where a {"ref": …} to a secret
resource in the same template counts as the credential. There provider must
also read as a literal or a parameter carrying one, since it is what decides
whether the rule applies.
The managed offering has its own resource type in a template —
naturali_ai_provider, taking a default_model and no credential at all. See
Formations.
Which models a credential can actually run
GET /v1/projects/{project_id}/ai-providers/{ai_provider_id}/models
asks the vendor, using this record's own credential and configuration, and
returns provider-native model ids — the same strings default_model and an
agent's model carry. Reachability is a property of the credential, not of the
vendor: two records for the same vendor can answer differently, so read the list
from the record you intend to generate with.
Create a provider uses it as the proof
a new credential works.
Price overrides
GET and
PUT /v1/projects/{project_id}/ai-providers/{ai_provider_id}/prices
carry per-provider price overrides, keyed on (model, effective_from). An
override prices this provider record — an enterprise-negotiated rate, or a
gateway with markup — and wins over the platform default when cost is computed.
effective_from must be in the future for a (model, component) that is
already priced, so a rate change never rewrites the cost of calls already made.
A first price for a pair nothing prices yet may be dated now, so a new override
starts metering immediately rather than after a gap.
A managed provider is the exception: its
book is naturali's, priced from the catalog as the record is
created and reconciled daily against it, so
PUT answers 403 managed_price_book_read_only there and writes nothing. The prices it carries
are the ones GET /v1/models publishes for the
model. Reading the book back is unaffected, and so is every provider of your
own — an override on a credential you pay for is yours to set.
Deleting a provider in use
DELETE /v1/projects/{project_id}/ai-providers/{ai_provider_id}
answers 409 AI_PROVIDER_HAS_DEPENDENTS while an agent or a
model route target still names it, and force does not
override that — repoint or delete those first. Deleting the provider does not
delete the secret it authenticated with.
Price overrides and metered usage also answer 409, but force=true clears
them: it deletes the overrides and unlinks the usage history, which is kept —
the spend stays on your usage rollup, it simply stops naming a
provider that no longer exists. error.meta reports the counts and a forcible
flag saying whether a force=true retry would succeed.
A managed provider always carries price
overrides — it is priced against the catalog as it is created — so deleting one
always takes force=true, even with nothing pointing at it. A
formation that owns one does this for you when the formation
is deleted.
Examples
List a project's providers
- CLI
- SDK
- curl
naturali list-ai-providers --project-id proj_V1StGXR8Z5jdHi6B
const { data: providers } = await naturali.aiProviders.listAiProviders({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
});
curl https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/ai-providers \
-H "Authorization: Bearer $NATURALI_TOKEN"
Ask a provider which models it can run
- CLI
- SDK
- curl
naturali list-ai-provider-models \
--project-id proj_V1StGXR8Z5jdHi6B \
--ai-provider-id aip_V1StGXR8Z5jdHi6B
const { data } = await naturali.aiProviders.listAiProviderModels({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
ai_provider_id: 'aip_V1StGXR8Z5jdHi6B',
},
});
curl https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/ai-providers/aip_V1StGXR8Z5jdHi6B/models \
-H "Authorization: Bearer $NATURALI_TOKEN"
Rotate to a new credential
- CLI
- SDK
- curl
naturali update-ai-provider \
--project-id proj_V1StGXR8Z5jdHi6B \
--ai-provider-id aip_V1StGXR8Z5jdHi6B \
--secret-id sec_9fJk2LmNpQrStUvW
await naturali.aiProviders.updateAiProvider({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
ai_provider_id: 'aip_V1StGXR8Z5jdHi6B',
},
body: { secret_id: 'sec_9fJk2LmNpQrStUvW' },
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/ai-providers/aip_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "secret_id": "sec_9fJk2LmNpQrStUvW" }'