config.json. Adding a new provider means creating a Go package under pkg/services/, implementing the correct interface, and registering the provider in the factory so it can be chosen by name. This guide walks through every step.
Before starting, read
pkg/services/interfaces.go and pkg/services/factory.go. The factory is the single file that wires provider names to concrete implementations — most of your registration work happens there.Service Interfaces
Every provider must satisfy one or more of the following Go interfaces. These are defined inpkg/services/interfaces.go and pkg/services/llmapi/api.go.
LLMService
Chat must stream tokens incrementally by calling onToken for each delta and return nil on success or a wrapped error on failure. Context cancellation must abort the stream and return promptly.
STTService
Transcribe is the minimum requirement. If the upstream provider offers a streaming WebSocket or gRPC API, also implement STTStreamingService — the pipeline will use it automatically to reduce first-token latency.
TTSService
Steps
Inside, create at minimum one Go file — conventionally
llm.go, stt.go, or tts.go depending on which service you are implementing. Mirror the structure of an existing provider such as pkg/services/groq/ or pkg/services/elevenlabs/.pkg/services/myprovider/
├── client.go # HTTP/gRPC client construction and auth
├── llm.go # LLMService implementation (if applicable)
├── stt.go # STTService / STTStreamingService (if applicable)
└── tts.go # TTSService / TTSStreamingService (if applicable)
package myprovider
// LLMService implements services.LLMService using MyProvider's API.
type LLMService struct {
client *http.Client
apiKey string
model string
}
// NewLLMService creates an LLMService.
// If apiKey is empty, config.GetEnv("MYPROVIDER_API_KEY", "") is used.
func NewLLMService(apiKey, model string) *LLMService {
if apiKey == "" {
apiKey = config.GetEnv("MYPROVIDER_API_KEY", "")
}
if model == "" {
model = "myprovider-default-model"
}
return &LLMService{client: &http.Client{Timeout: 30 * time.Second}, apiKey: apiKey, model: model}
}
Implement
Chat, Transcribe, or Speak (and their streaming variants) on your struct. A few requirements apply to every implementation:Context cancellation. Every network call must respect
ctx. Pass it to HTTP requests, gRPC calls, or WebSocket dials. Return immediately when ctx.Done() is closed:func (s *LLMService) Chat(ctx context.Context, messages []map[string]any, onToken func(*frames.LLMTextFrame)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, body)
if err != nil {
return fmt.Errorf("myprovider: build request: %w", err)
}
// ...
}
Streaming (LLM). Call
onToken once per content delta. Do not buffer the full response before calling it:tf := &frames.LLMTextFrame{}
tf.TextFrame = frames.TextFrame{
DataFrame: frames.DataFrame{Base: frames.NewBase()},
Text: delta,
AppendToContext: true,
}
tf.IncludesInterFrameSpace = true
if onToken != nil {
onToken(tf)
}
Streaming (TTS). Write
*frames.TTSAudioRawFrame values to outCh as PCM chunks arrive. Do not close outCh — the pipeline owns the channel lifetime.Logging. Use the existing
pkg/logger package. Avoid fmt.Println and avoid logging in the hot path (per-token, per-audio-chunk).Metrics. Record latency and error counts using the patterns in
pkg/metrics/prom.go. See pkg/observers/metrics.go for how existing providers increment counters.// SupportedLLMProviders — add if your provider implements LLMService.
var SupportedLLMProviders = []string{
// ... existing entries ...
ProviderMyProvider,
}
// SupportedSTTProviders — add if your provider implements STTService.
var SupportedSTTProviders = []string{
// ... existing entries ...
ProviderMyProvider,
}
// SupportedTTSProviders — add if your provider implements TTSService.
var SupportedTTSProviders = []string{
// ... existing entries ...
ProviderMyProvider,
}
Next, add a
case to each relevant factory switch. Add your import at the top of the file alongside the existing provider imports:The first argument to
cfg.GetAPIKey is the key used in the api_keys map of config.json; the second is the environment variable fallback. Callers can then supply the credential either way:{
"llm_provider": "myprovider",
"model": "myprovider-chat-v1",
"api_keys": {
"myprovider": "sk-..."
}
}
Never hardcode API keys or secrets in source code. The
apiKeyForProvider + environment variable pattern is the only approved mechanism for credential injection.package myprovider_test
import (
"context"
"testing"
"voxray-go/pkg/services/myprovider"
)
func TestLLMServiceChat_Success(t *testing.T) {
// Use httptest.NewServer to mock the provider's HTTP endpoint.
// Verify that onToken is called for each delta and no error is returned.
}
func TestLLMServiceChat_ContextCancel(t *testing.T) {
// Cancel the context mid-stream and assert that Chat returns promptly
// with a context-related error.
}
func TestLLMServiceChat_ProviderError(t *testing.T) {
// Return a non-2xx status from the mock server.
// Assert the error is wrapped with "myprovider" in the message.
}
Integration tests that exercise the live API are gated behind an environment variable check so they are skipped in CI unless the key is present:
func TestLLMServiceChat_Integration(t *testing.T) {
apiKey := os.Getenv("MYPROVIDER_API_KEY")
if apiKey == "" {
t.Skip("MYPROVIDER_API_KEY not set; skipping integration test")
}
svc := myprovider.NewLLMService(apiKey, "myprovider-chat-v1")
// ... run a real completion and assert non-empty output ...
}
Provider Checklist
Use this checklist before opening a pull request. Every box must be checked. Configuration- No hardcoded API keys or secrets anywhere in the package.
- API key is wired through
apiKeyForProviderwith both aconfig.jsonkey and an environment variable fallback. - All config fields (model, voice, language, region, base URL, etc.) are documented in the PR description and in
docs/build/integrations/<provider>.mdx. - Reasonable defaults are provided for optional fields (model name, sample rate, language, etc.).
- The struct satisfies the interface at compile time (add
var _ services.LLMService = (*LLMService)(nil)if helpful). - If the provider supports streaming,
STTStreamingServiceorTTSStreamingServiceis also implemented, not just the batch interface. -
RealtimeServiceis implemented if the provider offers a realtime/duplex API (and registered inSupportedRealtimeProviders).
-
context.Contextis passed to every network call; cancellation aborts the operation promptly. - All errors from the upstream SDK or HTTP response are wrapped with provider context (
fmt.Errorf("myprovider: %w", err)). - No
panicin public API paths or on transient provider errors. - Goroutines launched inside the package are tied to a
context.Contextand exit when it is cancelled. - Shared mutable state (if any) is protected by a mutex with documented assumptions.
- Prometheus metrics (latency histogram, error counter) are recorded consistently with other providers.
- Logging uses
pkg/loggerand avoids noisy per-token or per-chunk log lines.
- Unit tests cover at minimum: success path, context cancellation, and upstream error response.
- Mock or recorded fixtures are used so unit tests are offline and deterministic.
- Integration test is added under
tests/pkg/services/<provider>/and is skipped whenMYPROVIDER_API_KEYis unset. -
go test ./...passes with no failures and no race conditions (go test -race ./...).
- Provider constant added to the
constblock infactory.go. - Constant appended to all applicable
Supported*Providersslices. -
caseadded in all applicable factory switch statements (NewLLMFromConfig,NewSTTFromConfig,NewTTSFromConfig). -
caseadded inapiKeyForProvider. - Provider name is consistent across constant, config key, env var prefix, and documentation.