wuenlp_tools.config

  1from __future__ import annotations
  2
  3import json
  4import os
  5from dataclasses import dataclass
  6from pathlib import Path
  7from typing import Any, Dict, Optional, Union
  8
  9from wuenlp_tools.utils import _UserPath
 10
 11__all__ = [
 12    "APIConfig",
 13    "api_config",
 14    "set_api_base_url",
 15    "get_api_base_url",
 16    "get_endpoint_url",
 17    "LightLLMProxyConfig",
 18    "LocalLLMConfig",
 19    "WUENLP_DATA_DIR",
 20    "read_llm_config",
 21    "write_llm_config",
 22    "get_lightllm_config",
 23    "get_local_llm_config",
 24    "get_pull_lock_dir",
 25    "get_litellm_metadata",
 26    "configure_lightllm_proxy",
 27    "show_lightllm_status",
 28    "get_ssc_repo_root",
 29    "get_ssc_scene_model_path",
 30    "get_ssc_suspense_model_root",
 31    "read_ssc_config",
 32    "write_ssc_config",
 33]
 34
 35
 36class APIConfig:
 37    """Global configuration for WueNLP API endpoints."""
 38
 39    _instance: Optional["APIConfig"] = None
 40
 41    def __init__(self):
 42        self._base_url: str = os.getenv(
 43            "WUENLP_API_BASE_URL", "https://wuenlp-api.professor-x.de"
 44        )
 45
 46    @classmethod
 47    def get_instance(cls) -> "APIConfig":
 48        """Get singleton instance of APIConfig."""
 49        if cls._instance is None:
 50            cls._instance = cls()
 51        return cls._instance
 52
 53    @property
 54    def base_url(self) -> str:
 55        """Get the base URL for WueNLP API."""
 56        return self._base_url
 57
 58    @base_url.setter
 59    def base_url(self, value: str) -> None:
 60        """Set the base URL for WueNLP API."""
 61        if value.endswith("/"):
 62            value = value.rstrip("/")
 63        self._base_url = value
 64
 65    def get_endpoint_url(self, endpoint: str) -> str:
 66        """Get full URL for a specific endpoint."""
 67        if not endpoint.startswith("/"):
 68            endpoint = "/" + endpoint
 69        if not endpoint.endswith("/"):
 70            endpoint = endpoint + "/"
 71        return f"{self._base_url}{endpoint}"
 72
 73
 74# Global instance
 75api_config = APIConfig.get_instance()
 76
 77
 78def set_api_base_url(url: str) -> None:
 79    """Set the global API base URL."""
 80    api_config.base_url = url
 81
 82
 83def get_api_base_url() -> str:
 84    """Get the global API base URL."""
 85    return api_config.base_url
 86
 87
 88def get_endpoint_url(endpoint: str) -> str:
 89    """Get full URL for a specific endpoint."""
 90    return api_config.get_endpoint_url(endpoint)
 91
 92
 93# LLM Configuration
 94WUENLP_DATA_DIR = _UserPath.home() / ".local/share/wuenlp-tools"
 95LLM_CONFIG_PATH = WUENLP_DATA_DIR / "llm_config.json"
 96DEFAULT_PULL_LOCK_DIR = WUENLP_DATA_DIR / "pull-locks"
 97PROJECT_LLM_CONFIG_CANDIDATES = (
 98    ".wuenlp_llm_config.json",
 99    "wuenlp_llm_config.json",
100)
101
102
103@dataclass
104class LightLLMProxyConfig:
105    enabled: bool = False
106    base_url: str = "http://localhost:8080/v1/"
107    model_prefixes: Optional[Dict[str, str]] = None
108
109    def __post_init__(self):
110        if self.model_prefixes is None:
111            self.model_prefixes = {
112                "openai": "openai/",
113                "openrouter": "openrouter/",
114                "ollama": "ollama/",
115                "local": "ollama/",
116            }
117
118
119@dataclass
120class LocalLLMConfig:
121    base_url: Optional[str] = None
122    port: Union[str, int] = "auto"
123    auto_pull: bool = True
124    manage_process: Union[str, bool] = "auto"
125    pull_lock_dir: Optional[str] = None
126
127
128def read_llm_config() -> dict:
129    """Read LLM config from JSON file. Returns empty dict if file doesn't exist."""
130    if os.path.isfile(LLM_CONFIG_PATH):
131        try:
132            with open(LLM_CONFIG_PATH, "r") as f:
133                return json.load(f)
134        except json.JSONDecodeError:
135            return {}
136    return {}
137
138
139def write_llm_config(config: dict):
140    """Write the LLM config dictionary to the JSON file."""
141    os.makedirs(os.path.dirname(LLM_CONFIG_PATH), exist_ok=True)
142    with open(LLM_CONFIG_PATH, "w") as f:
143        json.dump(config, f, indent=2)
144
145
146def get_lightllm_config() -> LightLLMProxyConfig:
147    """Get LightLLM proxy configuration."""
148    config = read_llm_config()
149    proxy_config = config.get("lightllm_proxy", {})
150    return LightLLMProxyConfig(**proxy_config)
151
152
153def get_local_llm_config() -> LocalLLMConfig:
154    """Get local Ollama configuration."""
155    config = read_llm_config()
156    local_config = config.get("local_llm", {})
157    return LocalLLMConfig(**local_config)
158
159
160def get_pull_lock_dir() -> Path:
161    """Directory for cross-job Ollama pull locks."""
162    env_dir = os.environ.get("WUENLP_PULL_LOCK_DIR")
163    if env_dir:
164        return Path(os.path.expanduser(env_dir))
165
166    cfg = get_local_llm_config()
167    if cfg.pull_lock_dir:
168        return Path(os.path.expanduser(cfg.pull_lock_dir))
169
170    ollama_models = os.environ.get("OLLAMA_MODELS")
171    if ollama_models and _is_slurm():
172        return Path(os.path.expanduser(ollama_models)) / ".wuenlp-pull-locks"
173
174    return DEFAULT_PULL_LOCK_DIR
175
176
177def _is_slurm() -> bool:
178    return bool(os.environ.get("SLURM_JOB_ID"))
179
180
181def _read_project_llm_config() -> dict:
182    """
183    Read project-level LLM config from the current working directory.
184
185    The first existing file from PROJECT_LLM_CONFIG_CANDIDATES is used.
186    Returns an empty dict if no file is found or if parsing fails.
187    """
188    cwd = os.getcwd()
189    for filename in PROJECT_LLM_CONFIG_CANDIDATES:
190        candidate = os.path.join(cwd, filename)
191        if os.path.isfile(candidate):
192            try:
193                with open(candidate, "r") as f:
194                    return json.load(f)
195            except json.JSONDecodeError:
196                return {}
197    return {}
198
199
200def get_litellm_metadata() -> Dict[str, Any]:
201    """
202    Get merged LiteLLM metadata configuration.
203
204    Order of precedence (later overrides earlier):
205    1. System-level config at LLM_CONFIG_PATH, key "litellm_metadata"
206    2. Project-level config in the current working directory
207       (".wuenlp_llm_config.json" or "wuenlp_llm_config.json"),
208       key "litellm_metadata"
209
210    This is intended for generic tags/metadata that should end up in LiteLLM logs.
211    """
212    system_config = read_llm_config()
213    system_meta = system_config.get("litellm_metadata", {}) or {}
214
215    project_config = _read_project_llm_config()
216    project_meta = project_config.get("litellm_metadata", {}) or {}
217
218    merged: Dict[str, Any] = {}
219    merged.update(system_meta)
220    merged.update(project_meta)
221    return merged
222
223
224def configure_lightllm_proxy():
225    """Interactive configuration helper for LightLLM proxy settings."""
226    print("🔧 LightLLM Proxy Configuration")
227    print("=" * 40)
228
229    # Load current config
230    current_config = read_llm_config()
231    current_proxy = current_config.get("lightllm_proxy", {})
232
233    print(
234        f"\nCurrent status: {'ENABLED' if current_proxy.get('enabled', False) else 'DISABLED'}"
235    )
236    if current_proxy.get("base_url"):
237        print(f"Current proxy URL: {current_proxy['base_url']}")
238
239    # Ask if user wants to enable/disable
240    while True:
241        enable_input = input("\nEnable LightLLM proxy? [y/n]: ").strip().lower()
242        if enable_input in ["y", "yes", "true", "1"]:
243            enabled = True
244            break
245        elif enable_input in ["n", "no", "false", "0"]:
246            enabled = False
247            break
248        else:
249            print("Please enter 'y' for yes or 'n' for no")
250
251    if not enabled:
252        # Disable proxy
253        new_config = current_config.copy()
254        new_config["lightllm_proxy"] = {"enabled": False}
255        write_llm_config(new_config)
256        print("✅ LightLLM proxy disabled")
257        return
258
259    # Configure proxy settings
260    print("\n📝 Configuring proxy settings...")
261
262    # Base URL
263    current_url = current_proxy.get("base_url", "http://localhost:8080/v1/")
264    base_url = input(f"Proxy base URL [{current_url}]: ").strip()
265    if not base_url:
266        base_url = current_url
267
268    # Ensure URL ends with /v1/
269    if not base_url.endswith("/"):
270        base_url += "/"
271    if not base_url.endswith("v1/"):
272        if base_url.endswith("/"):
273            base_url += "v1/"
274        else:
275            base_url += "/v1/"
276
277    # Model prefixes
278    current_prefixes = current_proxy.get(
279        "model_prefixes",
280        {"openai": "openai/", "openrouter": "openrouter/", "ollama": "ollama/"},
281    )
282
283    print("\n🏷️  Configure model prefixes (press Enter to keep current):")
284
285    model_prefixes = {}
286    for provider in ["openai", "openrouter", "ollama"]:
287        current_prefix = current_prefixes.get(provider, f"{provider}/")
288        prefix = input(f"{provider.capitalize()} prefix [{current_prefix}]: ").strip()
289        if not prefix:
290            prefix = current_prefix
291        model_prefixes[provider] = prefix
292
293    # Build final config
294    new_proxy_config = {
295        "enabled": True,
296        "base_url": base_url,
297        "model_prefixes": model_prefixes,
298    }
299
300    new_config = current_config.copy()
301    new_config["lightllm_proxy"] = new_proxy_config
302
303    # Show summary
304    print("\n📋 Configuration Summary:")
305    print("   Status: ENABLED")
306    print(f"   Base URL: {base_url}")
307    print("   Model prefixes:")
308    for provider, prefix in model_prefixes.items():
309        print(f"     {provider}: {prefix}")
310
311    # Confirm and save
312    while True:
313        confirm = input("\nSave this configuration? [y/n]: ").strip().lower()
314        if confirm in ["y", "yes"]:
315            write_llm_config(new_config)
316            print(f"✅ Configuration saved to {LLM_CONFIG_PATH}")
317
318            # Check for API key
319            from wuenlp_tools.keys import LIGHTLLM_API_KEY
320
321            try:
322                # This will prompt for the key if not set
323                key_check = str(LIGHTLLM_API_KEY)
324                if key_check:
325                    print("✅ LightLLM API key is configured")
326                else:
327                    print("⚠️  No LightLLM API key set (may not be required)")
328            except Exception:
329                print("⚠️  No LightLLM API key set (may not be required)")
330
331            print("\n🎉 LightLLM proxy configuration complete!")
332            print(f"   All LLM requests will now be routed through: {base_url}")
333            break
334        elif confirm in ["n", "no"]:
335            print("❌ Configuration cancelled")
336            break
337        else:
338            print("Please enter 'y' to save or 'n' to cancel")
339
340
341def show_lightllm_status():
342    """Display current LightLLM proxy configuration status."""
343    config = get_lightllm_config()
344
345    print("🔍 LightLLM Proxy Status")
346    print("=" * 25)
347
348    if config.enabled:
349        print("✅ Status: ENABLED")
350        print(f"🌐 Base URL: {config.base_url}")
351        print("🏷️  Model prefixes:")
352        if config.model_prefixes:
353            for provider, prefix in config.model_prefixes.items():
354                print(f"   {provider}: {prefix}")
355
356        # Check API key
357        from wuenlp_tools.keys import LIGHTLLM_API_KEY
358
359        try:
360            key_check = str(LIGHTLLM_API_KEY)
361            key_status = "SET" if key_check else "NOT SET"
362        except Exception:
363            key_status = "NOT SET"
364        print(f"🔑 API Key: {key_status}")
365
366    else:
367        print("❌ Status: DISABLED")
368        print("💡 Run configure_lightllm_proxy() to enable")
369
370
371from wuenlp_tools.ssc_config import (  # noqa: E402
372    get_ssc_repo_root,
373    get_ssc_scene_model_path,
374    get_ssc_suspense_model_root,
375    read_ssc_config,
376    write_ssc_config,
377)
class APIConfig:
37class APIConfig:
38    """Global configuration for WueNLP API endpoints."""
39
40    _instance: Optional["APIConfig"] = None
41
42    def __init__(self):
43        self._base_url: str = os.getenv(
44            "WUENLP_API_BASE_URL", "https://wuenlp-api.professor-x.de"
45        )
46
47    @classmethod
48    def get_instance(cls) -> "APIConfig":
49        """Get singleton instance of APIConfig."""
50        if cls._instance is None:
51            cls._instance = cls()
52        return cls._instance
53
54    @property
55    def base_url(self) -> str:
56        """Get the base URL for WueNLP API."""
57        return self._base_url
58
59    @base_url.setter
60    def base_url(self, value: str) -> None:
61        """Set the base URL for WueNLP API."""
62        if value.endswith("/"):
63            value = value.rstrip("/")
64        self._base_url = value
65
66    def get_endpoint_url(self, endpoint: str) -> str:
67        """Get full URL for a specific endpoint."""
68        if not endpoint.startswith("/"):
69            endpoint = "/" + endpoint
70        if not endpoint.endswith("/"):
71            endpoint = endpoint + "/"
72        return f"{self._base_url}{endpoint}"

