# Provider credentials: tenant defaults and per-agent overrides

> How the voice runtime resolves LLM, ASR and TTS provider keys, which providers accept a per-agent override, what the masked meta returns, and how to diagnose a missing credential at hydrate.

The Credentials screen supplies the provider keys that the TeleQuick
voice runtime uses when it calls an LLM, an ASR engine, or a TTS engine.
Without a resolvable key for the providers an agent has selected in its
config, the agent fails at hydrate with `no credentials for <provider>`.

The screen writes to two scopes and never reads a secret back.

## The two scopes and the resolution order

| Scope | Written by | Redis key | Applies to |
| ----- | ---------- | --------- | ---------- |
| Tenant default | `admin.setProviderCredentials` | `provider_creds:{orgId}:{provider}` | Every agent in the organisation. |
| Per-agent override | `admin.setAgentProviderOverride` | `agent_provider_creds:{agentId}:{provider}` | The one selected agent. |

The runtime reads the **agent override first** and falls back to the
**tenant default** when the agent has no override for that provider. An
agent with no override is not broken — it simply inherits the org-level
key. In the **This agent** tab, a provider with no override shows
`uses tenant default` rather than `not set`.

Use an override when one agent must talk to a different OpenAI
organisation, a different Deepgram project, or a different ElevenLabs
voice library than the rest of the org.

Both scopes are org-scoped procedures. For the agent scope, the server
additionally reads `agent_config` and checks that `agentId` belongs to
`orgId` before any write or delete; a mismatch returns `NOT_FOUND`
(`Agent not found in this org`).

`admin.resolveAgentProviderCredentials` reports which scope actually
answered, via its `source` field.

## Which providers accept a per-agent override

Only six providers are on the runtime's per-agent resolve path:

`openai`, `anthropic`, `gemini`, `deepgram`, `elevenlabs`, `ollama`

The **This agent** tab lists exactly these. Every other provider in the
table below is tenant-scope only — set it once under **Tenant defaults**
and all agents in the org use it.

## Required fields per provider

Each provider writes a flat `payload` object of string fields. Every
field listed for a provider is required: the screen blocks a save with
`<field> required` rather than letting a half-filled payload (an Azure
key with no region, a PlayHT key with no user id) be stored and then
fail later on a live call. The server independently validates the
payload with `validateProviderPayload` and returns `BAD_REQUEST` if it
does not pass.

| Provider key | Label | Fields | Notes |
| ------------ | ----- | ------ | ----- |
| `openai` | OpenAI | `api_key` | GPT LLM + Whisper ASR. One bearer key for both. |
| `anthropic` | Anthropic | `api_key` | Claude family LLM. |
| `gemini` | Google Gemini | `api_key` | Gemini LLM via the AI Studio API. |
| `deepgram` | Deepgram | `api_key` | Streaming ASR (Nova) + Aura TTS. |
| `elevenlabs` | ElevenLabs | `api_key` | TTS voices. |
| `ollama` | Ollama (self-hosted) | `endpoint` | No auth — host:port only, e.g. `127.0.0.1:11434`. Not a secret field. |
| `cartesia` | Cartesia | `api_key` | Sonic TTS. |
| `playht` | PlayHT | `api_key`, `user_id` | Both required. `user_id` is not treated as a secret field. |
| `sarvam` | Sarvam.ai | `api_key` | Indic ASR (Saarika) + TTS (Bulbul). |
| `azure` | Azure Speech | `api_key`, `region` | `api_key` is the subscription key; `region` (e.g. `eastus`) is part of the URL. |
| `smallest` | smallest.ai | `api_key` | Lightning TTS + Pulse STT. |
| `xai` | xAI (Grok) | `api_key` | Grok realtime + TTS + STT. |
| `groq` | Groq | `api_key` | LLM inference + Whisper ASR. |
| `cerebras` | Cerebras | `api_key` | LLM inference. |
| `mistral` | Mistral | `api_key` | Mistral LLM + Voxtral ASR. |
| `together` | Together AI | `api_key` | Open-model LLM + Whisper ASR. |
| `fireworks` | Fireworks AI | `api_key` | Open-model LLM inference. |
| `openrouter` | OpenRouter | `api_key` | One key, multiple LLM vendors. |
| `deepseek` | DeepSeek | `api_key` | Chat + reasoner. |
| `perplexity` | Perplexity | `api_key` | Sonar web-grounded LLMs. |
| `fal` | fal.ai | `api_key` | Hosted model inference. |
| `rime` | Rime | `api_key` | TTS voices. |
| `lmnt` | LMNT | `api_key` | Streaming TTS. |
| `assemblyai` | AssemblyAI | `api_key` | Streaming ASR. |
| `gladia` | Gladia | `api_key` | Streaming ASR. |
| `soniox` | Soniox | `api_key` | Streaming ASR. |
| `speechmatics` | Speechmatics | `api_key` | Streaming ASR. |
| `gradium` | Gradium | `api_key` | Streaming ASR. |

