337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""Application configuration using pydantic-settings."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import field_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
# Path to config file for API key persistence
|
|
CONFIG_FILE_PATH = Path(__file__).parent.parent / "data" / "config.json"
|
|
ALLOWED_LOG_LEVELS = ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG")
|
|
|
|
|
|
def _read_config_json() -> dict[str, Any]:
|
|
"""Raw read of config.json (no key injection)."""
|
|
if CONFIG_FILE_PATH.exists():
|
|
try:
|
|
return json.loads(CONFIG_FILE_PATH.read_text())
|
|
except (json.JSONDecodeError, OSError):
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def _write_config_json(config: dict[str, Any]) -> None:
|
|
"""Raw write of config.json (no secret stripping)."""
|
|
CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_FILE_PATH.write_text(json.dumps(config, indent=2))
|
|
|
|
|
|
def load_config_file() -> dict[str, Any]:
|
|
"""Load non-secret configuration, with decrypted API keys injected.
|
|
|
|
API keys live in the encrypted SQLite store, not config.json. They are
|
|
injected here under ``api_keys`` so ``resolve_api_key(stored, provider)``
|
|
keeps resolving per-provider keys everywhere ``stored`` is built from this
|
|
function. ``save_config_file`` strips them again, so they never round-trip
|
|
back to disk.
|
|
"""
|
|
config = _read_config_json()
|
|
config["api_keys"] = get_api_keys_from_config()
|
|
return config
|
|
|
|
|
|
def save_config_file(config: dict[str, Any]) -> None:
|
|
"""Save non-secret configuration to config.json.
|
|
|
|
Secrets (``api_keys`` map and the legacy single ``api_key``) are stripped
|
|
before writing — they belong to the encrypted store only.
|
|
"""
|
|
config = dict(config)
|
|
config.pop("api_keys", None)
|
|
config.pop("api_key", None)
|
|
_write_config_json(config)
|
|
|
|
|
|
def get_api_keys_from_config() -> dict[str, str]:
|
|
"""Get decrypted API keys from the encrypted SQLite store.
|
|
|
|
Returns:
|
|
Dictionary with key-store provider names as keys and plaintext keys as
|
|
values (entries that fail to decrypt are omitted).
|
|
"""
|
|
from app.crypto import decrypt
|
|
from app.database import db
|
|
|
|
decrypted: dict[str, str] = {}
|
|
for provider, ciphertext in db.get_api_key_ciphertexts().items():
|
|
plaintext = decrypt(ciphertext)
|
|
if plaintext:
|
|
decrypted[provider] = plaintext
|
|
return decrypted
|
|
|
|
|
|
def save_api_keys_to_config(api_keys: dict[str, str]) -> None:
|
|
"""Replace the encrypted key store with ``api_keys`` (encrypting each).
|
|
|
|
Replace-all semantics mirror the legacy ``config["api_keys"] = api_keys``;
|
|
the config router reads-merges-saves the full map.
|
|
"""
|
|
from app.crypto import encrypt
|
|
from app.database import db
|
|
|
|
# Encrypt everything first, then swap in a single transaction, so a partial
|
|
# failure (encryption error or DB write) can never wipe previously stored
|
|
# keys mid-replace.
|
|
ciphertexts = {provider: encrypt(key) for provider, key in api_keys.items() if key}
|
|
db.replace_api_keys(ciphertexts)
|
|
|
|
|
|
def delete_api_key_from_config(provider: str) -> None:
|
|
"""Delete a specific API key from the encrypted store."""
|
|
from app.database import db
|
|
|
|
db.delete_api_key(provider)
|
|
|
|
|
|
def clear_all_api_keys() -> None:
|
|
"""Clear all API keys from the encrypted store and any legacy config slots."""
|
|
from app.database import db
|
|
|
|
db.clear_api_keys()
|
|
# Defensively clear any legacy plaintext remnants from config.json.
|
|
config = _read_config_json()
|
|
if "api_keys" in config or "api_key" in config:
|
|
config.pop("api_keys", None)
|
|
config.pop("api_key", None)
|
|
_write_config_json(config)
|
|
|
|
|
|
def migrate_legacy_keys() -> None:
|
|
"""Fold legacy plaintext keys from config.json into the encrypted store.
|
|
|
|
Idempotent and non-clobbering: an existing config.json ``api_keys`` map and
|
|
the legacy single ``api_key`` (mapped to its key-store provider via the
|
|
active provider) are written to the encrypted store **only if that provider
|
|
slot is empty**, then removed from config.json. This eliminates the
|
|
legacy-shadow bug where ``resolve_api_key`` returned one shared key for
|
|
every provider.
|
|
"""
|
|
config = _read_config_json()
|
|
legacy_map = config.get("api_keys")
|
|
legacy_single = config.get("api_key")
|
|
if not legacy_map and not legacy_single:
|
|
return
|
|
|
|
from app.crypto import encrypt
|
|
from app.database import db
|
|
|
|
existing = set(db.get_api_key_ciphertexts().keys())
|
|
|
|
if isinstance(legacy_map, dict):
|
|
for provider, key in legacy_map.items():
|
|
if key and provider not in existing:
|
|
db.set_api_key_ciphertext(provider, encrypt(key))
|
|
existing.add(provider)
|
|
|
|
if legacy_single:
|
|
# Map the active LLM provider to its key-store provider name.
|
|
provider = config.get("provider") or settings.llm_provider
|
|
key_provider = _LEGACY_PROVIDER_KEY_MAP.get(provider, provider)
|
|
if key_provider not in existing:
|
|
db.set_api_key_ciphertext(key_provider, encrypt(legacy_single))
|
|
|
|
# Strip the legacy slots from config.json now that they're in the store.
|
|
config.pop("api_keys", None)
|
|
config.pop("api_key", None)
|
|
_write_config_json(config)
|
|
|
|
|
|
# Mirror of llm._PROVIDER_KEY_MAP, duplicated to avoid importing llm.py (which
|
|
# pulls in litellm) at config import time.
|
|
_LEGACY_PROVIDER_KEY_MAP: dict[str, str] = {
|
|
"openai": "openai",
|
|
"openai_compatible": "openai_compatible",
|
|
"anthropic": "anthropic",
|
|
"gemini": "google",
|
|
"openrouter": "openrouter",
|
|
"deepseek": "deepseek",
|
|
"groq": "groq",
|
|
"ollama": "ollama",
|
|
}
|
|
|
|
|
|
def _get_llm_api_key_with_fallback() -> str:
|
|
"""Get LLM API key with fallback to config file.
|
|
|
|
Priority: Environment variable > config.json > empty string
|
|
"""
|
|
import os
|
|
|
|
# First check environment variable
|
|
env_key = os.environ.get("LLM_API_KEY", "")
|
|
if env_key:
|
|
return env_key
|
|
|
|
# Fallback to config file based on provider
|
|
config_keys = get_api_keys_from_config()
|
|
provider = os.environ.get("LLM_PROVIDER", "openai")
|
|
|
|
# Map provider to config key
|
|
provider_map = {
|
|
"openai": "openai",
|
|
"anthropic": "anthropic",
|
|
"gemini": "google",
|
|
"openrouter": "openrouter",
|
|
"deepseek": "deepseek",
|
|
"groq": "groq",
|
|
"ollama": "ollama",
|
|
}
|
|
|
|
config_provider = provider_map.get(provider, provider)
|
|
return config_keys.get(config_provider, "")
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Application settings loaded from environment variables."""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
extra="ignore",
|
|
)
|
|
|
|
# LLM Configuration
|
|
llm_provider: Literal[
|
|
"openai",
|
|
"openai_compatible",
|
|
"anthropic",
|
|
"openrouter",
|
|
"gemini",
|
|
"deepseek",
|
|
"groq",
|
|
"ollama",
|
|
] = "openai"
|
|
llm_model: str = "gpt-5-nano-2025-08-07"
|
|
llm_api_key: str = ""
|
|
llm_api_base: str | None = None # For Ollama or custom endpoints
|
|
log_llm: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = "WARNING"
|
|
|
|
@field_validator("llm_provider", mode="before")
|
|
@classmethod
|
|
def set_default_provider(cls, v: Any) -> str:
|
|
"""Handle empty string provider by defaulting to openai."""
|
|
if not v or (isinstance(v, str) and not v.strip()):
|
|
return "openai"
|
|
return v
|
|
|
|
@field_validator("log_llm", mode="before")
|
|
@classmethod
|
|
def normalize_log_llm_level(cls, v: Any) -> str:
|
|
"""Normalize LiteLLM log level from environment values."""
|
|
value = "WARNING" if not v else str(v).strip().upper()
|
|
if value not in ALLOWED_LOG_LEVELS:
|
|
raise ValueError(f"Invalid LOG_LLM: {value}. Allowed: {ALLOWED_LOG_LEVELS}")
|
|
return value
|
|
|
|
# Server Configuration
|
|
host: str = "0.0.0.0"
|
|
port: int = 8000
|
|
reload: bool = False
|
|
log_level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = "INFO"
|
|
frontend_base_url: str = "http://localhost:3000"
|
|
|
|
# Hard timeout (seconds) for a single resume tailoring/improve request — the
|
|
# backend wraps the improve flow in asyncio.wait_for(timeout=this). It MUST be
|
|
# kept in sync with the two frontend layers (Next.js `proxyTimeout` and the
|
|
# client AbortController, both driven by NEXT_PUBLIC_REQUEST_TIMEOUT_MS):
|
|
# whichever layer is shortest aborts first, so raising only one silently fails
|
|
# (this is why issue #776's backend-only workaround didn't work). Local LLMs
|
|
# (Ollama, llama.cpp, …) often need longer than the 240s default; bounded to
|
|
# [30, 1800]s so a stuck request can't hold a worker indefinitely.
|
|
request_timeout_seconds: int = 240
|
|
|
|
@field_validator("request_timeout_seconds", mode="before")
|
|
@classmethod
|
|
def clamp_request_timeout(cls, v: Any) -> int:
|
|
"""Clamp to [30, 1800] seconds; fall back to 240 on blank/invalid input."""
|
|
if v is None or (isinstance(v, str) and not v.strip()):
|
|
return 240
|
|
try:
|
|
seconds = int(float(str(v).strip()))
|
|
except (TypeError, ValueError, OverflowError):
|
|
# OverflowError guards against inf (int(float("inf"))); ValueError
|
|
# against nan/garbage. A bad env value must never crash startup.
|
|
return 240
|
|
return max(30, min(1800, seconds))
|
|
|
|
# Reasoning effort for models that support it (OpenAI gpt-5 family,
|
|
# Anthropic Claude 3.7+, DeepSeek R1, etc.). None means "do not send the
|
|
# param" — the default for maximum compatibility. LiteLLM drops this
|
|
# parameter for providers that don't support it (via drop_params=True).
|
|
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
|
|
|
|
@field_validator("reasoning_effort", mode="before")
|
|
@classmethod
|
|
def normalize_reasoning_effort(cls, v: Any) -> Any:
|
|
"""Treat empty string (common when env var is blank) as None."""
|
|
if isinstance(v, str) and not v.strip():
|
|
return None
|
|
return v
|
|
|
|
@field_validator("log_level", mode="before")
|
|
@classmethod
|
|
def normalize_log_level(cls, v: Any) -> str:
|
|
"""Normalize application log level from environment values."""
|
|
value = "INFO" if not v else str(v).strip().upper()
|
|
if value not in ALLOWED_LOG_LEVELS:
|
|
raise ValueError(f"Invalid LOG_LEVEL: {value}. Allowed: {ALLOWED_LOG_LEVELS}")
|
|
return value
|
|
|
|
# CORS Configuration
|
|
cors_origins: list[str] = [
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
]
|
|
|
|
@property
|
|
def effective_cors_origins(self) -> list[str]:
|
|
"""CORS origins including frontend_base_url for production deployments."""
|
|
origins = list(self.cors_origins)
|
|
url = self.frontend_base_url.strip().rstrip("/")
|
|
if url and url not in origins:
|
|
origins.append(url)
|
|
return origins
|
|
|
|
# Paths
|
|
data_dir: Path = Path(__file__).parent.parent / "data"
|
|
|
|
@property
|
|
def db_path(self) -> Path:
|
|
"""Path to the legacy TinyDB database file (migration source only)."""
|
|
return self.data_dir / "database.json"
|
|
|
|
@property
|
|
def sqlite_path(self) -> Path:
|
|
"""Path to the SQLite database file (primary data store)."""
|
|
return self.data_dir / "resume_matcher.db"
|
|
|
|
@property
|
|
def config_path(self) -> Path:
|
|
"""Path to config storage file."""
|
|
return self.data_dir / "config.json"
|
|
|
|
def get_effective_api_key(self) -> str:
|
|
"""Get the effective API key with config file fallback.
|
|
|
|
Priority: Environment/settings value > config.json > empty string
|
|
"""
|
|
if self.llm_api_key:
|
|
return self.llm_api_key
|
|
return _get_llm_api_key_with_fallback()
|
|
|
|
|
|
settings = Settings()
|