Multi-Provider Voice/Video/Text Architecture Foundation¶
Date: 2026-07-28 (Stage 1: STT); updated 2026-08-09 (Stage 2: TTS, partial)
Scope: Agent modality provider selection (Stage 1: voice input/STT, with stubs for video/text; Stage 2: voice output/TTS, partially implemented - see that section for exact scope)
Status: Stage 1 (STT) complete. Stage 2 (TTS) PARTIALLY implemented: the pluggable provider layer, per-agent selection, validation, and web UI are done and tested; the live-call streaming path (VoiceCallController#turn) does not yet consult an agent's TTS selection - see "Stage 2" below for the precise boundary and what remains for whoever picks this up next.
Overview¶
This document describes the architectural foundation enabling agents to explicitly select which provider handles each modality (voice/video/text), rather than using hardcoded fallback chains or silently inheriting from tenant configuration.
Key Design Principles¶
- Pluggable providers: New providers can be added without changing core logic. Each provider implements a simple interface and self-registers.
- Explicit selection: Agents have visible, editable provider choices per modality (no silent inheritance).
- Availability validation: Selected providers are validated at request time. Unavailable providers are rejected with clear error messages.
- Graceful degradation: Null provider values preserve legacy behavior (default fallback chains).
- Premium feature gating: Provider selection is restricted to PREMIUM+ tenants (FREE tier always uses platform defaults).
Architecture Components¶
1. Provider Enums (core/providers/modality/)¶
VoiceModelProvider¶
Enumeration of speech-to-text (STT) providers an agent can route audio through.
SIDECAR_WHISPER → Local Whisper (requires sidecar + knowledge profile)
SARVAM → Sarvam AI (requires tenant SarvamCredential)
ELEVENLABS → ElevenLabs STT (reserved for future, requires BYOK credential)
VIDEO_PROVIDER... → Reserved for future video-to-text providers
Key methods:
- getProviderId() - human-readable provider name ("sidecar-whisper", "sarvam", etc.)
- getDefaultModel() - provider's default model if not specified by agent
- isBeta() - flags experimental providers
VideoModelProvider & TextModelProvider¶
Stubs for future expansion. Defined now so schema is in place.
2. SpeechToTextProvider Interface¶
Abstraction all voice providers implement:
interface SpeechToTextProvider {
String transcribe(byte[] audioBytes, String filename) throws SpeechToTextException;
String transcribe(byte[] audioBytes, String filename, String model, String languageCode) throws SpeechToTextException;
boolean isAvailable();
String getUnavailabilityReason();
VoiceModelProvider getProvider();
}
Implementation contract:
- All methods must be thread-safe
- transcribe() throws typed exceptions (see exception hierarchy below)
- isAvailable() returns false if provider can't currently transcribe
- Providers are stateless (no per-call configuration stored)
3. Provider Implementations¶
SidecarWhisperProvider (core/providers/modality/SidecarWhisperProvider.java)¶
Routes voice through the sidecar's local Whisper instance.
- Availability check:
Optional<SidecarClient>is present AND sidecar is responsive - Unavailable reason: "Knowledge profile inactive" or "Sidecar service down"
- Error handling: Distinguishes between audio quality issues (SpeechToTextTranscriptionException) and provider failures (SpeechToTextProviderException)
SarvamSpeechToTextProvider (core/sarvam/.../sarvam/SarvamSpeechToTextProvider.java)¶
Routes voice through Sarvam AI's speech-to-text API.
- Availability check: Tenant has an active SarvamCredential
- Unavailable reason: "Sarvam API key not configured"
- Model support: Accepts model names like "saaras:v3" (default) or "saarika:v2.5"
- Language support: Auto-detects by default, or accepts BCP-47 codes ("hi-IN", "en-US", etc.)
- Extra modes: Supports "transcribe", "translate", "verbatim", "translit", "codemix" via the model parameter
4. Exception Hierarchy¶
All providers throw subclasses of SpeechToTextException:
SpeechToTextException (base)
├── SpeechToTextTranscriptionException
│ └── Audio quality issue (empty audio, unsupported format, no speech detected)
│ └── HTTP 400 response, user should retry with clearer audio
└── SpeechToTextProviderException
└── Provider unavailable or error (API unreachable, rate limited, invalid credentials)
└── HTTP 503 or fallback to another provider
└── Has isRetryable() flag (true = try next provider, false = permanent failure)
5. SpeechToTextProviderFactory (core/providers/modality/SpeechToTextProviderFactory.java)¶
Registry and selector for all available providers.
Key responsibilities:
- register(SpeechToTextProvider provider) - plugins self-register at startup
- getProvider(VoiceModelProvider enum) - returns Optional of implementation
- isAvailable(VoiceModelProvider enum) - checks if provider is registered AND available
- getUnavailabilityReason(enum) - human-readable explanation why provider is unavailable
- getAvailableProviders() - returns all currently usable providers (for UI dropdowns)
Initialization via SpeechToTextProviderConfiguration. Since ADR-0011 phase 2 it discovers every
SpeechToTextProvider bean by type, so a vendor adapter in its own module (core/sarvam) registers itself and
providers has no vendor dependency:
@Configuration
public class SpeechToTextProviderConfiguration {
public SpeechToTextProviderConfiguration(
SpeechToTextProviderFactory factory,
List<SpeechToTextProvider> providers
) {
ModalityProviderRegistration.registerAll(
"SpeechToTextProvider", providers, SpeechToTextProvider::getProvider, factory::register);
}
}
6. VoiceProviderValidator (core/providers/modality/VoiceProviderValidator.java)¶
Validates that a voice model provider/model selection is legal for this deployment.
Invariants enforced: - Provider and model must be set together (both null or both non-null) - If provider is set, model must be non-blank - Selected provider must be available on this deployment - Validation happens at agent creation/update time, before persistence
Used by:
- AgentService.updateVoiceModel() - rejects invalid selections
- Web UI validation (future) - client-side feedback before submission
7. Agent Entity Extensions¶
New columns (migration V148__agent_voice_model_provider.sql):
ALTER TABLE agent
ADD COLUMN voice_model_provider VARCHAR(32),
ADD COLUMN voice_model VARCHAR(128),
ADD COLUMN video_model_provider VARCHAR(32),
ADD COLUMN video_model VARCHAR(128),
ADD COLUMN text_model_provider VARCHAR(32);
New fields on Agent.java:
@Enumerated(EnumType.STRING)
@Column(name = "voice_model_provider", length = 32)
private VoiceModelProvider voiceModelProvider;
@Column(name = "voice_model", length = 128)
private String voiceModel;
// video_model_provider, video_model, text_model_provider: placeholders for Stage 2+
New methods:
- getVoiceModelProvider() / getVoiceModel() - getters (nullable)
- setVoiceModelProvider(provider, model) - atomic pair setter
- clearVoiceModelProvider() - reverts to default fallback chain
Migration strategy: Both columns are nullable on initial creation. Pre-existing agents have NULL and use the legacy fallback chain. New agents created via web UI have both set explicitly. Agents updated via PATCH endpoint have explicit selection applied.
8. Service Layer Integration¶
AgentService.updateVoiceModel()¶
@Transactional
public AgentResponse updateVoiceModel(UUID agentId, AgentVoiceModelUpdateRequest request) {
// 1. Load agent (404 if not found or not visible to caller)
// 2. Check edit-lock policy (Premium-only feature)
// 3. Validate provider selection via VoiceProviderValidator
// 4. Persist agent.setVoiceModelProvider(provider, model)
// 5. Return updated AgentResponse
}
Permissions & gates:
- FREE tier: Always throws TenantLimitExceededException. FREE tenants use hardcoded fallback chain.
- PREMIUM/UNLIMITED: Can set explicit providers if available on deployment
- Edit-lock: Same gate as updateModel() (24h grace period for new agents, then locked until upgrade)
9. Controller Endpoint¶
Endpoint: PATCH /api/agents/{agentId}/voice-model
Request body (AgentVoiceModelUpdateRequest):
{
"voiceModelProvider": "SIDECAR_WHISPER", // or "SARVAM", null to clear
"voiceModel": "openai/whisper-1" // or "saaras:v3", null to clear
}
Response: Full AgentResponse with updated voice model fields visible.
Error cases:
- 404 - agent not found
- 402 - FREE tier (provider selection is premium feature)
- 400 - provider/model validation failed (unavailable or mismatched)
- 403 - edit-lock active (24h grace period expired, needs upgrade)
10. Voice Endpoint Integration¶
Current behavior (VoiceCallController.turn()): 1. Try sidecar Whisper (if available) 2. Fall back to Sarvam 3. Fail with user-friendly error if both unavailable
Future behavior (after provider integration): 1. If agent has explicit voiceModelProvider set, use that 2. If null, use legacy fallback chain (Sidecar → Sarvam) 3. Log which provider was used for each transcription 4. Catch provider-specific exceptions and retry if retryable
Integration point:
VoiceModelProvider selectedProvider = agent.getVoiceModelProvider();
if (selectedProvider != null) {
// Route through agent's selected provider
SpeechToTextProvider provider = providerFactory.getProvider(selectedProvider)
.orElseThrow(() -> new VoiceCallException("Provider not available"));
userText = provider.transcribe(audioBytes, filename, agent.getVoiceModel(), null);
} else {
// Use legacy fallback chain (backwards compatibility)
userText = transcribeViaLegacyFallbackChain(audioBytes, filename);
}
Adding a New Provider¶
Step 1: Create enum value in VoiceModelProvider¶
public enum VoiceModelProvider {
NEW_PROVIDER("new-provider", "default-model-id")
}
Step 2: Implement SpeechToTextProvider¶
@Component
public class NewSpeechToTextProvider implements SpeechToTextProvider {
// Implement transcribe(), isAvailable(), getUnavailabilityReason(), getProvider()
}
Step 3: Register with factory¶
Add constructor injection to SpeechToTextProviderConfiguration:
factory.register(newProvider);
Step 4: Test availability checks¶
- Call
voiceProviderValidator.validate(NEW_PROVIDER, "model-id") - Verify
isAvailable()returns false when provider is misconfigured - Verify error messages are clear and actionable
Data Flow: Agent Voice Turn¶
VoiceCallController.turn(agentId, audioBytes)
↓
Agent agent = agentService.get(agentId)
↓
if agent.voiceModelProvider != null:
provider = providerFactory.getProvider(agent.voiceModelProvider)
text = provider.transcribe(audioBytes, filename, agent.voiceModel, null)
else:
text = legacyFallbackChain(audioBytes) // Sidecar → Sarvam
↓
AgentChatService.chat(agentId, ..., text)
↓
String reply = agentChatService.streamReply(...)
↓
ElevenLabsClient.textToSpeech(reply) ← still hardcoded for `turn()` (live calls) -
↓ see "Stage 2" below: only `speak()` (the
VoiceTurnResponse(userText, replyText, single-shot "play this reply" endpoint)
replyAudio) consults agent.ttsProvider today.
Stage 2: Text-to-Speech (TTS) Provider Selection (2026-08-09 - Partially Implemented)¶
Output-side twin of Stage 1: an agent's voice REPLIES were always synthesized through a
hardcoded ElevenLabs → Sarvam → text-only-fallback chain, invisible and unselectable. This
stage makes that chain explicitly selectable per agent, following Stage 1's exact pattern -
new enum, new interface, new factory, new validator, new provider implementations, new
Agent columns, new PATCH endpoint, new web UI selector - re-read the "Architecture
Components" section above with TtsModelProvider/TextToSpeechProvider/
TextToSpeechProviderFactory/TextToSpeechProviderValidator substituted for their STT
counterparts; the shape is identical.
What's DONE (built, compiled, tested green):
- core/providers/modality/TtsModelProvider.java - enum ELEVENLABS | SARVAM (both
implemented from day one, unlike Stage 1's ELEVENLABS-reserved-for-later - the chain
already spoke to both providers, this just makes the existing choice explicit).
- core/providers/modality/TextToSpeechProvider.java + TextToSpeechProviderFactory.java +
TextToSpeechProviderValidator.java + the exception hierarchy (TextToSpeechException /
TextToSpeechProviderException / TextToSpeechSynthesisException) - direct mirrors of
their STT equivalents.
- ElevenLabsTextToSpeechProvider.java / SarvamTextToSpeechProvider.java - real,
synthesis-capable implementations, not stubs. Read TextToSpeechProvider's javadoc
before adding a new caller: ElevenLabs's quota check is internal to ElevenLabsClient
(inherited automatically), Sarvam's is NOT (it lives on VoiceCallController as a private
method) - SarvamTextToSpeechProvider.synthesize() does NOT quota-check, by design, because
the one place that matters today (VoiceCallController) deliberately bypasses this
interface for its own routing (next point).
- agent.ttsProvider / agent.ttsModel (migration V196) + AgentService#updateTtsModel +
PATCH /api/agents/{id}/tts-model + AgentCreateRequest/AgentResponse fields - same
Premium-feature gating, same null-means-default-chain semantics as Stage 1.
- Web UI: TextToSpeechProviderSelector.tsx (mirrors VoiceProviderSelector.tsx, reusing the
same generic TranscriptionProviderOptions component - zero new generic UI code needed)
wired into AgentWorkbench.tsx's agent-settings panel, right next to the STT selector, both
in the creation form and the existing-agent editor. ElevenLabs/Sarvam availability badges
reuse api.getElevenLabsCredential()/api.getSarvamCredential(), the same calls
ProvidersPanel.tsx already uses.
- VoiceCallController#speak (POST /api/agents/{id}/speak, the single-shot "play this
reply" endpoint used by the text-chat UI) now passes the agent's ttsProvider through to a
new synthesizeSpeech(agentId, text, voiceId, ttsProvider) overload. ttsProvider ==
SARVAM skips ElevenLabs entirely and calls straight into the existing, already-quota-checked
trySarvamTts private helper - no new quota logic was written. ttsProvider == ELEVENLABS
(or unset) runs the original chain byte-for-byte unchanged. Either way a failure still falls
through to "text-only-fallback", same universal safety net as before.
What's NOT done - the honest boundary, read this before assuming Stage 2 is complete:
- VoiceCallController#turn (the live LiveKit voice-call streaming pipeline) does NOT
consult agent.ttsProvider at all. Its per-sentence synthesizeChunk method still runs
the original hardcoded ElevenLabs-then-Sarvam chain unconditionally, regardless of what an
agent has selected. This was a deliberate scope cut, not an oversight: synthesizeChunk and
its quota propagation carry extensive 2026-08-08 audit-fix comments (see that method's
javadoc) about exactly how a VoiceQuotaExceededException must surface mid-stream: rewiring
it to branch on an explicit provider risked reintroducing a bug that audit had JUST fixed,
for a live real-time-call code path that's materially harder to test than the single-shot
speak() endpoint. Next step for whoever picks this up: thread agent.ttsProvider
into turn()'s call to synthesizeChunk the same way speak() now does for
synthesizeSpeech, being careful to preserve the quota-propagation contract described in
that method's javadoc (a VoiceQuotaExceededException must still propagate as a Mono
error, not get swallowed).
- No per-agent TTS target-language selection - SarvamTextToSpeechProvider and the existing
chain both hardcode en-IN (same limitation Stage 1 has on the STT side for Sarvam's
mode parameter, never fully exposed either).
- No third TTS provider yet. Adding one: implement TextToSpeechProvider, add the enum value
to TtsModelProvider, register in TextToSpeechProviderConfiguration - exactly the "Adding
a New Provider" steps below, TTS-side.
Web UI Integration¶
Correction (2026-08-09): despite the "Placeholder" heading and "future work" framing below,
all four items were already built for STT by the time this correction was written -
VoiceProviderSelector.tsx, wired into AgentWorkbench.tsx's creation form and existing-agent
editor, populated from providerFactory.getAvailableProviders() via the providerStatus prop.
This doc was simply never updated after that work landed. The Stage 2 section above built the
direct TTS equivalent, TextToSpeechProviderSelector.tsx, reusing the exact same generic
TranscriptionProviderOptions component both selectors are thin wrappers over - so the four
items below are accurate as a description of what exists for BOTH modalities today, not as
outstanding future work:
- Agent creation form: Voice/TTS provider dropdowns (populated by
providerFactory.getAvailableProviders()/TextToSpeechProviderFactoryequivalent) - Agent edit page: Toggle + dropdown to change voice/TTS provider
- Validation: Real-time feedback if selected provider becomes unavailable (via the
providerStatusprop, computed client-side fromapi.getSarvamCredential()/api.getElevenLabsCredential()/sidecar health) - Fallback indicator: Show "Using platform default" when voiceModelProvider/ttsProvider is null
Backward Compatibility¶
- Pre-existing agents: Have
voice_model_provider = NULL. Voice endpoint uses legacy fallback chain (behavior unchanged). - New agents via web UI: Have explicit provider set (opt-in at creation time).
- Updated agents via PATCH: Receive new provider selection (can revert with
PATCH /voice-modelwith null provider). - Feature flag not needed: Null-check on agent.voiceModelProvider is the feature gate.
Testing Strategy¶
The test classes planned here at Stage 1 (VoiceProviderValidatorTest, SidecarWhisperProviderTest,
SarvamSpeechToTextProviderTest, SpeechToTextProviderFactoryTest, AgentServiceVoiceModelTest,
AgentControllerVoiceModelTest, VoiceCallControllerIntegrationTest) do not exist under those names. The
coverage in the tree today (v0.1.9):
core/providers:ModalityProviderConfigurationTest(registration by type)core/sarvam:SarvamProviderPluginsTest,SarvamSpeechToTextClientTest,SarvamTextToSpeechClientTestcore/elevenlabs:ElevenLabsTextToSpeechProviderTestcore/voice:VoiceCallControllerProviderTest,VoiceCallControllerTestcore/app:ProviderPluginDiscoveryTest(vendor providers are discovered, not named)
Manual Testing¶
- Create agent, explicitly set SIDECAR_WHISPER provider
- Call voice endpoint with audio
- Verify transcription routed through sidecar (check logs for "Sidecar Whisper transcription successful")
- Update agent to SARVAM
- Call voice endpoint again, verify routed through Sarvam
Key Files Reference¶
| File | Purpose |
|---|---|
core/providers/modality/VoiceModelProvider.java |
Enum: SIDECAR_WHISPER, SARVAM, ELEVENLABS, etc. |
core/providers/modality/SpeechToTextProvider.java |
Interface all providers implement |
core/providers/modality/SpeechToTextException.java |
Base exception class |
core/providers/modality/SpeechToTextTranscriptionException.java |
Audio quality errors (HTTP 400) |
core/providers/modality/SpeechToTextProviderException.java |
Provider errors (HTTP 503, retryable flag) |
core/providers/modality/SidecarWhisperProvider.java |
Sidecar Whisper implementation |
core/sarvam/.../SarvamSpeechToTextProvider.java |
Sarvam implementation (vendor module since ADR-0011 phase 2) |
core/providers/modality/SpeechToTextProviderFactory.java |
Registry and selector |
core/providers/modality/VoiceProviderValidator.java |
Validates provider/model selections |
core/providers/modality/SpeechToTextProviderConfiguration.java |
Spring config: registers providers |
core/agents/.../agents/core/Agent.java |
Added: voiceModelProvider, voiceModel fields + getters/setters |
core/agents/dto/AgentVoiceModelUpdateRequest.java |
PATCH request DTO |
core/agents/.../agents/core/AgentService.java |
Added: updateVoiceModel() method |
core/agents/.../agents/core/AgentController.java |
Added: PATCH /{id}/voice-model endpoint |
core/app/src/main/resources/db/migration/tenant/V148__agent_voice_model_provider.sql |
Schema: add voice_model_provider, voice_model columns |
core/sarvam/SarvamCredentialAvailabilityChecker.java |
Helper: checks if Sarvam is configured |
web/app/components/agents/VoiceProviderSelector.tsx |
STT web UI selector (thin wrapper over TranscriptionProviderOptions) |
Stage 2 (TTS, 2026-08-09) additions:
| File | Purpose |
|---|---|
core/providers/modality/TtsModelProvider.java |
Enum: ELEVENLABS, SARVAM |
core/providers/modality/TextToSpeechProvider.java |
Interface all TTS providers implement |
core/providers/modality/TextToSpeechException.java |
Base exception class |
core/providers/modality/TextToSpeechSynthesisException.java |
Input-text errors |
core/providers/modality/TextToSpeechProviderException.java |
Provider errors (retryable flag) |
core/elevenlabs/.../ElevenLabsTextToSpeechProvider.java |
ElevenLabs implementation (quota-checked via ElevenLabsClient) |
core/sarvam/.../SarvamTextToSpeechProvider.java |
Sarvam implementation (NOT quota-checked - see its javadoc) |
core/providers/modality/TextToSpeechProviderFactory.java |
Registry and selector |
core/providers/modality/TextToSpeechProviderValidator.java |
Validates provider/model selections |
core/providers/modality/TextToSpeechProviderConfiguration.java |
Spring config: registers providers |
core/providers/pom.xml |
Had an elevenlabs dependency at Stage 2; removed in ADR-0011 phase 2 (vendor modules now depend on providers, not the reverse) |
core/agents/.../agents/core/Agent.java |
Added: ttsProvider, ttsModel fields + getters/setters/clear |
core/agents/dto/AgentTtsModelUpdateRequest.java |
PATCH request DTO |
core/agents/.../agents/core/AgentService.java |
Added: updateTtsModel() method + create()-time TTS handling |
core/agents/.../agents/core/AgentController.java |
Added: PATCH /{id}/tts-model endpoint |
core/app/src/main/resources/db/migration/tenant/V196__agent_tts_provider.sql |
Schema: add tts_provider, tts_model columns |
core/voice/VoiceCallController.java |
speak() + new 4-arg synthesizeSpeech() overload consult agent.ttsProvider; turn()/synthesizeChunk do NOT yet - see Stage 2 section |
web/app/components/agents/TextToSpeechProviderSelector.tsx |
TTS web UI selector, wired into AgentWorkbench.tsx |
Future Expansion: Video & Text Providers¶
VideoModelProvider (Stage 2+)¶
- Enum values: VERTEX_VIDEO, GPT4V, etc.
- Interface:
VideoToTextProvider - Use case: Transcribe video content (multi-frame analysis, scene detection)
TextModelProvider (Stage 2+)¶
- Enum: INHERIT_FROM_PRIMARY, EXPLICIT
- Use case: Route text completions to a different LLM than the primary agent provider
- Example: Draft via fast cheap model, final synthesis via powerful model
Refactors needed:¶
- Generalize
SpeechToTextProvider→MediaProvider<InputType, OutputType> - Create
VoiceVideoTextProviderServiceto handle all modalities - Update AgentResponse to show all modality selections
Deployment Considerations¶
- Database migration runs automatically on first boot (Flyway)
- Sidecar availability checked on every startup via
SidecarWhisperProvider.isAvailable() - Sarvam configuration checked on every voice turn (credential may be added/removed)
- Provider factory initialized at Spring startup; providers are singletons
- No feature flags needed - availability checks are runtime, not compile-time
Success Metrics¶
- [ ] Agents can explicitly select voice provider at creation time
- [ ] Voice endpoint logs which provider was used for each transcription
- [ ] PREMIUM tenants can change voice provider; FREE tenants get clear error
- [ ] New provider implementations require only 3 files (enum value, provider class, factory registration)
- [ ] Web UI populates provider dropdown from available providers
- [ ] Fallback chain still works for backward-compatible agents (NULL provider)
- [ ] Clear error messages when selected provider is unavailable
References¶
core/voice/VoiceCallController.java- Current voice turn implementationcore/sarvam/- Existing Sarvam integrationcore/knowledge/SidecarClient.java- Existing Sidecar integration