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            "kiln_local_api_host": ConfigProperty(
181                str,
182                env_var="KILN_LOCAL_API_HOST",
183                default="127.0.0.1",
184                in_memory=True,
185            ),
186            "kiln_local_api_port": ConfigProperty(
187                int,
188                env_var="KILN_LOCAL_API_PORT",
189                default=8757,
190                in_memory=True,
191            ),
192            # Allow the user to set the path to lookup MCP server commands, like npx.
193            "custom_mcp_path": ConfigProperty(
194                str,
195                env_var="CUSTOM_MCP_PATH",
196            ),
197            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
198            MCP_SECRETS_KEY: ConfigProperty(
199                dict[str, str],
200                sensitive=True,
201            ),
202            "git_sync_projects": ConfigProperty(
203                dict,
204                default_lambda=lambda: {},
205                sensitive_keys=["pat_token", "oauth_token"],
206            ),
207            # has the user indicated it's for personal or work use?
208            "user_type": ConfigProperty(
209                str,  # "personal" or "work"
210            ),
211            # if the user has provided their work contact
212            "work_use_contact": ConfigProperty(
213                str,
214            ),
215            # if the user has provided their personal contact
216            "personal_use_contact": ConfigProperty(
217                str,
218            ),
219        }
220        self._lock = threading.Lock()
221        self._in_memory_settings: Dict[str, Any] = {}
222        self._settings = self.load_settings()
223
224    @classmethod
225    def shared(cls):
226        if cls._shared_instance is None:
227            cls._shared_instance = cls()
228        return cls._shared_instance
229
230    # Get a value, mockable for testing
231    def get_value(self, name: str) -> Any:
232        try:
233            return self.__getattr__(name)
234        except AttributeError:
235            return None
236
237    def kiln_local_api_base_url(self) -> str:
238        return f"http://{self.kiln_local_api_host}:{self.kiln_local_api_port}"
239
240    def __getattr__(self, name: str) -> Any:
241        if name == "_properties":
242            return super().__getattribute__("_properties")
243        if name not in self._properties:
244            return super().__getattribute__(name)
245
246        property_config = self._properties[name]
247
248        if property_config.in_memory:
249            if name in self._in_memory_settings:
250                value = self._in_memory_settings[name]
251                return value if value is None else property_config.type(value)
252        else:
253            if name in self._settings:
254                value = self._settings[name]
255                return value if value is None else property_config.type(value)
256
257        # Check environment variable
258        if property_config.env_var and property_config.env_var in os.environ:
259            value = os.environ[property_config.env_var]
260            return property_config.type(value)
261
262        # Use default value or default_lambda
263        if property_config.default_lambda:
264            value = property_config.default_lambda()
265        else:
266            value = property_config.default
267
268        return None if value is None else property_config.type(value)
269
270    def __setattr__(self, name, value):
271        if name in ("_properties", "_settings", "_lock", "_in_memory_settings"):
272            super().__setattr__(name, value)
273        elif name in self._properties:
274            if self._properties[name].in_memory:
275                with self._lock:
276                    self._in_memory_settings[name] = value
277            else:
278                self.update_settings({name: value})
279        else:
280            raise AttributeError(f"Config has no attribute '{name}'")
281
282    @classmethod
283    def settings_dir(cls, create=True) -> str:
284        settings_dir = os.path.join(Path.home(), ".kiln_ai")
285        if create and not os.path.exists(settings_dir):
286            os.makedirs(settings_dir)
287        return settings_dir
288
289    @classmethod
290    def settings_path(cls, create=True) -> str:
291        settings_dir = cls.settings_dir(create)
292        return os.path.join(settings_dir, "settings.yaml")
293
294    @classmethod
295    def load_settings(cls):
296        if not os.path.isfile(cls.settings_path(create=False)):
297            return {}
298        with open(cls.settings_path(), "r") as f:
299            settings = yaml.safe_load(f.read()) or {}
300        return settings
301
302    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
303        with self._lock:
304            filtered_disk = {
305                k: v
306                for k, v in self._settings.items()
307                if k not in self._properties or not self._properties[k].in_memory
308            }
309            combined = {**filtered_disk, **self._in_memory_settings}
310
311        if not hide_sensitive:
312            return combined
313
314        settings = {
315            k: "[hidden]"
316            if k in self._properties and self._properties[k].sensitive
317            else copy.deepcopy(v)
318            for k, v in combined.items()
319        }
320        # Hide sensitive keys in nested structures (lists of dicts, or dicts of dicts)
321        for key, value in settings.items():
322            if key in self._properties and self._properties[key].sensitive_keys:
323                sensitive_keys = self._properties[key].sensitive_keys or []
324                for sensitive_key in sensitive_keys:
325                    if isinstance(value, list):
326                        for item in value:
327                            if isinstance(item, dict) and sensitive_key in item:
328                                item[sensitive_key] = "[hidden]"
329                    elif isinstance(value, dict):
330                        if sensitive_key in value:
331                            value[sensitive_key] = "[hidden]"
332                        for item in value.values():
333                            if isinstance(item, dict) and sensitive_key in item:
334                                item[sensitive_key] = "[hidden]"
335
336        return settings
337
338    def save_setting(self, name: str, value: Any):
339        self.update_settings({name: value})
340
341    def update_settings(self, new_settings: Dict[str, Any]):
342        with self._lock:
343            in_memory_updates = {
344                k: v
345                for k, v in new_settings.items()
346                if k in self._properties and self._properties[k].in_memory
347            }
348            disk_updates = {
349                k: v
350                for k, v in new_settings.items()
351                if k not in self._properties or not self._properties[k].in_memory
352            }
353
354            if in_memory_updates:
355                self._in_memory_settings.update(in_memory_updates)
356
357            if disk_updates:
358                # Fresh load to avoid clobbering changes from other instances
359                current_settings = self.load_settings()
360                current_settings.update(disk_updates)
361                # remove None values
362                current_settings = {
363                    k: v for k, v in current_settings.items() if v is not None
364                }
365                with open(self.settings_path(), "w") as f:
366                    yaml.dump(current_settings, f)
367                self._settings = current_settings
368
369
370def _get_user_id():
371    try:
372        return getpass.getuser() or "unknown_user"
373    except Exception:
374        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            "kiln_local_api_host": ConfigProperty(
182                str,
183                env_var="KILN_LOCAL_API_HOST",
184                default="127.0.0.1",
185                in_memory=True,
186            ),
187            "kiln_local_api_port": ConfigProperty(
188                int,
189                env_var="KILN_LOCAL_API_PORT",
190                default=8757,
191                in_memory=True,
192            ),
193            # Allow the user to set the path to lookup MCP server commands, like npx.
194            "custom_mcp_path": ConfigProperty(
195                str,
196                env_var="CUSTOM_MCP_PATH",
197            ),
198            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
199            MCP_SECRETS_KEY: ConfigProperty(
200                dict[str, str],
201                sensitive=True,
202            ),
203            "git_sync_projects": ConfigProperty(
204                dict,
205                default_lambda=lambda: {},
206                sensitive_keys=["pat_token", "oauth_token"],
207            ),
208            # has the user indicated it's for personal or work use?
209            "user_type": ConfigProperty(
210                str,  # "personal" or "work"
211            ),
212            # if the user has provided their work contact
213            "work_use_contact": ConfigProperty(
214                str,
215            ),
216            # if the user has provided their personal contact
217            "personal_use_contact": ConfigProperty(
218                str,
219            ),
220        }
221        self._lock = threading.Lock()
222        self._in_memory_settings: Dict[str, Any] = {}
223        self._settings = self.load_settings()
224
225    @classmethod
226    def shared(cls):
227        if cls._shared_instance is None:
228            cls._shared_instance = cls()
229        return cls._shared_instance
230
231    # Get a value, mockable for testing
232    def get_value(self, name: str) -> Any:
233        try:
234            return self.__getattr__(name)
235        except AttributeError:
236            return None
237
238    def kiln_local_api_base_url(self) -> str:
239        return f"http://{self.kiln_local_api_host}:{self.kiln_local_api_port}"
240
241    def __getattr__(self, name: str) -> Any:
242        if name == "_properties":
243            return super().__getattribute__("_properties")
244        if name not in self._properties:
245            return super().__getattribute__(name)
246
247        property_config = self._properties[name]
248
249        if property_config.in_memory:
250            if name in self._in_memory_settings:
251                value = self._in_memory_settings[name]
252                return value if value is None else property_config.type(value)
253        else:
254            if name in self._settings:
255                value = self._settings[name]
256                return value if value is None else property_config.type(value)
257
258        # Check environment variable
259        if property_config.env_var and property_config.env_var in os.environ:
260            value = os.environ[property_config.env_var]
261            return property_config.type(value)
262
263        # Use default value or default_lambda
264        if property_config.default_lambda:
265            value = property_config.default_lambda()
266        else:
267            value = property_config.default
268
269        return None if value is None else property_config.type(value)
270
271    def __setattr__(self, name, value):
272        if name in ("_properties", "_settings", "_lock", "_in_memory_settings"):
273            super().__setattr__(name, value)
274        elif name in self._properties:
275            if self._properties[name].in_memory:
276                with self._lock:
277                    self._in_memory_settings[name] = value
278            else:
279                self.update_settings({name: value})
280        else:
281            raise AttributeError(f"Config has no attribute '{name}'")
282
283    @classmethod
284    def settings_dir(cls, create=True) -> str:
285        settings_dir = os.path.join(Path.home(), ".kiln_ai")
286        if create and not os.path.exists(settings_dir):
287            os.makedirs(settings_dir)
288        return settings_dir
289
290    @classmethod
291    def settings_path(cls, create=True) -> str:
292        settings_dir = cls.settings_dir(create)
293        return os.path.join(settings_dir, "settings.yaml")
294
295    @classmethod
296    def load_settings(cls):
297        if not os.path.isfile(cls.settings_path(create=False)):
298            return {}
299        with open(cls.settings_path(), "r") as f:
300            settings = yaml.safe_load(f.read()) or {}
301        return settings
302
303    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
304        with self._lock:
305            filtered_disk = {
306                k: v
307                for k, v in self._settings.items()
308                if k not in self._properties or not self._properties[k].in_memory
309            }
310            combined = {**filtered_disk, **self._in_memory_settings}
311
312        if not hide_sensitive:
313            return combined
314
315        settings = {
316            k: "[hidden]"
317            if k in self._properties and self._properties[k].sensitive
318            else copy.deepcopy(v)
319            for k, v in combined.items()
320        }
321        # Hide sensitive keys in nested structures (lists of dicts, or dicts of dicts)
322        for key, value in settings.items():
323            if key in self._properties and self._properties[key].sensitive_keys:
324                sensitive_keys = self._properties[key].sensitive_keys or []
325                for sensitive_key in sensitive_keys:
326                    if isinstance(value, list):
327                        for item in value:
328                            if isinstance(item, dict) and sensitive_key in item:
329                                item[sensitive_key] = "[hidden]"
330                    elif isinstance(value, dict):
331                        if sensitive_key in value:
332                            value[sensitive_key] = "[hidden]"
333                        for item in value.values():
334                            if isinstance(item, dict) and sensitive_key in item:
335                                item[sensitive_key] = "[hidden]"
336
337        return settings
338
339    def save_setting(self, name: str, value: Any):
340        self.update_settings({name: value})
341
342    def update_settings(self, new_settings: Dict[str, Any]):
343        with self._lock:
344            in_memory_updates = {
345                k: v
346                for k, v in new_settings.items()
347                if k in self._properties and self._properties[k].in_memory
348            }
349            disk_updates = {
350                k: v
351                for k, v in new_settings.items()
352                if k not in self._properties or not self._properties[k].in_memory
353            }
354
355            if in_memory_updates:
356                self._in_memory_settings.update(in_memory_updates)
357
358            if disk_updates:
359                # Fresh load to avoid clobbering changes from other instances
360                current_settings = self.load_settings()
361                current_settings.update(disk_updates)
362                # remove None values
363                current_settings = {
364                    k: v for k, v in current_settings.items() if v is not None
365                }
366                with open(self.settings_path(), "w") as f:
367                    yaml.dump(current_settings, f)
368                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            "kiln_local_api_host": ConfigProperty(
182                str,
183                env_var="KILN_LOCAL_API_HOST",
184                default="127.0.0.1",
185                in_memory=True,
186            ),
187            "kiln_local_api_port": ConfigProperty(
188                int,
189                env_var="KILN_LOCAL_API_PORT",
190                default=8757,
191                in_memory=True,
192            ),
193            # Allow the user to set the path to lookup MCP server commands, like npx.
194            "custom_mcp_path": ConfigProperty(
195                str,
196                env_var="CUSTOM_MCP_PATH",
197            ),
198            # Allow the user to set secrets for MCP servers, the key is mcp_server_id::key_name
199            MCP_SECRETS_KEY: ConfigProperty(
200                dict[str, str],
201                sensitive=True,
202            ),
203            "git_sync_projects": ConfigProperty(
204                dict,
205                default_lambda=lambda: {},
206                sensitive_keys=["pat_token", "oauth_token"],
207            ),
208            # has the user indicated it's for personal or work use?
209            "user_type": ConfigProperty(
210                str,  # "personal" or "work"
211            ),
212            # if the user has provided their work contact
213            "work_use_contact": ConfigProperty(
214                str,
215            ),
216            # if the user has provided their personal contact
217            "personal_use_contact": ConfigProperty(
218                str,
219            ),
220        }
221        self._lock = threading.Lock()
222        self._in_memory_settings: Dict[str, Any] = {}
223        self._settings = self.load_settings()
@classmethod
def shared(cls):
225    @classmethod
226    def shared(cls):
227        if cls._shared_instance is None:
228            cls._shared_instance = cls()
229        return cls._shared_instance
def get_value(self, name: str) -> Any:
232    def get_value(self, name: str) -> Any:
233        try:
234            return self.__getattr__(name)
235        except AttributeError:
236            return None
def kiln_local_api_base_url(self) -> str:
238    def kiln_local_api_base_url(self) -> str:
239        return f"http://{self.kiln_local_api_host}:{self.kiln_local_api_port}"
@classmethod
def settings_dir(cls, create=True) -> str:
283    @classmethod
284    def settings_dir(cls, create=True) -> str:
285        settings_dir = os.path.join(Path.home(), ".kiln_ai")
286        if create and not os.path.exists(settings_dir):
287            os.makedirs(settings_dir)
288        return settings_dir
@classmethod
def settings_path(cls, create=True) -> str:
290    @classmethod
291    def settings_path(cls, create=True) -> str:
292        settings_dir = cls.settings_dir(create)
293        return os.path.join(settings_dir, "settings.yaml")
@classmethod
def load_settings(cls):
295    @classmethod
296    def load_settings(cls):
297        if not os.path.isfile(cls.settings_path(create=False)):
298            return {}
299        with open(cls.settings_path(), "r") as f:
300            settings = yaml.safe_load(f.read()) or {}
301        return settings
def settings(self, hide_sensitive=False) -> Dict[str, Any]:
303    def settings(self, hide_sensitive=False) -> Dict[str, Any]:
304        with self._lock:
305            filtered_disk = {
306                k: v
307                for k, v in self._settings.items()
308                if k not in self._properties or not self._properties[k].in_memory
309            }
310            combined = {**filtered_disk, **self._in_memory_settings}
311
312        if not hide_sensitive:
313            return combined
314
315        settings = {
316            k: "[hidden]"
317            if k in self._properties and self._properties[k].sensitive
318            else copy.deepcopy(v)
319            for k, v in combined.items()
320        }
321        # Hide sensitive keys in nested structures (lists of dicts, or dicts of dicts)
322        for key, value in settings.items():
323            if key in self._properties and self._properties[key].sensitive_keys:
324                sensitive_keys = self._properties[key].sensitive_keys or []
325                for sensitive_key in sensitive_keys:
326                    if isinstance(value, list):
327                        for item in value:
328                            if isinstance(item, dict) and sensitive_key in item:
329                                item[sensitive_key] = "[hidden]"
330                    elif isinstance(value, dict):
331                        if sensitive_key in value:
332                            value[sensitive_key] = "[hidden]"
333                        for item in value.values():
334                            if isinstance(item, dict) and sensitive_key in item:
335                                item[sensitive_key] = "[hidden]"
336
337        return settings
def save_setting(self, name: str, value: Any):
339    def save_setting(self, name: str, value: Any):
340        self.update_settings({name: value})
def update_settings(self, new_settings: Dict[str, Any]):
342    def update_settings(self, new_settings: Dict[str, Any]):
343        with self._lock:
344            in_memory_updates = {
345                k: v
346                for k, v in new_settings.items()
347                if k in self._properties and self._properties[k].in_memory
348            }
349            disk_updates = {
350                k: v
351                for k, v in new_settings.items()
352                if k not in self._properties or not self._properties[k].in_memory
353            }
354
355            if in_memory_updates:
356                self._in_memory_settings.update(in_memory_updates)
357
358            if disk_updates:
359                # Fresh load to avoid clobbering changes from other instances
360                current_settings = self.load_settings()
361                current_settings.update(disk_updates)
362                # remove None values
363                current_settings = {
364                    k: v for k, v in current_settings.items() if v is not None
365                }
366                with open(self.settings_path(), "w") as f:
367                    yaml.dump(current_settings, f)
368                self._settings = current_settings