Skip to main content
How to add a speech-to-text, text-to-speech, or large-language-model vendor to apps/providers. Adding a provider means creating one folder and registering one creator function per capability. You never edit a central dispatch table.

Quick reference

Create, and only these:
Which of cloud/, adapters/, local/ you pick is the provider_type — see Cloud, adapter, or local. Never touch: apps/providers/factory.py, registry.py, schema.py. Discriminated unions and the catalog dump are derived from your registration, not edited. One required edit outside your folder: bump the relevant count in test_union_variant_counts_match_registry (apps/providers/tests/test_provider_schemas.py) — the number of STT/TTS/LLM variants it asserts. This is the one place a manual edit is expected; forgetting it just means the test fails and tells you, so it’s the intended way to catch a mis-registered provider. Only legitimate exception: apps/runtime/requirements.txt, if your vendor needs a Pipecat extra not already installed.
This page is the how-to. For why the registry works this way — discriminated unions, the catalog dump, and where credentials live — read Provider registry.

Before you start

Read apps/providers/README.md and open one existing vendor folder side by side with your editor. apps/providers/cloud/deepgram/ is the clearest STT example, cloud/cartesia/ the clearest TTS one, and cloud/openai/ shows one folder serving all three kinds. Check two things first:
  • Does Pipecat already support the vendor? If pipecat.services.<vendor> exists, your service.py is a handful of lines. If not, you are writing an adapter — see Cloud, adapter, or local.
  • What are the credentials? Everything the vendor authenticates with goes on an Auth class and is marked secret. Everything else — voices, endpoints, speeds — goes on Settings.

Cloud, adapter, or local

provider_type is not something you declare. apps/providers/schema.py derives it from the config class’s module path, so the directory you choose is the decision: Kenpath’s two LLM services also carry a provider-specific hangup convention — they end the call when their own streamed text contains the word “goodbye”, instead of the config-driven automatic_call_ending tool. See Voice pipeline → Provider-specific call ending: Kenpath. Put the folder in the wrong place and _provider_type() raises with a message telling you exactly that.

The six steps

These are the steps from apps/providers/README.md, which is the authoritative version.
  1. Add cloud/<name>/, adapters/<name>/, or local/<name>/ with catalog.py, config.py, and service.py. For STT/TTS, put languages + settings in one *_CAPABILITIES map in catalog.py (no vendor languages.py).
  2. Put credentials on Auth, knobs on Settings, and on Config set name: str = "Display Name" (the UI label) plus provider: Literal["…"] = "…".
  3. On STT/TTS language fields, use json_schema_extra=language_schema_extra(languages_map(CAPABILITIES)). Set settings_by_model_language: ClassVar = settings_tree(CAPABILITIES). Default model with model_ids(CAPABILITIES)[0] — do not put DEFAULT_*_MODEL in catalog.
  4. For selectable fields, set examples and optionally allow_custom_input=True (the catalog becomes input_mode both rather than options).
  5. In service.py, implement @register_stt / @register_tts / @register_llm creators that take the typed config and build the Pipecat (or adapter) service. Import Pipecat inside the creator so missing extras do not break package import.
  6. Do not edit a central if/elif in factory.py — registration plus load_providers() pick up the new module. The schema dump follows automatically.
Here is what happens to your folder once it exists: load_providers() imports every *.service module under cloud, adapters, and local, which runs your decorator, which fills the registry maps, which both the factory unions and the catalog dump read from. One registration reaches all of it.

The three module files

