Files
Owner/src/owner_voice_pet/config.py
T

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