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

Global configuration for WueNLP API endpoints.

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

Get singleton instance of APIConfig.

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

Get the base URL for WueNLP API.

def get_endpoint_url(self, endpoint: str) -> str:
64    def get_endpoint_url(self, endpoint: str) -> str:
65        """Get full URL for a specific endpoint."""
66        if not endpoint.startswith("/"):
67            endpoint = "/" + endpoint
68        if not endpoint.endswith("/"):
69            endpoint = endpoint + "/"
70        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:
77def set_api_base_url(url: str) -> None:
78    """Set the global API base URL."""
79    api_config.base_url = url

Set the global API base URL.

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

Get the global API base URL.

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

Get full URL for a specific endpoint.

@dataclass
class LightLLMProxyConfig:
102@dataclass
103class LightLLMProxyConfig:
104    enabled: bool = False
105    base_url: str = "http://localhost:8080/v1/"
106    model_prefixes: Optional[Dict[str, str]] = None
107
108    def __post_init__(self):
109        if self.model_prefixes is None:
110            self.model_prefixes = {
111                "openai": "openai/",
112                "openrouter": "openrouter/",
113                "ollama": "ollama/",
114                "local": "ollama/",
115            }
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:
118@dataclass
119class LocalLLMConfig:
120    base_url: Optional[str] = None
121    port: Union[str, int] = "auto"
122    auto_pull: bool = True
123    manage_process: Union[str, bool] = "auto"
124    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('/Users/zehe/.local/share/wuenlp-tools')

Path subclass for non-Windows systems.

On a POSIX system, instantiating a Path should return this object.
def read_llm_config() -> dict:
127def read_llm_config() -> dict:
128    """Read LLM config from JSON file. Returns empty dict if file doesn't exist."""
129    if os.path.isfile(LLM_CONFIG_PATH):
130        try:
131            with open(LLM_CONFIG_PATH, "r") as f:
132                return json.load(f)
133        except json.JSONDecodeError:
134            return {}
135    return {}

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

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

Write the LLM config dictionary to the JSON file.

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

Get LightLLM proxy configuration.

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

Get local Ollama configuration.

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

Directory for cross-job Ollama pull locks.

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

Interactive configuration helper for LightLLM proxy settings.

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

Display current LightLLM proxy configuration status.

def get_ssc_repo_root() -> str:
79def get_ssc_repo_root() -> str:
80    return _require_ssc_path(SSC_REPO_ROOT_KEY)
def get_ssc_scene_model_path() -> str:
83def get_ssc_scene_model_path() -> str:
84    return _require_ssc_path(SSC_SCENE_MODEL_PATH_KEY)
def get_ssc_suspense_model_root() -> str:
87def get_ssc_suspense_model_root() -> str:
88    return _require_ssc_path(SSC_SUSPENSE_MODEL_ROOT_KEY)
def read_ssc_config() -> dict[str, typing.Any]:
38def read_ssc_config() -> dict[str, Any]:
39    """Read merged SSC config: user file, then project file in cwd (project wins)."""
40    merged: dict[str, Any] = {}
41    if os.path.isfile(SSC_CONFIG_PATH):
42        try:
43            with open(SSC_CONFIG_PATH, encoding="utf-8") as f:
44                merged.update(json.load(f))
45        except json.JSONDecodeError:
46            pass
47    for filename in PROJECT_SSC_CONFIG_CANDIDATES:
48        candidate = os.path.join(os.getcwd(), filename)
49        if os.path.isfile(candidate):
50            try:
51                with open(candidate, encoding="utf-8") as f:
52                    merged.update(json.load(f))
53            except json.JSONDecodeError:
54                pass
55            break
56    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:
59def write_ssc_config(config: dict[str, Any]) -> None:
60    os.makedirs(os.path.dirname(SSC_CONFIG_PATH), exist_ok=True)
61    with open(SSC_CONFIG_PATH, "w", encoding="utf-8") as f:
62        json.dump(config, f, indent=2)