One capabilities map per kind keeps models, languages, and settings reviewable in a single diff. Helpers in apps/providers/capabilities.py derive model ids, language schema extras, and the canonical-keyed settings tree for /configuration/*.

Auth versus Settings

Each vendor config.py stacks three layers, in this inheritance order:
The split is load-bearing, not cosmetic: Two rules follow from it, and the tests enforce both:
  • Credentials never live on the bases. BaseSTTConfig and friends in base.py carry no api_key.
  • Endpoints and hosts belong on Settings, not Auth. base_url is configuration; a token is a secret. test_provider_auth_secrets_only_plus_auth_mro asserts the auth dump contains only the secret fields.
Multiple secret fields are fine. aws_bedrock declares both aws_access_key and aws_secret_key, and test_aws_bedrock_lists_credential_secrets pins that pair. Credentials are provider-level, not per-kind. One OpenAI key covers OpenAI STT, TTS, and LLM, because provider_level_auth("openai") merges the auth fields across the kinds sharing the provider id.

Languages and capabilities

Canonical language ids live once in apps/providers/languages.pyhi, en, en-US, multi, and the rest. For STT/TTS, each vendor maps vendor codes to those ids inside catalog.py as part of STT_CAPABILITIES / TTS_CAPABILITIES, next to per-language settings:
Settings keys must be vendor language codes only (same keys as languages) — never "*". Use expand_settings when the same knobs apply to every language. Wire helpers in config.py:
language_schema_extra() inverts and flattens the map into three keys the API serves: settings_tree() rekeys settings by canonical id for the dump (settings_by_model_language). resolve_settings(tree, model, language) takes that canonical id. The inversion keeps the first vendor code that maps to a canonical id. ElevenLabs sends or for Odia and auto for auto-detect, so language_codes["scribe_v2_realtime"]["od"] == "or" and ["multi"] == "auto" — the storage layer never sees the vendor spelling. Vendor defaults may differ on purpose. Deepgram STT defaults to multi, Deepgram TTS to en, Bhashini to hi. Do not force one default across providers.
Every canonical id you emit must already exist in LANGUAGES in apps/providers/languages.py. test_every_stt_tts_schema_with_language_has_structured_extras walks every provider’s examples and fails on an id that is not there. If your vendor supports a language VoicEra has no canonical id for, add it to LANGUAGES in the same change.

Controlling input_mode

input_mode tells the API consumer whether a field is a dropdown, a text box, or both. You do not set it. schema.py derives it: So a free-text override is a field with no examples:
A closed list is examples alone — Deepgram TTS is English-only, so its language uses allow_custom_input=False and comes out options. An open list with suggestions sets allow_custom_input=True and comes out both, which is what you want for model ids that the vendor adds to faster than you can ship a release. Secret fields get no input_mode at all — test_secrets_have_no_input_mode asserts it, and test_non_secret_fields_have_input_mode asserts every non-secret field has one. allow_custom_input itself is read for the derivation and then dropped; test_catalog_omits_schema_noise fails if it leaks into the dump.

Registering creators

Registration is by type annotation, not by string. registry._register reads the creator’s first parameter, resolves the config class from it, and takes the provider id from that class’s provider field default:
Four things in that snippet are conventions worth copying:
  • The Pipecat import is inside the function. apps/api imports apps.providers without Pipecat installed. A module-level Pipecat import would break the API for every provider, not just yours.
  • The parameter is annotated with a concrete config class. An unannotated or non-Pydantic first parameter raises a TypeError at import.
  • api_key(cfg.api_key) resolves a rotation list. When a vendor’s key field is str | list[str], this helper returns the first entry. registry.llm_settings(cfg) does the equivalent for LLM sampling knobs, emitting only the fields that are actually set.
  • Optional overrides go through kwargs. Passing base_url=None to a Pipecat service is not the same as omitting it.
Registering the same provider id twice for one kind raises ValueError at import — that is the collision check, and it fires before anything can silently shadow an existing vendor. adapters/bhashini/service.py looks identical, except the deferred import points at its own tts.py instead of Pipecat. adapters/kenpath/service.py does the same with llm.py.

Local providers: one extra registration call

A local/<vendor>/ provider talks to VoicEra’s own model server gateway instead of a third-party API. It follows the same catalog/config/service shape as cloud and adapter providers, but service.py makes one additional call before the @register_* decorator runs:
register_local(provider_id, gateway_model_id) (in apps/providers/availability.py) is what makes this provider’s authenticated flag mean something different from every cloud or adapter provider’s:
  • Cloud / adapter: authenticated means “this organisation has stored ProviderAuth credentials for this provider id.”
  • Local: authenticated means “the model server currently reports this model id as deployed.” is_authenticated() looks provider_id up in the register_local() map; if found, it does a 10-second-cached GET {MODEL_SERVER_URL}/models and checks whether gateway_model_id is in the response’s data[].id list, instead of checking stored credentials at all.
gateway_model_id is the model server’s own catalogue id for the slot (GATEWAY_MODEL_ID in the local provider’s catalog.py, matching the folder name under model-server/<slot>/ — see Adding a model). It is a separate namespace from the apps/providers provider id: indic_orpheus (provider id) points at the gateway id "orpheus"; indic_nemotron points at "indic-nemotron". They don’t have to match, and usually won’t. Because there is no third-party SDK to configure, a local provider’s own transport client is hand-written, the same way an adapter’s is:
  • local/indic_orpheus/tts.py wraps AsyncOpenAI pointed at the gateway’s OpenAI-compatible /v1/audio/speech, reading the gateway URL from MODEL_SERVER_URL via resolve_base_url() (raises RuntimeError if unset).
  • local/indic_nemotron/stt.py hand-rolls a websockets client against the gateway’s /v1/asr/ws, reading the URL from MODEL_SERVER_WS_URL via resolve_ws_url().
Auth is often unnecessary — the gateway itself has no auth layer, so indic_orpheus has no Auth class at all. Don’t add one your local provider doesn’t need.
No local LLM provider exists yet. model-server/llm/qwen3.5-4b/ is status: ready in the model server’s own catalog, but nothing in apps/providers/local/ wires it up, so agents cannot select it. If you are adding the first local LLM provider, local/indic_orpheus/ and local/indic_nemotron/ are still the closest structural templates — you’ll be writing the register_llm creator and the OpenAI-compatible client yourself.

Why you never edit factory.py

factory.py builds its discriminated unions from the registry:
create_stt_service then dispatches with get_creator(Kind.STT, cfg.provider)(cfg). There is no branch on provider name anywhere in the file. Adding a vendor changes the union’s membership and the creator map as a side effect of the decorator running, so:
  • AgentConfig.model_validate({...}) accepts your provider id without a schema change.
  • GET /configuration/stt lists it without a router change.
  • GET /auth/catalog exposes its secret fields without a router change.
  • The runtime builds it without an ai_service_factory change.
The same holds for schema.py. If you find yourself adding a provider name to a list outside your own folder, you have gone off the path.

Testing

apps/providers/tests/test_provider_schemas.py is a single module that tests the registry as a whole rather than each vendor, so most of it covers your provider automatically:
These generic checks will start applying to your folder the moment it is discovered: One check needs a manual edit. test_union_variant_counts_match_registry asserts exact counts:
Bump the number for the kinds you added. That failure is the test doing its job — it is how an accidentally unregistered or double-registered provider shows up. Add a vendor-specific test only where your provider does something the generic checks cannot see, such as a non-obvious vendor code inversion. test_elevenlabs_stt_odia_vendor_code_in_schema and test_sarvam_stt_auto_detect_vendor_code_is_unknown are the models to follow.
There is no CI. Run the suite yourself before opening a pull request. See Testing.

A worked example

Adding a fictional acme STT vendor that Pipecat already supports. 1. apps/providers/cloud/acme/catalog.py
2. apps/providers/cloud/acme/config.py
3. apps/providers/cloud/acme/service.py
4. An empty apps/providers/cloud/acme/__init__.py. 5. Bump the STT count in test_union_variant_counts_match_registry if needed, then run the suite:
6. Confirm the catalog picked it up:
You should see provider_type: "cloud", secrets: ["api_key"], model.input_mode: "both", a language entry carrying examples, model_options, and language_codes, and top-level settings_by_model_language. Nothing outside apps/providers/cloud/acme/ changed except possibly one integer in a test. If your vendor also needs a Pipecat extra, add it to the extras list in apps/runtime/requirements.txt — that is the one file outside your folder a cloud provider legitimately touches.