Global configuration for WueNLP API endpoints.

@classmethod
def get_instance(cls) -> APIConfig:
47    @classmethod
48    def get_instance(cls) -> "APIConfig":
49        """Get singleton instance of APIConfig."""
50        if cls._instance is None:
51            cls._instance = cls()
52        return cls._instance

Get singleton instance of APIConfig.

base_url: str
54    @property
55    def base_url(self) -> str:
56        """Get the base URL for WueNLP API."""
57        return self._base_url

Get the base URL for WueNLP API.

def get_endpoint_url(self, endpoint: str) -> str:
66    def get_endpoint_url(self, endpoint: str) -> str:
67        """Get full URL for a specific endpoint."""
68        if not endpoint.startswith("/"):
69            endpoint = "/" + endpoint
70        if not endpoint.endswith("/"):
71            endpoint = endpoint + "/"
72        return f"{self._base_url}{endpoint}"

Get full URL for a specific endpoint.

api_config = <APIConfig object>

Global configuration for WueNLP API endpoints.

def set_api_base_url(url: str) -> None:
79def set_api_base_url(url: str) -> None:
80    """Set the global API base URL."""
81    api_config.base_url = url

Set the global API base URL.

def get_api_base_url() -> str:
84def get_api_base_url() -> str:
85    """Get the global API base URL."""
86    return api_config.base_url

