# backend/app/runtime_config.py # 런타임 LLM 설정 오버레이(인메모리). app_setting 테이블 → 프로세스 메모리. # # 설계 의도: # - .env(Settings)는 ops 가 관리하는 읽기전용 기본값. # - 사용자가 설정 페이지에서 바꾸는 LLM 연결만 이 오버레이가 흡수해 즉시 반영한다. # - 오버레이가 비어 있으면(기본) effective_* 는 Settings 값과 100% 동일 → 데모/테스트 무손상. # - 테스트는 lifespan 을 띄우지 않으므로 load_from_db 가 호출되지 않아 항상 비어 있다. # _test/reset 은 clear() 를 호출해 같은 프로세스 내 잔여 오버레이를 비운다. from dataclasses import dataclass from .config import get_settings # 허용된 LLM 오버레이 키(이 외의 키는 무시). LLM_KEYS = ("llm.provider", "llm.model", "llm.host", "llm.timeout", "llm.api_key") # api_key 는 암호화 저장(crypto.encrypt_str) — DB 평문 금지. _SECRET_KEYS = ("llm.api_key",) _PROVIDERS = ("ollama", "openai", "gemini") # ollama | openai(호환) | gemini # 인메모리 오버레이. key → str value(api_key 는 복호화된 평문으로 보관). _overrides: dict[str, str] = {} @dataclass(frozen=True) class EffectiveLLM: provider: str # ollama | openai | gemini model: str host: str timeout: float api_key: str = "" def clear() -> None: """오버레이 비우기 → effective_* 가 .env 기본값으로 복귀.""" _overrides.clear() def snapshot() -> dict[str, str]: """현재 오버레이의 사본(디버그/응답용).""" return dict(_overrides) def set_llm(values: dict[str, object]) -> None: """LLM 오버레이 갱신. None/빈 값은 '기본으로 되돌리기'로 간주해 키를 제거한다.""" for k, v in values.items(): if k not in LLM_KEYS: continue if v is None or (isinstance(v, str) and v.strip() == ""): _overrides.pop(k, None) else: _overrides[k] = str(v) def load_from_db(session) -> None: """앱 시작 시(lifespan) app_setting → 오버레이 적재. 비밀키(api_key)는 복호화.""" from sqlmodel import select from .crypto import decrypt_str from .models import AppSetting _overrides.clear() for row in session.exec(select(AppSetting)).all(): if row.key in LLM_KEYS: _overrides[row.key] = decrypt_str(row.value) if row.key in _SECRET_KEYS else row.value def effective_llm() -> EffectiveLLM: """오버레이 > .env 우선순위로 합성한 유효 LLM 설정.""" st = get_settings() provider = _overrides.get("llm.provider") or st.llm_provider if provider not in _PROVIDERS: provider = "ollama" # 미지원/구 값(auto·heuristic) → 실 LLM 으로 강제 model = _overrides.get("llm.model") or st.ollama_model host = _overrides.get("llm.host") or st.ollama_host api_key = _overrides.get("llm.api_key") or st.llm_api_key raw_timeout = _overrides.get("llm.timeout") try: timeout = float(raw_timeout) if raw_timeout else st.llm_timeout except ValueError: timeout = st.llm_timeout return EffectiveLLM(provider=provider, model=model, host=host, timeout=timeout, api_key=api_key)