139 lines
5.1 KiB
Python
139 lines
5.1 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:
|
|
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
|