Get the global API base URL.

def get_endpoint_url(endpoint: str) -> str:
89def get_endpoint_url(endpoint: str) -> str:
90    """Get full URL for a specific endpoint."""
91    return api_config.get_endpoint_url(endpoint)

Get full URL for a specific endpoint.

@dataclass
class LightLLMProxyConfig:
104@dataclass
105class LightLLMProxyConfig:
106    enabled: bool = False
107    base_url: str = "http://localhost:8080/v1/"
108    model_prefixes: Optional[Dict[str, str]] = None
109
110    def __post_init__(self):
111        if self.model_prefixes is None:
112            self.model_prefixes = {
113                "openai": "openai/",
114                "openrouter": "openrouter/",
115                "ollama": "ollama/",
116                "local": "ollama/",
117            }
LightLLMProxyConfig( enabled: bool = False, base_url: str = 'http://localhost:8080/v1/', model_prefixes: Optional[Dict[str, str]] = None)
enabled: bool = False

bool(x) -> bool

Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

base_url: str = 'http://localhost:8080/v1/'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

model_prefixes: Optional[Dict[str, str]] = None
@dataclass
class LocalLLMConfig:
120@dataclass
121class LocalLLMConfig:
122    base_url: Optional[str] = None
123    port: Union[str, int] = "auto"
124    auto_pull: bool = True
125    manage_process: Union[str, bool] = "auto"
126    pull_lock_dir: Optional[str] = None
LocalLLMConfig( base_url: Optional[str] = None, port: Union[str, int] = 'auto', auto_pull: bool = True, manage_process: Union[str, bool] = 'auto', pull_lock_dir: Optional[str] = None)
base_url: Optional[str] = None
port: Union[str, int] = 'auto'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

