"""Environment-backed settings. Deliberate omissions: * No ``temperature`` / ``top_p``. The reasoning models this service targets reject them, and sampling was never the right lever for a scoring task anyway. Steer the model with the system prompt and structured outputs instead. """ from __future__ import annotations from functools import lru_cache from pathlib import Path from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Sole secrets file: backend/.env (repo root .env is not used). _BACKEND_ENV = Path(__file__).resolve().parents[2] / "backend" / ".env" # Model families that support structured outputs (``responses.parse``) and a reasoning # effort setting. A prefix check rather than an exact allowlist: OpenAI ships point # releases faster than this file can be updated, and rejecting a brand-new gpt-5.x # would be worse than the small risk of admitting one with a different feature set. # # The gpt-4o family is excluded on purpose: snapshots before 2024-08-06 lack structured # outputs, and distinguishing them by alias is not reliable. SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4") # "-chat-latest" variants track the ChatGPT product surface rather than the API model # line and do not expose reasoning effort. UNSUPPORTED_MODEL_SUFFIXES: tuple[str, ...] = ("-chat-latest",) # Mirrors openai.types.shared.reasoning_effort.ReasoningEffort. Per-model support # varies; the API rejects a level the chosen model does not implement. EFFORT_LEVELS: frozenset[str] = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"}) # Families that accept a `reasoning` parameter. gpt-4.1 is allowed as a model but is # not a reasoning model -- sending `reasoning` to it is a 400, so the adapter omits it. REASONING_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "o3", "o4") def supports_reasoning(model: str) -> bool: return model.startswith(REASONING_MODEL_PREFIXES) class Settings(BaseSettings): """Runtime configuration. Immutable once constructed.""" model_config = SettingsConfigDict( env_file=_BACKEND_ENV, # utf-8-sig, not utf-8: Windows editors and PowerShell's `-Encoding utf8` # write a BOM, which would otherwise become part of the first variable's # name and silently blank out that setting. env_file_encoding="utf-8-sig", extra="ignore", frozen=True, ) openai_api_key: str = "" openai_model: str = "gpt-5.4-mini" # Floor, not a suggestion: on a reasoning model this budget covers reasoning # tokens *and* the visible response. Anything lower truncates mid-JSON and the # candidate fails with MODEL_RESPONSE_INVALID. openai_max_output_tokens: int = Field(default=4000, ge=2048) openai_effort: str = "low" openai_max_retries: int = Field(default=3, ge=0) openai_timeout_seconds: float = Field(default=120.0, gt=0) # OpenAI prompt caching is automatic and cannot be switched off. This toggle only # controls whether a `prompt_cache_key` routing hint is sent (see services/llm.py). openai_enable_prompt_cache: bool = True scoring_concurrency: int = Field(default=5, ge=1) max_resumes_per_request: int = Field(default=50, ge=1) max_pdf_size_mb: int = Field(default=10, ge=1) max_jd_chars: int = Field(default=30_000, ge=1) max_resume_chars: int = Field(default=60_000, ge=1) log_format: str = "json" log_level: str = "INFO" @field_validator("openai_model") @classmethod def _validate_model(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("OPENAI_MODEL must not be empty.") if value.endswith(UNSUPPORTED_MODEL_SUFFIXES): raise ValueError( f"OPENAI_MODEL={value!r} is a chat-product variant and does not expose " "reasoning effort. Use the corresponding API model instead." ) if not value.startswith(SUPPORTED_MODEL_PREFIXES): families = ", ".join(SUPPORTED_MODEL_PREFIXES) raise ValueError( f"OPENAI_MODEL={value!r} is not a known structured-outputs model family. " f"Expected one of: {families}. If a newer family should be allowed, add " "its prefix to SUPPORTED_MODEL_PREFIXES." ) return value @field_validator("openai_effort") @classmethod def _validate_effort(cls, value: str) -> str: value = value.strip().lower() if value not in EFFORT_LEVELS: raise ValueError( f"OPENAI_EFFORT={value!r} is invalid. " f"Supported: {', '.join(sorted(EFFORT_LEVELS))}." ) return value @field_validator("log_format") @classmethod def _validate_log_format(cls, value: str) -> str: value = value.strip().lower() if value not in {"json", "text"}: raise ValueError("LOG_FORMAT must be 'json' or 'text'.") return value @property def max_pdf_size_bytes(self) -> int: return self.max_pdf_size_mb * 1024 * 1024 @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings()