Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| from pathlib import Path | |
| from pydantic import AliasChoices, Field, field_validator | |
| from pydantic_settings import BaseSettings, SettingsConfigDict | |
| # `server/.env` (gitignored): optional local overrides; process env always wins. | |
| # Repo-root `.env` is loaded first so `server/.env` can override shared keys (e.g. MAPBOX on worker only). | |
| _SERVER_DIR = Path(__file__).resolve().parent.parent.parent | |
| _REPO_ROOT = _SERVER_DIR.parent | |
| # PRO on-device VLM: default ``model.safetensors`` from ``NuTonic/lspace`` (pin matches Hub commit on ``main``). | |
| _DEFAULT_PRO_VLM_MODEL_DOWNLOAD_URL = ( | |
| "https://huggingface.co/NuTonic/lspace/resolve/" | |
| "3ec756bfc8a94fcb23801fe6925d832ab35595f2/model.safetensors" | |
| ) | |
| _DEFAULT_PRO_VLM_MODEL_SHA256 = ( | |
| "7e9ae0b2225c8755eb68924aa97f81c0826678f77f7832aa81c8398f5439cf5c" | |
| ) | |
| _DEFAULT_PRO_VLM_MODEL_SIZE_BYTES = 897_484_568 | |
| _DEFAULT_PRO_VLM_MODEL_REVISION = "3ec756bfc8a94fcb23801fe6925d832ab35595f2" | |
| _DEFAULT_PRO_VLM_MODEL_BUNDLE_ID = "NuTonic/lspace" | |
| class Settings(BaseSettings): | |
| model_config = SettingsConfigDict( | |
| env_prefix="", | |
| case_sensitive=False, | |
| populate_by_name=True, | |
| extra="ignore", | |
| env_file=( | |
| str(_REPO_ROOT / ".env"), | |
| str(_SERVER_DIR / ".env"), | |
| ), | |
| env_file_encoding="utf-8", | |
| ) | |
| leaderboard_database_url: str = Field( | |
| default="sqlite:///data/nutonic_leaderboard.db", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_LEADERBOARD_DATABASE_URL", | |
| "LEADERBOARD_DATABASE_URL", | |
| "leaderboard_database_url", | |
| ), | |
| description=( | |
| "SQLAlchemy URL for community leaderboard persistence (IMP-060). " | |
| "Use sqlite+pysqlite:///:memory: for ephemeral dev; tests default to in-memory." | |
| ), | |
| ) | |
| cors_origins: str = "" | |
| """Comma-separated allowed origins for browser clients; empty disables CORS middleware.""" | |
| # Runtime feature toggles (IMP-001); map to GET /api/v1/config → features.* | |
| # Safer internet-facing defaults: ranked/pro off until routes ship. Community LB POST defaults on so local | |
| # `uvicorn` matches `.env.example`; production profiles set FEATURE_COMMUNITY_LB_POST=false explicitly. | |
| feature_ranked: bool = Field( | |
| default=False, | |
| validation_alias=AliasChoices( | |
| "FEATURE_RANKED", | |
| "NUTONIC_FEATURE_RANKED", | |
| ), | |
| description="When true, ranked round start/submit/forfeit routes are active (`IMP-090` / `IMP-091`).", | |
| ) | |
| feature_community_lb_get: bool = Field( | |
| default=True, | |
| validation_alias=AliasChoices( | |
| "FEATURE_COMMUNITY_LB_GET", | |
| "NUTONIC_FEATURE_COMMUNITY_LB_GET", | |
| ), | |
| description="When false, community leaderboard GET returns 403 (`features.community_lb_get`).", | |
| ) | |
| feature_community_lb_post: bool = Field( | |
| default=True, | |
| validation_alias=AliasChoices( | |
| "FEATURE_COMMUNITY_LB_POST", | |
| "NUTONIC_FEATURE_COMMUNITY_LB_POST", | |
| ), | |
| description=( | |
| "When false, community leaderboard POST returns 403 (`features.community_lb_post`). " | |
| "Set false on internet-facing hosts that should not accept lab aggregate writes." | |
| ), | |
| ) | |
| feature_pro_jobs: bool = False | |
| feature_guesses_record: bool = Field( | |
| default=False, | |
| validation_alias=AliasChoices("NUTONIC_FEATURE_GUESSES_RECORD", "FEATURE_GUESSES_RECORD"), | |
| description="When true, allow POST /api/v1/maps/{map_id}/guesses/record (non-authoritative telemetry).", | |
| ) | |
| ranked_database_url: str = Field( | |
| default="sqlite:///data/nutonic_ranked.db", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_RANKED_DATABASE_URL", | |
| "RANKED_DATABASE_URL", | |
| "ranked_database_url", | |
| ), | |
| description="SQLite (or SQLAlchemy URL) for ranked round rows (IMP-090).", | |
| ) | |
| ranked_round_ttl_seconds: int = Field( | |
| default=900, | |
| validation_alias=AliasChoices("NUTONIC_RANKED_ROUND_TTL_SECONDS", "RANKED_ROUND_TTL_SECONDS"), | |
| description="TTL for round_ticket JWTs (seconds).", | |
| ) | |
| ranked_stale_open_round_max_age_seconds: int = Field( | |
| default=604_800, | |
| validation_alias=AliasChoices( | |
| "NUTONIC_RANKED_STALE_OPEN_ROUND_MAX_AGE_SECONDS", | |
| "RANKED_STALE_OPEN_ROUND_MAX_AGE_SECONDS", | |
| ), | |
| description=( | |
| "Abandoned ``open`` ranked rounds older than this many seconds are deleted on the next " | |
| "``POST /api/v1/ranked/rounds/start`` (best-effort housekeeping)." | |
| ), | |
| ) | |
| guess_telemetry_database_url: str = Field( | |
| default="sqlite:///data/nutonic_guess_telemetry.db", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_GUESS_TELEMETRY_DATABASE_URL", | |
| "GUESS_TELEMETRY_DATABASE_URL", | |
| "guess_telemetry_database_url", | |
| ), | |
| description="SQLite URL for optional guess telemetry rows.", | |
| ) | |
| enable_ops_gradio: bool = Field( | |
| default=False, | |
| validation_alias=AliasChoices("NUTONIC_ENABLE_OPS_GRADIO", "ENABLE_OPS_GRADIO"), | |
| description="Mount read-only Gradio /ops when gradio extra is installed (IMP-101).", | |
| ) | |
| inference_worker_base_url: str = Field( | |
| default="", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_INFERENCE_WORKER_BASE_URL", | |
| "INFERENCE_WORKER_BASE_URL", | |
| ), | |
| description=( | |
| "Optional origin for IMP-092 probes (e.g. `http://streetview_pano_service:7860`). " | |
| "When set, PRO job create performs GET `{base}/health` via InferenceClient." | |
| ), | |
| ) | |
| pro_materialization_service_url: str = Field( | |
| default="", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_PRO_MATERIALIZATION_SERVICE_URL", | |
| "PRO_MATERIALIZATION_SERVICE_URL", | |
| ), | |
| description=( | |
| "Optional origin for the PRO materialization worker (`inference/pro_materialization_service`). " | |
| "PRO jobs probe configured origins before execution; origins listed in `pro_required_origins` " | |
| "must succeed, while `pro_optional_origins` may be degraded without failing the job." | |
| ), | |
| ) | |
| lfm_vl_hint_service_url: str = Field( | |
| default="", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_LFM_VL_HINT_SERVICE_URL", | |
| "LFM_VL_HINT_SERVICE_URL", | |
| ), | |
| description=( | |
| "Optional origin for the LFM-VL hint/brief service. When set, PRO jobs can call " | |
| "`POST {origin}/v1/pro/brief/fuse` after materialization." | |
| ), | |
| ) | |
| pro_job_backend: str = Field( | |
| default="sqlite", | |
| validation_alias=AliasChoices("NUTONIC_PRO_JOB_BACKEND", "PRO_JOB_BACKEND"), | |
| description="PRO job persistence backend. Only `sqlite` is implemented today.", | |
| ) | |
| pro_job_database_url: str = Field( | |
| default="sqlite:///data/nutonic_pro_jobs.db", | |
| validation_alias=AliasChoices("NUTONIC_PRO_JOB_DATABASE_URL", "PRO_JOB_DATABASE_URL"), | |
| description="SQLAlchemy URL for persisted PRO job status and artifact metadata.", | |
| ) | |
| pro_required_origins: str = Field( | |
| default="pro_materialization", | |
| validation_alias=AliasChoices("NUTONIC_PRO_REQUIRED_ORIGINS", "PRO_REQUIRED_ORIGINS"), | |
| description="Comma-separated PRO origin names that must pass health probes before a job runs.", | |
| ) | |
| pro_optional_origins: str = Field( | |
| default="inference_worker", | |
| validation_alias=AliasChoices("NUTONIC_PRO_OPTIONAL_ORIGINS", "PRO_OPTIONAL_ORIGINS"), | |
| description="Comma-separated PRO origin names that may be degraded without failing the job.", | |
| ) | |
| pro_job_ttl_seconds: int = Field( | |
| default=86_400, | |
| validation_alias=AliasChoices("NUTONIC_PRO_JOB_TTL_SECONDS", "PRO_JOB_TTL_SECONDS"), | |
| description="Retention window for terminal PRO jobs and their artifacts.", | |
| ) | |
| pro_max_concurrent_jobs: int = Field( | |
| default=2, | |
| validation_alias=AliasChoices("NUTONIC_PRO_MAX_CONCURRENT_JOBS", "PRO_MAX_CONCURRENT_JOBS"), | |
| description="Maximum number of concurrently running in-process PRO jobs.", | |
| ) | |
| pro_job_poll_interval_seconds: float = Field( | |
| default=2.0, | |
| validation_alias=AliasChoices("NUTONIC_PRO_JOB_POLL_INTERVAL_SECONDS", "PRO_JOB_POLL_INTERVAL_SECONDS"), | |
| description="Default server-side PRO runner poll interval for future queue sweepers.", | |
| ) | |
| pro_artifact_root: str = Field( | |
| default="data/pro_artifacts", | |
| validation_alias=AliasChoices("NUTONIC_PRO_ARTIFACT_ROOT", "PRO_ARTIFACT_ROOT"), | |
| description="Filesystem root for PRO job artifact bytes.", | |
| ) | |
| pro_vlm_model_bundle_id: str = Field( | |
| default=_DEFAULT_PRO_VLM_MODEL_BUNDLE_ID, | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_BUNDLE_ID", "PRO_VLM_MODEL_BUNDLE_ID"), | |
| description="Published on-device PRO VLM model bundle id advertised to clients.", | |
| ) | |
| pro_vlm_model_revision: str = Field( | |
| default=_DEFAULT_PRO_VLM_MODEL_REVISION, | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_REVISION", "PRO_VLM_MODEL_REVISION"), | |
| description="Revision/version for the published on-device PRO VLM model.", | |
| ) | |
| pro_vlm_model_download_url: str = Field( | |
| default=_DEFAULT_PRO_VLM_MODEL_DOWNLOAD_URL, | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_DOWNLOAD_URL", "PRO_VLM_MODEL_DOWNLOAD_URL"), | |
| description=( | |
| "HTTPS URL for the VLM artifact (game CDN or Hugging Face `/resolve/…` for dev). " | |
| "Clients omit Nutonic auth when the host differs from the game server." | |
| ), | |
| ) | |
| pro_vlm_model_local_path: str = Field( | |
| default="", | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_LOCAL_PATH", "PRO_VLM_MODEL_LOCAL_PATH"), | |
| description=( | |
| "Optional path to a model bundle baked into the game-server image at publish time. " | |
| "When set, /api/v1/pro/vlm/model-manifest derives size/sha256 from this file." | |
| ), | |
| ) | |
| pro_vlm_model_sha256: str = Field( | |
| default=_DEFAULT_PRO_VLM_MODEL_SHA256, | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_SHA256", "PRO_VLM_MODEL_SHA256"), | |
| description="Lowercase hex sha256 for the model artifact; clients verify before use.", | |
| ) | |
| pro_vlm_model_size_bytes: int = Field( | |
| default=_DEFAULT_PRO_VLM_MODEL_SIZE_BYTES, | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_SIZE_BYTES", "PRO_VLM_MODEL_SIZE_BYTES"), | |
| description="Expected model artifact size in bytes.", | |
| ) | |
| pro_vlm_model_runtime: str = Field( | |
| default="leap", | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_RUNTIME", "PRO_VLM_MODEL_RUNTIME"), | |
| description="Client runtime hint, e.g. leap, coreml, onnx, or webgpu.", | |
| ) | |
| pro_vlm_model_contract_ids: str = Field( | |
| default="nutonic.pro.vlm.v1_512_s2_only", | |
| validation_alias=AliasChoices("NUTONIC_PRO_VLM_MODEL_CONTRACT_IDS", "PRO_VLM_MODEL_CONTRACT_IDS"), | |
| description="Comma-separated VLM image contract ids supported by the advertised model bundle.", | |
| ) | |
| inference_hmac_secret: str = Field( | |
| default="", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_INFERENCE_HMAC_SECRET", | |
| "INFERENCE_HMAC_SECRET", | |
| ), | |
| description=( | |
| "When non-empty, ``InferenceClient`` adds ``X-Nutonic-Timestamp``, ``X-Nutonic-Nonce``, " | |
| "and ``X-Nutonic-Signature`` (HMAC-SHA256 over a canonical line) to outbound worker ``GET`` " | |
| "requests such as health probes (IMP-092). Workers verify when deployed." | |
| ), | |
| ) | |
| expose_manifest_round_truth: bool = Field( | |
| default=False, | |
| validation_alias=AliasChoices( | |
| "NUTONIC_EXPOSE_MANIFEST_ROUND_TRUTH", | |
| "EXPOSE_MANIFEST_ROUND_TRUTH", | |
| ), | |
| description=( | |
| "When false, GET /api/v1/cache/manifest omits `locations` and `ai_guesses` (spoiler hygiene for " | |
| "world-readable manifests). Set true for local dev / CI fixtures that assert full catalog slices." | |
| ), | |
| ) | |
| manifest_full_path: str | None = Field( | |
| default=None, | |
| validation_alias=AliasChoices("NUTONIC_MANIFEST_FULL_PATH", "MANIFEST_FULL_PATH"), | |
| description=( | |
| "Optional path to assembled manifest.full.json (same schema as GET /api/v1/cache/manifest when " | |
| "truth is exposed). When set and the file exists, replaces builtin demo catalog for maps/locations/ai." | |
| ), | |
| ) | |
| hf_persistence_enabled: bool = Field( | |
| default=False, | |
| validation_alias=AliasChoices("NUTONIC_HF_PERSISTENCE_ENABLED", "HF_PERSISTENCE_ENABLED"), | |
| description="When true, mirror SQLite server DB files to a Hugging Face Dataset repo.", | |
| ) | |
| hf_persistence_required: bool = Field( | |
| default=False, | |
| validation_alias=AliasChoices("NUTONIC_HF_PERSISTENCE_REQUIRED", "HF_PERSISTENCE_REQUIRED"), | |
| description=( | |
| "When true with HF persistence enabled, fail fast if the dataset repo/token is missing " | |
| "or sync operations fail." | |
| ), | |
| ) | |
| hf_persistence_repo_id: str = Field( | |
| default="", | |
| validation_alias=AliasChoices("NUTONIC_HF_PERSISTENCE_REPO_ID", "HF_PERSISTENCE_REPO_ID"), | |
| description="Dataset repo id (owner/name) used for SQLite persistence mirroring.", | |
| ) | |
| hf_persistence_dataset_subdir: str = Field( | |
| default="server-persistence", | |
| validation_alias=AliasChoices("NUTONIC_HF_PERSISTENCE_SUBDIR", "HF_PERSISTENCE_SUBDIR"), | |
| description="Subdirectory inside the Dataset repo where DB files are stored.", | |
| ) | |
| hf_persistence_startup_pull_mode: str = Field( | |
| default="if_missing", | |
| validation_alias=AliasChoices( | |
| "NUTONIC_HF_PERSISTENCE_STARTUP_PULL_MODE", | |
| "HF_PERSISTENCE_STARTUP_PULL_MODE", | |
| ), | |
| description="Startup pull policy for local SQLite files: if_missing (default) or always.", | |
| ) | |
| def strip_origins(cls, v: object) -> str: | |
| if v is None: | |
| return "" | |
| return str(v).strip() | |
| def cors_origin_list(self) -> list[str]: | |
| if not self.cors_origins: | |
| return [] | |
| return [o.strip() for o in self.cors_origins.split(",") if o.strip()] | |
| def pro_required_origin_names(self) -> list[str]: | |
| return _split_csv(self.pro_required_origins) | |
| def pro_optional_origin_names(self) -> list[str]: | |
| return _split_csv(self.pro_optional_origins) | |
| def pro_vlm_model_contract_id_list(self) -> list[str]: | |
| return _split_csv(self.pro_vlm_model_contract_ids) | |
| jwt_secret: str = Field( | |
| default="dev-only-change-in-production-min-32b!!", | |
| validation_alias=AliasChoices("NUTONIC_JWT_SECRET", "JWT_SECRET"), | |
| description=( | |
| "HS256 signing key for anonymous session JWTs (IMP-030). Override in any shared or production deploy." | |
| ), | |
| ) | |
| jwt_ttl_seconds: int = 3600 | |
| """Access token lifetime in seconds.""" | |
| def load_settings() -> Settings: | |
| return Settings() | |
| def _split_csv(value: str) -> list[str]: | |
| return [part.strip() for part in value.split(",") if part.strip()] | |