auto_pull: bool = True

bool(x) -> bool

Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

manage_process: Union[str, bool] = 'auto'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

pull_lock_dir: Optional[str] = None
WUENLP_DATA_DIR = PosixPath('~/.local/share/wuenlp-tools')

Path whose repr uses ~ instead of the expanded home directory.

def read_llm_config() -> dict:
129def read_llm_config() -> dict:
130    """Read LLM config from JSON file. Returns empty dict if file doesn't exist."""
131    if os.path.isfile(LLM_CONFIG_PATH):
132        try:
133            with open(LLM_CONFIG_PATH, "r") as f:
134                return json.load(f)
135        except json.JSONDecodeError:
136            return {}
137    return {}

Read LLM config from JSON file. Returns empty dict if file doesn't exist.

def write_llm_config(config: dict):
140def write_llm_config(config: dict):
141    """Write the LLM config dictionary to the JSON file."""
142    os.makedirs(os.path.dirname(LLM_CONFIG_PATH), exist_ok=True)
143    with open(LLM_CONFIG_PATH, "w") as f:
144        json.dump(config, f, indent=2)

Write the LLM config dictionary to the JSON file.

def get_lightllm_config() -> LightLLMProxyConfig:
147def get_lightllm_config() -> LightLLMProxyConfig:
148    """Get LightLLM proxy configuration."""
149    config = read_llm_config()
150    proxy_config = config.get("lightllm_proxy", {})
151    return LightLLMProxyConfig(**proxy_config)

