Files
ai-operator/internal/config/loader.go
T

273 lines
21 KiB
Go

package config
import (
"bufio"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
const DefaultEnvPath = "/etc/ai-operator/ai-operator.env"
func ResolveEnvPath(cliPath string) string {
if cliPath != "" {
return cliPath
}
if p := os.Getenv("AI_OPERATOR_ENV_FILE"); p != "" {
return p
}
return DefaultEnvPath
}
func Load(cliPath string) (Config, string, error) {
path := ResolveEnvPath(cliPath)
v, err := readEnvFile(path)
if err != nil {
return Config{}, path, err
}
cfg := Config{
App: AppConfig{v["APP_ENV"], v["LOG_LEVEL"]},
Asterisk: AsteriskConfig{ARIURL: v["ASTERISK_ARI_URL"], ARIWSURL: v["ASTERISK_ARI_WS_URL"], ARIUser: v["ASTERISK_ARI_USER"], ARIPassword: v["ASTERISK_ARI_PASSWORD"], ARIApp: v["ASTERISK_ARI_APP"], ARIWSAuthMode: valueOrDefault(v["ASTERISK_ARI_WS_AUTH_MODE"], "basic"), MediaMode: v["ASTERISK_MEDIA_MODE"], MediaCodec: v["ASTERISK_MEDIA_CODEC"], MediaWSBaseURL: deriveMediaWSBaseURL(v["ASTERISK_MEDIA_WS_BASE_URL"], v["ASTERISK_ARI_WS_URL"])},
Voice: VoiceConfig{Provider: valueOrDefault(v["VOICE_PROVIDER"], "fake"), DefaultLanguage: valueOrDefault(v["VOICE_DEFAULT_LANGUAGE"], ""), DefaultRegionCode: valueOrDefault(v["VOICE_DEFAULT_REGION_CODE"], "")},
Dialogue: DialogueConfig{DefaultLanguage: valueOrDefault(v["DIALOGUE_DEFAULT_LANGUAGE"], ""), DefaultRegionCode: valueOrDefault(v["DIALOGUE_DEFAULT_REGION_CODE"], ""), EnforceLanguageAndRegion: boolDefault(valueOrDefault(v["DIALOGUE_ENFORCE_LANGUAGE_REGION"], "true"), true), LanguageSelectionEnabled: boolDefault(valueOrDefault(v["LANGUAGE_SELECTION_ENABLED"], "true"), true), LanguageConfidenceThreshold: floatDefault(v["LANGUAGE_CONFIDENCE_THRESHOLD"], 0.70), LanguageChangeConfidenceThreshold: floatDefault(v["LANGUAGE_CHANGE_CONFIDENCE_THRESHOLD"], 0.90), LanguageAllowChangeAfterSelection: boolDefault(valueOrDefault(v["LANGUAGE_ALLOW_CHANGE_AFTER_SELECTION"], "true"), true), LanguageDefaultOnTimeout: valueOrDefault(v["LANGUAGE_DEFAULT_ON_TIMEOUT"], ""), RegionResolverEnabled: boolDefault(valueOrDefault(v["REGION_RESOLVER_ENABLED"], "true"), true), RegionConfidenceThreshold: floatDefault(v["REGION_CONFIDENCE_THRESHOLD"], 0.70), RegionChangeConfidenceThreshold: floatDefault(v["REGION_CHANGE_CONFIDENCE_THRESHOLD"], 0.90), RegionAllowChangeAfterSelection: boolDefault(valueOrDefault(v["REGION_ALLOW_CHANGE_AFTER_SELECTION"], "true"), true), RegionDefaultOnTimeout: valueOrDefault(v["REGION_DEFAULT_ON_TIMEOUT"], ""), RegionEnableDisabledSpecial: boolDefault(valueOrDefault(v["REGION_ENABLE_DISABLED_SPECIAL_REGIONS"], "false"), false)},
OpenAI: OpenAIConfig{APIKey: v["OPENAI_API_KEY"], RealtimeURL: valueOrDefault(v["OPENAI_REALTIME_URL"], "wss://api.openai.com/v1/realtime"), RealtimeModel: valueOrDefault(v["OPENAI_REALTIME_MODEL"], "gpt-realtime-2"), RealtimeVoice: v["OPENAI_REALTIME_VOICE"], RealtimeInstructions: v["OPENAI_REALTIME_INSTRUCTIONS"], RealtimeInputAudioFormat: valueOrDefault(v["OPENAI_REALTIME_INPUT_AUDIO_FORMAT"], "pcm16"), RealtimeOutputAudioFormat: valueOrDefault(v["OPENAI_REALTIME_OUTPUT_AUDIO_FORMAT"], "pcm16"), RealtimeInputSampleRate: intDefault(v["OPENAI_REALTIME_INPUT_SAMPLE_RATE"], 24000), RealtimeOutputSampleRate: intDefault(v["OPENAI_REALTIME_OUTPUT_SAMPLE_RATE"], 24000), RealtimeTurnDetection: valueOrDefault(v["OPENAI_REALTIME_TURN_DETECTION"], "server_vad"), RealtimeReasoningEffort: valueOrDefault(v["OPENAI_REALTIME_REASONING_EFFORT"], "low"), RealtimeConnectTimeout: durationDefault(v["OPENAI_REALTIME_CONNECT_TIMEOUT"], 10*time.Second), RealtimeSessionTimeout: durationDefault(v["OPENAI_REALTIME_SESSION_TIMEOUT"], 60*time.Second), RealtimeMaxAudioChunkBytes: intDefault(v["OPENAI_REALTIME_MAX_AUDIO_CHUNK_BYTES"], 32768), RealtimeLiveSmokeEnabled: boolDefault(v["OPENAI_REALTIME_LIVE_SMOKE_ENABLED"], false), RealtimeInitialGreeting: boolDefault(v["OPENAI_REALTIME_INITIAL_GREETING"], true), RealtimeMaxSessionSeconds: intDefault(v["OPENAI_REALTIME_MAX_SESSION_SECONDS"], 60), RealtimeMaxInputAudioBytes: intDefault(v["OPENAI_REALTIME_MAX_INPUT_AUDIO_BYTES"], 10485760), RealtimeMaxOutputAudioBytes: intDefault(v["OPENAI_REALTIME_MAX_OUTPUT_AUDIO_BYTES"], 10485760)},
STT: STTConfig{Provider: valueOrDefault(v["STT_PROVIDER"], "elevenlabs"), Mode: valueOrDefault(v["ELEVENLABS_STT_MODE"], "realtime"), Model: valueOrDefault(v["ELEVENLABS_STT_MODEL"], "scribe_realtime_v2"), LanguageAuto: boolDefault(valueOrDefault(v["ELEVENLABS_STT_LANGUAGE_AUTO"], "true"), true), LanguageCode: v["ELEVENLABS_STT_LANGUAGE_CODE"], SampleRate: intDefault(v["ELEVENLABS_STT_SAMPLE_RATE"], 16000), InputFormat: valueOrDefault(v["ELEVENLABS_STT_INPUT_FORMAT"], "pcm_16000"), PartialEnabled: boolDefault(valueOrDefault(v["ELEVENLABS_STT_PARTIAL_ENABLED"], "true"), true), CommittedOnlyForLLM: boolDefault(valueOrDefault(v["ELEVENLABS_STT_COMMITTED_ONLY_FOR_LLM"], "true"), true), Timeout: durationDefault(v["ELEVENLABS_STT_TIMEOUT"], 15*time.Second)},
LLM: LLMConfig{Provider: valueOrDefault(v["LLM_PROVIDER"], "openai"), Model: valueOrDefault(v["LLM_MODEL"], "gpt-4.1-nano"), Temperature: floatDefault(v["LLM_TEMPERATURE"], 0.2), MaxOutputTokens: intDefault(v["LLM_MAX_OUTPUT_TOKENS"], 300), Timeout: durationDefault(v["LLM_TIMEOUT"], 15*time.Second), Stream: boolDefault(valueOrDefault(v["LLM_STREAM"], "true"), true), MaxToolLoops: intDefault(v["LLM_MAX_TOOL_LOOPS"], 3), FirstChunkTimeoutMS: intDefault(v["LLM_FIRST_CHUNK_TIMEOUT_MS"], 1200), StreamChunkMinChars: intDefault(v["LLM_STREAM_CHUNK_MIN_CHARS"], 30), StreamChunkMaxChars: intDefault(v["LLM_STREAM_CHUNK_MAX_CHARS"], 160)},
Eleven: ElevenLabsConfig{APIKey: v["ELEVENLABS_API_KEY"], VoiceIDRU: v["ELEVENLABS_VOICE_ID_RU"], VoiceIDKK: v["ELEVENLABS_VOICE_ID_KK"], STTURL: valueOrDefault(v["ELEVENLABS_STT_URL"], "wss://api.elevenlabs.io/v1/speech-to-text/realtime"), TTSURL: valueOrDefault(v["ELEVENLABS_TTS_URL"], "wss://api.elevenlabs.io/v1/text-to-speech"), TTSMode: valueOrDefault(v["ELEVENLABS_TTS_MODE"], "websocket"), TTSModelID: valueOrDefault(v["ELEVENLABS_TTS_MODEL_ID"], "eleven_flash_v2_5"), TTSExpressiveModelID: valueOrDefault(v["ELEVENLABS_TTS_EXPRESSIVE_MODEL_ID"], "eleven_v3"), TTSOutputFormat: valueOrDefault(v["ELEVENLABS_TTS_OUTPUT_FORMAT"], "pcm_16000"), TTSSampleRate: intDefault(v["ELEVENLABS_TTS_SAMPLE_RATE"], 16000), TTSStability: floatDefault(v["ELEVENLABS_TTS_STABILITY"], 0.42), TTSSimilarityBoost: floatDefault(v["ELEVENLABS_TTS_SIMILARITY_BOOST"], 0.8), TTSStyle: floatDefault(v["ELEVENLABS_TTS_STYLE"], 0.25), TTSUseSpeakerBoost: boolDefault(valueOrDefault(v["ELEVENLABS_TTS_USE_SPEAKER_BOOST"], "true"), true), TTSOptimizeStreamingLatency: intDefault(v["ELEVENLABS_TTS_OPTIMIZE_STREAMING_LATENCY"], 3), TTSTimeout: durationDefault(v["ELEVENLABS_TTS_TIMEOUT"], 15*time.Second)},
Natural: NaturalnessConfig{Enabled: boolDefault(valueOrDefault(v["VOICE_NATURALNESS_ENABLED"], "true"), true), AudioTagsEnabled: boolDefault(valueOrDefault(v["VOICE_AUDIO_TAGS_ENABLED"], "true"), true), AudioTagsMode: valueOrDefault(v["VOICE_AUDIO_TAGS_MODE"], "controlled"), AllowNonverbalTags: boolDefault(valueOrDefault(v["VOICE_ALLOW_NONVERBAL_TAGS"], "true"), true), AllowCough: boolDefault(valueOrDefault(v["VOICE_ALLOW_COUGH"], "false"), false), AllowLaugh: boolDefault(valueOrDefault(v["VOICE_ALLOW_LAUGH"], "true"), true), MaxAudioTagsPerResponse: intDefault(v["VOICE_MAX_AUDIO_TAGS_PER_RESPONSE"], 2), RemoveAudioTagsFromTranscript: boolDefault(valueOrDefault(v["VOICE_REMOVE_AUDIO_TAGS_FROM_TRANSCRIPT"], "true"), true)},
Pipeline: PipelineConfig{InitialGreeting: boolDefault(valueOrDefault(v["PIPELINE_INITIAL_GREETING"], "true"), true), BargeIn: boolDefault(valueOrDefault(v["PIPELINE_BARGE_IN"], "true"), true), FlushOnUserSpeech: boolDefault(valueOrDefault(v["PIPELINE_FLUSH_ON_USER_SPEECH"], "true"), true), MaxTurnSeconds: intDefault(v["PIPELINE_MAX_TURN_SECONDS"], 20), MaxResponseChars: intDefault(v["PIPELINE_MAX_RESPONSE_CHARS"], 600), AllowGlobalKBWithoutRegion: boolDefault(valueOrDefault(v["PIPELINE_ALLOW_GLOBAL_KB_WITHOUT_REGION"], "true"), true), TTFBTargetMS: intDefault(v["PIPELINE_TTFB_TARGET_MS"], 1200), TTSStartAfterChars: intDefault(v["PIPELINE_TTS_START_AFTER_CHARS"], 60), TTSStartAfterPunctuation: boolDefault(valueOrDefault(v["PIPELINE_TTS_START_AFTER_PUNCTUATION"], "true"), true)},
Database: DatabaseConfig{URL: v["DATABASE_URL"], MaxOpenConns: intDefault(v["DATABASE_MAX_OPEN_CONNS"], 10), MaxIdleConns: intDefault(v["DATABASE_MAX_IDLE_CONNS"], 5), ConnMaxLifetime: durationDefault(v["DATABASE_CONN_MAX_LIFETIME"], 30*time.Minute), MigrationsEnabled: boolDefault(valueOrDefault(v["DATABASE_MIGRATIONS_ENABLED"], "true"), true)},
Embedding: EmbeddingConfig{Provider: valueOrDefault(v["EMBEDDING_PROVIDER"], "fake"), Model: valueOrDefault(v["EMBEDDING_MODEL"], "text-embedding-3-small"), Dimensions: intDefault(v["EMBEDDING_DIMENSIONS"], 1536), BatchSize: intDefault(v["EMBEDDING_BATCH_SIZE"], 32), MaxInputChars: intDefault(v["EMBEDDING_MAX_INPUT_CHARS"], 8000), OpenAILiveEnabled: boolDefault(valueOrDefault(v["EMBEDDING_OPENAI_LIVE_ENABLED"], "false"), false)},
KB: KBConfig{CrossLanguageFallback: boolDefault(valueOrDefault(v["KB_CROSS_LANGUAGE_FALLBACK"], "true"), true), DefaultLimit: intDefault(v["KB_DEFAULT_LIMIT"], 5), MaxLimit: intDefault(v["KB_MAX_LIMIT"], 10), MinScore: floatDefault(v["KB_MIN_SCORE"], 0.20), QueryMaxChars: intDefault(v["KB_QUERY_MAX_CHARS"], 1000)},
Handoff: HandoffConfig{Mode: valueOrDefault(v["HANDOFF_MODE"], "disabled_stub"), Enabled: boolDefault(valueOrDefault(v["HANDOFF_ENABLED"], "false"), false), TargetEndpoint: v["HANDOFF_TARGET_ENDPOINT"], DialplanContext: v["HANDOFF_DIALPLAN_CONTEXT"], DialplanExtension: v["HANDOFF_DIALPLAN_EXTENSION"], DialplanPriority: intDefault(v["HANDOFF_DIALPLAN_PRIORITY"], 1), QueueName: v["HANDOFF_QUEUE_NAME"], Timeout: durationDefault(v["HANDOFF_TIMEOUT"], 30*time.Second), MaxAttempts: intDefault(v["HANDOFF_MAX_ATTEMPTS"], 1), PlayMessageBeforeTransfer: boolDefault(valueOrDefault(v["HANDOFF_PLAY_MESSAGE_BEFORE_TRANSFER"], "true"), true), HangupAfterStub: boolDefault(valueOrDefault(v["HANDOFF_HANGUP_AFTER_STUB"], "false"), false), AllowInTestRouteOnly: boolDefault(valueOrDefault(v["HANDOFF_ALLOW_IN_TEST_ROUTE_ONLY"], "true"), true), MaxSummaryChars: intDefault(v["HANDOFF_MAX_SUMMARY_CHARS"], 500)},
Fallback: FallbackConfig{MaxLanguageFailures: intDefault(v["FALLBACK_MAX_LANGUAGE_FAILURES"], 3), MaxRegionFailures: intDefault(v["FALLBACK_MAX_REGION_FAILURES"], 3), MaxNoAnswer: intDefault(v["FALLBACK_MAX_NO_ANSWER"], 2), MaxKBUnavailable: intDefault(v["FALLBACK_MAX_KB_UNAVAILABLE"], 1), MaxAIErrors: intDefault(v["FALLBACK_MAX_AI_ERRORS"], 1), MaxMediaErrors: intDefault(v["FALLBACK_MAX_MEDIA_ERRORS"], 1), MaxToolErrors: intDefault(v["FALLBACK_MAX_TOOL_ERRORS"], 2), CallTimeout: durationDefault(v["FALLBACK_CALL_TIMEOUT"], 5*time.Minute)},
Audit: AuditConfig{Enabled: boolDefault(valueOrDefault(v["AUDIT_ENABLED"], "true"), true), Sink: valueOrDefault(v["AUDIT_SINK"], "postgres"), FailClosed: boolDefault(valueOrDefault(v["AUDIT_FAIL_CLOSED"], "false"), false), StoreTranscripts: boolDefault(valueOrDefault(v["AUDIT_STORE_TRANSCRIPTS"], "true"), true), StoreTranscriptDeltas: boolDefault(valueOrDefault(v["AUDIT_STORE_TRANSCRIPT_DELTAS"], "false"), false), StoreRawTranscripts: boolDefault(valueOrDefault(v["AUDIT_STORE_RAW_TRANSCRIPTS"], "false"), false), StoreRedactedTranscripts: boolDefault(valueOrDefault(v["AUDIT_STORE_REDACTED_TRANSCRIPTS"], "true"), true), StoreToolArgs: boolDefault(valueOrDefault(v["AUDIT_STORE_TOOL_ARGS"], "true"), true), StoreToolResults: boolDefault(valueOrDefault(v["AUDIT_STORE_TOOL_RESULTS"], "true"), true), StoreKBResults: boolDefault(valueOrDefault(v["AUDIT_STORE_KB_RESULTS"], "true"), true), StoreProviderEvents: boolDefault(valueOrDefault(v["AUDIT_STORE_PROVIDER_EVENTS"], "true"), true), StoreMediaStats: boolDefault(valueOrDefault(v["AUDIT_STORE_MEDIA_STATS"], "true"), true), MaxTranscriptChars: intDefault(v["AUDIT_MAX_TRANSCRIPT_CHARS"], 4000), MaxToolArgChars: intDefault(v["AUDIT_MAX_TOOL_ARG_CHARS"], 4000), MaxToolResultChars: intDefault(v["AUDIT_MAX_TOOL_RESULT_CHARS"], 8000), MaxEventMetadataChars: intDefault(v["AUDIT_MAX_EVENT_METADATA_CHARS"], 8000), RetentionDays: intDefault(v["AUDIT_RETENTION_DAYS"], 180), TranscriptRetentionDays: intDefault(v["TRANSCRIPT_RETENTION_DAYS"], 30), ToolAuditRetentionDays: intDefault(v["TOOL_AUDIT_RETENTION_DAYS"], 180), ErrorAuditRetentionDays: intDefault(v["ERROR_AUDIT_RETENTION_DAYS"], 365), ExportMaxEvents: intDefault(v["AUDIT_EXPORT_MAX_EVENTS"], 10000), RedactionEnabled: boolDefault(valueOrDefault(v["AUDIT_REDACTION_ENABLED"], "true"), true), RedactionStrict: boolDefault(valueOrDefault(v["AUDIT_REDACTION_STRICT"], "true"), true), LogFullPhone: boolDefault(valueOrDefault(v["AUDIT_LOG_FULL_PHONE"], "false"), false), LogFullIIN: boolDefault(valueOrDefault(v["AUDIT_LOG_FULL_IIN"], "false"), false), LogFullCard: boolDefault(valueOrDefault(v["AUDIT_LOG_FULL_CARD"], "false"), false)},
}
if err := cfg.Validate(); err != nil {
return Config{}, path, err
}
return cfg, path, nil
}
func readEnvFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open env file: %w", err)
}
defer f.Close()
m := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, val, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("invalid env line: %s", line)
}
m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(val), `"'`)
}
return m, sc.Err()
}
func (c Config) Validate() error {
req := map[string]string{"APP_ENV": c.App.Env, "LOG_LEVEL": c.App.LogLevel, "ASTERISK_ARI_URL": c.Asterisk.ARIURL, "ASTERISK_ARI_WS_URL": c.Asterisk.ARIWSURL, "ASTERISK_ARI_USER": c.Asterisk.ARIUser, "ASTERISK_ARI_PASSWORD": c.Asterisk.ARIPassword, "ASTERISK_ARI_APP": c.Asterisk.ARIApp, "ASTERISK_MEDIA_MODE": c.Asterisk.MediaMode, "ASTERISK_MEDIA_CODEC": c.Asterisk.MediaCodec}
for k, v := range req {
if strings.TrimSpace(v) == "" {
return fmt.Errorf("missing required env: %s", k)
}
}
if !oneOf(c.App.Env, "dev", "staging", "prod") {
return errors.New("APP_ENV must be dev, staging, or prod")
}
if !oneOf(c.App.LogLevel, "debug", "info", "warn", "error") {
return errors.New("LOG_LEVEL must be debug, info, warn, or error")
}
if !strings.HasPrefix(c.Asterisk.ARIURL, "http://127.0.0.1") && !strings.HasPrefix(c.Asterisk.ARIURL, "http://localhost") {
return errors.New("ASTERISK_ARI_URL must use localhost")
}
if !strings.HasPrefix(c.Asterisk.ARIWSURL, "ws://127.0.0.1") && !strings.HasPrefix(c.Asterisk.ARIWSURL, "ws://localhost") {
return errors.New("ASTERISK_ARI_WS_URL must use localhost")
}
if !oneOf(c.Asterisk.ARIWSAuthMode, "basic", "query_api_key", "auto") {
return errors.New("ASTERISK_ARI_WS_AUTH_MODE must be basic, query_api_key, or auto")
}
if !oneOf(c.Voice.Provider, "fake", "openai_realtime", "pipeline_elevenlabs", "pipeline_elevenlabs_streaming") {
return errors.New("VOICE_PROVIDER must be fake, openai_realtime, pipeline_elevenlabs, or pipeline_elevenlabs_streaming")
}
if c.Dialogue.DefaultLanguage != "" && !oneOf(c.Dialogue.DefaultLanguage, "ru", "kk") {
return errors.New("DIALOGUE_DEFAULT_LANGUAGE must be ru, kk, or empty")
}
if c.App.Env == "prod" && !c.Dialogue.EnforceLanguageAndRegion {
return errors.New("DIALOGUE_ENFORCE_LANGUAGE_REGION cannot be false in prod")
}
if c.App.Env == "prod" && !c.Dialogue.LanguageSelectionEnabled {
return errors.New("LANGUAGE_SELECTION_ENABLED cannot be false in prod")
}
if c.Dialogue.LanguageDefaultOnTimeout != "" {
return errors.New("LANGUAGE_DEFAULT_ON_TIMEOUT must stay empty in TZ-07")
}
if c.App.Env == "prod" && !c.Dialogue.RegionResolverEnabled {
return errors.New("REGION_RESOLVER_ENABLED cannot be false in prod")
}
if c.Dialogue.RegionDefaultOnTimeout != "" {
return errors.New("REGION_DEFAULT_ON_TIMEOUT must stay empty in TZ-08")
}
if !oneOf(c.OpenAI.RealtimeInputAudioFormat, "pcm16", "g711_ulaw", "g711_alaw") {
return errors.New("OPENAI_REALTIME_INPUT_AUDIO_FORMAT invalid")
}
if !oneOf(c.OpenAI.RealtimeOutputAudioFormat, "pcm16", "g711_ulaw", "g711_alaw") {
return errors.New("OPENAI_REALTIME_OUTPUT_AUDIO_FORMAT invalid")
}
if !oneOf(c.OpenAI.RealtimeTurnDetection, "server_vad", "semantic_vad", "disabled") {
return errors.New("OPENAI_REALTIME_TURN_DETECTION invalid")
}
if !oneOf(c.OpenAI.RealtimeReasoningEffort, "minimal", "low", "medium", "high") {
return errors.New("OPENAI_REALTIME_REASONING_EFFORT invalid")
}
if !oneOf(c.Asterisk.MediaMode, "chan_websocket", "external_media_rtp") {
return errors.New("ASTERISK_MEDIA_MODE must be chan_websocket or external_media_rtp")
}
if c.Asterisk.MediaMode != "chan_websocket" {
return errors.New("TZ expects ASTERISK_MEDIA_MODE=chan_websocket")
}
if !oneOf(c.Asterisk.MediaCodec, "slin16", "alaw", "ulaw") {
return errors.New("ASTERISK_MEDIA_CODEC invalid")
}
if c.STT.Provider != "" && !oneOf(c.STT.Provider, "elevenlabs") {
return errors.New("STT_PROVIDER must be elevenlabs")
}
if c.STT.Mode != "" && !oneOf(c.STT.Mode, "realtime") {
return errors.New("ELEVENLABS_STT_MODE must be realtime")
}
if c.STT.SampleRate <= 0 {
return errors.New("ELEVENLABS_STT_SAMPLE_RATE invalid")
}
if c.LLM.Provider != "" && !oneOf(c.LLM.Provider, "openai") {
return errors.New("LLM_PROVIDER must be openai")
}
if c.LLM.MaxToolLoops <= 0 || c.LLM.StreamChunkMinChars <= 0 || c.LLM.StreamChunkMaxChars < c.LLM.StreamChunkMinChars {
return errors.New("invalid LLM streaming settings")
}
if c.Eleven.TTSMode != "" && !oneOf(c.Eleven.TTSMode, "websocket") {
return errors.New("ELEVENLABS_TTS_MODE must be websocket")
}
if c.Eleven.TTSSampleRate <= 0 || c.Eleven.TTSOptimizeStreamingLatency < 0 {
return errors.New("invalid ElevenLabs TTS settings")
}
if c.Natural.AudioTagsMode != "" && !oneOf(c.Natural.AudioTagsMode, "controlled") {
return errors.New("VOICE_AUDIO_TAGS_MODE must be controlled")
}
if c.Natural.MaxAudioTagsPerResponse < 0 {
return errors.New("VOICE_MAX_AUDIO_TAGS_PER_RESPONSE invalid")
}
if c.Pipeline.MaxTurnSeconds <= 0 || c.Pipeline.MaxResponseChars <= 0 || c.Pipeline.TTFBTargetMS <= 0 || c.Pipeline.TTSStartAfterChars <= 0 {
return errors.New("invalid PIPELINE settings")
}
if !oneOf(c.Embedding.Provider, "fake", "openai") {
return errors.New("EMBEDDING_PROVIDER must be fake or openai")
}
if c.Embedding.Provider == "openai" && c.OpenAI.APIKey == "" {
return errors.New("OPENAI_API_KEY is required when EMBEDDING_PROVIDER=openai")
}
if c.Embedding.Dimensions != 1536 {
return errors.New("EMBEDDING_DIMENSIONS must be 1536 in TZ-09")
}
if c.KB.DefaultLimit <= 0 || c.KB.MaxLimit <= 0 || c.KB.DefaultLimit > c.KB.MaxLimit {
return errors.New("invalid KB limit settings")
}
if !oneOf(c.Handoff.Mode, "disabled_stub", "ari_redirect", "dialplan_continue", "hangup_after_message") {
return errors.New("HANDOFF_MODE invalid")
}
if c.Handoff.MaxAttempts <= 0 || c.Handoff.MaxSummaryChars <= 0 || c.Handoff.Timeout <= 0 {
return errors.New("invalid HANDOFF settings")
}
if c.Handoff.Enabled {
switch c.Handoff.Mode {
case "ari_redirect":
if strings.TrimSpace(c.Handoff.TargetEndpoint) == "" {
return errors.New("HANDOFF_TARGET_ENDPOINT is required for ari_redirect")
}
case "dialplan_continue":
if strings.TrimSpace(c.Handoff.DialplanContext) == "" || strings.TrimSpace(c.Handoff.DialplanExtension) == "" {
return errors.New("HANDOFF_DIALPLAN_CONTEXT and HANDOFF_DIALPLAN_EXTENSION are required for dialplan_continue")
}
}
}
if c.Fallback.MaxLanguageFailures <= 0 || c.Fallback.MaxRegionFailures <= 0 || c.Fallback.MaxNoAnswer <= 0 || c.Fallback.CallTimeout <= 0 {
return errors.New("invalid FALLBACK settings")
}
if c.App.Env == "prod" && !c.Audit.Enabled {
return errors.New("AUDIT_ENABLED cannot be false in prod")
}
if c.Audit.Sink != "postgres" {
return errors.New("AUDIT_SINK must be postgres")
}
if c.Audit.MaxTranscriptChars <= 0 || c.Audit.MaxToolArgChars <= 0 || c.Audit.MaxToolResultChars <= 0 || c.Audit.MaxEventMetadataChars <= 0 {
return errors.New("invalid AUDIT max char settings")
}
if c.Audit.RetentionDays <= 0 || c.Audit.TranscriptRetentionDays <= 0 || c.Audit.ToolAuditRetentionDays <= 0 || c.Audit.ErrorAuditRetentionDays <= 0 || c.Audit.ExportMaxEvents <= 0 {
return errors.New("invalid AUDIT retention settings")
}
if !c.Audit.RedactionEnabled && c.App.Env == "prod" {
return errors.New("AUDIT_REDACTION_ENABLED cannot be false in prod")
}
return nil
}
func oneOf(v string, allowed ...string) bool {
for _, a := range allowed {
if v == a {
return true
}
}
return false
}
func valueOrDefault(v, f string) string {
if strings.TrimSpace(v) == "" {
return f
}
return v
}
func floatDefault(v string, d float64) float64 {
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n
}
return d
}
func intDefault(v string, d int) int {
if n, err := strconv.Atoi(v); err == nil {
return n
}
return d
}
func boolDefault(v string, d bool) bool {
if v == "" {
return d
}
b, err := strconv.ParseBool(v)
if err != nil {
return d
}
return b
}
func durationDefault(v string, d time.Duration) time.Duration {
if x, err := time.ParseDuration(v); err == nil {
return x
}
return d
}
func deriveMediaWSBaseURL(value, ariWSURL string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimRight(value, "/")
}
base := strings.TrimRight(ariWSURL, "/")
if strings.HasSuffix(base, "/ari/events") {
return strings.TrimSuffix(base, "/ari/events") + "/media"
}
return "ws://127.0.0.1:8088/media"
}