"""Settings, read from environment variables (see .env.example).""" from __future__ import annotations import os from dataclasses import dataclass from functools import lru_cache from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent def _env(name: str, default: str) -> str: value = os.environ.get(name) return value if value not in (None, "") else default @dataclass(frozen=True) class Settings: # Model: one model for every tester in Stage 1, via any OpenAI-compatible endpoint (OpenRouter by default). llm_api_key: str llm_base_url: str llm_model: str # scene generation (/simulate/generate) code_llm_model: str # ROS 2 code generation: OpenCode's model, or the direct backend's llm_timeout: float # Storage data_dir: Path testers_file: Path log_db: Path # MuJoCo generation menagerie_dir: Path mjcf_max_attempts: int mjcf_sim_seconds: float validate_timeout: float # ROS 2 generation codegen_backend: str # "opencode" or "direct" opencode_bin: str opencode_timeout: float code_max_attempts: int # Simulation runs (Modal or RunPod serverless, results in Cloudflare R2) sim_backend: str # "modal" or "runpod" modal_app: str runpod_api_key: str runpod_endpoint_id: str run_execution_timeout_s: int run_max_duration_s: float run_burst_warn: int # log a warning when a tester starts more runs than this within 10 minutes report_secret: str # signs report links public_base_url: str # how testers reach this API (for report links); empty = use the request's host # Spend: estimated from each job's run time; new jobs are refused once the month's estimate reaches the limit. monthly_credit_usd: float # 0 = no limit # ...and each tester gets an equal share of it, enforced before every job against the job's worst case. compute_testers: int # how many testers share the compute credit compute_per_tester_usd: float # 0 = monthly_credit_usd / compute_testers storage_limit_gb: float # new jobs are refused while the R2 bucket holds more than this (0 = no limit) job_prices: dict # USD per second by hardware: t4, l4, cpu, runpod job_overhead_s: float # billed seconds per job beyond the worker's own timing (start-up, idle shutdown) isaac_daily_limit: int # Isaac Sim renders per day, across all testers auto_review: bool # review every finished simulation video with Cosmos Reason # AI budget in rupees for the test group: one shared pool, an equal allocation per tester. Only billable # AI calls count (OpenRouter-exempt ones are recorded but not deducted); GPU/CPU jobs have their own # dollar limit above (Modal's free credit). usd_inr: float budget_total_inr: float budget_per_user_inr: float confirm_above_inr: float # ask before running a task estimated to cost more than this admin_testers: tuple # testers who see the team budget view hf_namespace: str # Hugging Face user/org that dataset exports are pushed to (with the worker's HF_TOKEN) foxglove_org: str # optional Foxglove organization slug for the embedded viewer # Prompt enhancement through OpenCode with a free OpenCode Zen model (never OpenRouter, never billed). prompt_enhance: bool enhance_model: str # "/" enhance_timeout: float zen_api_key: str # OpenCode Zen key, if the free model asks for one # Debugging: the first repair goes to a free OpenCode Zen model first (paid tier only if that call fails). free_debug: bool debug_model: str # "opencode/" owner_api_key: str # persistent key for the owner (bypasses budgets, doesn't count as tester) @property def compute_share_usd(self) -> float: """Each tester's monthly GPU/CPU allowance in dollars (0 = no per-tester limit).""" if self.compute_per_tester_usd > 0: return self.compute_per_tester_usd if self.monthly_credit_usd > 0 and self.compute_testers > 0: return round(self.monthly_credit_usd / self.compute_testers, 6) return 0.0 @property def interfaces_file(self) -> Path: return self.data_dir / "ros2_interfaces.json" @property def mjcf_schema_file(self) -> Path: return self.data_dir / "mjcf_schema.json" # Estimated USD per second of container time, GPU plus the CPU and memory that come with it # (Modal list prices, Sept 2026; override with JOB_PRICES="t4=...,l4=...,cpu=...,runpod=..."). DEFAULT_PRICES = {"t4": 0.00019, "l4": 0.00025, "cpu": 0.00004, "runpod": 0.00031} def _prices(spec: str) -> dict: prices = dict(DEFAULT_PRICES) for part in filter(None, (p.strip() for p in spec.split(","))): key, _, value = part.partition("=") prices[key.strip()] = float(value) return prices @lru_cache def get_settings() -> Settings: data_dir = Path(_env("ROSDIFF_DATA_DIR", str(REPO_ROOT / "data"))).resolve() return Settings( llm_api_key=_env("LLM_API_KEY", ""), llm_base_url=_env("LLM_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/"), llm_model=_env("LLM_MODEL", "z-ai/glm-5.2"), # DeepSeek V4 Flash: 79.0% on SWE-bench Verified (V4 Pro: 80.6%) at about $0.10/$0.20 per M tokens on # OpenRouter (Sept 2026) - enough for a test phase. For more quality: deepseek/deepseek-v4-pro. code_llm_model=_env("CODE_LLM_MODEL", "deepseek/deepseek-v4-flash"), llm_timeout=float(_env("LLM_TIMEOUT", "180")), data_dir=data_dir, testers_file=Path(_env("ROSDIFF_TESTERS_FILE", str(REPO_ROOT / "testers.yaml"))), log_db=Path(_env("ROSDIFF_LOG_DB", str(data_dir / "generations.sqlite3"))), menagerie_dir=Path(_env("MENAGERIE_DIR", str(data_dir / "sources" / "mujoco_menagerie"))), mjcf_max_attempts=int(_env("MJCF_MAX_ATTEMPTS", "3")), mjcf_sim_seconds=float(_env("MJCF_SIM_SECONDS", "2.0")), validate_timeout=float(_env("VALIDATE_TIMEOUT", "60")), codegen_backend=_env("CODEGEN_BACKEND", "opencode"), opencode_bin=_env("OPENCODE_BIN", "opencode"), opencode_timeout=float(_env("OPENCODE_TIMEOUT", "600")), code_max_attempts=int(_env("CODE_MAX_ATTEMPTS", "3")), sim_backend=_env("SIM_BACKEND", "modal").lower(), modal_app=_env("MODAL_APP", "rosdiff-sim"), runpod_api_key=_env("RUNPOD_API_KEY", ""), runpod_endpoint_id=_env("RUNPOD_ENDPOINT_ID", ""), run_execution_timeout_s=int(_env("RUN_EXECUTION_TIMEOUT_S", "600")), run_max_duration_s=float(_env("RUN_MAX_DURATION_S", "60")), run_burst_warn=int(_env("RUN_BURST_WARN", "10")), report_secret=_env("REPORT_SECRET", ""), public_base_url=_env("PUBLIC_BASE_URL", "").rstrip("/"), monthly_credit_usd=float(_env("MONTHLY_CREDIT_LIMIT_USD", "29.98")), compute_testers=int(_env("COMPUTE_TESTERS", "4")), compute_per_tester_usd=float(_env("COMPUTE_PER_TESTER_USD", "0")), storage_limit_gb=float(_env("STORAGE_LIMIT_GB", "9")), job_prices=_prices(_env("JOB_PRICES", "")), job_overhead_s=float(_env("JOB_OVERHEAD_S", "45")), isaac_daily_limit=int(_env("ISAAC_DAILY_LIMIT", "10")), auto_review=_env("AUTO_REVIEW", "false").lower() in ("1", "true", "yes"), hf_namespace=_env("HF_NAMESPACE", "rosdiff"), usd_inr=float(_env("USD_INR", "88")), budget_total_inr=float(_env("BUDGET_TOTAL_INR", "500")), budget_per_user_inr=float(_env("BUDGET_PER_USER_INR", "125")), confirm_above_inr=float(_env("CONFIRM_ABOVE_INR", "10")), admin_testers=tuple(n.strip() for n in _env("ADMIN_TESTERS", "").split(",") if n.strip()), foxglove_org=_env("FOXGLOVE_ORG", ""), prompt_enhance=_env("PROMPT_ENHANCE", "true").lower() in ("1", "true", "yes"), enhance_model=_env("ENHANCE_MODEL", "opencode/deepseek-v4-flash-free"), enhance_timeout=float(_env("ENHANCE_TIMEOUT", "90")), zen_api_key=_env("ZEN_API_KEY", ""), free_debug=_env("FREE_DEBUG", "true").lower() in ("1", "true", "yes"), debug_model=_env("DEBUG_MODEL", "opencode/deepseek-v4-flash-free"), owner_api_key=_env("OWNER_API_KEY", ""), )