Get LightLLM proxy configuration.

def get_local_llm_config() -> LocalLLMConfig:
154def get_local_llm_config() -> LocalLLMConfig:
155    """Get local Ollama configuration."""
156    config = read_llm_config()
157    local_config = config.get("local_llm", {})
158    return LocalLLMConfig(**local_config)

Get local Ollama configuration.

def get_pull_lock_dir() -> pathlib.Path:
161def get_pull_lock_dir() -> Path:
162    """Directory for cross-job Ollama pull locks."""
163    env_dir = os.environ.get("WUENLP_PULL_LOCK_DIR")
164    if env_dir:
165        return Path(os.path.expanduser(env_dir))
166
167    cfg = get_local_llm_config()
168    if cfg.pull_lock_dir:
169        return Path(os.path.expanduser(cfg.pull_lock_dir))
170
171    ollama_models = os.environ.get("OLLAMA_MODELS")
172    if ollama_models and _is_slurm():
173        return Path(os.path.expanduser(ollama_models)) / ".wuenlp-pull-locks"
174
175    return DEFAULT_PULL_LOCK_DIR

Directory for cross-job Ollama pull locks.

def get_litellm_metadata() -> Dict[str, Any]:
201def get_litellm_metadata() -> Dict[str, Any]:
202    """
203    Get merged LiteLLM metadata configuration.
204
205    Order of precedence (later overrides earlier):
206    1. System-level config at LLM_CONFIG_PATH, key "litellm_metadata"
207    2. Project-level config in the current working directory
208       (".wuenlp_llm_config.json" or "wuenlp_llm_config.json"),
209       key "litellm_metadata"
210
211    This is intended for generic tags/metadata that should end up in LiteLLM logs.
212    """
213    system_config = read_llm_config()
214    system_meta = system_config.get("litellm_metadata", {}) or {}
215
216    project_config = _read_project_llm_config()
217    project_meta = project_config.get("litellm_metadata", {}) or {}
218
219    merged: Dict[str, Any] = {}
220    merged.update(system_meta)
221    merged.update(project_meta)
222    return merged

