File size: 8,293 Bytes
f850954
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da839c2
 
 
4508afe
 
f850954
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da839c2
 
 
5dbd0bf
 
 
 
 
 
 
1a36c6b
 
52ba0e1
 
 
115459a
1a36c6b
 
 
 
5b063c2
 
 
 
 
 
 
 
b357d7d
04ad412
52ba0e1
 
 
 
 
536e671
 
 
8501103
52ba0e1
 
 
 
 
 
 
 
 
5dbd0bf
f850954
 
 
 
 
 
 
 
 
1a36c6b
 
 
 
 
 
 
 
 
 
 
 
 
f850954
 
 
 
da839c2
 
536e671
a4ef305
 
 
f850954
 
 
 
 
 
 
 
 
 
 
536e671
da839c2
 
5dbd0bf
 
 
 
 
 
 
52ba0e1
 
 
37dfe11
1a36c6b
 
 
916d469
b357d7d
5b063c2
 
 
 
 
04ad412
52ba0e1
 
 
 
536e671
 
8501103
f850954
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""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  # "<opencode provider>/<model>"
    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/<model>"
    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", ""),
    )