Skip to main content
apps/providers holds Pydantic configs and a factory that build Pipecat (and first-party) STT, TTS, and LLM services. Adding a vendor means adding a folder — no central list, no if/elif, no second copy of the catalog in the API or a client form.
To add a vendor, follow Adding an AI provider. This page explains why the package is shaped the way it is.

The problem with a central if/elif

The obvious way to support many vendors is one dispatch function that branches on a provider string, plus a hand-maintained list of provider names and their fields for the UI. That gives you three places to edit per vendor and three places to get out of sync — and the list a client sees is only ever as fresh as the last time someone remembered to update it. VoicEra inverts it. Each vendor module registers itself; everything else is derived: load_providers() walks cloud/, adapters/, and local/ with pkgutil.iter_modules and imports every <vendor>.service module so the decorators run. A vendor package without a service.py is skipped rather than raising. Package roots are resolved from __package__, not a hard-coded apps.providers prefix, so the tree still loads when pytest imports it as voicera.apps.providers. registry._register reads the config class off the creator’s first type hint and the provider id off that class’s provider field default. Registering two different config classes under the same provider id raises ValueError at import time.

Registry, factory, schema

factory.py builds one Annotated[Union[...], Field(discriminator="provider")] per kind from the registered classes, then composes them into AgentConfig:
Pydantic picks the right config class from the provider discriminator and validates the rest against it, so an unknown provider or a missing required field fails at validation, not at call time. Vendor creators import Pipecat inside the function body, so a missing per-vendor extra breaks only that vendor rather than the whole package import. The package __init__ goes further and loads factory symbols lazily through __getattr__, which is why a catalog dump does not need loguru or Pipecat installed at all. apps/providers/cloud/factory.py is a deprecated re-export kept for compatibility. Import from apps.providers or apps.providers.factory.

Auth vs Settings vs Config

Each vendor config.py stacks three layers:
  1. Auth — credentials (api_key, auth_token, …). Secrets are marked with json_schema_extra={"secret": True}.
  2. Settings — vendor knobs (voice, speed, base_url, grpc_url, …).
  3. Config — Auth + Settings + the matching Base*Config, contributing provider, model, and language.
Credentials never live on the bases. Endpoints and hosts belong on Settings, not Auth. The split is what lets ProviderAuth store exactly the secret fields and nothing else — validate_auth_payload rejects any non-secret catalog field outright. Deepgram is a compact example:
name is the UI display label; provider is the discriminator. Both are required for registration to work.

Provider types

ProviderType has three values, derived in schema._provider_type from the config class’s module path rather than declared: A config class outside all three raises ValueError, which keeps the layout honest.

The schema dump

provider_schemas(kind) returns a readable catalog, not raw JSON Schema — no $defs, $ref, or anyOf. Every entry carries the provider id, display name, provider type, first docstring line, required fields, the secret field names, and a per-field description:
kind, provider, and name are omitted from fields — they are catalog top-level, not form inputs. Field entries carry default, minimum/maximum from Pydantic constraints, and the whitelisted extras secret, examples, multiline, model_options, language_codes, and docs_url. The secrets list is always present, even when empty.

How input_mode is derived

A client form needs to know whether a field is a dropdown, a text box, or both. _input_mode derives it rather than making each vendor declare it: Secrets get no input_mode at all — a secret is always a password input, never a picker. allow_custom_input is read for the derivation but is not itself dumped into the catalog.

Languages

Canonical language ids live in languages.pyhi, en, en-US, multi, and the rest of the Indian language set with their labels. They are what the database stores and what a client selects; they are stable and provider-agnostic. Each STT/TTS vendor maps its own codes to those ids inside catalog.py as STT_CAPABILITIES / TTS_CAPABILITIES (alongside per-vendor-code settings), then wires helpers in config:
The dump then exposes three related keys on that field: Plus top-level settings_by_model_language keyed by canonical language ids (no "*"). Labels ship once, globally, on configuration_defaults()["languages"] — vendors never carry their own label strings. Vendor defaults differ on purpose. Deepgram STT defaults to multi; other vendors default to en, en-US, or hi. Do not force a single default across providers. Mid-call language switching does not exist. A language is chosen per agent config and holds for the whole call.

configuration_defaults()

configuration_defaults() is the one-shot envelope for a configuration UI:
DEFAULT_SERVICE_PROVIDERS in schema.py picks Deepgram for STT, ElevenLabs for TTS, and OpenAI for LLM. The function validates that each of those is actually registered and raises ValueError if not, so a default can never point at a vendor that was removed.

The catalog endpoints

apps/api/app/routers/configuration.py is a thin pass-through to the schema dump. Every route needs a bearer token. The languages query parameter takes comma-separated canonical ids and filters as an AND — a provider must support all of them to be listed. An unknown language id returns 400; an unknown provider id returns 404. apps.telephony mirrors this package’s structure with its own registry and schema module, which is why the same router serves both. See Telephony model.

Current inventory

Generated by running provider_schemas() against the live registry, not counted by hand:
22 cloud vendors, 2 adapters, and 2 local providers. Several vendors register for more than one kind, so the per-kind lists overlap. That is 13 STT providers, 15 TTS providers, and 10 LLM providers. No local LLM provider exists yet — see Adding an AI provider.
Bhashini covers STT (Dhruva WebSocket ASR, api_key) and TTS (NVCF gRPC, auth_token + function_id) — different transports and different credentials under one provider id. Kenpath is LLM only, and authenticates with an RSA private key rather than an API key — see Providers.