Get merged LiteLLM metadata configuration.

Order of precedence (later overrides earlier):

  1. System-level config at LLM_CONFIG_PATH, key "litellm_metadata"
  2. Project-level config in the current working directory (".wuenlp_llm_config.json" or "wuenlp_llm_config.json"), key "litellm_metadata"

This is intended for generic tags/metadata that should end up in LiteLLM logs.

def configure_lightllm_proxy():
225def configure_lightllm_proxy():
226    """Interactive configuration helper for LightLLM proxy settings."""
227    print("🔧 LightLLM Proxy Configuration")
228    print("=" * 40)
229
230    # Load current config
231    current_config = read_llm_config()
232    current_proxy = current_config.get("lightllm_proxy", {})
233
234    print(
235        f"\nCurrent status: {'ENABLED' if current_proxy.get('enabled', False) else 'DISABLED'}"
236    )
237    if current_proxy.get("base_url"):
238        print(f"Current proxy URL: {current_proxy['base_url']}")
239
240    # Ask if user wants to enable/disable
241    while True:
242        enable_input = input("\nEnable LightLLM proxy? [y/n]: ").strip().lower()
243        if enable_input in ["y", "yes", "true", "1"]:
244            enabled = True
245            break
246        elif enable_input in ["n", "no", "false", "0"]:
247            enabled = False
248            break
249        else:
250            print("Please enter 'y' for yes or 'n' for no")
251
252    if not enabled:
253        # Disable proxy
254        new_config = current_config.copy()
255        new_config["lightllm_proxy"] = {"enabled": False}
256        write_llm_config(new_config)
257        print("✅ LightLLM proxy disabled")
258        return
259
260    # Configure proxy settings
261    print("\n📝 Configuring proxy settings...")
262
263    # Base URL
264    current_url = current_proxy.get("base_url", "http://localhost:8080/v1/")
265    base_url = input(f"Proxy base URL [{current_url}]: ").strip()
266    if not base_url:
267        base_url = current_url
268
269    # Ensure URL ends with /v1/
270    if not base_url.endswith("/"):
271        base_url += "/"
272    if not base_url.endswith("v1/"):
273        if base_url.endswith("/"):
274            base_url += "v1/"
275        else:
276            base_url += "/v1/"
277
278    # Model prefixes
279    current_prefixes = current_proxy.get(
280        "model_prefixes",
281        {"openai": "openai/", "openrouter": "openrouter/", "ollama": "ollama/"},
282    )
283
284    print("\n🏷️  Configure model prefixes (press Enter to keep current):")
285
286    model_prefixes = {}
287    for provider in ["openai", "openrouter", "ollama"]:
288        current_prefix = current_prefixes.get(provider, f"{provider}/")
289        prefix = input(f"{provider.capitalize()} prefix [{current_prefix}]: ").strip()
290        if not prefix:
291            prefix = current_prefix
292        model_prefixes[provider] = prefix
293
294    # Build final config
295    new_proxy_config = {
296        "enabled": True,
297        "base_url": base_url,
298        "model_prefixes": model_prefixes,
299    }
300
301    new_config = current_config.copy()
302    new_config["lightllm_proxy"] = new_proxy_config
303
304    # Show summary
305    print("\n📋 Configuration Summary:")
306    print("   Status: ENABLED")
307    print(f"   Base URL: {base_url}")
308    print("   Model prefixes:")
309    for provider, prefix in model_prefixes.items():
310        print(f"     {provider}: {prefix}")
311
312    # Confirm and save
313    while True:
314        confirm = input("\nSave this configuration? [y/n]: ").strip().lower()
315        if confirm in ["y", "yes"]:
316            write_llm_config(new_config)
317            print(f"✅ Configuration saved to {LLM_CONFIG_PATH}")
318
319            # Check for API key
320            from wuenlp_tools.keys import LIGHTLLM_API_KEY
321
322            try:
323                # This will prompt for the key if not set
324                key_check = str(LIGHTLLM_API_KEY)
325                if key_check:
326                    print("✅ LightLLM API key is configured")
327                else:
328                    print("⚠️  No LightLLM API key set (may not be required)")
329            except Exception:
330                print("⚠️  No LightLLM API key set (may not be required)")
331
332            print("\n🎉 LightLLM proxy configuration complete!")
333            print(f"   All LLM requests will now be routed through: {base_url}")
334            break
335        elif confirm in ["n", "no"]:
336            print("❌ Configuration cancelled")
337            break
338        else:
339            print("Please enter 'y' to save or 'n' to cancel")

