from __future__ import annotations import contextlib import os import warnings from pathlib import Path from typing import Annotated, Literal, get_args, get_origin, get_type_hints from urllib.parse import urlparse SCRIPT_DIR = Path(__file__).absolute().parent # "skills" directory that contains "sn-*" skills (e.g. "sn-image-base", "sn-infographic", etc.) SKILLS_DIR = SCRIPT_DIR.parents[1] def prepare_env() -> None: try: from dotenv import load_dotenv except ImportError: warnings.warn("python-dotenv is not installed, `.env` files will be ignored", stacklevel=2) return # Priorities: # 1. ".env" in the agent's config directory: # - openclaw: ~/.openclaw/.env # - hermes: ~/.openclaw/.env # 2. ".env" in current working directory. (depends on how the agent runs the skill) # 3. Environment variables # ------------------------------------------------------------ # In reverse order of priority, the latter overrides the former: # 3 -- do nothing; overridden by other env files # 2 -- load_dotenv(override=True) # 1 -- if "OPENCLAW_SHELL" in os.environ: agent_config_dir = Path("~/.openclaw").expanduser() else: agent_config_dir = Path("~/.hermes").expanduser() if (dotenv_path := agent_config_dir / ".env").exists(): load_dotenv(dotenv_path, override=True) prepare_env() class Field: """Metadata marker that pairs a field with one or more env var names. Env vars are tried in order; the first env var that is set is returned. """ __slots__ = ("env_names", "required", "secret") def __init__(self, *env_names: str, required: bool = False, secret: bool = False) -> None: self.env_names: tuple[str, ...] | None = tuple(env_names) if env_names else None self.required = required self.secret = secret def resolve(self, target_type: type | None = None) -> str | int | float | None: """Return the first env var value that is set, converted to target_type. Args: target_type: The type to convert to (str, int, float, etc.) or None. If not int or float, returns the raw string. Returns: The converted value, or None if none of the env vars exist. """ if not self.env_names: return None for n in self.env_names: if n in os.environ: raw = os.environ[n] if target_type is int: return int(raw) if target_type is float: return float(raw) # For other types (Literal, etc.), return raw string return raw return None class Configs: """Central registry of env var names and built-in defaults. Fields annotated with ``Annotated[str, EnvVar(...)]`` are resolved in ``__init__``: env vars are tried in order; if none is set, the class-level default is kept. """ # global defaults shared by all SN capabilities. SN_API_KEY: Annotated[str, Field("SN_API_KEY", secret=True)] = "" SN_BASE_URL: Annotated[str, Field("SN_BASE_URL")] = "" # image-generate SN_IMAGE_GEN_API_KEY: Annotated[ str, Field("SN_IMAGE_GEN_API_KEY", "SN_API_KEY", required=True, secret=True) ] = "" SN_IMAGE_GEN_BASE_URL: Annotated[ str, Field("SN_IMAGE_GEN_BASE_URL", "SN_BASE_URL", required=True) ] = "https://token.sensenova.cn/v1" SN_IMAGE_GEN_MODEL_TYPE: Annotated[ Literal["sensenova", "nano-banana", "openai-image"], Field("SN_IMAGE_GEN_MODEL_TYPE") ] = "sensenova" SN_IMAGE_GEN_MODEL: Annotated[str, Field("SN_IMAGE_GEN_MODEL")] = "sensenova-u1-fast" # chat runtime shared by text and vision commands; command-specific # SN_TEXT_* / SN_VISION_* values override these defaults. SN_CHAT_API_KEY: Annotated[str, Field("SN_CHAT_API_KEY", "SN_API_KEY", secret=True)] = "" SN_CHAT_BASE_URL: Annotated[str, Field("SN_CHAT_BASE_URL", "SN_BASE_URL")] = ( "https://token.sensenova.cn/v1" ) SN_CHAT_TYPE: Annotated[ Literal["anthropic-messages", "openai-completions"], Field("SN_CHAT_TYPE") ] = "openai-completions" SN_CHAT_MODEL: Annotated[str, Field("SN_CHAT_MODEL")] = "sensenova-6.7-flash-lite" SN_TEXT_API_KEY: Annotated[ str, Field("SN_TEXT_API_KEY", "SN_CHAT_API_KEY", "SN_API_KEY", secret=True) ] = "" SN_TEXT_BASE_URL: Annotated[ str, Field("SN_TEXT_BASE_URL", "SN_CHAT_BASE_URL", "SN_BASE_URL") ] = "" SN_TEXT_TYPE: Annotated[ Literal["anthropic-messages", "openai-completions"], Field("SN_TEXT_TYPE", "SN_CHAT_TYPE"), ] = "" SN_TEXT_MODEL: Annotated[str, Field("SN_TEXT_MODEL", "SN_CHAT_MODEL")] = ( "sensenova-6.7-flash-lite" ) SN_VISION_API_KEY: Annotated[ str, Field("SN_VISION_API_KEY", "SN_CHAT_API_KEY", "SN_API_KEY", secret=True) ] = "" SN_VISION_BASE_URL: Annotated[ str, Field("SN_VISION_BASE_URL", "SN_CHAT_BASE_URL", "SN_BASE_URL") ] = "" SN_VISION_TYPE: Annotated[ Literal["anthropic-messages", "openai-completions"], Field("SN_VISION_TYPE", "SN_CHAT_TYPE"), ] = "" SN_VISION_MODEL: Annotated[str, Field("SN_VISION_MODEL", "SN_CHAT_MODEL")] = ( "sensenova-6.7-flash-lite" ) def __init__(self) -> None: for field, hint in get_type_hints(type(self), include_extras=True).items(): env_var = next((a for a in get_args(hint) if isinstance(a, Field)), None) if env_var is None: continue # Extract the actual type (unwrap Annotated, handle Literal) origin = get_origin(hint) actual_type = get_args(hint)[0] if origin is Annotated else hint if (val := env_var.resolve(actual_type)) is not None: setattr(self, field, val) def to_string(self, mask_secrets: bool = True) -> str: rows = [] for field_name, hint in get_type_hints(type(self), include_extras=True).items(): field = next((a for a in get_args(hint) if isinstance(a, Field)), None) value = getattr(self, field_name, None) v = str(value) if mask_secrets and v and field and field.secret: if len(v) > 10: v = f"{v[:6]}{'*' * (len(v) - 10)}{v[-4:]}" elif len(v) > 4: v = f"{v[:4]}{'*' * (len(v) - 4)}" else: v = "*" * len(v) rows.append(f"{field_name}: {v}") return "\n".join(rows) def validate_configs(self) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: field_env_names: dict[str, tuple[str, ...] | str] = {} errors: list[tuple[str, str]] = [] for field_name, hint in get_type_hints(type(self), include_extras=True).items(): field = next((a for a in get_args(hint) if isinstance(a, Field)), None) if field is None: continue if env_names := field.env_names: if len(env_names) > 1: field_env_names[field_name] = env_names elif len(env_names) == 1: field_env_names[field_name] = env_names[0] value = getattr(self, field_name, None) if not value: if field.required: if field_name == "SN_IMAGE_GEN_API_KEY": msg = ( "Image generation API key is not set; configure SN_API_KEY, " "or configure SN_IMAGE_GEN_API_KEY only for an image-generation-specific override" ) else: msg = f"Field '{field_name}' is required but not set; try setting the environment variable(s) {field.env_names}" errors.append((field_name, msg)) continue # Check fields combination rules: if not self.SN_IMAGE_GEN_MODEL: errors.append(( "SN_IMAGE_GEN_MODEL", f"SN_IMAGE_GEN_MODEL is required when SN_IMAGE_GEN_MODEL_TYPE is {self.SN_IMAGE_GEN_MODEL_TYPE!r}", )) warnings: list[tuple[str, str]] = [] runtime_checks = { "text": { "api_key": ("SN_TEXT_API_KEY",), "base_url": ("SN_TEXT_BASE_URL", "SN_CHAT_BASE_URL"), "model": ("SN_TEXT_MODEL",), "type": ("SN_TEXT_TYPE", "SN_CHAT_TYPE"), }, "vision": { "api_key": ("SN_VISION_API_KEY",), "base_url": ("SN_VISION_BASE_URL", "SN_CHAT_BASE_URL"), "model": ("SN_VISION_MODEL",), "type": ("SN_VISION_TYPE", "SN_CHAT_TYPE"), }, } for runtime, checks in runtime_checks.items(): for field_kind, keys in checks.items(): if any(getattr(self, key) for key in keys): continue env_help = " / ".join( ", ".join(field_env_names[key]) if isinstance(field_env_names.get(key), tuple) else str(field_env_names.get(key, key)) for key in keys ) warnings.append(( keys[0], f"{keys[0]} is not set; {runtime} {field_kind} may be unavailable. Try setting: {env_help}", )) # check urls errors.extend( ( key, f"{key} is not a valid base URL: {getattr(self, key)}", ) for key in ("SN_CHAT_BASE_URL", "SN_TEXT_BASE_URL", "SN_VISION_BASE_URL") if getattr(self, key) and not is_valid_base_url(getattr(self, key)) ) errors.extend( ( key, f"{key} is not a valid base URL: {getattr(self, key)}", ) for key in ( "SN_BASE_URL", "SN_IMAGE_GEN_BASE_URL", ) if getattr(self, key) and not is_valid_base_url(getattr(self, key)) ) return errors, warnings def get_annotated_field(self, field_name: str) -> Field | None: hints = get_type_hints(type(self), include_extras=True) if field_name not in hints: return None hint = hints[field_name] field_inst = next((a for a in get_args(hint) if isinstance(a, Field)), None) return field_inst def get_env_var_help(self, field_name: str) -> str: """Return a help string describing which environment variables can be used to set the specified configuration field. Args: field_name: The name of the configuration field (e.g., "SN_CHAT_API_KEY"). Returns: A string describing the environment variable(s) that control this field. Returns an error message if the field does not exist or has no EnvVar annotation. """ if not hasattr(type(self), field_name): return f"Field '{field_name}' does not exist in Configs." field_inst = self.get_annotated_field(field_name) if field_inst is None: return f"Field '{field_name}' is not configurable via environment variables." current_value = getattr(self, field_name) env_names = list(field_inst.env_names) if field_inst.env_names else [] if len(env_names) == 1: return ( f"To set '{field_name}', configure the environment variable: {env_names[0]}\n" f"Current value: {current_value!r}" ) else: env_list = ", ".join(env_names) return ( f"To set '{field_name}', configure one of these environment variables: {env_list}\n" f"They are tried in order; the first set value is used.\n" f"Current value: {current_value!r}" ) def is_valid_base_url(url: str) -> bool: with contextlib.suppress(ValueError): parsed = urlparse(url) return bool(parsed.scheme and parsed.netloc) return False def reload_env() -> None: global global_configs prepare_env() try: global_configs = Configs() print("✅ Reloaded global_configs") except Exception as e: warnings.warn(f"Failed to reload global_configs: {e}", stacklevel=2) global_configs = Configs()