The payload shape is shared: the contact-centre credentials screen and
the voice AI credentials screen write the same object for a given
provider. The server persists whatever validated object it is given, so
a client that invented its own field names would store credentials the
runtime cannot read.

## Write-only storage and what the mask shows

Keys are write-only from this screen. The secret fields of the payload
are sealed under the owning org (`sealProviderPayload(payload, orgId)`)
before being written to Redis — including for a per-agent override,
which is sealed under the **org**, not the agent, so the engine (which
knows the agent's org) can open the hydrated config derived from it.

The meta queries return only presence and a mask, never the secret:

- `admin.getProviderCredentialsMeta` / `admin.getProviderCredentialsMetaAll` — tenant scope
- `admin.getAgentProviderOverrideMeta` / `admin.getAgentProviderOverrideMetaAll` — agent scope

The screen uses the `…All` variants: one batched query per tab returns a
map keyed by provider key, so a page load issues a single request rather
than one per provider row.

Each entry carries `configured` and, when available, `masked`. The
screen renders the mask string (first/last characters of the stored
value) as the row's badge; if `masked` is an object it renders the first
string value; if it is absent but `configured` is true it renders
`configured`. You can recognise which key is stored, but you cannot read
it back. To change a key, save a new one — **Replace** overwrites the
stored value.

## What happens when you save

For a **tenant default** (`admin.setProviderCredentials`):

1. `validateProviderPayload(provider, payload)` runs. On failure the call
   returns `BAD_REQUEST` with the validator's message.
2. The filtered payload is sealed under `orgId`.
3. It is written to `provider_creds:{orgId}:{provider}`.

For a **per-agent override** (`admin.setAgentProviderOverride`):

1. The agent is looked up in `agent_config` by `id` + `org_id`;
   `NOT_FOUND` if it does not belong to the org.
2. The payload is validated and sealed under the owning org.
3. It is written to `agent_provider_creds:{agentId}:{provider}`.
4. **Re-hydration.** `hydrateAndPublish` republishes the agent's runtime
   config to `agent:{agentId}:config`, so the new key is in effect for the
   next call that originates. This step is best-effort — it is wrapped in
   a try/catch because the `agent_config` row may not exist yet during
   early setup. A hydration miss is recovered by the next explicit
   `admin.hydrateAgent` or by the next originate.
5. **Audit.** An entry is recorded with `action: 'set-credential'`,
   `resourceType: 'agent_provider_override'`, and
   `resourceId: "{agentId}:{provider}"`. The `after` payload contains only
   the **field names** that were written (for example `['openai_api_key']`)
   — never the key bytes — so the trail proves a credential changed
   without exposing it to anyone with audit-read.

Note the asymmetry: writing a tenant default does not itself re-hydrate
any agent. If you change a tenant key and want it reflected immediately,
call `admin.hydrateAgent` for the affected agent. That procedure returns
`ready`, `missing_providers` and `hydrated_at_ms`, and records an audit
entry with `action: 'hydrate'` so you can later correlate a save with the
hydration that followed it.

## Removing a key or an override

Both removals are confirmed in the UI before they run.

`admin.removeProviderCredentials` deletes
`provider_creds:{orgId}:{provider}`. Every agent that was relying on that
tenant default now has no credential for that provider unless it carries
its own override.

`admin.removeAgentProviderOverride` verifies the agent belongs to the org,
deletes `agent_provider_creds:{agentId}:{provider}`, re-hydrates the agent
(best-effort, as above), and records an audit entry with
`action: 'delete-credential'`. Removing an override does not remove the
tenant default — the agent falls back to it.

Deleting an agent entirely is handled by `admin.unpublishAgent`, which
wipes the hydrated config and sweeps the agent's provider override keys
for every provider, so a future agent that reuses the same id does not
inherit ghost credentials.

## Diagnosing missing credentials at hydrate

`admin.resolveAgentProviderCredentials` is the read-only check behind the
"is this agent ready" warning. It loads the agent's `llm_provider`,
`asr_provider` and `tts_provider`, resolves credentials for the agent
(override first, tenant default second), and returns:

| Field | Meaning |
| ----- | ------- |
| `ready` | True when nothing the agent selected is missing. |
| `missing` | The credential field names that could not be resolved. |
| `source` | Which scope answered the resolve. |

Only the providers the agent actually selected are required. The
procedure deliberately does not report every unpopulated provider —
telling an Anthropic user to fill in Gemini is noise.

The slot values map to required credential fields as follows:

| Agent provider slot value | Required field |
| ------------------------- | -------------- |
| `openai` | `openai_api_key` |
| `anthropic` | `anthropic_api_key` |
| `google` | `gemini_api_key` |
| `deepgram` | `deepgram_api_key` |
| `elevenlabs` | `elevenlabs_api_key` |

Note that the Gemini credential is stored under the provider key
`gemini`, while the agent config slot that requires it is `google`.

To fix a `no credentials for X` failure:

1. Confirm which providers the agent selected in its config (LLM, ASR,
   TTS).
2. Open **Credentials → Tenant defaults** and check that the matching
   provider row shows a mask rather than `not set`.
3. If the agent should use different keys, set them under **This agent**
   — available only for the six overridable providers, and only once an
   agent is selected.
4. Call `admin.hydrateAgent` (the screen's Save path on the agent config,
   or the explicit hydrate action) and check `ready` and
   `missing_providers` in the response before starting a test call.

## Procedure reference

| Procedure | Scope | Kind |
| --------- | ----- | ---- |
| `admin.getProviderCredentialsMeta` | Tenant | Query — presence + mask for one provider |
| `admin.getProviderCredentialsMetaAll` | Tenant | Query — map of provider key → meta |
| `admin.setProviderCredentials` | Tenant | Mutation — validate, seal, write |
| `admin.removeProviderCredentials` | Tenant | Mutation — delete |
| `admin.getAgentProviderOverrideMeta` | Agent | Query — presence + mask for one provider |
| `admin.getAgentProviderOverrideMetaAll` | Agent | Query — map of provider key → meta |
| `admin.setAgentProviderOverride` | Agent | Mutation — validate, seal, write, hydrate, audit |
| `admin.removeAgentProviderOverride` | Agent | Mutation — delete, hydrate, audit |
| `admin.resolveAgentProviderCredentials` | Agent | Query — `ready`, `missing`, `source` |
| `admin.hydrateAgent` | Agent | Mutation — republish config, returns `ready`, `missing_providers`, `hydrated_at_ms` |
| `admin.unpublishAgent` | Agent | Mutation — wipe hydrated config and all overrides |
