647 lines
27 KiB
Python
647 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .models import ErrorCode, ProviderError
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AppConfig:
|
|
assistant_mode: str = "turn_based_voice_pet"
|
|
wake_word: str = "小杰小杰"
|
|
sample_rate: int = 16000
|
|
channels: int = 1
|
|
llm_base_url: str = "https://token-plan-cn.xiaomimimo.com/v1"
|
|
llm_api_key: str | None = None
|
|
llm_model: str = "mimo-v2.5"
|
|
llm_api_style: str = "chat_completions"
|
|
llm_stream: bool = True
|
|
realtime_transcript_enabled: bool = True
|
|
realtime_transcript_idle_timeout_ms: int = 1500
|
|
audio_input_device: str | None = None
|
|
audio_output_device: str | None = None
|
|
asset_dir: Path = Path("assets/pet")
|
|
log_dir: Path = Path("logs")
|
|
wake_provider: str = "local_kws"
|
|
wake_keywords_file: Path | None = None
|
|
wake_kws_threshold: float = 0.15
|
|
wake_kws_score: float = 1.0
|
|
wake_ack_text: str = "我在"
|
|
post_playback_drain_ms: int = 0
|
|
pipeline_mode: str = "live_turn_based"
|
|
endpoint_mode: str = "primary_speaker"
|
|
noise_filter_enabled: bool = True
|
|
noise_filter_provider: str = "sherpa_onnx_gtcrn"
|
|
wake_denoise_enabled: bool = False
|
|
speaker_profile_ms: int = 600
|
|
speaker_profile_min_ms: int = 120
|
|
speaker_absent_ms: int = 300
|
|
speaker_similarity_threshold: float = 0.70
|
|
speaker_min_rms: float = 0.012
|
|
vad_provider: str = "hybrid"
|
|
vad_threshold: float = 0.5
|
|
vad_min_duration_ms: int = 250
|
|
vad_end_silence_ms: int = 350
|
|
vad_no_speech_timeout_ms: int = 5000
|
|
vad_max_recording_ms: int = 12000
|
|
speech_provider: str = "local"
|
|
asr_model: str = "mimo-v2.5-asr"
|
|
tts_model: str = "mimo-v2.5-tts"
|
|
tts_voice: str = "mimo_default"
|
|
speech_models_dir: Path = Path("models")
|
|
context_mode: str = "session_memory"
|
|
context_max_messages: int = 12
|
|
context_max_chars: int = 12000
|
|
continuous_dialog_enabled: bool = True
|
|
continuation_decision_provider: str = "hybrid"
|
|
continuation_confidence_threshold: float = 0.65
|
|
followup_listen_timeout_ms: int = 3000
|
|
barge_in_enabled: bool = True
|
|
barge_in_min_speech_ms: int = 250
|
|
barge_in_echo_guard_ms: int = 500
|
|
barge_in_speaker_gate_enabled: bool = True
|
|
barge_in_user_similarity_threshold: float = 0.62
|
|
barge_in_assistant_reject_threshold: float = 0.72
|
|
barge_in_listen_interval_ms: int = 20
|
|
barge_in_chunk_ms: int = 30
|
|
barge_in_debug: bool = False
|
|
end_chime_enabled: bool = True
|
|
end_chime_file: Path = Path("assets/sounds/codex-notification.wav")
|
|
end_chime_frequency_hz: int = 880
|
|
end_chime_duration_ms: int = 140
|
|
audio_apm_provider: str = "webrtc"
|
|
audio_aec_enabled: bool = True
|
|
audio_ns_enabled: bool = True
|
|
audio_agc_enabled: bool = True
|
|
audio_apm_required: bool = True
|
|
audio_frame_ms: int = 20
|
|
audio_ring_buffer_ms: int = 3000
|
|
interrupt_enabled: bool = True
|
|
interrupt_target_latency_ms: int = 200
|
|
streaming_stt_provider: str = "faster_whisper"
|
|
streaming_stt_product_candidate: str = "sensevoice"
|
|
streaming_tts_provider: str = "cosyvoice"
|
|
memory_enabled: bool = True
|
|
memory_provider: str = "faiss_sqlite"
|
|
memory_top_k: int = 5
|
|
memory_auto_save_sensitive: bool = False
|
|
tool_router_enabled: bool = True
|
|
tool_max_calls_per_turn: int = 5
|
|
tool_timeout_ms: int = 30000
|
|
openinterpreter_enabled: bool = False
|
|
openinterpreter_command: str = "openinterpreter"
|
|
browser_playwright_enabled: bool = False
|
|
computer_control_enabled: bool = False
|
|
|
|
@classmethod
|
|
def from_dotenv(cls, path: str | Path = ".env", prefix: str = "OWNER_") -> "AppConfig":
|
|
values = parse_dotenv(Path(path))
|
|
|
|
def get(name: str, default: str | None = None) -> str | None:
|
|
value = values.get(f"{prefix}{name}")
|
|
return default if value is None or value == "" else value
|
|
|
|
def get_bool(name: str, default: str = "0") -> bool:
|
|
return (get(name, default) or default).lower() not in {"0", "false", "no"}
|
|
|
|
def get_bool_compat(name: str, legacy_name: str, default: str = "0") -> bool:
|
|
value = get(name)
|
|
if value is None:
|
|
value = get(legacy_name, default)
|
|
return (value or default).lower() not in {"0", "false", "no"}
|
|
|
|
return cls(
|
|
assistant_mode=(
|
|
get("ASSISTANT_MODE", "turn_based_voice_pet") or "turn_based_voice_pet"
|
|
).lower(),
|
|
wake_word=get("WAKE_WORD", "小杰小杰") or "小杰小杰",
|
|
sample_rate=int(get("SAMPLE_RATE", "16000") or "16000"),
|
|
channels=int(get("CHANNELS", "1") or "1"),
|
|
llm_base_url=(get("LLM_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1") or "").rstrip("/"),
|
|
llm_api_key=get("LLM_API_KEY"),
|
|
llm_model=get("LLM_MODEL", "mimo-v2.5") or "mimo-v2.5",
|
|
llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions",
|
|
llm_stream=get_bool_compat("LLM_STREAMING_ENABLED", "LLM_STREAM", "1"),
|
|
realtime_transcript_enabled=(get("REALTIME_TRANSCRIPT_ENABLED", "1") or "1").lower()
|
|
not in {"0", "false", "no"},
|
|
realtime_transcript_idle_timeout_ms=int(
|
|
get("REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS", "1500") or "1500"
|
|
),
|
|
audio_input_device=get("AUDIO_INPUT_DEVICE"),
|
|
audio_output_device=get("AUDIO_OUTPUT_DEVICE"),
|
|
asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"),
|
|
log_dir=Path(get("LOG_DIR", "logs") or "logs"),
|
|
wake_provider=(get("WAKE_PROVIDER", "local_kws") or "local_kws").lower(),
|
|
wake_keywords_file=Path(value) if (value := get("WAKE_KEYWORDS_FILE")) else None,
|
|
wake_kws_threshold=float(get("WAKE_KWS_THRESHOLD", "0.15") or "0.15"),
|
|
wake_kws_score=float(get("WAKE_KWS_SCORE", "1.0") or "1.0"),
|
|
wake_ack_text=get("WAKE_ACK_TEXT", "我在") or "我在",
|
|
post_playback_drain_ms=int(get("POST_PLAYBACK_DRAIN_MS", "0") or "0"),
|
|
pipeline_mode=(get("PIPELINE_MODE", "live_turn_based") or "live_turn_based").lower(),
|
|
endpoint_mode=(get("ENDPOINT_MODE", "primary_speaker") or "primary_speaker").lower(),
|
|
noise_filter_enabled=(get("NOISE_FILTER_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
|
|
noise_filter_provider=(get("NOISE_FILTER_PROVIDER", "sherpa_onnx_gtcrn") or "sherpa_onnx_gtcrn").lower(),
|
|
wake_denoise_enabled=(get("WAKE_DENOISE_ENABLED", "0") or "0").lower() in {"1", "true", "yes"},
|
|
speaker_profile_ms=int(get("SPEAKER_PROFILE_MS", "600") or "600"),
|
|
speaker_profile_min_ms=int(get("SPEAKER_PROFILE_MIN_MS", "120") or "120"),
|
|
speaker_absent_ms=int(get("SPEAKER_ABSENT_MS", "300") or "300"),
|
|
speaker_similarity_threshold=float(
|
|
get("SPEAKER_SIMILARITY_THRESHOLD", "0.70") or "0.70"
|
|
),
|
|
speaker_min_rms=float(get("SPEAKER_MIN_RMS", "0.012") or "0.012"),
|
|
vad_provider=(get("VAD_PROVIDER", "hybrid") or "hybrid").lower(),
|
|
vad_threshold=float(get("VAD_THRESHOLD", "0.5") or "0.5"),
|
|
vad_min_duration_ms=int(get("VAD_MIN_DURATION_MS", "250") or "250"),
|
|
vad_end_silence_ms=int(get("VAD_END_SILENCE_MS", "350") or "350"),
|
|
vad_no_speech_timeout_ms=int(get("VAD_NO_SPEECH_TIMEOUT_MS", "5000") or "5000"),
|
|
vad_max_recording_ms=int(get("VAD_MAX_RECORDING_MS", "12000") or "12000"),
|
|
speech_provider=(get("SPEECH_PROVIDER", "local") or "local").lower(),
|
|
asr_model=get("ASR_MODEL", "mimo-v2.5-asr") or "mimo-v2.5-asr",
|
|
tts_model=get("TTS_MODEL", "mimo-v2.5-tts") or "mimo-v2.5-tts",
|
|
tts_voice=get("TTS_VOICE", "mimo_default") or "mimo_default",
|
|
speech_models_dir=Path(get("SPEECH_MODELS_DIR", "models") or "models"),
|
|
context_mode=(get("CONTEXT_MODE", "session_memory") or "session_memory").lower(),
|
|
context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"),
|
|
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
|
|
continuous_dialog_enabled=(get("CONTINUOUS_DIALOG_ENABLED", "1") or "1").lower()
|
|
not in {"0", "false", "no"},
|
|
continuation_decision_provider=(
|
|
get("CONTINUATION_DECISION_PROVIDER", "hybrid") or "hybrid"
|
|
).lower(),
|
|
continuation_confidence_threshold=float(
|
|
get("CONTINUATION_CONFIDENCE_THRESHOLD", "0.65") or "0.65"
|
|
),
|
|
followup_listen_timeout_ms=int(get("FOLLOWUP_LISTEN_TIMEOUT_MS", "3000") or "3000"),
|
|
barge_in_enabled=(get("BARGE_IN_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
|
|
barge_in_min_speech_ms=int(get("BARGE_IN_MIN_SPEECH_MS", "250") or "250"),
|
|
barge_in_echo_guard_ms=int(get("BARGE_IN_ECHO_GUARD_MS", "500") or "500"),
|
|
barge_in_speaker_gate_enabled=(get("BARGE_IN_SPEAKER_GATE_ENABLED", "1") or "1").lower()
|
|
not in {"0", "false", "no"},
|
|
barge_in_user_similarity_threshold=float(
|
|
get("BARGE_IN_USER_SIMILARITY_THRESHOLD", "0.62") or "0.62"
|
|
),
|
|
barge_in_assistant_reject_threshold=float(
|
|
get("BARGE_IN_ASSISTANT_REJECT_THRESHOLD", "0.72") or "0.72"
|
|
),
|
|
barge_in_listen_interval_ms=int(get("BARGE_IN_LISTEN_INTERVAL_MS", "20") or "20"),
|
|
barge_in_chunk_ms=int(get("BARGE_IN_CHUNK_MS", "30") or "30"),
|
|
barge_in_debug=(get("BARGE_IN_DEBUG", "0") or "0").lower() not in {"0", "false", "no"},
|
|
end_chime_enabled=(get("END_CHIME_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
|
|
end_chime_file=Path(
|
|
get("END_CHIME_FILE", "assets/sounds/codex-notification.wav")
|
|
or "assets/sounds/codex-notification.wav"
|
|
),
|
|
end_chime_frequency_hz=int(get("END_CHIME_FREQUENCY_HZ", "880") or "880"),
|
|
end_chime_duration_ms=int(get("END_CHIME_DURATION_MS", "140") or "140"),
|
|
audio_apm_provider=(get("AUDIO_APM_PROVIDER", "webrtc") or "webrtc").lower(),
|
|
audio_aec_enabled=get_bool("AUDIO_AEC_ENABLED", "1"),
|
|
audio_ns_enabled=get_bool("AUDIO_NS_ENABLED", "1"),
|
|
audio_agc_enabled=get_bool("AUDIO_AGC_ENABLED", "1"),
|
|
audio_apm_required=get_bool("AUDIO_APM_REQUIRED", "1"),
|
|
audio_frame_ms=int(get("AUDIO_FRAME_MS", "20") or "20"),
|
|
audio_ring_buffer_ms=int(get("AUDIO_RING_BUFFER_MS", "3000") or "3000"),
|
|
interrupt_enabled=get_bool("INTERRUPT_ENABLED", "1"),
|
|
interrupt_target_latency_ms=int(get("INTERRUPT_TARGET_LATENCY_MS", "200") or "200"),
|
|
streaming_stt_provider=(
|
|
get("STREAMING_STT_PROVIDER", "faster_whisper") or "faster_whisper"
|
|
).lower(),
|
|
streaming_stt_product_candidate=(
|
|
get("STREAMING_STT_PRODUCT_CANDIDATE", "sensevoice") or "sensevoice"
|
|
).lower(),
|
|
streaming_tts_provider=(
|
|
get("STREAMING_TTS_PROVIDER", "cosyvoice") or "cosyvoice"
|
|
).lower(),
|
|
memory_enabled=get_bool("MEMORY_ENABLED", "1"),
|
|
memory_provider=(get("MEMORY_PROVIDER", "faiss_sqlite") or "faiss_sqlite").lower(),
|
|
memory_top_k=int(get("MEMORY_TOP_K", "5") or "5"),
|
|
memory_auto_save_sensitive=get_bool("MEMORY_AUTO_SAVE_SENSITIVE", "0"),
|
|
tool_router_enabled=get_bool("TOOL_ROUTER_ENABLED", "1"),
|
|
tool_max_calls_per_turn=int(get("TOOL_MAX_CALLS_PER_TURN", "5") or "5"),
|
|
tool_timeout_ms=int(get("TOOL_TIMEOUT_MS", "30000") or "30000"),
|
|
openinterpreter_enabled=get_bool("OPENINTERPRETER_ENABLED", "0"),
|
|
openinterpreter_command=(
|
|
get("OPENINTERPRETER_COMMAND", "openinterpreter") or "openinterpreter"
|
|
),
|
|
browser_playwright_enabled=get_bool("BROWSER_PLAYWRIGHT_ENABLED", "0"),
|
|
computer_control_enabled=get_bool("COMPUTER_CONTROL_ENABLED", "0"),
|
|
)
|
|
|
|
@classmethod
|
|
def from_env(cls, prefix: str = "OWNER_") -> "AppConfig":
|
|
return cls.from_dotenv(".env", prefix=prefix)
|
|
|
|
def require_llm_credentials(self) -> None:
|
|
if not self.llm_api_key:
|
|
raise ProviderError(
|
|
code=ErrorCode.LLM_API_KEY_MISSING,
|
|
message="OWNER_LLM_API_KEY is required for cloud LLM calls",
|
|
retryable=False,
|
|
provider="openai-compatible",
|
|
stage="llm",
|
|
)
|
|
|
|
def validate_basic(self) -> list[ProviderError]:
|
|
errors: list[ProviderError] = []
|
|
if self.assistant_mode not in {"turn_based_voice_pet", "full_duplex_agent"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_ASSISTANT_MODE must be turn_based_voice_pet or full_duplex_agent",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.sample_rate <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"sample_rate must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.channels <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"channels must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.llm_api_style not in {"chat_completions", "responses"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_LLM_API_STYLE must be chat_completions or responses",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.speech_provider not in {"cloud", "local"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_SPEECH_PROVIDER must be cloud or local",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.realtime_transcript_idle_timeout_ms < 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS must be non-negative",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.wake_provider not in {"local_kws"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_WAKE_PROVIDER must be local_kws",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.wake_kws_threshold <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_WAKE_KWS_THRESHOLD must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.wake_kws_score <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_WAKE_KWS_SCORE must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.noise_filter_provider not in {"sherpa_onnx_gtcrn"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_NOISE_FILTER_PROVIDER must be sherpa_onnx_gtcrn",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.post_playback_drain_ms < 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_POST_PLAYBACK_DRAIN_MS must be non-negative",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.pipeline_mode not in {"live_turn_based"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_PIPELINE_MODE must be live_turn_based",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.endpoint_mode not in {"primary_speaker", "vad"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_ENDPOINT_MODE must be primary_speaker or vad",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
for name, value in {
|
|
"OWNER_SPEAKER_PROFILE_MS": self.speaker_profile_ms,
|
|
"OWNER_SPEAKER_PROFILE_MIN_MS": self.speaker_profile_min_ms,
|
|
"OWNER_SPEAKER_ABSENT_MS": self.speaker_absent_ms,
|
|
}.items():
|
|
if value <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
f"{name} must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if not 0 < self.speaker_similarity_threshold <= 1:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_SPEAKER_SIMILARITY_THRESHOLD must be in (0, 1]",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.speaker_min_rms <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_SPEAKER_MIN_RMS must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.vad_provider not in {"hybrid", "local", "energy"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_VAD_PROVIDER must be hybrid, local, or energy",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.vad_threshold <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_VAD_THRESHOLD must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
for name, value in {
|
|
"OWNER_VAD_MIN_DURATION_MS": self.vad_min_duration_ms,
|
|
"OWNER_VAD_END_SILENCE_MS": self.vad_end_silence_ms,
|
|
"OWNER_VAD_NO_SPEECH_TIMEOUT_MS": self.vad_no_speech_timeout_ms,
|
|
"OWNER_VAD_MAX_RECORDING_MS": self.vad_max_recording_ms,
|
|
}.items():
|
|
if value <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
f"{name} must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if not self.llm_base_url.startswith(("http://", "https://")):
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_LLM_BASE_URL must start with http:// or https://",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.context_mode not in {"session_memory"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_CONTEXT_MODE must be session_memory",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.continuation_decision_provider not in {"hybrid", "rule", "llm"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_CONTINUATION_DECISION_PROVIDER must be hybrid, rule, or llm",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if not 0 < self.continuation_confidence_threshold <= 1:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_CONTINUATION_CONFIDENCE_THRESHOLD must be in (0, 1]",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
for name, value in {
|
|
"OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS": self.followup_listen_timeout_ms,
|
|
"OWNER_BARGE_IN_MIN_SPEECH_MS": self.barge_in_min_speech_ms,
|
|
"OWNER_BARGE_IN_ECHO_GUARD_MS": self.barge_in_echo_guard_ms,
|
|
}.items():
|
|
if value < 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
f"{name} must be non-negative",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
for name, value in {
|
|
"OWNER_BARGE_IN_LISTEN_INTERVAL_MS": self.barge_in_listen_interval_ms,
|
|
"OWNER_BARGE_IN_CHUNK_MS": self.barge_in_chunk_ms,
|
|
}.items():
|
|
if value <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
f"{name} must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
for name, value in {
|
|
"OWNER_BARGE_IN_USER_SIMILARITY_THRESHOLD": self.barge_in_user_similarity_threshold,
|
|
"OWNER_BARGE_IN_ASSISTANT_REJECT_THRESHOLD": self.barge_in_assistant_reject_threshold,
|
|
}.items():
|
|
if not 0 < value <= 1:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
f"{name} must be in (0, 1]",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
for name, value in {
|
|
"OWNER_END_CHIME_FREQUENCY_HZ": self.end_chime_frequency_hz,
|
|
"OWNER_END_CHIME_DURATION_MS": self.end_chime_duration_ms,
|
|
"OWNER_AUDIO_FRAME_MS": self.audio_frame_ms,
|
|
"OWNER_AUDIO_RING_BUFFER_MS": self.audio_ring_buffer_ms,
|
|
"OWNER_INTERRUPT_TARGET_LATENCY_MS": self.interrupt_target_latency_ms,
|
|
"OWNER_MEMORY_TOP_K": self.memory_top_k,
|
|
"OWNER_TOOL_MAX_CALLS_PER_TURN": self.tool_max_calls_per_turn,
|
|
"OWNER_TOOL_TIMEOUT_MS": self.tool_timeout_ms,
|
|
}.items():
|
|
if value <= 0:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
f"{name} must be positive",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.audio_apm_provider not in {"webrtc", "fake", "disabled"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_AUDIO_APM_PROVIDER must be webrtc, fake, or disabled",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.streaming_stt_provider not in {"faster_whisper", "sensevoice", "sherpa_onnx"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_STREAMING_STT_PROVIDER must be faster_whisper, sensevoice, or sherpa_onnx",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.streaming_stt_product_candidate not in {"sensevoice", "faster_whisper", "sherpa_onnx"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_STREAMING_STT_PRODUCT_CANDIDATE must be sensevoice, faster_whisper, or sherpa_onnx",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.streaming_tts_provider not in {"cosyvoice", "macos_say"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_STREAMING_TTS_PROVIDER must be cosyvoice or macos_say",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.memory_provider not in {"faiss_sqlite"}:
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_MEMORY_PROVIDER must be faiss_sqlite",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
if self.openinterpreter_enabled and not self.openinterpreter_command.strip():
|
|
errors.append(
|
|
ProviderError(
|
|
ErrorCode.CONFIG_MISSING_VALUE,
|
|
"OWNER_OPENINTERPRETER_COMMAND must be non-empty when Open Interpreter is enabled",
|
|
False,
|
|
"config",
|
|
"startup",
|
|
)
|
|
)
|
|
return errors
|
|
|
|
def api_url(self, path: str) -> str:
|
|
normalized = path if path.startswith("/") else f"/{path}"
|
|
base = self.llm_base_url.rstrip("/")
|
|
if base.endswith("/v1") and normalized.startswith("/v1/"):
|
|
return base + normalized[3:]
|
|
return base + normalized
|
|
|
|
|
|
def parse_dotenv(path: Path) -> dict[str, str]:
|
|
if not path.exists():
|
|
return {}
|
|
values: dict[str, str] = {}
|
|
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("export "):
|
|
line = line.removeprefix("export ").strip()
|
|
if "=" not in line:
|
|
raise ValueError(f"invalid .env line {line_no}: missing '='")
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
value = _strip_dotenv_value(value.strip())
|
|
if not key:
|
|
raise ValueError(f"invalid .env line {line_no}: empty key")
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def _strip_dotenv_value(value: str) -> str:
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
return value[1:-1]
|
|
if " #" in value:
|
|
return value.split(" #", 1)[0].rstrip()
|
|
return value
|