first commit
This commit is contained in:
313
sn-image-base/scripts/sn_image_base/configs.py
Normal file
313
sn-image-base/scripts/sn_image_base/configs.py
Normal file
@@ -0,0 +1,313 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user