Use base URL for chat completions

This commit is contained in:
Thales Maciel 2026-02-10 11:00:52 -03:00
parent a0c3b02ab1
commit ad66a0d3cb
4 changed files with 320 additions and 493 deletions

View file

@ -10,43 +10,21 @@ def _parse_bool(val: str) -> bool:
@dataclass
class Config:
hotkey: str = "Cmd+m"
edit_hotkey: str = "Cmd+n"
ffmpeg_input: str = "pulse:default"
ffmpeg_path: str = ""
whisper_model: str = "base"
whisper_lang: str = "en"
whisper_device: str = "cpu"
record_timeout_sec: int = 120
edit_record_timeout_sec: int = 120
injection_backend: str = "clipboard"
edit_injection_backend: str = "clipboard"
ai_enabled: bool = False
ai_model: str = "llama3.2:3b"
ai_temperature: float = 0.0
ai_system_prompt_file: str = ""
ai_base_url: str = "http://localhost:11434/v1/chat/completions"
ai_api_key: str = ""
ai_timeout_sec: int = 20
edit_ai_enabled: bool = True
edit_ai_temperature: float = 0.0
edit_ai_system_prompt_file: str = ""
edit_window: dict = field(default_factory=lambda: {"width": 800, "height": 400})
context_capture: dict = field(default_factory=lambda: {"provider": "i3ipc", "on_focus_change": "abort"})
context_rules: list[dict] = field(default_factory=list)
languages: dict = field(
daemon: dict = field(default_factory=lambda: {"hotkey": "Cmd+m"})
recording: dict = field(default_factory=lambda: {"input": "pulse:default"})
transcribing: dict = field(default_factory=lambda: {"model": "base", "device": "cpu"})
injection: dict = field(default_factory=lambda: {"backend": "clipboard"})
ai_cleanup: dict = field(
default_factory=lambda: {
"en": {"code": "en", "hotkey": "Cmd+m", "label": "English"},
"ptBR": {"code": "pt-BR", "hotkey": "Cmd+b", "label": "Português (Brasil)"},
"enabled": False,
"model": "llama3.2:3b",
"temperature": 0.0,
"base_url": "http://localhost:11434",
"api_key": "",
}
)
edit_language_detection: dict = field(default_factory=lambda: {"enabled": True, "provider": "langdetect", "fallback_code": "en"})
def default_path() -> Path:
@ -58,112 +36,92 @@ def load(path: str | None) -> Config:
p = Path(path) if path else default_path()
if p.exists():
data = json.loads(p.read_text(encoding="utf-8"))
for k, v in data.items():
if hasattr(cfg, k):
setattr(cfg, k, v)
if any(k in data for k in ("daemon", "recording", "transcribing", "injection", "ai_cleanup", "ai")):
for k, v in data.items():
if hasattr(cfg, k):
setattr(cfg, k, v)
else:
cfg.daemon["hotkey"] = data.get("hotkey", cfg.daemon["hotkey"])
cfg.recording["input"] = data.get("ffmpeg_input", cfg.recording["input"])
cfg.transcribing["model"] = data.get("whisper_model", cfg.transcribing["model"])
cfg.transcribing["device"] = data.get("whisper_device", cfg.transcribing["device"])
cfg.injection["backend"] = data.get("injection_backend", cfg.injection["backend"])
cfg.ai_cleanup["enabled"] = data.get("ai_enabled", cfg.ai_cleanup["enabled"])
cfg.ai_cleanup["model"] = data.get("ai_model", cfg.ai_cleanup["model"])
cfg.ai_cleanup["temperature"] = data.get("ai_temperature", cfg.ai_cleanup["temperature"])
cfg.ai_cleanup["base_url"] = data.get("ai_base_url", cfg.ai_cleanup["base_url"])
cfg.ai_cleanup["api_key"] = data.get("ai_api_key", cfg.ai_cleanup["api_key"])
if not isinstance(cfg.context_capture, dict):
cfg.context_capture = {"provider": "i3ipc", "on_focus_change": "abort"}
if not isinstance(cfg.context_rules, list):
cfg.context_rules = []
if not isinstance(cfg.daemon, dict):
cfg.daemon = {"hotkey": "Cmd+m"}
if not isinstance(cfg.recording, dict):
cfg.recording = {"input": "pulse:default"}
if not isinstance(cfg.transcribing, dict):
cfg.transcribing = {"model": "base", "device": "cpu"}
if not isinstance(cfg.injection, dict):
cfg.injection = {"backend": "clipboard"}
if not isinstance(cfg.ai_cleanup, dict):
cfg.ai_cleanup = {
"enabled": False,
"model": "llama3.2:3b",
"temperature": 0.0,
"base_url": "http://localhost:11434",
"api_key": "",
}
if isinstance(getattr(cfg, "ai", None), dict) and not cfg.ai_cleanup:
cfg.ai_cleanup = cfg.ai
if hasattr(cfg, "ai"):
try:
delattr(cfg, "ai")
except Exception:
pass
# env overrides
if os.getenv("WHISPER_MODEL"):
cfg.whisper_model = os.environ["WHISPER_MODEL"]
if os.getenv("WHISPER_LANG"):
cfg.whisper_lang = os.environ["WHISPER_LANG"]
cfg.transcribing["model"] = os.environ["WHISPER_MODEL"]
if os.getenv("WHISPER_DEVICE"):
cfg.whisper_device = os.environ["WHISPER_DEVICE"]
cfg.transcribing["device"] = os.environ["WHISPER_DEVICE"]
if os.getenv("WHISPER_FFMPEG_IN"):
cfg.ffmpeg_input = os.environ["WHISPER_FFMPEG_IN"]
cfg.recording["input"] = os.environ["WHISPER_FFMPEG_IN"]
if os.getenv("LEL_FFMPEG_PATH"):
cfg.ffmpeg_path = os.environ["LEL_FFMPEG_PATH"]
if os.getenv("LEL_RECORD_TIMEOUT_SEC"):
cfg.record_timeout_sec = int(os.environ["LEL_RECORD_TIMEOUT_SEC"])
if os.getenv("LEL_EDIT_RECORD_TIMEOUT_SEC"):
cfg.edit_record_timeout_sec = int(os.environ["LEL_EDIT_RECORD_TIMEOUT_SEC"])
if os.getenv("LEL_HOTKEY"):
cfg.hotkey = os.environ["LEL_HOTKEY"]
if os.getenv("LEL_EDIT_HOTKEY"):
cfg.edit_hotkey = os.environ["LEL_EDIT_HOTKEY"]
cfg.daemon["hotkey"] = os.environ["LEL_HOTKEY"]
if os.getenv("LEL_INJECTION_BACKEND"):
cfg.injection_backend = os.environ["LEL_INJECTION_BACKEND"]
if os.getenv("LEL_EDIT_INJECTION_BACKEND"):
cfg.edit_injection_backend = os.environ["LEL_EDIT_INJECTION_BACKEND"]
cfg.injection["backend"] = os.environ["LEL_INJECTION_BACKEND"]
if os.getenv("LEL_AI_CLEANUP_ENABLED"):
cfg.ai_cleanup["enabled"] = _parse_bool(os.environ["LEL_AI_CLEANUP_ENABLED"])
if os.getenv("LEL_AI_CLEANUP_MODEL"):
cfg.ai_cleanup["model"] = os.environ["LEL_AI_CLEANUP_MODEL"]
if os.getenv("LEL_AI_CLEANUP_TEMPERATURE"):
cfg.ai_cleanup["temperature"] = float(os.environ["LEL_AI_CLEANUP_TEMPERATURE"])
if os.getenv("LEL_AI_CLEANUP_BASE_URL"):
cfg.ai_cleanup["base_url"] = os.environ["LEL_AI_CLEANUP_BASE_URL"]
if os.getenv("LEL_AI_CLEANUP_API_KEY"):
cfg.ai_cleanup["api_key"] = os.environ["LEL_AI_CLEANUP_API_KEY"]
if os.getenv("LEL_AI_ENABLED"):
cfg.ai_enabled = _parse_bool(os.environ["LEL_AI_ENABLED"])
cfg.ai_cleanup["enabled"] = _parse_bool(os.environ["LEL_AI_ENABLED"])
if os.getenv("LEL_AI_MODEL"):
cfg.ai_model = os.environ["LEL_AI_MODEL"]
cfg.ai_cleanup["model"] = os.environ["LEL_AI_MODEL"]
if os.getenv("LEL_AI_TEMPERATURE"):
cfg.ai_temperature = float(os.environ["LEL_AI_TEMPERATURE"])
if os.getenv("LEL_AI_SYSTEM_PROMPT_FILE"):
cfg.ai_system_prompt_file = os.environ["LEL_AI_SYSTEM_PROMPT_FILE"]
cfg.ai_cleanup["temperature"] = float(os.environ["LEL_AI_TEMPERATURE"])
if os.getenv("LEL_AI_BASE_URL"):
cfg.ai_base_url = os.environ["LEL_AI_BASE_URL"]
cfg.ai_cleanup["base_url"] = os.environ["LEL_AI_BASE_URL"]
if os.getenv("LEL_AI_API_KEY"):
cfg.ai_api_key = os.environ["LEL_AI_API_KEY"]
if os.getenv("LEL_AI_TIMEOUT_SEC"):
cfg.ai_timeout_sec = int(os.environ["LEL_AI_TIMEOUT_SEC"])
if os.getenv("LEL_EDIT_AI_ENABLED"):
cfg.edit_ai_enabled = _parse_bool(os.environ["LEL_EDIT_AI_ENABLED"])
if os.getenv("LEL_EDIT_AI_TEMPERATURE"):
cfg.edit_ai_temperature = float(os.environ["LEL_EDIT_AI_TEMPERATURE"])
if os.getenv("LEL_EDIT_AI_SYSTEM_PROMPT_FILE"):
cfg.edit_ai_system_prompt_file = os.environ["LEL_EDIT_AI_SYSTEM_PROMPT_FILE"]
if os.getenv("LEL_LANGUAGES_JSON"):
cfg.languages = json.loads(os.environ["LEL_LANGUAGES_JSON"])
if os.getenv("LEL_EDIT_LANG_FALLBACK"):
cfg.edit_language_detection["fallback_code"] = os.environ["LEL_EDIT_LANG_FALLBACK"]
if os.getenv("LEL_CONTEXT_PROVIDER"):
cfg.context_capture["provider"] = os.environ["LEL_CONTEXT_PROVIDER"]
if os.getenv("LEL_CONTEXT_ON_FOCUS_CHANGE"):
cfg.context_capture["on_focus_change"] = os.environ["LEL_CONTEXT_ON_FOCUS_CHANGE"]
cfg.ai_cleanup["api_key"] = os.environ["LEL_AI_API_KEY"]
validate(cfg)
return cfg
def redacted_dict(cfg: Config) -> dict:
d = cfg.__dict__.copy()
d["ai_api_key"] = ""
if isinstance(d.get("ai_cleanup"), dict):
d["ai_cleanup"] = d["ai_cleanup"].copy()
d["ai_cleanup"]["api_key"] = ""
return d
def validate(cfg: Config) -> None:
if not cfg.hotkey:
raise ValueError("hotkey cannot be empty")
if not cfg.edit_hotkey:
raise ValueError("edit_hotkey cannot be empty")
if cfg.record_timeout_sec <= 0:
raise ValueError("record_timeout_sec must be > 0")
if cfg.edit_record_timeout_sec <= 0:
raise ValueError("edit_record_timeout_sec must be > 0")
if cfg.context_capture.get("provider") not in {"i3ipc"}:
raise ValueError("context_capture.provider must be i3ipc")
if cfg.context_capture.get("on_focus_change") not in {"abort"}:
raise ValueError("context_capture.on_focus_change must be abort")
if not isinstance(cfg.context_rules, list):
cfg.context_rules = []
if not isinstance(cfg.edit_window, dict):
cfg.edit_window = {"width": 800, "height": 400}
if not isinstance(cfg.languages, dict) or not cfg.languages:
raise ValueError("languages must be a non-empty map")
seen_hotkeys = set()
for name, info in cfg.languages.items():
if not isinstance(info, dict):
raise ValueError(f"languages[{name}] must be an object")
code = info.get("code")
hotkey = info.get("hotkey")
if not code or not hotkey:
raise ValueError(f"languages[{name}] must include code and hotkey")
if hotkey in seen_hotkeys:
raise ValueError(f"duplicate hotkey in languages: {hotkey}")
seen_hotkeys.add(hotkey)
if not isinstance(cfg.edit_language_detection, dict):
cfg.edit_language_detection = {"enabled": True, "provider": "langdetect", "fallback_code": "en"}
if cfg.edit_language_detection.get("provider") not in {"langdetect"}:
raise ValueError("edit_language_detection.provider must be langdetect")
if not cfg.daemon.get("hotkey"):
raise ValueError("daemon.hotkey cannot be empty")