Files
srbhr--resume-matcher/apps/backend/app/config.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

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()