from __future__ import annotations from dataclasses import dataclass from pathlib import Path from .models import ErrorCode, ProviderError @dataclass(frozen=True, slots=True) class AppConfig: wake_word: str = "小杰小杰" sample_rate: int = 16000 channels: int = 1 llm_base_url: str = "https://newapi.mkbk.shop" llm_api_key: str | None = None llm_model: str = "gpt-5.4-mini" llm_api_style: str = "chat_completions" llm_stream: bool = True audio_input_device: str | None = None audio_output_device: str | None = None asset_dir: Path = Path("assets/pet") log_dir: Path = Path("logs") speech_models_dir: Path = Path("models") context_max_messages: int = 12 context_max_chars: int = 12000 @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 return cls( 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://newapi.mkbk.shop") or "").rstrip("/"), llm_api_key=get("LLM_API_KEY"), llm_model=get("LLM_MODEL", "gpt-5.4-mini") or "gpt-5.4-mini", llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions", llm_stream=(get("LLM_STREAM", "1") or "1").lower() not in {"0", "false", "no"}, 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"), speech_models_dir=Path(get("SPEECH_MODELS_DIR", "models") or "models"), context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"), context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"), ) @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.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 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", ) ) return errors 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