Interactive configuration helper for LightLLM proxy settings.

def show_lightllm_status():
342def show_lightllm_status():
343    """Display current LightLLM proxy configuration status."""
344    config = get_lightllm_config()
345
346    print("🔍 LightLLM Proxy Status")
347    print("=" * 25)
348
349    if config.enabled:
350        print("✅ Status: ENABLED")
351        print(f"🌐 Base URL: {config.base_url}")
352        print("🏷️  Model prefixes:")
353        if config.model_prefixes:
354            for provider, prefix in config.model_prefixes.items():
355                print(f"   {provider}: {prefix}")
356
357        # Check API key
358        from wuenlp_tools.keys import LIGHTLLM_API_KEY
359
360        try:
361            key_check = str(LIGHTLLM_API_KEY)
362            key_status = "SET" if key_check else "NOT SET"
363        except Exception:
364            key_status = "NOT SET"
365        print(f"🔑 API Key: {key_status}")
366
367    else:
368        print("❌ Status: DISABLED")
369        print("💡 Run configure_lightllm_proxy() to enable")

Display current LightLLM proxy configuration status.

def get_ssc_repo_root() -> str:
81def get_ssc_repo_root() -> str:
82    return _require_ssc_path(SSC_REPO_ROOT_KEY)
def get_ssc_scene_model_path() -> str:
85def get_ssc_scene_model_path() -> str:
86    return _require_ssc_path(SSC_SCENE_MODEL_PATH_KEY)
def get_ssc_suspense_model_root() -> str:
89def get_ssc_suspense_model_root() -> str:
90    return _require_ssc_path(SSC_SUSPENSE_MODEL_ROOT_KEY)
def read_ssc_config() -> dict[str, typing.Any]:
40def read_ssc_config() -> dict[str, Any]:
41    """Read merged SSC config: user file, then project file in cwd (project wins)."""
42    merged: dict[str, Any] = {}
43    if os.path.isfile(SSC_CONFIG_PATH):
44        try:
45            with open(SSC_CONFIG_PATH, encoding="utf-8") as f:
46                merged.update(json.load(f))
47        except json.JSONDecodeError:
48            pass
49    for filename in PROJECT_SSC_CONFIG_CANDIDATES:
50        candidate = os.path.join(os.getcwd(), filename)
51        if os.path.isfile(candidate):
52            try:
53                with open(candidate, encoding="utf-8") as f:
54                    merged.update(json.load(f))
55            except json.JSONDecodeError:
56                pass
57            break
58    return merged

Read merged SSC config: user file, then project file in cwd (project wins).

def write_ssc_config(config: dict[str, typing.Any]) -> None:
61def write_ssc_config(config: dict[str, Any]) -> None:
62    os.makedirs(os.path.dirname(SSC_CONFIG_PATH), exist_ok=True)
63    with open(SSC_CONFIG_PATH, "w", encoding="utf-8") as f:
64        json.dump(config, f, indent=2)