kiln_ai.utils.config

  1import copy
  2import getpass
  3import os
  4import threading
  5from pathlib import Path
  6from typing import Any, Callable, Dict, List, Optional
  7
  8import yaml
  9
 10# Configuration keys
 11MCP_SECRETS_KEY = "mcp_secrets"
 12
 13
 14class ConfigProperty:
 15    def __init__(
 16        self,
 17        type_: type,
 18        default: Any = None,
 19        env_var: Optional[str] = None,
 20        default_lambda: Optional[Callable[[], Any]] = None,
 21        sensitive: bool = False,
 22        sensitive_keys: Optional[List[str]] = None,
 23        in_memory: bool = False,
 24    ):
 25        self.type = type_
 26        self.default = default
 27        self.env_var = env_var
 28        self.default_lambda = default_lambda
 29        self.sensitive = sensitive
 30        self.sensitive_keys = sensitive_keys
 31        self.in_memory = in_memory
 32
 33
 34class Config:
 35    _shared_instance = None
 36
 37    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 38        self._properties: Dict[str, ConfigProperty] = properties or {
 39            "user_id": ConfigProperty(
 40                str,
 41                env_var="KILN_USER_ID",
 42                default_lambda=_get_user_id,
 43            ),
 44            "autosave_runs": ConfigProperty(
 45                bool,
 46                env_var="KILN_AUTOSAVE_RUNS",
 47                default=True,
 48                in_memory=True,
 49            ),
 50            "open_ai_api_key": ConfigProperty(
 51                str,
 52                env_var="OPENAI_API_KEY",
 53                sensitive=True,
 54            ),
 55            "groq_api_key": ConfigProperty(
 56                str,
 57                env_var="GROQ_API_KEY",
 58                sensitive=True,
 59            ),
 60            "ollama_base_url": ConfigProperty(
 61                str,
 62                env_var="OLLAMA_BASE_URL",
 63            ),
 64            "docker_model_runner_base_url": ConfigProperty(
 65                str,
 66                env_var="DOCKER_MODEL_RUNNER_BASE_URL",
 67            ),
 68            "bedrock_access_key": ConfigProperty(
 69                str,
 70                env_var="AWS_ACCESS_KEY_ID",
 71                sensitive=True,
 72            ),
 73            "bedrock_secret_key": ConfigProperty(
 74                str,
 75                env_var="AWS_SECRET_ACCESS_KEY",
 76                sensitive=True,
 77            ),
 78            "open_router_api_key": ConfigProperty(
 79                str,
 80                env_var="OPENROUTER_API_KEY",
 81                sensitive=True,
 82            ),
 83            "fireworks_api_key": ConfigProperty(
 84                str,
 85                env_var="FIREWORKS_API_KEY",
 86                sensitive=True,
 87            ),
 88            "fireworks_account_id": ConfigProperty(
 89                str,
 90                env_var="FIREWORKS_ACCOUNT_ID",
 91            ),
 92            "anthropic_api_key": ConfigProperty(
 93                str,
 94                env_var="ANTHROPIC_API_KEY",
 95                sensitive=True,
 96            ),
 97            "gemini_api_key": ConfigProperty(
 98                str,
 99                env_var="GEMINI_API_KEY",
100                sensitive=True,
101            ),
102            "projects": ConfigProperty(
103                list,
104                default_lambda=lambda: [],
105            ),
106            "azure_openai_api_key": ConfigProperty(
107                str,
108                env_var="AZURE_OPENAI_API_KEY",
109                sensitive=True,
110            ),
111            "azure_openai_endpoint": ConfigProperty(
112                str,
113                env_var="AZURE_OPENAI_ENDPOINT",
114            ),
115            "huggingface_api_key": ConfigProperty(
116                str,
117                env_var="HUGGINGFACE_API_KEY",
118                sensitive=True,
119            ),
120            "vertex_project_id": ConfigProperty(
121                str,
122                env_var="VERTEX_PROJECT_ID",
123            ),
124            "vertex_location": ConfigProperty(
125                str,
126                env_var="VERTEX_LOCATION",
127            ),
128            "together_api_key": ConfigProperty(
129                str,
130                env_var="TOGETHERAI_API_KEY",
131                sensitive=True,
132            ),
133            "wandb_api_key": ConfigProperty(
134                str,
135                env_var="WANDB_API_KEY",
136                sensitive=True,
137            ),
138            "wandb_entity": ConfigProperty(
139                str,
140                env_var="WANDB_ENTITY",
141            ),
142            "siliconflow_cn_api_key": ConfigProperty(
143                str,
144                env_var="SILICONFLOW_CN_API_KEY",
145                sensitive=True,
146            ),
147            "wandb_base_url": ConfigProperty(
148                str,
149                env_var="WANDB_BASE_URL",
150            ),
151            # Legacy custom models, replaced by user_model_registry below
152            "custom_models": ConfigProperty(
153                list,
154                default_lambda=lambda: [],
155            ),
156            "user_model_registry": ConfigProperty(
157                list,
158                default_lambda=lambda: [],
159            ),
160            "openai_compatible_providers": ConfigProperty(
161                list,
162                default_lambda=lambda: [],
163                sensitive_keys=["api_key"],
164            ),
165            "cerebras_api_key": ConfigProperty(
166                str,
167                env_var="CEREBRAS_API_KEY",
168                sensitive=True,
169            ),
170            "kiln_copilot_api_key": ConfigProperty(
171                str,
172                env_var="KILN_COPILOT_API_KEY",
173                sensitive=True,
174            ),
175            "enable_demo_tools": ConfigProperty(
176                bool,
177                env_var="ENABLE_DEMO_TOOLS",
178                default=False,
179            ),
180            # Allow the user to set the path to lookup MCP server commands, like npx.
181            "custom_mcp_path": ConfigProperty(
182                str,
183                env_var="CUSTOM_MCP_PATH",
184            ),
185            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
186            MCP_SECRETS_KEY: ConfigProperty(
187                dict[str, str],
188                sensitive=True,
189            ),
190            # has the user indicated it's for personal or work use?
191            "user_type": ConfigProperty(
192                str,  # "personal" or "work"
193            ),
194            # if the user has provided their work contact
195            "work_use_contact": ConfigProperty(
196                str,
197            ),
198            # if the user has provided their personal contact
199            "personal_use_contact": ConfigProperty(
200                str,
201            ),
202        }
203        self._lock = threading.Lock()
204        self._in_memory_settings: Dict[str, Any] = {}
205        self._settings = self.load_settings()
206
207    @classmethod
208    def shared(cls):
209        if cls._shared_instance is None:
210            cls._shared_instance = cls()
211        return cls._shared_instance
212
213    # Get a value, mockable for testing
214    def get_value(self, name: str) -> Any:
215        try:
216            return self.__getattr__(name)
217        except AttributeError:
218            return None
219
220    def __getattr__(self, name: str) -> Any:
221        if name == "_properties":
222            return super().__getattribute__("_properties")
223        if name not in self._properties:
224            return super().__getattribute__(name)
225
226        property_config = self._properties[name]
227
228        if property_config.in_memory:
229            if name in self._in_memory_settings:
230                value = self._in_memory_settings[name]
231                return value if value is None else property_config.type(value)
232        else:
233            if name in self._settings:
234                value = self._settings[name]
235                return value if value is None else property_config.type(value)
236
237        # Check environment variable
238        if property_config.env_var and property_config.env_var in os.environ:
239            value = os.environ[property_config.env_var]
240            return property_config.type(value)
241
242        # Use default value or default_lambda
243        if property_config.default_lambda:
244            value = property_config.default_lambda()
245        else:
246            value = property_config.default
247
248        return None if value is None else property_config.type(value)
249
250    def __setattr__(self, name, value):
251        if name in ("_properties", "_settings", "_lock", "_in_memory_settings"):
252            super().__setattr__(name, value)
253        elif name in self._properties:
254            if self._properties[name].in_memory:
255                with self._lock:
256                    self._in_memory_settings[name] = value
257            else:
258                self.update_settings({name: value})
259        else:
260            raise AttributeError(f"Config has no attribute '{name}'")
261
262    @classmethod
263    def settings_dir(cls, create=True) -> str:
264        settings_dir = os.path.join(Path.home(), ".kiln_ai")
265        if create and not os.path.exists(settings_dir):
266            os.makedirs(settings_dir)
267        return settings_dir
268
269    @classmethod
270    def settings_path(cls, create=True) -> str:
271        settings_dir = cls.settings_dir(create)
272        return os.path.join(settings_dir, "settings.yaml")
273
274    @classmethod
275    def load_settings(cls):
276        if not os.path.isfile(cls.settings_path(create=False)):
277            return {}
278        with open(cls.settings_path(), "r") as f:
279            settings = yaml.safe_load(f.read()) or {}
280        return settings
281
282    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
283        with self._lock:
284            filtered_disk = {
285                k: v
286                for k, v in self._settings.items()
287                if k not in self._properties or not self._properties[k].in_memory
288            }
289            combined = {**filtered_disk, **self._in_memory_settings}
290
291        if not hide_sensitive:
292            return combined
293
294        settings = {
295            k: "[hidden]"
296            if k in self._properties and self._properties[k].sensitive
297            else copy.deepcopy(v)
298            for k, v in combined.items()
299        }
300        # Hide sensitive keys in lists. Could generalize this if we every have more types, but right not it's only needed for root elements of lists
301        for key, value in settings.items():
302            if key in self._properties and self._properties[key].sensitive_keys:
303                sensitive_keys = self._properties[key].sensitive_keys or []
304                for sensitive_key in sensitive_keys:
305                    if isinstance(value, list):
306                        for item in value:
307                            if sensitive_key in item:
308                                item[sensitive_key] = "[hidden]"
309
310        return settings
311
312    def save_setting(self, name: str, value: Any):
313        self.update_settings({name: value})
314
315    def update_settings(self, new_settings: Dict[str, Any]):
316        with self._lock:
317            in_memory_updates = {
318                k: v
319                for k, v in new_settings.items()
320                if k in self._properties and self._properties[k].in_memory
321            }
322            disk_updates = {
323                k: v
324                for k, v in new_settings.items()
325                if k not in self._properties or not self._properties[k].in_memory
326            }
327
328            if in_memory_updates:
329                self._in_memory_settings.update(in_memory_updates)
330
331            if disk_updates:
332                # Fresh load to avoid clobbering changes from other instances
333                current_settings = self.load_settings()
334                current_settings.update(disk_updates)
335                # remove None values
336                current_settings = {
337                    k: v for k, v in current_settings.items() if v is not None
338                }
339                with open(self.settings_path(), "w") as f:
340                    yaml.dump(current_settings, f)
341                self._settings = current_settings
342
343
344def _get_user_id():
345    try:
346        return getpass.getuser() or "unknown_user"
347    except Exception:
348        return "unknown_user"
MCP_SECRETS_KEY = 'mcp_secrets'
class ConfigProperty:
15class ConfigProperty:
16    def __init__(
17        self,
18        type_: type,
19        default: Any = None,
20        env_var: Optional[str] = None,
21        default_lambda: Optional[Callable[[], Any]] = None,
22        sensitive: bool = False,
23        sensitive_keys: Optional[List[str]] = None,
24        in_memory: bool = False,
25    ):
26        self.type = type_
27        self.default = default
28        self.env_var = env_var
29        self.default_lambda = default_lambda
30        self.sensitive = sensitive
31        self.sensitive_keys = sensitive_keys
32        self.in_memory = in_memory
ConfigProperty( type_: type, default: Any = None, env_var: Optional[str] = None, default_lambda: Optional[Callable[[], Any]] = None, sensitive: bool = False, sensitive_keys: Optional[List[str]] = None, in_memory: bool = False)
16    def __init__(
17        self,
18        type_: type,
19        default: Any = None,
20        env_var: Optional[str] = None,
21        default_lambda: Optional[Callable[[], Any]] = None,
22        sensitive: bool = False,
23        sensitive_keys: Optional[List[str]] = None,
24        in_memory: bool = False,
25    ):
26        self.type = type_
27        self.default = default
28        self.env_var = env_var
29        self.default_lambda = default_lambda
30        self.sensitive = sensitive
31        self.sensitive_keys = sensitive_keys
32        self.in_memory = in_memory
type
default
env_var
default_lambda
sensitive
sensitive_keys
in_memory
class Config:
 35class Config:
 36    _shared_instance = None
 37
 38    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 39        self._properties: Dict[str, ConfigProperty] = properties or {
 40            "user_id": ConfigProperty(
 41                str,
 42                env_var="KILN_USER_ID",
 43                default_lambda=_get_user_id,
 44            ),
 45            "autosave_runs": ConfigProperty(
 46                bool,
 47                env_var="KILN_AUTOSAVE_RUNS",
 48                default=True,
 49                in_memory=True,
 50            ),
 51            "open_ai_api_key": ConfigProperty(
 52                str,
 53                env_var="OPENAI_API_KEY",
 54                sensitive=True,
 55            ),
 56            "groq_api_key": ConfigProperty(
 57                str,
 58                env_var="GROQ_API_KEY",
 59                sensitive=True,
 60            ),
 61            "ollama_base_url": ConfigProperty(
 62                str,
 63                env_var="OLLAMA_BASE_URL",
 64            ),
 65            "docker_model_runner_base_url": ConfigProperty(
 66                str,
 67                env_var="DOCKER_MODEL_RUNNER_BASE_URL",
 68            ),
 69            "bedrock_access_key": ConfigProperty(
 70                str,
 71                env_var="AWS_ACCESS_KEY_ID",
 72                sensitive=True,
 73            ),
 74            "bedrock_secret_key": ConfigProperty(
 75                str,
 76                env_var="AWS_SECRET_ACCESS_KEY",
 77                sensitive=True,
 78            ),
 79            "open_router_api_key": ConfigProperty(
 80                str,
 81                env_var="OPENROUTER_API_KEY",
 82                sensitive=True,
 83            ),
 84            "fireworks_api_key": ConfigProperty(
 85                str,
 86                env_var="FIREWORKS_API_KEY",
 87                sensitive=True,
 88            ),
 89            "fireworks_account_id": ConfigProperty(
 90                str,
 91                env_var="FIREWORKS_ACCOUNT_ID",
 92            ),
 93            "anthropic_api_key": ConfigProperty(
 94                str,
 95                env_var="ANTHROPIC_API_KEY",
 96                sensitive=True,
 97            ),
 98            "gemini_api_key": ConfigProperty(
 99                str,
100                env_var="GEMINI_API_KEY",
101                sensitive=True,
102            ),
103            "projects": ConfigProperty(
104                list,
105                default_lambda=lambda: [],
106            ),
107            "azure_openai_api_key": ConfigProperty(
108                str,
109                env_var="AZURE_OPENAI_API_KEY",
110                sensitive=True,
111            ),
112            "azure_openai_endpoint": ConfigProperty(
113                str,
114                env_var="AZURE_OPENAI_ENDPOINT",
115            ),
116            "huggingface_api_key": ConfigProperty(
117                str,
118                env_var="HUGGINGFACE_API_KEY",
119                sensitive=True,
120            ),
121            "vertex_project_id": ConfigProperty(
122                str,
123                env_var="VERTEX_PROJECT_ID",
124            ),
125            "vertex_location": ConfigProperty(
126                str,
127                env_var="VERTEX_LOCATION",
128            ),
129            "together_api_key": ConfigProperty(
130                str,
131                env_var="TOGETHERAI_API_KEY",
132                sensitive=True,
133            ),
134            "wandb_api_key": ConfigProperty(
135                str,
136                env_var="WANDB_API_KEY",
137                sensitive=True,
138            ),
139            "wandb_entity": ConfigProperty(
140                str,
141                env_var="WANDB_ENTITY",
142            ),
143            "siliconflow_cn_api_key": ConfigProperty(
144                str,
145                env_var="SILICONFLOW_CN_API_KEY",
146                sensitive=True,
147            ),
148            "wandb_base_url": ConfigProperty(
149                str,
150                env_var="WANDB_BASE_URL",
151            ),
152            # Legacy custom models, replaced by user_model_registry below
153            "custom_models": ConfigProperty(
154                list,
155                default_lambda=lambda: [],
156            ),
157            "user_model_registry": ConfigProperty(
158                list,
159                default_lambda=lambda: [],
160            ),
161            "openai_compatible_providers": ConfigProperty(
162                list,
163                default_lambda=lambda: [],
164                sensitive_keys=["api_key"],
165            ),
166            "cerebras_api_key": ConfigProperty(
167                str,
168                env_var="CEREBRAS_API_KEY",
169                sensitive=True,
170            ),
171            "kiln_copilot_api_key": ConfigProperty(
172                str,
173                env_var="KILN_COPILOT_API_KEY",
174                sensitive=True,
175            ),
176            "enable_demo_tools": ConfigProperty(
177                bool,
178                env_var="ENABLE_DEMO_TOOLS",
179                default=False,
180            ),
181            # Allow the user to set the path to lookup MCP server commands, like npx.
182            "custom_mcp_path": ConfigProperty(
183                str,
184                env_var="CUSTOM_MCP_PATH",
185            ),
186            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
187            MCP_SECRETS_KEY: ConfigProperty(
188                dict[str, str],
189                sensitive=True,
190            ),
191            # has the user indicated it's for personal or work use?
192            "user_type": ConfigProperty(
193                str,  # "personal" or "work"
194            ),
195            # if the user has provided their work contact
196            "work_use_contact": ConfigProperty(
197                str,
198            ),
199            # if the user has provided their personal contact
200            "personal_use_contact": ConfigProperty(
201                str,
202            ),
203        }
204        self._lock = threading.Lock()
205        self._in_memory_settings: Dict[str, Any] = {}
206        self._settings = self.load_settings()
207
208    @classmethod
209    def shared(cls):
210        if cls._shared_instance is None:
211            cls._shared_instance = cls()
212        return cls._shared_instance
213
214    # Get a value, mockable for testing
215    def get_value(self, name: str) -> Any:
216        try:
217            return self.__getattr__(name)
218        except AttributeError:
219            return None
220
221    def __getattr__(self, name: str) -> Any:
222        if name == "_properties":
223            return super().__getattribute__("_properties")
224        if name not in self._properties:
225            return super().__getattribute__(name)
226
227        property_config = self._properties[name]
228
229        if property_config.in_memory:
230            if name in self._in_memory_settings:
231                value = self._in_memory_settings[name]
232                return value if value is None else property_config.type(value)
233        else:
234            if name in self._settings:
235                value = self._settings[name]
236                return value if value is None else property_config.type(value)
237
238        # Check environment variable
239        if property_config.env_var and property_config.env_var in os.environ:
240            value = os.environ[property_config.env_var]
241            return property_config.type(value)
242
243        # Use default value or default_lambda
244        if property_config.default_lambda:
245            value = property_config.default_lambda()
246        else:
247            value = property_config.default
248
249        return None if value is None else property_config.type(value)
250
251    def __setattr__(self, name, value):
252        if name in ("_properties", "_settings", "_lock", "_in_memory_settings"):
253            super().__setattr__(name, value)
254        elif name in self._properties:
255            if self._properties[name].in_memory:
256                with self._lock:
257                    self._in_memory_settings[name] = value
258            else:
259                self.update_settings({name: value})
260        else:
261            raise AttributeError(f"Config has no attribute '{name}'")
262
263    @classmethod
264    def settings_dir(cls, create=True) -> str:
265        settings_dir = os.path.join(Path.home(), ".kiln_ai")
266        if create and not os.path.exists(settings_dir):
267            os.makedirs(settings_dir)
268        return settings_dir
269
270    @classmethod
271    def settings_path(cls, create=True) -> str:
272        settings_dir = cls.settings_dir(create)
273        return os.path.join(settings_dir, "settings.yaml")
274
275    @classmethod
276    def load_settings(cls):
277        if not os.path.isfile(cls.settings_path(create=False)):
278            return {}
279        with open(cls.settings_path(), "r") as f:
280            settings = yaml.safe_load(f.read()) or {}
281        return settings
282
283    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
284        with self._lock:
285            filtered_disk = {
286                k: v
287                for k, v in self._settings.items()
288                if k not in self._properties or not self._properties[k].in_memory
289            }
290            combined = {**filtered_disk, **self._in_memory_settings}
291
292        if not hide_sensitive:
293            return combined
294
295        settings = {
296            k: "[hidden]"
297            if k in self._properties and self._properties[k].sensitive
298            else copy.deepcopy(v)
299            for k, v in combined.items()
300        }
301        # Hide sensitive keys in lists. Could generalize this if we every have more types, but right not it's only needed for root elements of lists
302        for key, value in settings.items():
303            if key in self._properties and self._properties[key].sensitive_keys:
304                sensitive_keys = self._properties[key].sensitive_keys or []
305                for sensitive_key in sensitive_keys:
306                    if isinstance(value, list):
307                        for item in value:
308                            if sensitive_key in item:
309                                item[sensitive_key] = "[hidden]"
310
311        return settings
312
313    def save_setting(self, name: str, value: Any):
314        self.update_settings({name: value})
315
316    def update_settings(self, new_settings: Dict[str, Any]):
317        with self._lock:
318            in_memory_updates = {
319                k: v
320                for k, v in new_settings.items()
321                if k in self._properties and self._properties[k].in_memory
322            }
323            disk_updates = {
324                k: v
325                for k, v in new_settings.items()
326                if k not in self._properties or not self._properties[k].in_memory
327            }
328
329            if in_memory_updates:
330                self._in_memory_settings.update(in_memory_updates)
331
332            if disk_updates:
333                # Fresh load to avoid clobbering changes from other instances
334                current_settings = self.load_settings()
335                current_settings.update(disk_updates)
336                # remove None values
337                current_settings = {
338                    k: v for k, v in current_settings.items() if v is not None
339                }
340                with open(self.settings_path(), "w") as f:
341                    yaml.dump(current_settings, f)
342                self._settings = current_settings
Config( properties: Optional[Dict[str, ConfigProperty]] = None)
 38    def __init__(self, properties: Dict[str, ConfigProperty] | None = None):
 39        self._properties: Dict[str, ConfigProperty] = properties or {
 40            "user_id": ConfigProperty(
 41                str,
 42                env_var="KILN_USER_ID",
 43                default_lambda=_get_user_id,
 44            ),
 45            "autosave_runs": ConfigProperty(
 46                bool,
 47                env_var="KILN_AUTOSAVE_RUNS",
 48                default=True,
 49                in_memory=True,
 50            ),
 51            "open_ai_api_key": ConfigProperty(
 52                str,
 53                env_var="OPENAI_API_KEY",
 54                sensitive=True,
 55            ),
 56            "groq_api_key": ConfigProperty(
 57                str,
 58                env_var="GROQ_API_KEY",
 59                sensitive=True,
 60            ),
 61            "ollama_base_url": ConfigProperty(
 62                str,
 63                env_var="OLLAMA_BASE_URL",
 64            ),
 65            "docker_model_runner_base_url": ConfigProperty(
 66                str,
 67                env_var="DOCKER_MODEL_RUNNER_BASE_URL",
 68            ),
 69            "bedrock_access_key": ConfigProperty(
 70                str,
 71                env_var="AWS_ACCESS_KEY_ID",
 72                sensitive=True,
 73            ),
 74            "bedrock_secret_key": ConfigProperty(
 75                str,
 76                env_var="AWS_SECRET_ACCESS_KEY",
 77                sensitive=True,
 78            ),
 79            "open_router_api_key": ConfigProperty(
 80                str,
 81                env_var="OPENROUTER_API_KEY",
 82                sensitive=True,
 83            ),
 84            "fireworks_api_key": ConfigProperty(
 85                str,
 86                env_var="FIREWORKS_API_KEY",
 87                sensitive=True,
 88            ),
 89            "fireworks_account_id": ConfigProperty(
 90                str,
 91                env_var="FIREWORKS_ACCOUNT_ID",
 92            ),
 93            "anthropic_api_key": ConfigProperty(
 94                str,
 95                env_var="ANTHROPIC_API_KEY",
 96                sensitive=True,
 97            ),
 98            "gemini_api_key": ConfigProperty(
 99                str,
100                env_var="GEMINI_API_KEY",
101                sensitive=True,
102            ),
103            "projects": ConfigProperty(
104                list,
105                default_lambda=lambda: [],
106            ),
107            "azure_openai_api_key": ConfigProperty(
108                str,
109                env_var="AZURE_OPENAI_API_KEY",
110                sensitive=True,
111            ),
112            "azure_openai_endpoint": ConfigProperty(
113                str,
114                env_var="AZURE_OPENAI_ENDPOINT",
115            ),
116            "huggingface_api_key": ConfigProperty(
117                str,
118                env_var="HUGGINGFACE_API_KEY",
119                sensitive=True,
120            ),
121            "vertex_project_id": ConfigProperty(
122                str,
123                env_var="VERTEX_PROJECT_ID",
124            ),
125            "vertex_location": ConfigProperty(
126                str,
127                env_var="VERTEX_LOCATION",
128            ),
129            "together_api_key": ConfigProperty(
130                str,
131                env_var="TOGETHERAI_API_KEY",
132                sensitive=True,
133            ),
134            "wandb_api_key": ConfigProperty(
135                str,
136                env_var="WANDB_API_KEY",
137                sensitive=True,
138            ),
139            "wandb_entity": ConfigProperty(
140                str,
141                env_var="WANDB_ENTITY",
142            ),
143            "siliconflow_cn_api_key": ConfigProperty(
144                str,
145                env_var="SILICONFLOW_CN_API_KEY",
146                sensitive=True,
147            ),
148            "wandb_base_url": ConfigProperty(
149                str,
150                env_var="WANDB_BASE_URL",
151            ),
152            # Legacy custom models, replaced by user_model_registry below
153            "custom_models": ConfigProperty(
154                list,
155                default_lambda=lambda: [],
156            ),
157            "user_model_registry": ConfigProperty(
158                list,
159                default_lambda=lambda: [],
160            ),
161            "openai_compatible_providers": ConfigProperty(
162                list,
163                default_lambda=lambda: [],
164                sensitive_keys=["api_key"],
165            ),
166            "cerebras_api_key": ConfigProperty(
167                str,
168                env_var="CEREBRAS_API_KEY",
169                sensitive=True,
170            ),
171            "kiln_copilot_api_key": ConfigProperty(
172                str,
173                env_var="KILN_COPILOT_API_KEY",
174                sensitive=True,
175            ),
176            "enable_demo_tools": ConfigProperty(
177                bool,
178                env_var="ENABLE_DEMO_TOOLS",
179                default=False,
180            ),
181            # Allow the user to set the path to lookup MCP server commands, like npx.
182            "custom_mcp_path": ConfigProperty(
183                str,
184                env_var="CUSTOM_MCP_PATH",
185            ),
186            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
187            MCP_SECRETS_KEY: ConfigProperty(
188                dict[str, str],
189                sensitive=True,
190            ),
191            # has the user indicated it's for personal or work use?
192            "user_type": ConfigProperty(
193                str,  # "personal" or "work"
194            ),
195            # if the user has provided their work contact
196            "work_use_contact": ConfigProperty(
197                str,
198            ),
199            # if the user has provided their personal contact
200            "personal_use_contact": ConfigProperty(
201                str,
202            ),
203        }
204        self._lock = threading.Lock()
205        self._in_memory_settings: Dict[str, Any] = {}
206        self._settings = self.load_settings()
@classmethod
def shared(cls):
208    @classmethod
209    def shared(cls):
210        if cls._shared_instance is None:
211            cls._shared_instance = cls()
212        return cls._shared_instance
def get_value(self, name: str) -> Any:
215    def get_value(self, name: str) -> Any:
216        try:
217            return self.__getattr__(name)
218        except AttributeError:
219            return None
@classmethod
def settings_dir(cls, create=True) -> str:
263    @classmethod
264    def settings_dir(cls, create=True) -> str:
265        settings_dir = os.path.join(Path.home(), ".kiln_ai")
266        if create and not os.path.exists(settings_dir):
267            os.makedirs(settings_dir)
268        return settings_dir
@classmethod
def settings_path(cls, create=True) -> str:
270    @classmethod
271    def settings_path(cls, create=True) -> str:
272        settings_dir = cls.settings_dir(create)
273        return os.path.join(settings_dir, "settings.yaml")
@classmethod
def load_settings(cls):
275    @classmethod
276    def load_settings(cls):
277        if not os.path.isfile(cls.settings_path(create=False)):
278            return {}
279        with open(cls.settings_path(), "r") as f:
280            settings = yaml.safe_load(f.read()) or {}
281        return settings
def settings(self, hide_sensitive=False) -> Dict[str, Any]:
283    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
284        with self._lock:
285            filtered_disk = {
286                k: v
287                for k, v in self._settings.items()
288                if k not in self._properties or not self._properties[k].in_memory
289            }
290            combined = {**filtered_disk, **self._in_memory_settings}
291
292        if not hide_sensitive:
293            return combined
294
295        settings = {
296            k: "[hidden]"
297            if k in self._properties and self._properties[k].sensitive
298            else copy.deepcopy(v)
299            for k, v in combined.items()
300        }
301        # Hide sensitive keys in lists. Could generalize this if we every have more types, but right not it's only needed for root elements of lists
302        for key, value in settings.items():
303            if key in self._properties and self._properties[key].sensitive_keys:
304                sensitive_keys = self._properties[key].sensitive_keys or []
305                for sensitive_key in sensitive_keys:
306                    if isinstance(value, list):
307                        for item in value:
308                            if sensitive_key in item:
309                                item[sensitive_key] = "[hidden]"
310
311        return settings
def save_setting(self, name: str, value: Any):
313    def save_setting(self, name: str, value: Any):
314        self.update_settings({name: value})
def update_settings(self, new_settings: Dict[str, Any]):
316    def update_settings(self, new_settings: Dict[str, Any]):
317        with self._lock:
318            in_memory_updates = {
319                k: v
320                for k, v in new_settings.items()
321                if k in self._properties and self._properties[k].in_memory
322            }
323            disk_updates = {
324                k: v
325                for k, v in new_settings.items()
326                if k not in self._properties or not self._properties[k].in_memory
327            }
328
329            if in_memory_updates:
330                self._in_memory_settings.update(in_memory_updates)
331
332            if disk_updates:
333                # Fresh load to avoid clobbering changes from other instances
334                current_settings = self.load_settings()
335                current_settings.update(disk_updates)
336                # remove None values
337                current_settings = {
338                    k: v for k, v in current_settings.items() if v is not None
339                }
340                with open(self.settings_path(), "w") as f:
341                    yaml.dump(current_settings, f)
342                self._settings = current_settings